mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
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:
co-authored by
Claude Opus 5
parent
3aa9179424
commit
e838224264
@@ -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"
|
||||
|
||||
+36
-10
@@ -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 }) });
|
||||
|
||||
+41
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user