feat(editor): Add an option to sync canvas with log view (#15391)

This commit is contained in:
Suguru Inoue
2025-05-22 17:25:58 +02:00
committed by GitHub
parent b1da30f493
commit 9938e63a66
19 changed files with 383 additions and 49 deletions

View File

@@ -23,6 +23,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { deepCopy } from 'n8n-workflow';
import { createTestTaskData } from '@/__tests__/mocks';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
describe('LogsPanel', () => {
const VIEWPORT_HEIGHT = 800;
@@ -33,6 +34,7 @@ describe('LogsPanel', () => {
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
function render() {
return renderComponent(LogsPanel, {
@@ -67,6 +69,8 @@ describe('LogsPanel', () => {
ndvStore = mockedStore(useNDVStore);
uiStore = mockedStore(useUIStore);
Object.defineProperty(document.body, 'offsetHeight', {
configurable: true,
get() {
@@ -374,18 +378,59 @@ describe('LogsPanel', () => {
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
});
it('should allow to select previous and next row via keyboard shortcut', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
describe('selection', () => {
beforeEach(() => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
});
const rendered = render();
const overview = rendered.getByTestId('logs-overview');
it('should allow to select previous and next row via keyboard shortcut', async () => {
const { getByTestId, findByRole } = render();
const overview = getByTestId('logs-overview');
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should not select a log for the selected node on canvas if sync is disabled', async () => {
logsStore.toggleLogSelectionSync(false);
const { findByRole, rerender } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
uiStore.lastSelectedNode = 'AI Agent';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should automatically select a log for the selected node on canvas if sync is enabled', async () => {
logsStore.toggleLogSelectionSync(true);
const { rerender, findByRole } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
uiStore.lastSelectedNode = 'AI Agent';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
});
it('should automatically expand and select a log for the selected node on canvas if the log entry is collapsed', async () => {
logsStore.toggleLogSelectionSync(true);
const { rerender, findByRole, getByLabelText, findByText, queryByText } = render();
await fireEvent.click(await findByText('AI Agent'));
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.click(getByLabelText('Toggle row'));
await rerender({});
expect(queryByText(/AI Model/)).not.toBeInTheDocument();
uiStore.lastSelectedNode = 'AI Model';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
});
});

View File

@@ -61,6 +61,7 @@ const { selected, select, selectNext, selectPrev } = useLogsSelection(
execution,
entries,
flatLogEntries,
toggleExpanded,
);
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
@@ -69,10 +70,12 @@ const isLogDetailsVisuallyOpen = computed(
);
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
isOpen: isOpen.value,
isSyncSelectionEnabled: logsStore.isLogSelectionSyncedWithCanvas,
showToggleButton: !isPoppedOut.value,
showPopOutButton: canPopOut.value && !isPoppedOut.value,
onPopOut,
onToggleOpen,
onToggleSyncSelection: logsStore.toggleLogSelectionSync,
}));
function handleResizeOverviewPanelEnd() {

View File

@@ -2,16 +2,22 @@
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { N8nActionDropdown, N8nIconButton } from '@n8n/design-system';
import { computed } from 'vue';
const { isOpen, showToggleButton, showPopOutButton } = defineProps<{
const {
isOpen,
isSyncSelectionEnabled: isSyncEnabled,
showToggleButton,
showPopOutButton,
} = defineProps<{
isOpen: boolean;
isSyncSelectionEnabled: boolean;
showToggleButton: boolean;
showPopOutButton: boolean;
}>();
const emit = defineEmits<{ popOut: []; toggleOpen: [] }>();
const emit = defineEmits<{ popOut: []; toggleOpen: []; toggleSyncSelection: [] }>();
const appStyles = useStyles();
const locales = useI18n();
@@ -20,20 +26,54 @@ const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.
const toggleButtonText = computed(() =>
locales.baseText(isOpen ? 'runData.panel.actions.collapse' : 'runData.panel.actions.open'),
);
const menuItems = computed(() => [
{
id: 'toggleSyncSelection' as const,
label: locales.baseText('runData.panel.actions.sync'),
checked: isSyncEnabled,
},
...(showPopOutButton ? [{ id: 'popOut' as const, label: popOutButtonText.value }] : []),
]);
function handleSelectMenuItem(selected: string) {
// This switch looks redundant, but needed to pass type checker
switch (selected) {
case 'popOut':
emit(selected);
return;
case 'toggleSyncSelection':
emit(selected);
return;
}
}
</script>
<template>
<div :class="$style.container">
<N8nTooltip v-if="showPopOutButton" :z-index="tooltipZIndex" :content="popOutButtonText">
<N8nTooltip
v-if="!isOpen && showPopOutButton"
:z-index="tooltipZIndex"
:content="popOutButtonText"
>
<N8nIconButton
icon="pop-out"
type="secondary"
type="tertiary"
text
size="small"
icon-size="medium"
:aria-label="popOutButtonText"
@click.stop="emit('popOut')"
/>
</N8nTooltip>
<N8nActionDropdown
v-if="isOpen"
icon-size="small"
activator-icon="ellipsis-h"
activator-size="small"
:items="menuItems"
:teleported="false /* for PiP window */"
@select="handleSelectMenuItem"
/>
<KeyboardShortcutTooltip
v-if="showToggleButton"
:label="locales.baseText('generic.shortcutHint')"
@@ -41,12 +81,12 @@ const toggleButtonText = computed(() =>
:z-index="tooltipZIndex"
>
<N8nIconButton
type="secondary"
type="tertiary"
text
size="small"
icon-size="medium"
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
:aria-label="toggleButtonText"
style="color: var(--color-text-base)"
@click.stop="emit('toggleOpen')"
/>
</KeyboardShortcutTooltip>
@@ -58,8 +98,7 @@ const toggleButtonText = computed(() =>
display: flex;
}
.container button {
border: none;
color: var(--color-text-light);
.container button:hover {
background-color: var(--color-background-base);
}
</style>

View File

@@ -1,28 +1,48 @@
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
import {
findLogEntryRec,
findSelectedLogEntry,
getDepth,
getEntryAtRelativeIndex,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { canvasEventBus } from '@/event-bus/canvas';
import type { IExecutionResponse } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
import { watch } from 'vue';
import { computed, ref, type ComputedRef } from 'vue';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
tree: ComputedRef<LogEntry[]>,
flatLogEntries: ComputedRef<LogEntry[]>,
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
) {
const telemetry = useTelemetry();
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
const logsStore = useLogsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
function syncSelectionToCanvasIfEnabled(value: LogEntry) {
if (!logsStore.isLogSelectionSyncedWithCanvas) {
return;
}
canvasEventBus.emit('nodes:select', { ids: [value.node.id], panIntoView: true });
}
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
if (value) {
syncSelectionToCanvasIfEnabled(value);
telemetry.track('User selected node in log view', {
node_type: value.node.type,
node_id: value.node.id,
@@ -39,7 +59,8 @@ export function useLogsSelection(
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
: entries[entries.length - 1];
select(prevEntry);
manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
syncSelectionToCanvasIfEnabled(prevEntry);
}
function selectNext() {
@@ -48,8 +69,40 @@ export function useLogsSelection(
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
: entries[0];
select(nextEntry);
manualLogEntrySelection.value = { type: 'selected', id: nextEntry.id };
syncSelectionToCanvasIfEnabled(nextEntry);
}
// Synchronize selection from canvas
watch(
[() => uiStore.lastSelectedNode, () => logsStore.isLogSelectionSyncedWithCanvas],
([selectedOnCanvas, shouldSync]) => {
if (
!shouldSync ||
!selectedOnCanvas ||
canvasStore.hasRangeSelection ||
selected.value?.node.name === selectedOnCanvas
) {
return;
}
const entry = findLogEntryRec((e) => e.node.name === selectedOnCanvas, tree.value);
if (!entry) {
return;
}
manualLogEntrySelection.value = { type: 'selected', id: entry.id };
let parent = entry.parent;
while (parent !== undefined) {
toggleExpand(parent, true);
parent = parent.parent;
}
},
{ immediate: true },
);
return { selected, select, selectPrev, selectNext };
}

View File

@@ -5,8 +5,9 @@ export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
const collapsedEntries = ref<Record<string, boolean>>({});
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
function toggleExpanded(treeNode: LogEntry) {
collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
collapsedEntries.value[treeNode.id] =
expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
}
return {

View File

@@ -488,13 +488,16 @@ function createLogTreeRec(context: LogTreeCreationContext) {
);
}
export function findLogEntryRec(id: string, entries: LogEntry[]): LogEntry | undefined {
export function findLogEntryRec(
isMatched: (entry: LogEntry) => boolean,
entries: LogEntry[],
): LogEntry | undefined {
for (const entry of entries) {
if (entry.id === id) {
if (isMatched(entry)) {
return entry;
}
const child = findLogEntryRec(id, entry.children);
const child = findLogEntryRec(isMatched, entry.children);
if (child) {
return child;
@@ -514,7 +517,7 @@ export function findSelectedLogEntry(
case 'none':
return undefined;
case 'selected': {
const entry = findLogEntryRec(selection.id, entries);
const entry = findLogEntryRec((e) => e.id === selection.id, entries);
if (entry) {
return entry;

View File

@@ -9,6 +9,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useVueFlow } from '@vue-flow/core';
import { SIMULATE_NODE_TYPE } from '@/constants';
import { canvasEventBus } from '@/event-bus/canvas';
const matchMedia = global.window.matchMedia;
// @ts-expect-error Initialize window object
@@ -156,6 +157,24 @@ describe('Canvas', () => {
expect(emitted()['update:node:name']).toEqual([['1']]);
});
it('should update viewport if nodes:select event is received with panIntoView=true', async () => {
const node = createCanvasNodeElement({ position: { x: -1000, y: -500 } });
renderComponent({
props: {
id: 'c0',
nodes: [node],
eventBus: canvasEventBus,
},
});
const { getViewport } = useVueFlow('c0');
expect(getViewport()).toEqual({ x: 0, y: 0, zoom: 1 });
canvasEventBus.emit('nodes:select', { ids: [node.id], panIntoView: true });
await waitFor(() => expect(getViewport()).toEqual({ x: 1100, y: 600, zoom: 1 }));
});
it('should not emit `update:node:name` event if long key press', async () => {
vi.useFakeTimers();

View File

@@ -19,7 +19,7 @@ import type {
CanvasNodeData,
} from '@/types';
import { CanvasNodeRenderType } from '@/types';
import { getMousePosition, GRID_SIZE } from '@/utils/nodeViewUtils';
import { updateViewportToContainNodes, getMousePosition, GRID_SIZE } from '@/utils/nodeViewUtils';
import { isPresent } from '@/utils/typesUtils';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
@@ -72,6 +72,7 @@ const emit = defineEmits<{
'update:logs-open': [open?: boolean];
'update:logs:input-open': [open?: boolean];
'update:logs:output-open': [open?: boolean];
'update:has-range-selection': [isActive: boolean];
'click:node': [id: string, position: XYPosition];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
@@ -153,6 +154,7 @@ const {
viewport,
dimensions,
nodesSelectionActive,
userSelectionRect,
setViewport,
onEdgeMouseLeave,
onEdgeMouseEnter,
@@ -402,9 +404,21 @@ function onSelectNode() {
emit('update:node:selected', lastSelectedNode.value?.id);
}
function onSelectNodes({ ids }: CanvasEventBusEvents['nodes:select']) {
function onSelectNodes({ ids, panIntoView }: CanvasEventBusEvents['nodes:select']) {
clearSelectedNodes();
addSelectedNodes(ids.map(findNode).filter(isPresent));
if (panIntoView) {
const nodes = ids.map(findNode).filter(isPresent);
if (nodes.length === 0) {
return;
}
const newViewport = updateViewportToContainNodes(viewport.value, dimensions.value, nodes, 100);
void setViewport(newViewport, { duration: 200 });
}
}
function onToggleNodeEnabled(id: string) {
@@ -798,6 +812,10 @@ watch(() => props.readOnly, setReadonly, {
immediate: true,
});
watch([nodesSelectionActive, userSelectionRect], ([isActive, rect]) =>
emit('update:has-range-selection', isActive || (rect?.width ?? 0) > 0 || (rect?.height ?? 0) > 0),
);
/**
* Provide
*/