From b9e03515bd6f3d048e4df9d312366e40eb7cc123 Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Wed, 11 Jun 2025 14:46:19 +0200 Subject: [PATCH] feat(editor): Remember different panel state for sub nodes (#16189) --- packages/frontend/editor-ui/src/constants.ts | 1 + .../logs/components/LogsPanel.test.ts | 33 ++++++++++++++++--- .../logs/composables/useLogsSelection.ts | 11 +++++++ .../editor-ui/src/features/logs/logs.utils.ts | 7 ++-- .../editor-ui/src/stores/logs.store.test.ts | 31 +++++++++++++++++ .../editor-ui/src/stores/logs.store.ts | 28 +++++++++++++--- 6 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 packages/frontend/editor-ui/src/stores/logs.store.test.ts diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index d0800338ea..adc5da37e1 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -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_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_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE'; export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES'; export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS = 'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS'; diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts index 2f86d1d249..1ef7958a61 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts @@ -11,6 +11,7 @@ import { aiAgentNode, aiChatExecutionResponse, aiChatWorkflow, + aiManualExecutionResponse, aiManualWorkflow, chatTriggerNode, nodeTypes, @@ -107,6 +108,8 @@ describe('LogsPanel', () => { y: 0, height: VIEWPORT_HEIGHT, } as DOMRect); + + localStorage.clear(); }); afterEach(() => { @@ -138,6 +141,30 @@ describe('LogsPanel', () => { 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 () => { workflowsStore.setWorkflow(aiChatWorkflow); @@ -384,8 +411,6 @@ describe('LogsPanel', () => { it('should toggle input and output panel when the button is clicked', async () => { logsStore.toggleOpen(true); - logsStore.toggleInputOpen(false); - logsStore.toggleOutputOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); @@ -393,12 +418,12 @@ describe('LogsPanel', () => { 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(); 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(); await fireEvent.click(header.getByText('Output')); diff --git a/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts b/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts index 4b90298035..4877284d2b 100644 --- a/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts +++ b/packages/frontend/editor-ui/src/features/logs/composables/useLogsSelection.ts @@ -4,6 +4,7 @@ import { findSelectedLogEntry, getDepth, getEntryAtRelativeIndex, + isSubNodeLog, } from '@/features/logs/logs.utils'; import { useTelemetry } from '@/composables/useTelemetry'; import { canvasEventBus } from '@/event-bus/canvas'; @@ -72,6 +73,16 @@ export function useLogsSelection( syncSelectionToCanvasIfEnabled(nextEntry); } + watch( + selected, + (sel) => { + if (sel) { + logsStore.setSubNodeSelected(isSubNodeLog(sel)); + } + }, + { immediate: true }, + ); + // Synchronize selection from canvas watch( [() => uiStore.lastSelectedNode, () => logsStore.isLogSelectionSyncedWithCanvas], diff --git a/packages/frontend/editor-ui/src/features/logs/logs.utils.ts b/packages/frontend/editor-ui/src/features/logs/logs.utils.ts index 123b652457..a52bad57c1 100644 --- a/packages/frontend/editor-ui/src/features/logs/logs.utils.ts +++ b/packages/frontend/editor-ui/src/features/logs/logs.utils.ts @@ -100,8 +100,7 @@ function getChildNodes( // Get the first level of children const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1); - const isExecutionRoot = - treeNode.parent === undefined || treeNode.executionId !== treeNode.parent.executionId; + const isExecutionRoot = !isSubNodeLog(treeNode); return connectedSubNodes.flatMap((subNodeName) => (context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => { @@ -539,3 +538,7 @@ export function restoreChatHistory( return [...(userMessage ? [userMessage] : []), ...(botMessage ? [botMessage] : [])]; } + +export function isSubNodeLog(logEntry: LogEntry): boolean { + return logEntry.parent !== undefined && logEntry.parent.executionId === logEntry.executionId; +} diff --git a/packages/frontend/editor-ui/src/stores/logs.store.test.ts b/packages/frontend/editor-ui/src/stores/logs.store.test.ts new file mode 100644 index 0000000000..6306f65968 --- /dev/null +++ b/packages/frontend/editor-ui/src/stores/logs.store.test.ts @@ -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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/stores/logs.store.ts b/packages/frontend/editor-ui/src/stores/logs.store.ts index 2a8a8a644f..d1b0d4e244 100644 --- a/packages/frontend/editor-ui/src/stores/logs.store.ts +++ b/packages/frontend/editor-ui/src/stores/logs.store.ts @@ -2,6 +2,7 @@ import { type LogDetailsPanelState } from '@/features/logs/logs.types'; import { useTelemetry } from '@/composables/useTelemetry'; import { LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL, + LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE, LOCAL_STORAGE_LOGS_PANEL_OPEN, LOCAL_STORAGE_LOGS_SYNC_SELECTION, } from '@/constants'; @@ -26,7 +27,13 @@ export const useLogsStore = defineStore('logs', () => { LOG_DETAILS_PANEL_STATE.OUTPUT, { writeDefaults: false }, ); + const detailsStateSubNode = useLocalStorage( + 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 isSubNodeSelected = ref(false); const telemetry = useTelemetry(); @@ -42,22 +49,28 @@ export const useLogsStore = defineStore('logs', () => { preferPoppedOut.value = value; } + function setSubNodeSelected(value: boolean) { + isSubNodeSelected.value = value; + } + function toggleInputOpen(open?: boolean) { const statesWithInput: LogDetailsPanelState[] = [ LOG_DETAILS_PANEL_STATE.INPUT, 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) { 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', { pane: 'input', 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.BOTH, ]; - const wasOpen = statesWithOutput.includes(detailsState.value); + const stateRef = isSubNodeSelected.value ? detailsStateSubNode : detailsState; + const wasOpen = statesWithOutput.includes(stateRef.value); if (open === wasOpen) { 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', { pane: 'output', newState: wasOpen ? 'hidden' : 'visible', + isSubNode: isSubNodeSelected.value, }); } @@ -87,12 +102,15 @@ export const useLogsStore = defineStore('logs', () => { return { state, isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED), - detailsState: computed(() => detailsState.value), + detailsState: computed(() => + isSubNodeSelected.value ? detailsStateSubNode.value : detailsState.value, + ), height: computed(() => height.value), isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value), setHeight, toggleOpen, setPreferPoppedOut, + setSubNodeSelected, toggleInputOpen, toggleOutputOpen, toggleLogSelectionSync,