mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add an option to sync canvas with log view (#15391)
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user