feat(editor): Add telemetry events for Canvas Groups (no-changelog) (#32570)

This commit is contained in:
Daria
2026-06-18 17:16:30 +03:00
committed by GitHub
parent e0f4f93afb
commit cafeed1a9f
6 changed files with 324 additions and 5 deletions
@@ -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);
}
}
/**
@@ -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',
}),
);
});
});
@@ -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();
},
},
@@ -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' }),
);
});
});
@@ -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));
},
};
}