mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
feat(editor): Add telemetry events for Canvas Groups (no-changelog) (#32570)
This commit is contained in:
+93
-1
@@ -28,7 +28,17 @@ import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { GROUP_PADDING_Y_BOTTOM, GROUP_PADDING_Y_TOP } from '../stores/canvasNodeGroups.constants';
|
||||
import { NodeGroupViewKey, type CanvasNodeGroupView } from '../composables/useCanvasNodeGroupView';
|
||||
import {
|
||||
NodeGroupViewKey,
|
||||
useCanvasNodeGroupView,
|
||||
type CanvasNodeGroupView,
|
||||
} from '../composables/useCanvasNodeGroupView';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
|
||||
const trackSpy = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({ track: trackSpy })),
|
||||
}));
|
||||
|
||||
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore>;
|
||||
|
||||
@@ -564,4 +574,86 @@ describe('Canvas', () => {
|
||||
expect(emitted()['delete:nodes']?.[0]).toEqual([['a', 'b']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node group telemetry', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.spyOn(usePostHog(), 'isFeatureEnabled').mockImplementation(
|
||||
(name) => name === CANVAS_NODES_GROUPING_EXPERIMENT.name,
|
||||
);
|
||||
});
|
||||
|
||||
function buildNodeGroupView() {
|
||||
return useCanvasNodeGroupView({
|
||||
workflowId: () => 'wf-test',
|
||||
getCurrentGroupIds: () => workflowDocumentStore.allGroups.map((group) => group.id),
|
||||
onNodeGroupsChange: workflowDocumentStore.onNodeGroupsChange,
|
||||
isGroupingEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
it('tracks an ungroup with the group-toolbar source when the ungroup button is clicked', async () => {
|
||||
const group = workflowDocumentStore.createGroup(['a', 'b'], 'My Group');
|
||||
const groupNode = createCanvasGroupElement({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
nodeIds: ['a', 'b'],
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { nodes: [groupNode] } });
|
||||
|
||||
await waitFor(() => expect(getByTestId('canvas-node-group-ungroup')).toBeInTheDocument());
|
||||
await fireEvent.click(getByTestId('canvas-node-group-ungroup'));
|
||||
|
||||
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||
'User ungrouped nodes',
|
||||
expect.objectContaining({
|
||||
group_id: group.id,
|
||||
group_title: 'My Group',
|
||||
node_ids: ['a', 'b'],
|
||||
node_count: 2,
|
||||
source: 'group-toolbar',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks an expand when a collapsed group is toggled open', async () => {
|
||||
const group = workflowDocumentStore.createGroup(['a', 'b'], 'My Group');
|
||||
const nodeGroupView = buildNodeGroupView();
|
||||
const groupNode = createCanvasGroupElement({ id: group.id, name: group.name });
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { nodes: [groupNode] },
|
||||
global: { provide: { [NodeGroupViewKey as symbol]: nodeGroupView } },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('canvas-node-group-toggle')).toBeInTheDocument());
|
||||
await fireEvent.click(getByTestId('canvas-node-group-toggle'));
|
||||
|
||||
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||
'User expanded group',
|
||||
expect.objectContaining({ group_id: group.id, source: 'group-toolbar' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks a collapse when an expanded group is toggled shut', async () => {
|
||||
const group = workflowDocumentStore.createGroup(['a', 'b'], 'My Group');
|
||||
const nodeGroupView = buildNodeGroupView();
|
||||
nodeGroupView.toggleCollapsed(group.id); // start expanded
|
||||
const groupNode = createCanvasGroupElement({ id: group.id, name: group.name });
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { nodes: [groupNode] },
|
||||
global: { provide: { [NodeGroupViewKey as symbol]: nodeGroupView } },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('canvas-node-group-toggle')).toBeInTheDocument());
|
||||
await fireEvent.click(getByTestId('canvas-node-group-toggle'));
|
||||
|
||||
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||
'User collapsed group',
|
||||
expect.objectContaining({ group_id: group.id, source: 'group-toolbar' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,10 @@ import CanvasNodeGroupTitleBar from './elements/groups/CanvasNodeGroupTitleBar.v
|
||||
import CanvasSelectionToolbar from './elements/selection/CanvasSelectionToolbar.vue';
|
||||
import { useCanvasNodeGroupActions } from '../composables/useCanvasNodeGroupActions';
|
||||
import { useCanvasNodeGroupDrag } from '../composables/useCanvasNodeGroupDrag';
|
||||
import {
|
||||
useCanvasNodeGroupTelemetry,
|
||||
type CanvasNodeGroupEventSource,
|
||||
} from '../composables/useCanvasNodeGroupTelemetry';
|
||||
import { NodeGroupViewKey } from '../composables/useCanvasNodeGroupView';
|
||||
import { useExperimentalNdvStore } from '../experimental/experimentalNdv.store';
|
||||
import { type ContextMenuAction } from '@/features/shared/contextMenu/composables/useContextMenuItems';
|
||||
@@ -384,6 +388,10 @@ function onToggleZoomMode() {
|
||||
|
||||
function onNodeGroupCreated(groupId: string) {
|
||||
autofocusGroupTitleId.value = groupId;
|
||||
const group = workflowDocumentStore.value.getGroupById(groupId);
|
||||
if (group) {
|
||||
groupTelemetry.trackGrouped(group, 'group-toolbar');
|
||||
}
|
||||
}
|
||||
|
||||
function onNodeGroupTitleFocused(groupId: string) {
|
||||
@@ -401,9 +409,14 @@ const {
|
||||
readOnly: () => props.readOnly || props.suppressInteraction,
|
||||
});
|
||||
|
||||
const groupTelemetry = useCanvasNodeGroupTelemetry();
|
||||
|
||||
function onKeyboardGroup() {
|
||||
const group = groupSelection();
|
||||
if (group) autofocusGroupTitleId.value = group.id;
|
||||
if (group) {
|
||||
autofocusGroupTitleId.value = group.id;
|
||||
groupTelemetry.trackGrouped(group, 'keyboard-shortcut');
|
||||
}
|
||||
}
|
||||
|
||||
const keyMap = computed(() => {
|
||||
@@ -479,7 +492,7 @@ const keyMap = computed(() => {
|
||||
// Through the same path as the title-bar button so push effects
|
||||
// are committed before each group is removed.
|
||||
for (const groupId of selectedGroupIds.value) {
|
||||
onCanvasGroupUngroup(groupId);
|
||||
onCanvasGroupUngroup(groupId, 'keyboard-shortcut');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -620,7 +633,17 @@ function onCanvasGroupToggle(groupId: string) {
|
||||
|
||||
if (!injectedNodeGroupView) return;
|
||||
|
||||
if (injectedNodeGroupView.isGroupCollapsed(groupId)) {
|
||||
const isCollapsed = injectedNodeGroupView.isGroupCollapsed(groupId);
|
||||
const group = workflowDocumentStore.value.getGroupById(groupId);
|
||||
if (group) {
|
||||
if (isCollapsed) {
|
||||
groupTelemetry.trackCollapsed(group, 'group-toolbar');
|
||||
} else {
|
||||
groupTelemetry.trackExpanded(group, 'group-toolbar');
|
||||
}
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
// Collapsing hides the members, so drop them from the selection to clear the lingering box.
|
||||
const memberNodeIds = workflowDocumentStore.value.getGroupById(groupId)?.nodeIds ?? [];
|
||||
const selectedMembers = memberNodeIds
|
||||
@@ -642,7 +665,12 @@ function onCanvasGroupNameUpdate(groupId: string, name: string) {
|
||||
workflowDocumentStore.value.updateName(groupId, name);
|
||||
}
|
||||
|
||||
function onCanvasGroupUngroup(groupId: string) {
|
||||
function onCanvasGroupUngroup(
|
||||
groupId: string,
|
||||
source: CanvasNodeGroupEventSource = 'group-toolbar',
|
||||
) {
|
||||
// Capture before deletion — the group is gone by the time we track.
|
||||
const group = workflowDocumentStore.value.getGroupById(groupId);
|
||||
// Ungrouping a collapsed group makes its hidden members reappear, so expand
|
||||
// it first: the expansion pushes overlapping nodes aside, and the commit
|
||||
// below persists that displacement (the group is gone after, so the push
|
||||
@@ -654,6 +682,10 @@ function onCanvasGroupUngroup(groupId: string) {
|
||||
// pushing first — same principle as a newly created group not pushing.
|
||||
commitPushedPositionsForSourceGroups([groupId]);
|
||||
workflowDocumentStore.value.deleteGroup(groupId);
|
||||
|
||||
if (group) {
|
||||
groupTelemetry.trackUngrouped(group, source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import type { VNode } from 'vue';
|
||||
|
||||
import { useCanvasNodeGroupOperationGuards } from './useCanvasNodeGroupOperationGuards';
|
||||
import {
|
||||
createWorkflowDocumentId,
|
||||
useWorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { CANVAS_NODES_GROUPING_EXPERIMENT } from '@/app/constants';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
|
||||
const trackSpy = vi.hoisted(() => vi.fn());
|
||||
const showToastSpy = vi.hoisted(() => vi.fn((_config: { message: VNode }) => ({ close: vi.fn() })));
|
||||
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({ track: trackSpy })),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: () => ({ showToast: showToastSpy }),
|
||||
}));
|
||||
|
||||
// The toast message is a vnode tree: span[ message, ' ', a(onClick) ].
|
||||
// Reach into it for the ungroup link and fire its click handler.
|
||||
function clickUngroupLink() {
|
||||
const message = showToastSpy.mock.calls[0][0].message;
|
||||
const link = (message.children as VNode[])[2];
|
||||
const onClick = (link.props as { onClick: (event: MouseEvent) => void }).onClick;
|
||||
onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as MouseEvent);
|
||||
}
|
||||
|
||||
describe('useCanvasNodeGroupOperationGuards', () => {
|
||||
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
workflowDocumentStore = useWorkflowDocumentStore(
|
||||
createWorkflowDocumentId(workflowsStore.workflowId),
|
||||
);
|
||||
vi.spyOn(usePostHog(), 'isFeatureEnabled').mockImplementation(
|
||||
(name) => name === CANVAS_NODES_GROUPING_EXPERIMENT.name,
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks an ungroup when the update-blocked toast ungroup link is clicked', () => {
|
||||
const group = workflowDocumentStore.createGroup(['prev'], 'Group A');
|
||||
workflowDocumentStore.createGroup(['new'], 'Group B');
|
||||
|
||||
const guards = useCanvasNodeGroupOperationGuards();
|
||||
|
||||
// Replacing a grouped node with a node from a different group is blocked,
|
||||
// which surfaces the toast carrying the ungroup link.
|
||||
const allowed = guards.isNodeReplacementAllowedForNodeGroups({
|
||||
previousNodeId: 'prev',
|
||||
newNodeId: 'new',
|
||||
nodeIds: [],
|
||||
connectionsToRemove: [],
|
||||
connectionsToAdd: [],
|
||||
connectionsBySourceNode: {},
|
||||
});
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(showToastSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trackSpy).not.toHaveBeenCalled();
|
||||
|
||||
clickUngroupLink();
|
||||
|
||||
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||
'User ungrouped nodes',
|
||||
expect.objectContaining({
|
||||
group_id: group.id,
|
||||
group_title: 'Group A',
|
||||
node_ids: ['prev'],
|
||||
node_count: 1,
|
||||
source: 'update-blocked-toast',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+3
@@ -17,6 +17,7 @@ import {
|
||||
useWorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useCanvasNodeGroupTelemetry } from './useCanvasNodeGroupTelemetry';
|
||||
|
||||
type ConnectionChangeAction = 'add' | 'remove';
|
||||
type InvalidGroupValidationResult = Extract<GroupValidationResult, { valid: false }>;
|
||||
@@ -65,6 +66,7 @@ export function useCanvasNodeGroupOperationGuards() {
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const groupTelemetry = useCanvasNodeGroupTelemetry();
|
||||
const { isSelectionGroupable } = useSelectionValidation();
|
||||
|
||||
function applyAddConnection(
|
||||
@@ -192,6 +194,7 @@ export function useCanvasNodeGroupOperationGuards() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
workflowDocumentStore.value.deleteGroup(group.id);
|
||||
groupTelemetry.trackUngrouped(group, 'update-blocked-toast');
|
||||
notification?.close();
|
||||
},
|
||||
},
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { computed } from 'vue';
|
||||
import type { IWorkflowGroup } from 'n8n-workflow';
|
||||
|
||||
import { useCanvasNodeGroupTelemetry } from './useCanvasNodeGroupTelemetry';
|
||||
|
||||
const trackSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({ track: trackSpy })),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/workflowDocument.store', () => ({
|
||||
injectWorkflowDocumentStore: vi.fn(() => computed(() => ({ workflowId: 'wf-test' }))),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: vi.fn(() => ({ pushRef: 'push-ref-test' })),
|
||||
}));
|
||||
|
||||
function makeGroup(overrides: Partial<IWorkflowGroup> = {}): IWorkflowGroup {
|
||||
return { id: 'group-1', nodeIds: ['a', 'b', 'c'], name: 'My Group', ...overrides };
|
||||
}
|
||||
|
||||
describe('useCanvasNodeGroupTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['trackGrouped', 'User grouped nodes'],
|
||||
['trackUngrouped', 'User ungrouped nodes'],
|
||||
['trackCollapsed', 'User collapsed group'],
|
||||
['trackExpanded', 'User expanded group'],
|
||||
] as const)('%s fires "%s" with the full property set', (method, eventName) => {
|
||||
const telemetry = useCanvasNodeGroupTelemetry();
|
||||
|
||||
telemetry[method](makeGroup(), 'group-toolbar');
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledWith(eventName, {
|
||||
workflow_id: 'wf-test',
|
||||
group_id: 'group-1',
|
||||
node_ids: ['a', 'b', 'c'],
|
||||
node_count: 3,
|
||||
group_title: 'My Group',
|
||||
source: 'group-toolbar',
|
||||
push_ref: 'push-ref-test',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through the event source', () => {
|
||||
const telemetry = useCanvasNodeGroupTelemetry();
|
||||
|
||||
telemetry.trackGrouped(makeGroup(), 'keyboard-shortcut');
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
'User grouped nodes',
|
||||
expect.objectContaining({ source: 'keyboard-shortcut' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { IWorkflowGroup } from 'n8n-workflow';
|
||||
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
export type CanvasNodeGroupEventSource =
|
||||
| 'group-toolbar'
|
||||
| 'keyboard-shortcut'
|
||||
| 'update-blocked-toast';
|
||||
|
||||
/**
|
||||
* Telemetry for canvas node groups: capturing how users
|
||||
* group, ungroup, collapse and expand groups.
|
||||
*/
|
||||
export function useCanvasNodeGroupTelemetry() {
|
||||
const telemetry = useTelemetry();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
function buildProperties(group: IWorkflowGroup, source: CanvasNodeGroupEventSource) {
|
||||
return {
|
||||
workflow_id: workflowDocumentStore.value.workflowId,
|
||||
group_id: group.id,
|
||||
node_ids: group.nodeIds,
|
||||
node_count: group.nodeIds.length,
|
||||
group_title: group.name,
|
||||
source,
|
||||
push_ref: rootStore.pushRef,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackGrouped(group: IWorkflowGroup, source: CanvasNodeGroupEventSource) {
|
||||
telemetry.track('User grouped nodes', buildProperties(group, source));
|
||||
},
|
||||
trackUngrouped(group: IWorkflowGroup, source: CanvasNodeGroupEventSource) {
|
||||
telemetry.track('User ungrouped nodes', buildProperties(group, source));
|
||||
},
|
||||
trackCollapsed(group: IWorkflowGroup, source: CanvasNodeGroupEventSource) {
|
||||
telemetry.track('User collapsed group', buildProperties(group, source));
|
||||
},
|
||||
trackExpanded(group: IWorkflowGroup, source: CanvasNodeGroupEventSource) {
|
||||
telemetry.track('User expanded group', buildProperties(group, source));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user