mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Add an option to sync canvas with log view (#15391)
This commit is contained in:
@@ -117,6 +117,7 @@ defineExpose({ open, close });
|
||||
<span :class="$style.label">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
|
||||
<span v-if="item.badge">
|
||||
<N8nBadge theme="primary" size="xsmall" v-bind="item.badgeProps">
|
||||
{{ item.badge }}
|
||||
|
||||
@@ -10,4 +10,5 @@ export interface ActionDropdownItem {
|
||||
disabled?: boolean;
|
||||
shortcut?: KeyboardShortcut;
|
||||
customClass?: string;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -484,6 +484,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
|
||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||
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_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
|
||||
4
packages/frontend/editor-ui/src/event-bus/canvas.ts
Normal file
4
packages/frontend/editor-ui/src/event-bus/canvas.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { CanvasEventBusEvents } from '@/types';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
|
||||
export const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
||||
@@ -1824,6 +1824,7 @@
|
||||
"runData.panel.actions.collapse": "Collapse panel",
|
||||
"runData.panel.actions.open": "Open panel",
|
||||
"runData.panel.actions.popOut": "Pop out panel",
|
||||
"runData.panel.actions.sync": "Sync selection with canvas",
|
||||
"saveButton.save": "@:_reusableBaseText.save",
|
||||
"saveButton.saved": "Saved",
|
||||
"saveWorkflowButton.hint": "Save workflow",
|
||||
|
||||
@@ -13,13 +13,20 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
const aiNodes = computed<INodeUi[]>(() =>
|
||||
nodes.value.filter((node) => node.type.includes('langchain')),
|
||||
);
|
||||
const hasRangeSelection = ref(false);
|
||||
|
||||
function setHasRangeSelection(value: boolean) {
|
||||
hasRangeSelection.value = value;
|
||||
}
|
||||
|
||||
return {
|
||||
newNodeInsertPosition,
|
||||
isLoading: loadingService.isLoading,
|
||||
aiNodes,
|
||||
hasRangeSelection: computed(() => hasRangeSelection.value),
|
||||
startLoading: loadingService.startLoading,
|
||||
setLoadingText: loadingService.setLoadingText,
|
||||
stopLoading: loadingService.stopLoading,
|
||||
setHasRangeSelection,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
type LogDetailsPanelState,
|
||||
} from '@/components/CanvasChat/types/logs';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL, LOCAL_STORAGE_LOGS_PANEL_OPEN } from '@/constants';
|
||||
import {
|
||||
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
|
||||
LOCAL_STORAGE_LOGS_PANEL_OPEN,
|
||||
LOCAL_STORAGE_LOGS_SYNC_SELECTION,
|
||||
} from '@/constants';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
@@ -25,6 +29,8 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
||||
{ writeDefaults: false },
|
||||
);
|
||||
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
function setHeight(value: number) {
|
||||
@@ -77,15 +83,21 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleLogSelectionSync(value?: boolean) {
|
||||
isLogSelectionSyncedWithCanvas.value = value ?? !isLogSelectionSyncedWithCanvas.value;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
|
||||
detailsState: computed(() => detailsState.value),
|
||||
height: computed(() => height.value),
|
||||
isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value),
|
||||
setHeight,
|
||||
toggleOpen,
|
||||
setPreferPoppedOut,
|
||||
toggleInputOpen,
|
||||
toggleOutputOpen,
|
||||
toggleLogSelectionSync,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ export type CanvasEventBusEvents = {
|
||||
fitView: never;
|
||||
'saved:workflow': never;
|
||||
'open:execution': IExecutionResponse;
|
||||
'nodes:select': { ids: string[] };
|
||||
'nodes:select': { ids: string[]; panIntoView?: boolean };
|
||||
'nodes:action': {
|
||||
ids: string[];
|
||||
action: keyof CanvasNodeEventBusEvents;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getGenericHints,
|
||||
getNewNodePosition,
|
||||
NODE_SIZE,
|
||||
updateViewportToContainNodes,
|
||||
} from './nodeViewUtils';
|
||||
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
@@ -17,6 +18,8 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mock, type MockProxy } from 'vitest-mock-extended';
|
||||
import { SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import type { GraphNode } from '@vue-flow/core';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
describe('getGenericHints', () => {
|
||||
let mockWorkflowNode: MockProxy<INode>;
|
||||
@@ -397,3 +400,66 @@ describe('getNodesGroupSize', () => {
|
||||
expect(he).toBe(NODE_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe(updateViewportToContainNodes, () => {
|
||||
it('should return the same viewport if given node is already in the viewport', () => {
|
||||
const result = updateViewportToContainNodes(
|
||||
{ x: 0, y: 0, zoom: 2 },
|
||||
{ width: 1000, height: 800 },
|
||||
[createTestGraphNode({ position: { x: 0, y: 0 }, dimensions: { width: 36, height: 36 } })],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 0, y: 0, zoom: 2 });
|
||||
});
|
||||
|
||||
it('should return updated viewport with minimal position change to include node outside northwest edge', () => {
|
||||
const result = updateViewportToContainNodes(
|
||||
{ x: 0, y: 0, zoom: 2 },
|
||||
{ width: 1000, height: 800 },
|
||||
[
|
||||
createTestGraphNode({
|
||||
position: { x: -10, y: -20 },
|
||||
dimensions: { width: 36, height: 36 },
|
||||
}),
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 20, y: 40, zoom: 2 });
|
||||
});
|
||||
|
||||
it('should return updated viewport with minimal position change to include node outside southeast edge', () => {
|
||||
const result = updateViewportToContainNodes(
|
||||
{ x: 0, y: 0, zoom: 2 },
|
||||
{ width: 1000, height: 800 },
|
||||
[
|
||||
createTestGraphNode({
|
||||
position: { x: 500, y: 400 },
|
||||
dimensions: { width: 36, height: 36 },
|
||||
}),
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: -72, y: -72, zoom: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
function createTestGraphNode(data: Partial<GraphNode> = {}): GraphNode {
|
||||
return {
|
||||
computedPosition: { z: 0, ...(data.position ?? { x: 0, y: 0 }) },
|
||||
handleBounds: {},
|
||||
dimensions: { width: 0, height: 0 },
|
||||
isParent: true,
|
||||
selected: false,
|
||||
resizing: false,
|
||||
dragging: false,
|
||||
data: undefined,
|
||||
events: {},
|
||||
type: '',
|
||||
id: uuid(),
|
||||
position: { x: 0, y: 0 },
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ import type {
|
||||
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||
import type { RouteLocation } from 'vue-router';
|
||||
import type { ViewportBoundaries } from '@/types';
|
||||
import {
|
||||
getRectOfNodes,
|
||||
type Dimensions,
|
||||
type GraphNode,
|
||||
type Rect,
|
||||
type ViewportTransform,
|
||||
} from '@vue-flow/core';
|
||||
|
||||
/*
|
||||
* Canvas constants and functions
|
||||
@@ -530,3 +537,66 @@ export const getNodeViewTab = (route: RouteLocation): string | null => {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function getBounds(
|
||||
{ x, y, zoom }: ViewportTransform,
|
||||
{ width, height }: Dimensions,
|
||||
): ViewportBoundaries {
|
||||
const xMin = -x / zoom;
|
||||
const yMin = -y / zoom;
|
||||
const xMax = (width - x) / zoom;
|
||||
const yMax = (height - y) / zoom;
|
||||
|
||||
return { xMin, yMin, xMax, yMax };
|
||||
}
|
||||
|
||||
function addPadding({ x, y, width, height }: Rect, amount: number): Rect {
|
||||
return {
|
||||
x: x - amount,
|
||||
y: y - amount,
|
||||
width: width + amount * 2,
|
||||
height: height + amount * 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateViewportToContainNodes(
|
||||
viewport: ViewportTransform,
|
||||
dimensions: Dimensions,
|
||||
nodes: GraphNode[],
|
||||
padding: number,
|
||||
): ViewportTransform {
|
||||
function computeDelta(start: number, end: number, min: number, max: number) {
|
||||
if (start >= min && end <= max) {
|
||||
// Both ends are already in the range, no need for adjustment
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (start < min) {
|
||||
if (end > max) {
|
||||
// Neither end is in the range, in this case we don't make
|
||||
// any adjustment (for now; we could adjust zoom to fit in viewport)
|
||||
return 0;
|
||||
}
|
||||
|
||||
return min - start;
|
||||
}
|
||||
|
||||
return max - end;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return viewport;
|
||||
}
|
||||
|
||||
const zoom = viewport.zoom;
|
||||
const rect = addPadding(getRectOfNodes(nodes), padding / zoom);
|
||||
const { xMax, xMin, yMax, yMin } = getBounds(viewport, dimensions);
|
||||
const dx = computeDelta(rect.x, rect.x + rect.width, xMin, xMax);
|
||||
const dy = computeDelta(rect.y, rect.y + rect.height, yMin, yMax);
|
||||
|
||||
return {
|
||||
x: viewport.x + dx * zoom,
|
||||
y: viewport.y + dy * zoom,
|
||||
zoom,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ import type {
|
||||
} from '@vue-flow/core';
|
||||
import type {
|
||||
CanvasConnectionCreateData,
|
||||
CanvasEventBusEvents,
|
||||
CanvasNode,
|
||||
CanvasNodeMoveEvent,
|
||||
ConnectStartEvent,
|
||||
@@ -97,7 +96,7 @@ import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { getNodesWithNormalizedPosition, getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||
import { getBounds, getNodesWithNormalizedPosition, getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
|
||||
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
||||
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
|
||||
@@ -105,7 +104,6 @@ import { nodeViewEventBus } from '@/event-bus';
|
||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { N8nCallout } from '@n8n/design-system';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||
@@ -123,6 +121,7 @@ import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
|
||||
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { canvasEventBus } from '@/event-bus/canvas';
|
||||
|
||||
defineOptions({
|
||||
name: 'NodeView',
|
||||
@@ -178,8 +177,6 @@ const foldersStore = useFoldersStore();
|
||||
const agentRequestStore = useAgentRequestStore();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
||||
|
||||
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
|
||||
route,
|
||||
});
|
||||
@@ -1584,17 +1581,9 @@ async function onSaveFromWithinExecutionDebug() {
|
||||
const viewportTransform = ref<ViewportTransform>({ x: 0, y: 0, zoom: 1 });
|
||||
const viewportDimensions = ref<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
const viewportBoundaries = computed<ViewportBoundaries>(() => {
|
||||
const { x, y, zoom } = viewportTransform.value;
|
||||
const { width, height } = viewportDimensions.value;
|
||||
|
||||
const xMin = -x / zoom;
|
||||
const yMin = -y / zoom;
|
||||
const xMax = (width - x) / zoom;
|
||||
const yMax = (height - y) / zoom;
|
||||
|
||||
return { xMin, yMin, xMax, yMax };
|
||||
});
|
||||
const viewportBoundaries = computed<ViewportBoundaries>(() =>
|
||||
getBounds(viewportTransform.value, viewportDimensions.value),
|
||||
);
|
||||
|
||||
function onViewportChange(viewport: ViewportTransform, dimensions: Dimensions) {
|
||||
viewportTransform.value = viewport;
|
||||
@@ -1934,6 +1923,7 @@ onBeforeUnmount(() => {
|
||||
@update:logs-open="logsStore.toggleOpen($event)"
|
||||
@update:logs:input-open="logsStore.toggleInputOpen"
|
||||
@update:logs:output-open="logsStore.toggleOutputOpen"
|
||||
@update:has-range-selection="canvasStore.setHasRangeSelection"
|
||||
@open:sub-workflow="onOpenSubWorkflow"
|
||||
@click:node="onClickNode"
|
||||
@click:node:add="onClickNodeAdd"
|
||||
|
||||
Reference in New Issue
Block a user