mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Remember different panel state for sub nodes (#16189)
This commit is contained in:
@@ -484,6 +484,7 @@ export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL
|
|||||||
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
||||||
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION';
|
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION';
|
||||||
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
|
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
|
||||||
|
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
|
||||||
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
||||||
export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
|
export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
|
||||||
'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS';
|
'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
aiAgentNode,
|
aiAgentNode,
|
||||||
aiChatExecutionResponse,
|
aiChatExecutionResponse,
|
||||||
aiChatWorkflow,
|
aiChatWorkflow,
|
||||||
|
aiManualExecutionResponse,
|
||||||
aiManualWorkflow,
|
aiManualWorkflow,
|
||||||
chatTriggerNode,
|
chatTriggerNode,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
@@ -107,6 +108,8 @@ describe('LogsPanel', () => {
|
|||||||
y: 0,
|
y: 0,
|
||||||
height: VIEWPORT_HEIGHT,
|
height: VIEWPORT_HEIGHT,
|
||||||
} as DOMRect);
|
} as DOMRect);
|
||||||
|
|
||||||
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -138,6 +141,30 @@ describe('LogsPanel', () => {
|
|||||||
expect(await rendered.findByTestId('chat-header')).toBeInTheDocument();
|
expect(await rendered.findByTestId('chat-header')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render only output panel of selected node by default', async () => {
|
||||||
|
logsStore.toggleOpen(true);
|
||||||
|
workflowsStore.setWorkflow(aiManualWorkflow);
|
||||||
|
workflowsStore.setWorkflowExecutionData(aiManualExecutionResponse);
|
||||||
|
|
||||||
|
const rendered = render();
|
||||||
|
|
||||||
|
expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Agent');
|
||||||
|
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
||||||
|
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render both input and output panel of selected node by default if it is sub node', async () => {
|
||||||
|
logsStore.toggleOpen(true);
|
||||||
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
|
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||||
|
|
||||||
|
const rendered = render();
|
||||||
|
|
||||||
|
expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Model');
|
||||||
|
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||||
|
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('opens collapsed panel when clicked', async () => {
|
it('opens collapsed panel when clicked', async () => {
|
||||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
|
|
||||||
@@ -384,8 +411,6 @@ describe('LogsPanel', () => {
|
|||||||
|
|
||||||
it('should toggle input and output panel when the button is clicked', async () => {
|
it('should toggle input and output panel when the button is clicked', async () => {
|
||||||
logsStore.toggleOpen(true);
|
logsStore.toggleOpen(true);
|
||||||
logsStore.toggleInputOpen(false);
|
|
||||||
logsStore.toggleOutputOpen(true);
|
|
||||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||||
|
|
||||||
@@ -393,12 +418,12 @@ describe('LogsPanel', () => {
|
|||||||
|
|
||||||
const header = within(rendered.getByTestId('log-details-header'));
|
const header = within(rendered.getByTestId('log-details-header'));
|
||||||
|
|
||||||
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||||
|
|
||||||
await fireEvent.click(header.getByText('Input'));
|
await fireEvent.click(header.getByText('Input'));
|
||||||
|
|
||||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
||||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||||
|
|
||||||
await fireEvent.click(header.getByText('Output'));
|
await fireEvent.click(header.getByText('Output'));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
findSelectedLogEntry,
|
findSelectedLogEntry,
|
||||||
getDepth,
|
getDepth,
|
||||||
getEntryAtRelativeIndex,
|
getEntryAtRelativeIndex,
|
||||||
|
isSubNodeLog,
|
||||||
} from '@/features/logs/logs.utils';
|
} from '@/features/logs/logs.utils';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { canvasEventBus } from '@/event-bus/canvas';
|
import { canvasEventBus } from '@/event-bus/canvas';
|
||||||
@@ -72,6 +73,16 @@ export function useLogsSelection(
|
|||||||
syncSelectionToCanvasIfEnabled(nextEntry);
|
syncSelectionToCanvasIfEnabled(nextEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selected,
|
||||||
|
(sel) => {
|
||||||
|
if (sel) {
|
||||||
|
logsStore.setSubNodeSelected(isSubNodeLog(sel));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Synchronize selection from canvas
|
// Synchronize selection from canvas
|
||||||
watch(
|
watch(
|
||||||
[() => uiStore.lastSelectedNode, () => logsStore.isLogSelectionSyncedWithCanvas],
|
[() => uiStore.lastSelectedNode, () => logsStore.isLogSelectionSyncedWithCanvas],
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ function getChildNodes(
|
|||||||
|
|
||||||
// Get the first level of children
|
// Get the first level of children
|
||||||
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
|
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
|
||||||
const isExecutionRoot =
|
const isExecutionRoot = !isSubNodeLog(treeNode);
|
||||||
treeNode.parent === undefined || treeNode.executionId !== treeNode.parent.executionId;
|
|
||||||
|
|
||||||
return connectedSubNodes.flatMap((subNodeName) =>
|
return connectedSubNodes.flatMap((subNodeName) =>
|
||||||
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
|
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
|
||||||
@@ -539,3 +538,7 @@ export function restoreChatHistory(
|
|||||||
|
|
||||||
return [...(userMessage ? [userMessage] : []), ...(botMessage ? [botMessage] : [])];
|
return [...(userMessage ? [userMessage] : []), ...(botMessage ? [botMessage] : [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSubNodeLog(logEntry: LogEntry): boolean {
|
||||||
|
return logEntry.parent !== undefined && logEntry.parent.executionId === logEntry.executionId;
|
||||||
|
}
|
||||||
|
|||||||
31
packages/frontend/editor-ui/src/stores/logs.store.test.ts
Normal file
31
packages/frontend/editor-ui/src/stores/logs.store.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { useLogsStore } from './logs.store';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||||
|
|
||||||
|
describe('logs.store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detailsState', () => {
|
||||||
|
it('should return value depending on whether the selected node is sub node or not', () => {
|
||||||
|
const store = useLogsStore();
|
||||||
|
|
||||||
|
// Initial state: OUTPUT for regular node, BOTH for sub nodes
|
||||||
|
expect(store.detailsState).toBe(LOG_DETAILS_PANEL_STATE.OUTPUT);
|
||||||
|
store.setSubNodeSelected(true);
|
||||||
|
expect(store.detailsState).toBe(LOG_DETAILS_PANEL_STATE.BOTH);
|
||||||
|
|
||||||
|
store.toggleOutputOpen(false); // regular node unchanged, sub node to INPUT
|
||||||
|
expect(store.detailsState).toBe(LOG_DETAILS_PANEL_STATE.INPUT);
|
||||||
|
store.setSubNodeSelected(false);
|
||||||
|
expect(store.detailsState).toBe(LOG_DETAILS_PANEL_STATE.OUTPUT);
|
||||||
|
|
||||||
|
store.toggleInputOpen(true); // regular node to BOTH, sub node unchanged
|
||||||
|
expect(store.detailsState).toBe(LOG_DETAILS_PANEL_STATE.BOTH);
|
||||||
|
store.setSubNodeSelected(true);
|
||||||
|
expect(store.detailsState).toBe(LOG_DETAILS_PANEL_STATE.INPUT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { type LogDetailsPanelState } from '@/features/logs/logs.types';
|
|||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import {
|
import {
|
||||||
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
|
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
|
||||||
|
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE,
|
||||||
LOCAL_STORAGE_LOGS_PANEL_OPEN,
|
LOCAL_STORAGE_LOGS_PANEL_OPEN,
|
||||||
LOCAL_STORAGE_LOGS_SYNC_SELECTION,
|
LOCAL_STORAGE_LOGS_SYNC_SELECTION,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
@@ -26,7 +27,13 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
||||||
{ writeDefaults: false },
|
{ writeDefaults: false },
|
||||||
);
|
);
|
||||||
|
const detailsStateSubNode = useLocalStorage<LogDetailsPanelState>(
|
||||||
|
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE,
|
||||||
|
LOG_DETAILS_PANEL_STATE.BOTH,
|
||||||
|
{ writeDefaults: false },
|
||||||
|
);
|
||||||
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
|
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
|
||||||
|
const isSubNodeSelected = ref(false);
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
@@ -42,22 +49,28 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
preferPoppedOut.value = value;
|
preferPoppedOut.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSubNodeSelected(value: boolean) {
|
||||||
|
isSubNodeSelected.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleInputOpen(open?: boolean) {
|
function toggleInputOpen(open?: boolean) {
|
||||||
const statesWithInput: LogDetailsPanelState[] = [
|
const statesWithInput: LogDetailsPanelState[] = [
|
||||||
LOG_DETAILS_PANEL_STATE.INPUT,
|
LOG_DETAILS_PANEL_STATE.INPUT,
|
||||||
LOG_DETAILS_PANEL_STATE.BOTH,
|
LOG_DETAILS_PANEL_STATE.BOTH,
|
||||||
];
|
];
|
||||||
const wasOpen = statesWithInput.includes(detailsState.value);
|
const stateRef = isSubNodeSelected.value ? detailsStateSubNode : detailsState;
|
||||||
|
const wasOpen = statesWithInput.includes(stateRef.value);
|
||||||
|
|
||||||
if (open === wasOpen) {
|
if (open === wasOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
detailsState.value = wasOpen ? LOG_DETAILS_PANEL_STATE.OUTPUT : LOG_DETAILS_PANEL_STATE.BOTH;
|
stateRef.value = wasOpen ? LOG_DETAILS_PANEL_STATE.OUTPUT : LOG_DETAILS_PANEL_STATE.BOTH;
|
||||||
|
|
||||||
telemetry.track('User toggled log view sub pane', {
|
telemetry.track('User toggled log view sub pane', {
|
||||||
pane: 'input',
|
pane: 'input',
|
||||||
newState: wasOpen ? 'hidden' : 'visible',
|
newState: wasOpen ? 'hidden' : 'visible',
|
||||||
|
isSubNode: isSubNodeSelected.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,17 +79,19 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
||||||
LOG_DETAILS_PANEL_STATE.BOTH,
|
LOG_DETAILS_PANEL_STATE.BOTH,
|
||||||
];
|
];
|
||||||
const wasOpen = statesWithOutput.includes(detailsState.value);
|
const stateRef = isSubNodeSelected.value ? detailsStateSubNode : detailsState;
|
||||||
|
const wasOpen = statesWithOutput.includes(stateRef.value);
|
||||||
|
|
||||||
if (open === wasOpen) {
|
if (open === wasOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
detailsState.value = wasOpen ? LOG_DETAILS_PANEL_STATE.INPUT : LOG_DETAILS_PANEL_STATE.BOTH;
|
stateRef.value = wasOpen ? LOG_DETAILS_PANEL_STATE.INPUT : LOG_DETAILS_PANEL_STATE.BOTH;
|
||||||
|
|
||||||
telemetry.track('User toggled log view sub pane', {
|
telemetry.track('User toggled log view sub pane', {
|
||||||
pane: 'output',
|
pane: 'output',
|
||||||
newState: wasOpen ? 'hidden' : 'visible',
|
newState: wasOpen ? 'hidden' : 'visible',
|
||||||
|
isSubNode: isSubNodeSelected.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +102,15 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
|
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
|
||||||
detailsState: computed(() => detailsState.value),
|
detailsState: computed(() =>
|
||||||
|
isSubNodeSelected.value ? detailsStateSubNode.value : detailsState.value,
|
||||||
|
),
|
||||||
height: computed(() => height.value),
|
height: computed(() => height.value),
|
||||||
isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value),
|
isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value),
|
||||||
setHeight,
|
setHeight,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
setPreferPoppedOut,
|
setPreferPoppedOut,
|
||||||
|
setSubNodeSelected,
|
||||||
toggleInputOpen,
|
toggleInputOpen,
|
||||||
toggleOutputOpen,
|
toggleOutputOpen,
|
||||||
toggleLogSelectionSync,
|
toggleLogSelectionSync,
|
||||||
|
|||||||
Reference in New Issue
Block a user