feat(editor): Remember different panel state for sub nodes (#16189)

This commit is contained in:
Suguru Inoue
2025-06-11 14:46:19 +02:00
committed by GitHub
parent 21b84ef4e7
commit b9e03515bd
6 changed files with 100 additions and 11 deletions

View File

@@ -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';

View File

@@ -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'));

View File

@@ -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],

View File

@@ -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;
}

View 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);
});
});
});

View File

@@ -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<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 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,