feat(editor): Build the new group shapes on the canvas (no-changelog) (#39319)

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Miguel Ángel Moreno
2026-09-24 19:41:52 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 3aa9179424
commit e838224264
11 changed files with 420 additions and 37 deletions
@@ -4751,6 +4751,7 @@
"canvas.nodeGroup.expand": "Expand",
"canvas.nodeGroup.collapse": "Collapse",
"canvas.nodeGroup.addToChat": "Add group to chat",
"canvas.nodeGroup.holdsTrigger": "Contains a trigger",
"canvas.nodeGroup.prompt.renameGroup": "Rename group",
"canvas.nodeGroup.duplicateName": "A group with this name already exists",
"canvas.nodeGroup.renameBlocked.title": "Group not renamed",
@@ -84,7 +84,7 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
import type { Connection } from '@vue-flow/core';
import { useClipboard } from '@vueuse/core';
import { createCanvasConnectionHandleString } from '@/features/workflows/canvas/canvas.utils';
import { isVNode, nextTick, reactive, ref } from 'vue';
import { isVNode, nextTick, reactive, ref, shallowRef } from 'vue';
import type { CanvasLayoutEvent } from '@/features/workflows/canvas/composables/useCanvasLayout';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
@@ -106,6 +106,14 @@ const mockRoute = reactive({
const mockRouterReplace = vi.fn();
// This file turns every PostHog flag on, so the group rules need their own
// switches: most of these tests describe the strict rules.
const allowTriggerInGroup = shallowRef(false);
const allowMultipleBoundaryNodes = shallowRef(false);
vi.mock('@/app/composables/useNodeGroupRules', () => ({
useNodeGroupRules: () => ({ allowTriggerInGroup, allowMultipleBoundaryNodes }),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-router')>()),
useRoute: () => mockRoute,
@@ -254,6 +262,8 @@ describe('useCanvasOperations', () => {
) as WritableDocumentStore;
mockedStore(usePostHog).isFeatureEnabled.mockReturnValue(true);
allowTriggerInGroup.value = false;
allowMultipleBoundaryNodes.value = false;
mockedStore(useTypeAvailabilityPoliciesStore).getNodeTypeAvailability.mockImplementation(
(name) => ({ name, available: true }),
);
@@ -2895,6 +2905,35 @@ describe('useCanvasOperations', () => {
expect(toast.showToast).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});
it('leaves the group alone when the boundary rule already accepts the connection', () => {
allowMultipleBoundaryNodes.value = true;
const toast = useToast();
const nodeA = createGroupedNode('a', 'A');
const nodeB = createGroupedNode('b', 'B');
const nodeC = createGroupedNode('c', 'C');
const nodeD = createGroupedNode('d', 'D');
// B and C both feed D and neither feeds the other, so the drawn edge gives
// the group a second entry node rather than an edge into its middle.
const group = { id: 'group', nodeIds: [nodeB.id, nodeC.id, nodeD.id], name: 'Group 1' };
const { workflowDocumentStore } = setupGroupedCanvas({
nodes: [nodeA, nodeB, nodeC, nodeD],
connections: createConnectionsBySource(
workflowConnection(nodeA, nodeB),
workflowConnection(nodeB, nodeD),
workflowConnection(nodeC, nodeD),
),
groups: [group],
});
const addNodesToGroupSpy = vi.spyOn(workflowDocumentStore, 'addNodesToGroup');
const { createConnection } = useCanvasOperations();
createConnection(canvasConnection(nodeA, nodeC));
expect(addNodesToGroupSpy).not.toHaveBeenCalled();
expectConnectionAdded(nodeA, nodeC);
expect(toast.showToast).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});
it('records the auto-extend and the connection in one bulk when tracking history', () => {
const historyStore = mockedStore(useHistoryStore);
const nodeA = createGroupedNode('a', 'A');
@@ -1,9 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import type { IConnections, INode, IWorkflowGroup } from 'n8n-workflow';
import type { IConnections, INode, INodeTypeDescription, IWorkflowGroup } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { shallowRef } from 'vue';
import { useInvalidNodeGroupCleanup } from './useInvalidNodeGroupCleanup';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
@@ -22,6 +24,12 @@ vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showMessage: showMessageSpy }),
}));
const allowTriggerInGroup = shallowRef(false);
const allowMultipleBoundaryNodes = shallowRef(false);
vi.mock('@/app/composables/useNodeGroupRules', () => ({
useNodeGroupRules: () => ({ allowTriggerInGroup, allowMultipleBoundaryNodes }),
}));
const WORKFLOW_ID = 'test-workflow';
function createConnection(from: string, to: string): IConnections {
@@ -46,10 +54,67 @@ function setupDocumentStore({
return store;
}
/** A trigger and the node after it, grouped together. */
function setupTriggeredGroup() {
const store = setupDocumentStore({
nodes: [
createTestNode({ id: 'node-a', name: 'Node A', type: 'n8n-nodes-base.manualTrigger' }),
createTestNode({ id: 'node-b', name: 'Node B' }),
],
connections: createConnection('Node A', 'Node B'),
nodeGroups: [{ id: 'group-1', name: 'Group 1', nodeIds: ['node-a', 'node-b'] }],
});
// `getNodeType` is a Pinia computed exposed as a getter on the store proxy.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(useNodeTypesStore() as any, 'getNodeType', 'get').mockReturnValue(
(type: string) =>
({
group: type === 'n8n-nodes-base.manualTrigger' ? ['trigger'] : ['transform'],
}) as unknown as INodeTypeDescription,
);
return store;
}
describe('useInvalidNodeGroupCleanup', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
allowTriggerInGroup.value = false;
allowMultipleBoundaryNodes.value = false;
});
it('removes a group that holds a trigger while both rules are off', () => {
const store = setupTriggeredGroup();
const { removeInvalidNodeGroups } = useInvalidNodeGroupCleanup();
const removed = removeInvalidNodeGroups(store);
expect(removed).toHaveLength(1);
expect(showMessageSpy).toHaveBeenCalled();
});
it('keeps a group that holds a trigger while the trigger rule is on', () => {
allowTriggerInGroup.value = true;
const store = setupTriggeredGroup();
const { removeInvalidNodeGroups } = useInvalidNodeGroupCleanup();
const removed = removeInvalidNodeGroups(store);
expect(removed).toEqual([]);
expect(showMessageSpy).not.toHaveBeenCalled();
});
it('removes a group that holds a trigger while only the boundary rule is on', () => {
allowMultipleBoundaryNodes.value = true;
const store = setupTriggeredGroup();
const { removeInvalidNodeGroups } = useInvalidNodeGroupCleanup();
const removed = removeInvalidNodeGroups(store);
expect(removed).toHaveLength(1);
expect(showMessageSpy).toHaveBeenCalled();
});
it('keeps valid groups and shows no toast', () => {
@@ -1,15 +1,15 @@
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { IWorkflowGroup } from 'n8n-workflow';
import { validateNodeSelectionForGrouping } from 'n8n-workflow';
import { escapeHtml } from 'xss';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { INodeUi } from '@/Interface';
import { useNodeGroupRules } from '@/app/composables/useNodeGroupRules';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
import type { INodeUi } from '@/Interface';
/**
* Removes node groups that this instance's backend would reject on save
@@ -24,6 +24,7 @@ import { useToast } from '@n8n/composables/useToast';
export function useInvalidNodeGroupCleanup() {
const nodeTypesStore = useNodeTypesStore();
const rootStore = useRootStore();
const { allowTriggerInGroup, allowMultipleBoundaryNodes } = useNodeGroupRules();
const toast = useToast();
const telemetry = useTelemetry();
const i18n = useI18n();
@@ -49,6 +50,8 @@ export function useInvalidNodeGroupCleanup() {
connectionsBySourceNode: store.connectionsBySourceNode,
getNodeType: (node) => nodeTypesStore.getNodeType(node.type, node.typeVersion),
existingNodeGroups: allGroups.filter((other) => other.id !== group.id),
allowTriggerInGroup: allowTriggerInGroup.value,
allowMultipleBoundaryNodes: allowMultipleBoundaryNodes.value,
}).valid;
}
@@ -1,23 +1,31 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import type { IConnections, INodeTypeDescription } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { createApp, shallowRef } from 'vue';
import { useSelectionValidation } from './useSelectionValidation';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { STICKY_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { INodeUi } from '@/Interface';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { STICKY_NODE_TYPE } from '@/app/constants/nodeTypes';
const TEST_WF_ID = 'test-wf-validation';
const INJECTED_WF_ID = 'injected-wf-validation';
const allowTriggerInGroup = shallowRef(false);
const allowMultipleBoundaryNodes = shallowRef(false);
vi.mock('@/app/composables/useNodeGroupRules', () => ({
useNodeGroupRules: () => ({ allowTriggerInGroup, allowMultipleBoundaryNodes }),
}));
function makeNode(overrides: Partial<INodeUi> = {}): INodeUi {
return {
id: overrides.id ?? overrides.name ?? 'a',
@@ -50,6 +58,58 @@ type LinearGraphFixture = {
connections: IConnections;
};
const triggerNodeTypes: Record<string, INodeTypeDescription> = {
'n8n-nodes-base.manualTrigger': makeNodeType({
name: 'n8n-nodes-base.manualTrigger',
group: ['trigger'],
}),
'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }),
};
/** A feeds B and C, which each leave the selection: two exit nodes. */
function makeTwoExitGraph(): LinearGraphFixture {
const connections: IConnections = {
A: {
main: [
[
{ node: 'B', type: 'main', index: 0 },
{ node: 'C', type: 'main', index: 0 },
],
],
},
B: { main: [[{ node: 'OutsideOne', type: 'main', index: 0 }]] },
C: { main: [[{ node: 'OutsideTwo', type: 'main', index: 0 }]] },
};
return {
nodes: {
a: makeNode({ id: 'a', name: 'A' }),
b: makeNode({ id: 'b', name: 'B' }),
c: makeNode({ id: 'c', name: 'C' }),
},
connections,
};
}
/** Outside reaches A and B, which both join at C: two entry nodes. */
function makeTwoEntryGraph(): LinearGraphFixture {
const connections: IConnections = {
OutsideOne: { main: [[{ node: 'A', type: 'main', index: 0 }]] },
OutsideTwo: { main: [[{ node: 'B', type: 'main', index: 0 }]] },
A: { main: [[{ node: 'C', type: 'main', index: 0 }]] },
B: { main: [[{ node: 'C', type: 'main', index: 0 }]] },
};
return {
nodes: {
a: makeNode({ id: 'a', name: 'A' }),
b: makeNode({ id: 'b', name: 'B' }),
c: makeNode({ id: 'c', name: 'C' }),
},
connections,
};
}
function makeLinearGraph(): LinearGraphFixture {
const a = makeNode({ id: 'a', name: 'A' });
const b = makeNode({ id: 'b', name: 'B' });
@@ -116,6 +176,8 @@ describe('useSelectionValidation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
useWorkflowsStore().workflowId = TEST_WF_ID;
allowTriggerInGroup.value = false;
allowMultipleBoundaryNodes.value = false;
});
afterEach(() => {
@@ -206,6 +268,92 @@ describe('useSelectionValidation', () => {
}
});
describe('with the trigger rule on alone', () => {
beforeEach(() => {
allowTriggerInGroup.value = true;
});
it('accepts a selection that holds a trigger', () => {
const graph = makeLinearGraph();
graph.nodes.a.type = 'n8n-nodes-base.manualTrigger';
setupGraph(graph, triggerNodeTypes);
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b']).valid).toBe(true);
});
it('still refuses a selection with two entry nodes', () => {
const graph = makeTwoEntryGraph();
setupGraph(graph, { 'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }) });
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b', 'c']).valid).toBe(false);
});
});
describe('with the boundary rule on alone', () => {
beforeEach(() => {
allowMultipleBoundaryNodes.value = true;
});
it('accepts a selection with two entry nodes', () => {
const graph = makeTwoEntryGraph();
setupGraph(graph, { 'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }) });
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b', 'c']).valid).toBe(true);
});
it('accepts a selection with two exit nodes', () => {
const graph = makeTwoExitGraph();
setupGraph(graph, { 'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }) });
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b', 'c']).valid).toBe(true);
});
it('still refuses a selection that holds a trigger', () => {
const graph = makeLinearGraph();
graph.nodes.a.type = 'n8n-nodes-base.manualTrigger';
setupGraph(graph, triggerNodeTypes);
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b']).valid).toBe(false);
});
it('still refuses to extract a selection with two entry nodes', () => {
const graph = makeTwoEntryGraph();
setupGraph(graph, { 'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }) });
const { isSelectionExtractable } = useSelectionValidation();
expect(isSelectionExtractable(['a', 'b', 'c']).valid).toBe(false);
});
});
it('refuses a selection with two entry nodes while both rules are off', () => {
const graph = makeTwoEntryGraph();
setupGraph(graph, { 'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }) });
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b', 'c']).valid).toBe(false);
});
it('refuses a selection with two exit nodes while both rules are off', () => {
const graph = makeTwoExitGraph();
setupGraph(graph, { 'n8n-nodes-base.set': makeNodeType({ name: 'n8n-nodes-base.set' }) });
const { isSelectionGroupable } = useSelectionValidation();
expect(isSelectionGroupable(['a', 'b', 'c']).valid).toBe(false);
});
it('returns invalid-subgraph when the selection skips an intermediate node', () => {
// A → B → C; selecting only A and C leaves B (on the path) outside the
// selection, so the subgraph parser rejects the selection.
@@ -11,6 +11,7 @@ import {
validateNodeSelectionForGrouping,
} from 'n8n-workflow';
import { useNodeGroupRules } from '@/app/composables/useNodeGroupRules';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { STICKY_NODE_TYPE } from '@/app/constants/nodeTypes';
@@ -26,6 +27,7 @@ type GroupValidationOptions = {
export function useSelectionValidation() {
const nodeTypesStore = useNodeTypesStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const { allowTriggerInGroup, allowMultipleBoundaryNodes } = useNodeGroupRules();
/**
* Expands a selection of node ids to include all sub-nodes (memory, tools,
@@ -73,6 +75,8 @@ export function useSelectionValidation() {
return validateNodeSelectionForGrouping({
...getValidationInput(nodeIds, connectionsBySourceNode),
existingNodeGroups,
allowTriggerInGroup: allowTriggerInGroup.value,
allowMultipleBoundaryNodes: allowMultipleBoundaryNodes.value,
});
}
@@ -14,11 +14,14 @@ import {
MODAL_CONFIRM,
} from '@/app/constants';
import { useMessage } from '@/app/composables/useMessage';
import { findGroupIdsWithTrigger } from '../nodeGroups.utils';
import { useNodeGroupRules } from '@/app/composables/useNodeGroupRules';
import { useSelectionValidation } from '@/app/composables/useSelectionValidation';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useUsersStore } from '@n8n/stores/users.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { NODE_CREATOR_SHORTCUT_COACHMARK_KEY } from '@/features/shared/nodeCreator/composables/useNodeCreatorShortcutCoachmark';
import type { NodeCreatorOpenSource } from '@/Interface';
@@ -218,6 +221,7 @@ const props = withDefaults(
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const message = useMessage();
const toast = useToast();
@@ -451,6 +455,20 @@ const {
});
const { isSelectionExtractable } = useSelectionValidation();
const { allowTriggerInGroup } = useNodeGroupRules();
// Groups that start the workflow themselves
const groupIdsWithTrigger = computed(() => {
if (!allowTriggerInGroup.value) {
return new Set<string>();
}
return findGroupIdsWithTrigger(
workflowDocumentStore.value.allGroups,
(nodeId) => workflowDocumentStore.value.getNodeById(nodeId),
(nodeType) => nodeTypesStore.isTriggerNode(nodeType),
);
});
// Groups that can be extracted to sub-workflows
const extractableGroupIds = computed(() => {
@@ -1926,6 +1944,7 @@ defineExpose({
:autofocus-group-id="autofocusGroupTitleId"
:read-only="readOnly || suppressInteraction"
:can-extract="extractableGroupIds.has(parseCanvasGroupNodeId(nodeProps.id) ?? '')"
:has-trigger="groupIdsWithTrigger.has(parseCanvasGroupNodeId(nodeProps.id) ?? '')"
@toggle="onCanvasGroupToggle"
@update:name="onCanvasGroupNameUpdate"
@update:description="onCanvasGroupDescriptionUpdate"
@@ -1,10 +1,19 @@
import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { h } from 'vue';
import type { IWorkflowGroup } from 'n8n-workflow';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { h } from 'vue';
import CanvasNodeGroupTitleBar from './CanvasNodeGroupTitleBar.vue';
import type { CanvasGroupNodeData } from '../../../canvas.types';
import {
NodeGroupDescriptionVisibilityKey,
useCanvasNodeGroupDescriptionVisibility,
} from '../../../composables/useCanvasNodeGroupDescriptionVisibility';
import { GROUP_HEADER_HEIGHT } from '../../../stores/canvasNodeGroups.constants';
import { renderComponent } from '@/__tests__/render';
// Handle requires a <VueFlow> ancestor. Mock it as an inert div so the
// title bar can render in isolation. Other VueFlow imports are type-only.
@@ -37,16 +46,11 @@ const { isNodeContextEnabled } = vi.hoisted(() => {
const { ref } = require('vue');
return { isNodeContextEnabled: ref(false) };
});
vi.mock('@/features/ai/instanceAi/composables/useIsNodeContextEnabled', () => ({
useIsNodeContextEnabled: () => isNodeContextEnabled,
}));
import CanvasNodeGroupTitleBar from './CanvasNodeGroupTitleBar.vue';
import { GROUP_HEADER_HEIGHT } from '../../../stores/canvasNodeGroups.constants';
import { useCanvasNodeGroupDescriptionVisibility } from '../../../composables/useCanvasNodeGroupDescriptionVisibility';
import { NodeGroupDescriptionVisibilityKey } from '../../../composables/useCanvasNodeGroupDescriptionVisibility';
import type { CanvasGroupNodeData } from '../../../canvas.types';
const baseGroup: IWorkflowGroup = {
id: 'g1',
nodeIds: ['a', 'b'],
@@ -77,6 +81,7 @@ describe('CanvasNodeGroupTitleBar', () => {
readOnly: boolean;
selected: boolean;
canExtract: boolean;
hasTrigger: boolean;
}> = {},
descriptionVisibility?: ReturnType<typeof useCanvasNodeGroupDescriptionVisibility>,
) {
@@ -94,10 +99,31 @@ describe('CanvasNodeGroupTitleBar', () => {
readOnly: props.readOnly ?? false,
selected: props.selected ?? false,
canExtract: props.canExtract ?? false,
hasTrigger: props.hasTrigger ?? false,
},
});
}
describe('trigger mark', () => {
it('renders the zap when a collapsed group holds a trigger', () => {
const wrapper = render({ hasTrigger: true, data: makeData({ isCollapsed: true }) });
expect(wrapper.queryByTestId('canvas-node-group-trigger-mark')).toBeTruthy();
});
it('renders no zap when the group holds no trigger', () => {
const wrapper = render({ hasTrigger: false, data: makeData({ isCollapsed: true }) });
expect(wrapper.queryByTestId('canvas-node-group-trigger-mark')).toBeNull();
});
it('renders no zap while the group is open, where the triggers are visible', () => {
const wrapper = render({ hasTrigger: true, data: makeData({ isCollapsed: false }) });
expect(wrapper.queryByTestId('canvas-node-group-trigger-mark')).toBeNull();
});
});
describe('chevron caption and icon by state', () => {
it('renders chevron-down with Expand label when collapsed', () => {
const wrapper = render({ data: makeData({ isCollapsed: true }) });
@@ -1,4 +1,7 @@
<script setup lang="ts">
import { N8nIcon, N8nIconButton, N8nInlineTextEdit, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { Handle, Position, useVueFlow } from '@vue-flow/core';
import {
computed,
inject,
@@ -10,26 +13,25 @@ import {
useTemplateRef,
watch,
} from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nIcon, N8nIconButton, N8nInlineTextEdit, N8nTooltip } from '@n8n/design-system';
import { Handle, Position, useVueFlow } from '@vue-flow/core';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import CanvasNodeStatusMark from '../nodes/render-types/parts/CanvasNodeStatusMark.vue';
import { useZoomAdjustedValues } from '../../../composables/useZoomAdjustedValues';
import { HOVER_DELAY } from '@/app/constants';
import {
GROUP_HEADER_HEIGHT as HEADER_HEIGHT,
GROUP_DESCRIPTION_MAX_LENGTH,
GROUP_DESCRIPTION_MIN_ZOOM,
} from '../../../stores/canvasNodeGroups.constants';
import { computeGroupFrameRects } from '../../../composables/useCanvasMapping.groups';
import { NodeGroupDescriptionVisibilityKey } from '../../../composables/useCanvasNodeGroupDescriptionVisibility';
import {
CANVAS_NODE_GROUP_HANDLE_LEFT,
CANVAS_NODE_GROUP_HANDLE_RIGHT,
createCanvasGroupNodeId,
type CanvasGroupNodeData,
} from '../../../canvas.types';
import { computeGroupFrameRects } from '../../../composables/useCanvasMapping.groups';
import { NodeGroupDescriptionVisibilityKey } from '../../../composables/useCanvasNodeGroupDescriptionVisibility';
import { useZoomAdjustedValues } from '../../../composables/useZoomAdjustedValues';
import {
GROUP_HEADER_HEIGHT as HEADER_HEIGHT,
GROUP_DESCRIPTION_MAX_LENGTH,
GROUP_DESCRIPTION_MIN_ZOOM,
} from '../../../stores/canvasNodeGroups.constants';
import CanvasNodeStatusMark from '../nodes/render-types/parts/CanvasNodeStatusMark.vue';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import { HOVER_DELAY } from '@/app/constants';
import { useIsNodeContextEnabled } from '@/features/ai/instanceAi/composables/useIsNodeContextEnabled';
const UNGROUP_NODES_SHORTCUT = { metaKey: true, shiftKey: true, keys: ['G'] };
@@ -49,12 +51,14 @@ const props = withDefaults(
/** Whether the group's members form a selection that can be converted
* to a sub-workflow (extraction is stricter than grouping). */
canExtract?: boolean;
hasTrigger?: boolean;
}>(),
{
autofocusGroupId: null,
readOnly: false,
selected: false,
canExtract: false,
hasTrigger: false,
},
);
@@ -485,6 +489,17 @@ function onWrapperPointerDown(event: PointerEvent) {
</div>
</div>
<!-- N8nIcon marks its svg aria-hidden, so the name lives on this wrapper. -->
<div
v-if="hasTrigger && isCollapsed"
:class="$style.triggerMark"
role="img"
:aria-label="i18n.baseText('canvas.nodeGroup.holdsTrigger')"
data-test-id="canvas-node-group-trigger-mark"
>
<N8nIcon icon="bolt-filled" size="large" />
</div>
<div :class="$style.content" data-test-id="canvas-node-group-header">
<div :class="$style.titleColumn">
<div :class="$style.titleRow">
@@ -878,6 +893,18 @@ function onWrapperPointerDown(event: PointerEvent) {
white-space: nowrap;
}
.triggerMark {
position: absolute;
right: 100%;
top: 0;
pointer-events: none;
display: flex;
align-items: center;
height: 100%;
padding-right: var(--spacing--sm);
color: var(--color--primary);
}
.infoIcon {
flex-shrink: 0;
color: var(--text-color--subtler);
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import type { IWorkflowGroup } from 'n8n-workflow';
import { snapshotGroup, deleteGroupWithHistory } from './nodeGroups.utils';
import { snapshotGroup, deleteGroupWithHistory, findGroupIdsWithTrigger } from './nodeGroups.utils';
import { RemoveNodeGroupCommand } from '@/app/models/history';
import type { useHistoryStore } from '@/app/stores/history.store';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
@@ -75,3 +75,38 @@ describe('deleteGroupWithHistory', () => {
expect(command.group.nodeIds).toEqual(['a', 'b']);
});
});
describe('findGroupIdsWithTrigger', () => {
const nodes: Record<string, { type: string }> = {
trigger: { type: 'n8n-nodes-base.manualTrigger' },
step: { type: 'n8n-nodes-base.noOp' },
gone: undefined as unknown as { type: string },
};
const getNodeById = (nodeId: string) => nodes[nodeId];
const isTriggerNode = (nodeType: string) => nodeType === 'n8n-nodes-base.manualTrigger';
it('returns the groups that hold a trigger', () => {
const withTrigger = createGroup({ id: 'g1', nodeIds: ['trigger', 'step'] });
const withoutTrigger = createGroup({ id: 'g2', nodeIds: ['step'] });
const result = findGroupIdsWithTrigger(
[withTrigger, withoutTrigger],
getNodeById,
isTriggerNode,
);
expect(result).toEqual(new Set(['g1']));
});
it('returns an empty set when no group holds a trigger', () => {
const group = createGroup({ id: 'g1', nodeIds: ['step'] });
expect(findGroupIdsWithTrigger([group], getNodeById, isTriggerNode)).toEqual(new Set());
});
it('ignores a member id that resolves to no node', () => {
const group = createGroup({ id: 'g1', nodeIds: ['gone'] });
expect(findGroupIdsWithTrigger([group], getNodeById, isTriggerNode)).toEqual(new Set());
});
});
@@ -8,6 +8,22 @@ export function snapshotGroup(group: IWorkflowGroup): IWorkflowGroup {
return { ...group, nodeIds: [...group.nodeIds] };
}
/** Ids of the groups that hold a trigger, so the canvas can mark them. */
export function findGroupIdsWithTrigger(
groups: IWorkflowGroup[],
getNodeById: (nodeId: string) => { type: string } | undefined,
isTriggerNode: (nodeType: string) => boolean,
): Set<string> {
const groupsWithTrigger = groups.filter((group) =>
group.nodeIds.some((nodeId) => {
const node = getNodeById(nodeId);
return node ? isTriggerNode(node.type) : false;
}),
);
return new Set(groupsWithTrigger.map((group) => group.id));
}
/**
* Deletes a group and pushes an undo command that restores it.
* Snapshots the group at call time, so callers pass the group whose current