mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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_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';
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user