mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Add telemetry event for tidy up feature (no-changelog) (#13831)
This commit is contained in:
@@ -47,7 +47,11 @@ import CanvasBackground from './elements/background/CanvasBackground.vue';
|
|||||||
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
||||||
import { useCanvasLayout } from '@/composables/useCanvasLayout';
|
import {
|
||||||
|
type CanvasLayoutEvent,
|
||||||
|
type CanvasLayoutSource,
|
||||||
|
useCanvasLayout,
|
||||||
|
} from '@/composables/useCanvasLayout';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
@@ -90,6 +94,7 @@ const emit = defineEmits<{
|
|||||||
'save:workflow': [];
|
'save:workflow': [];
|
||||||
'create:workflow': [];
|
'create:workflow': [];
|
||||||
'drag-and-drop': [position: XYPosition, event: DragEvent];
|
'drag-and-drop': [position: XYPosition, event: DragEvent];
|
||||||
|
'tidy-up': [CanvasLayoutEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -290,7 +295,7 @@ const keyMap = computed(() => {
|
|||||||
ctrl_alt_n: () => emit('create:workflow'),
|
ctrl_alt_n: () => emit('create:workflow'),
|
||||||
ctrl_enter: () => emit('run:workflow'),
|
ctrl_enter: () => emit('run:workflow'),
|
||||||
ctrl_s: () => emit('save:workflow'),
|
ctrl_s: () => emit('save:workflow'),
|
||||||
shift_alt_t: onTidyUp,
|
shift_alt_t: async () => await onTidyUp('keyboard-shortcut'),
|
||||||
};
|
};
|
||||||
return fullKeymap;
|
return fullKeymap;
|
||||||
});
|
});
|
||||||
@@ -635,15 +640,16 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
|
|||||||
case 'change_color':
|
case 'change_color':
|
||||||
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
|
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
|
||||||
case 'tidy_up':
|
case 'tidy_up':
|
||||||
return await onTidyUp();
|
return await onTidyUp('context-menu');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTidyUp() {
|
async function onTidyUp(source: CanvasLayoutSource) {
|
||||||
const applyOnSelection = selectedNodes.value.length > 1;
|
const applyOnSelection = selectedNodes.value.length > 1;
|
||||||
const { nodes } = layout(applyOnSelection ? 'selection' : 'all');
|
const target = applyOnSelection ? 'selection' : 'all';
|
||||||
|
const result = layout(target);
|
||||||
|
|
||||||
onUpdateNodesPosition(nodes.map((node) => ({ id: node.id, position: { x: node.x, y: node.y } })));
|
emit('tidy-up', { result, target, source });
|
||||||
|
|
||||||
if (!applyOnSelection) {
|
if (!applyOnSelection) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -867,11 +873,12 @@ provide(CanvasKey, {
|
|||||||
:position="controlsPosition"
|
:position="controlsPosition"
|
||||||
:show-interactive="false"
|
:show-interactive="false"
|
||||||
:zoom="viewport.zoom"
|
:zoom="viewport.zoom"
|
||||||
|
:read-only="readOnly"
|
||||||
@zoom-to-fit="onFitView"
|
@zoom-to-fit="onFitView"
|
||||||
@zoom-in="onZoomIn"
|
@zoom-in="onZoomIn"
|
||||||
@zoom-out="onZoomOut"
|
@zoom-out="onZoomOut"
|
||||||
@reset-zoom="onResetZoom"
|
@reset-zoom="onResetZoom"
|
||||||
@tidy-up="onTidyUp"
|
@tidy-up="onTidyUp('canvas-button')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('CanvasControlButtons', () => {
|
|||||||
expect(wrapper.getByTestId('zoom-in-button')).toBeVisible();
|
expect(wrapper.getByTestId('zoom-in-button')).toBeVisible();
|
||||||
expect(wrapper.getByTestId('zoom-out-button')).toBeVisible();
|
expect(wrapper.getByTestId('zoom-out-button')).toBeVisible();
|
||||||
expect(wrapper.getByTestId('zoom-to-fit')).toBeVisible();
|
expect(wrapper.getByTestId('zoom-to-fit')).toBeVisible();
|
||||||
|
expect(wrapper.getByTestId('tidy-up-button')).toBeVisible();
|
||||||
|
|
||||||
expect(wrapper.html()).toMatchSnapshot();
|
expect(wrapper.html()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -29,4 +30,24 @@ describe('CanvasControlButtons', () => {
|
|||||||
|
|
||||||
expect(wrapper.getByTestId('reset-zoom-button')).toBeVisible();
|
expect(wrapper.getByTestId('reset-zoom-button')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should hide the reset zoom button when zoom is equal to 1', () => {
|
||||||
|
const wrapper = renderComponent({
|
||||||
|
props: {
|
||||||
|
zoom: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.queryByTestId('reset-zoom-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide the tidy up button when canvas is read-only', () => {
|
||||||
|
const wrapper = renderComponent({
|
||||||
|
props: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.queryByTestId('tidy-up-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
|
readOnly: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ function onTidyUp() {
|
|||||||
/>
|
/>
|
||||||
</KeyboardShortcutTooltip>
|
</KeyboardShortcutTooltip>
|
||||||
<KeyboardShortcutTooltip
|
<KeyboardShortcutTooltip
|
||||||
|
v-if="!readOnly"
|
||||||
:label="i18n.baseText('nodeView.tidyUp')"
|
:label="i18n.baseText('nodeView.tidyUp')"
|
||||||
:shortcut="{ shiftKey: true, altKey: true, keys: ['T'] }"
|
:shortcut="{ shiftKey: true, altKey: true, keys: ['T'] }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { createCanvasGraphEdge, createCanvasGraphNode } from '../__tests__/data';
|
import { createCanvasGraphEdge, createCanvasGraphNode } from '../__tests__/data';
|
||||||
import { CanvasNodeRenderType, type CanvasNodeData } from '../types';
|
import { CanvasNodeRenderType, type CanvasNodeData } from '../types';
|
||||||
import { useCanvasLayout, type LayoutResult } from './useCanvasLayout';
|
import { useCanvasLayout, type CanvasLayoutResult } from './useCanvasLayout';
|
||||||
import { STICKY_NODE_TYPE } from '../constants';
|
import { STICKY_NODE_TYPE } from '../constants';
|
||||||
import { GRID_SIZE } from '../utils/nodeViewUtils';
|
import { GRID_SIZE } from '../utils/nodeViewUtils';
|
||||||
|
|
||||||
vi.mock('@vue-flow/core');
|
vi.mock('@vue-flow/core');
|
||||||
|
|
||||||
function matchesGrid(result: LayoutResult) {
|
function matchesGrid(result: CanvasLayoutResult) {
|
||||||
return result.nodes.every((node) => node.x % GRID_SIZE === 0 && node.y % GRID_SIZE === 0);
|
return result.nodes.every((node) => node.x % GRID_SIZE === 0 && node.y % GRID_SIZE === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils';
|
|||||||
|
|
||||||
export type CanvasLayoutOptions = { id?: string };
|
export type CanvasLayoutOptions = { id?: string };
|
||||||
export type CanvasLayoutTarget = 'selection' | 'all';
|
export type CanvasLayoutTarget = 'selection' | 'all';
|
||||||
|
export type CanvasLayoutSource = 'keyboard-shortcut' | 'canvas-button' | 'context-menu';
|
||||||
export type CanvasLayoutTargetData = {
|
export type CanvasLayoutTargetData = {
|
||||||
nodes: Array<GraphNode<CanvasNodeData>>;
|
nodes: Array<GraphNode<CanvasNodeData>>;
|
||||||
edges: CanvasConnection[];
|
edges: CanvasConnection[];
|
||||||
@@ -25,7 +26,13 @@ export type NodeLayoutResult = {
|
|||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
export type LayoutResult = { boundingBox: BoundingBox; nodes: NodeLayoutResult[] };
|
export type CanvasLayoutResult = { boundingBox: BoundingBox; nodes: NodeLayoutResult[] };
|
||||||
|
|
||||||
|
export type CanvasLayoutEvent = {
|
||||||
|
result: CanvasLayoutResult;
|
||||||
|
source: CanvasLayoutSource;
|
||||||
|
target: CanvasLayoutTarget;
|
||||||
|
};
|
||||||
|
|
||||||
export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>;
|
export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>;
|
||||||
|
|
||||||
@@ -300,7 +307,7 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function layout(target: CanvasLayoutTarget): LayoutResult {
|
function layout(target: CanvasLayoutTarget): CanvasLayoutResult {
|
||||||
const { nodes, edges } = getTargetData(target);
|
const { nodes, edges } = getTargetData(target);
|
||||||
|
|
||||||
const nonStickyNodes = nodes
|
const nonStickyNodes = nodes
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import { useClipboard } from '@/composables/useClipboard';
|
|||||||
import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
|
import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
||||||
|
import { useTelemetry } from './useTelemetry';
|
||||||
|
|
||||||
vi.mock('vue-router', async (importOriginal) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const actual = await importOriginal<{}>();
|
const actual = await importOriginal<{}>();
|
||||||
@@ -79,9 +81,12 @@ vi.mock('@/composables/useClipboard', async () => {
|
|||||||
return { useClipboard: vi.fn(() => ({ copy: copySpy })) };
|
return { useClipboard: vi.fn(() => ({ copy: copySpy })) };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@/composables/useTelemetry', () => ({
|
vi.mock('@/composables/useTelemetry', () => {
|
||||||
useTelemetry: () => ({ track: vi.fn() }),
|
const track = vi.fn();
|
||||||
}));
|
return {
|
||||||
|
useTelemetry: () => ({ track }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('useCanvasOperations', () => {
|
describe('useCanvasOperations', () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -446,6 +451,90 @@ describe('useCanvasOperations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tidyUp', () => {
|
||||||
|
it('records history for multiple node position updates', () => {
|
||||||
|
const historyStore = useHistoryStore();
|
||||||
|
const event: CanvasLayoutEvent = {
|
||||||
|
source: 'canvas-button',
|
||||||
|
target: 'all',
|
||||||
|
result: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'node1', x: 100, y: 100 },
|
||||||
|
{ id: 'node2', x: 200, y: 200 },
|
||||||
|
],
|
||||||
|
boundingBox: { height: 100, width: 100, x: 0, y: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
|
||||||
|
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
|
||||||
|
|
||||||
|
const { tidyUp } = useCanvasOperations({ router });
|
||||||
|
tidyUp(event);
|
||||||
|
|
||||||
|
expect(startRecordingUndoSpy).toHaveBeenCalled();
|
||||||
|
expect(stopRecordingUndoSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates positions for multiple nodes', () => {
|
||||||
|
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
const event: CanvasLayoutEvent = {
|
||||||
|
source: 'canvas-button',
|
||||||
|
target: 'all',
|
||||||
|
result: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'node1', x: 100, y: 100 },
|
||||||
|
{ id: 'node2', x: 200, y: 200 },
|
||||||
|
],
|
||||||
|
boundingBox: { height: 100, width: 100, x: 0, y: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
|
||||||
|
workflowsStore.getNodeById
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
createTestNode({
|
||||||
|
id: event.result.nodes[0].id,
|
||||||
|
position: [event.result.nodes[0].x, event.result.nodes[0].y],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
createTestNode({
|
||||||
|
id: event.result.nodes[1].id,
|
||||||
|
position: [event.result.nodes[1].x, event.result.nodes[1].y],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { tidyUp } = useCanvasOperations({ router });
|
||||||
|
tidyUp(event);
|
||||||
|
|
||||||
|
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]);
|
||||||
|
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send a "User tidied up workflow" telemetry event', () => {
|
||||||
|
const event: CanvasLayoutEvent = {
|
||||||
|
source: 'canvas-button',
|
||||||
|
target: 'all',
|
||||||
|
result: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'node1', x: 100, y: 100 },
|
||||||
|
{ id: 'node2', x: 200, y: 200 },
|
||||||
|
],
|
||||||
|
boundingBox: { height: 100, width: 100, x: 0, y: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { tidyUp } = useCanvasOperations({ router });
|
||||||
|
tidyUp(event);
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith('User tidied up canvas', {
|
||||||
|
nodes_count: 2,
|
||||||
|
source: 'canvas-button',
|
||||||
|
target: 'all',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('updateNodePosition', () => {
|
describe('updateNodePosition', () => {
|
||||||
it('should update node position', () => {
|
it('should update node position', () => {
|
||||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ import { useClipboard } from '@/composables/useClipboard';
|
|||||||
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
||||||
import { isPresent } from '../utils/typesUtils';
|
import { isPresent } from '../utils/typesUtils';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
||||||
|
|
||||||
type AddNodeData = Partial<INodeUi> & {
|
type AddNodeData = Partial<INodeUi> & {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -164,6 +165,22 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||||||
* Node operations
|
* Node operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function tidyUp({ result, source, target }: CanvasLayoutEvent) {
|
||||||
|
updateNodesPosition(
|
||||||
|
result.nodes.map(({ id, x, y }) => ({ id, position: { x, y } })),
|
||||||
|
{ trackBulk: true, trackHistory: true },
|
||||||
|
);
|
||||||
|
trackTidyUp({ result, source, target });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackTidyUp({ result, source, target }: CanvasLayoutEvent) {
|
||||||
|
telemetry.track('User tidied up canvas', {
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
nodes_count: result.nodes.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateNodesPosition(
|
function updateNodesPosition(
|
||||||
events: CanvasNodeMoveEvent[],
|
events: CanvasNodeMoveEvent[],
|
||||||
{ trackHistory = false, trackBulk = true } = {},
|
{ trackHistory = false, trackBulk = true } = {},
|
||||||
@@ -1995,6 +2012,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||||||
revertAddNode,
|
revertAddNode,
|
||||||
updateNodesPosition,
|
updateNodesPosition,
|
||||||
updateNodePosition,
|
updateNodePosition,
|
||||||
|
tidyUp,
|
||||||
revertUpdateNodePosition,
|
revertUpdateNodePosition,
|
||||||
setNodeActive,
|
setNodeActive,
|
||||||
setNodeActiveByName,
|
setNodeActiveByName,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWo
|
|||||||
import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
|
import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
|
||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
|
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -175,6 +176,7 @@ const { runWorkflow, runEntireWorkflow, stopCurrentExecution, stopWaitingForWebh
|
|||||||
const {
|
const {
|
||||||
updateNodePosition,
|
updateNodePosition,
|
||||||
updateNodesPosition,
|
updateNodesPosition,
|
||||||
|
tidyUp,
|
||||||
revertUpdateNodePosition,
|
revertUpdateNodePosition,
|
||||||
renameNode,
|
renameNode,
|
||||||
revertRenameNode,
|
revertRenameNode,
|
||||||
@@ -580,6 +582,10 @@ const allTriggerNodesDisabled = computed(() => {
|
|||||||
return disabledTriggerNodes.length === triggerNodes.value.length;
|
return disabledTriggerNodes.length === triggerNodes.value.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onTidyUp(event: CanvasLayoutEvent) {
|
||||||
|
tidyUp(event);
|
||||||
|
}
|
||||||
|
|
||||||
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
|
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
|
||||||
updateNodesPosition(events, { trackHistory: true });
|
updateNodesPosition(events, { trackHistory: true });
|
||||||
}
|
}
|
||||||
@@ -1769,6 +1775,7 @@ onBeforeUnmount(() => {
|
|||||||
@create:workflow="onCreateWorkflow"
|
@create:workflow="onCreateWorkflow"
|
||||||
@viewport-change="onViewportChange"
|
@viewport-change="onViewportChange"
|
||||||
@drag-and-drop="onDragAndDrop"
|
@drag-and-drop="onDragAndDrop"
|
||||||
|
@tidy-up="onTidyUp"
|
||||||
>
|
>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<LazySetupWorkflowCredentialsButton :class="$style.setupCredentialsButtonWrapper" />
|
<LazySetupWorkflowCredentialsButton :class="$style.setupCredentialsButtonWrapper" />
|
||||||
|
|||||||
Reference in New Issue
Block a user