mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(core): Add node group descriptions to the workflow SDK (no-changelog) (#35254)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6f44922d00
commit
68e4c528b9
@@ -840,6 +840,22 @@ describe('AST Interpreter', () => {
|
||||
interpretSDKCode(code, sdkFunctions);
|
||||
expect(connectMock).toHaveBeenCalledWith('source', 0, 'target', 0);
|
||||
});
|
||||
|
||||
it("should forward group()'s options object to the workflow builder", () => {
|
||||
const groupMock = vi.fn();
|
||||
sdkFunctions.workflow = vi.fn(() => ({
|
||||
group: groupMock,
|
||||
}));
|
||||
// Members are irrelevant here — this pins the third argument surviving evaluation.
|
||||
const code = `
|
||||
const wf = workflow('id', 'name');
|
||||
export default wf.group('Ingestion', [], { description: 'Pulls the CRM contacts' });
|
||||
`;
|
||||
interpretSDKCode(code, sdkFunctions);
|
||||
expect(groupMock).toHaveBeenCalledWith('Ingestion', [], {
|
||||
description: 'Pulls the CRM contacts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security - dangerous globals (defense-in-depth)', () => {
|
||||
|
||||
@@ -1458,7 +1458,14 @@ export function generateCode(
|
||||
const nodeName = idToNodeName.get(id);
|
||||
return nodeName !== undefined ? [getVarName(nodeName, ctx)] : [];
|
||||
});
|
||||
workflowCalls.push(` .group('${escapeString(group.name)}', [${memberVars.join(', ')}])`);
|
||||
|
||||
const options = group.description
|
||||
? `, { description: '${escapeString(group.description)}' }`
|
||||
: '';
|
||||
|
||||
workflowCalls.push(
|
||||
` .group('${escapeString(group.name)}', [${memberVars.join(', ')}]${options})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { GROUP_DESCRIPTION_MAX_LENGTH, MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import { generateWorkflowCode } from './codegen';
|
||||
import { parseWorkflowCodeToBuilder } from './codegen/parse-workflow-code';
|
||||
import type { WorkflowJSON } from './types/base';
|
||||
import type { GroupOptions, WorkflowJSON } from './types/base';
|
||||
import { workflow } from './workflow-builder';
|
||||
import { node, trigger } from './workflow-builder/node-builders/node-builder';
|
||||
import { generateDeterministicGroupId } from './workflow-builder/string-utils';
|
||||
|
||||
const WF_ID = 'wf-groups-1';
|
||||
|
||||
function buildGroupedWorkflow() {
|
||||
function buildGroupedWorkflow(options?: GroupOptions) {
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
version: 1,
|
||||
config: { name: 'Start' },
|
||||
});
|
||||
@@ -28,7 +30,7 @@ function buildGroupedWorkflow() {
|
||||
.add(start)
|
||||
.to(fetchNode)
|
||||
.to(transform)
|
||||
.group('Ingestion', [fetchNode, transform]);
|
||||
.group('Ingestion', [fetchNode, transform], options);
|
||||
}
|
||||
|
||||
describe('SDK node groups', () => {
|
||||
@@ -47,7 +49,7 @@ describe('SDK node groups', () => {
|
||||
|
||||
it('drops members that do not resolve to a node in the workflow', () => {
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
version: 1,
|
||||
config: { name: 'Start' },
|
||||
});
|
||||
@@ -63,7 +65,7 @@ describe('SDK node groups', () => {
|
||||
|
||||
it('omits nodeGroups entirely when no group was declared', () => {
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
version: 1,
|
||||
config: { name: 'Start' },
|
||||
});
|
||||
@@ -72,6 +74,99 @@ describe('SDK node groups', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('group description', () => {
|
||||
it('emits the description alongside the group name and members', () => {
|
||||
const json = buildGroupedWorkflow({ description: 'Pulls the CRM contacts' }).toJSON();
|
||||
|
||||
const idByName = new Map(json.nodes.map((n) => [n.name, n.id]));
|
||||
expect(json.nodeGroups).toHaveLength(1);
|
||||
expect(json.nodeGroups![0].name).toBe('Ingestion');
|
||||
expect(json.nodeGroups![0].nodeIds).toEqual([
|
||||
idByName.get('Fetch data'),
|
||||
idByName.get('Transform'),
|
||||
]);
|
||||
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
|
||||
});
|
||||
|
||||
it('caps the description at the canvas limit', () => {
|
||||
const description = 'x'.repeat(GROUP_DESCRIPTION_MAX_LENGTH + 50);
|
||||
const json = buildGroupedWorkflow({ description }).toJSON();
|
||||
|
||||
expect(json.nodeGroups![0].description).toBe('x'.repeat(GROUP_DESCRIPTION_MAX_LENGTH));
|
||||
});
|
||||
|
||||
it('leaves the description key off a group declared without options', () => {
|
||||
const json = buildGroupedWorkflow().toJSON();
|
||||
|
||||
expect(json.nodeGroups![0]).not.toHaveProperty('description');
|
||||
});
|
||||
|
||||
it('leaves the description key off when the authored description is blank', () => {
|
||||
const json = buildGroupedWorkflow({ description: '' }).toJSON();
|
||||
|
||||
expect(json.nodeGroups![0]).not.toHaveProperty('description');
|
||||
});
|
||||
|
||||
it('keeps a description authored in code through the build', () => {
|
||||
const code = `
|
||||
const start = trigger({ type: '${MANUAL_TRIGGER_NODE_TYPE}', version: 1, config: { name: 'Start' } });
|
||||
const a = node({ type: 'n8n-nodes-base.set', version: 3, config: { name: 'A' } });
|
||||
|
||||
export default workflow('${WF_ID}', 'wf')
|
||||
.add(start)
|
||||
.to(a)
|
||||
.group('G', [a], { description: 'Pulls the CRM contacts' });
|
||||
`;
|
||||
|
||||
const json = parseWorkflowCodeToBuilder(code).toJSON();
|
||||
|
||||
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
|
||||
});
|
||||
|
||||
function describedWorkflowJSON(description: unknown): WorkflowJSON {
|
||||
return {
|
||||
id: WF_ID,
|
||||
name: 'wf',
|
||||
nodes: [
|
||||
{
|
||||
id: 'id-a',
|
||||
name: 'A',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
nodeGroups: [
|
||||
{ id: 'random-uuid', name: 'G', nodeIds: ['id-a'], description: description as string },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('preserves the description across a fromJSON round-trip', () => {
|
||||
const json = workflow.fromJSON(describedWorkflowJSON('Pulls the CRM contacts')).toJSON();
|
||||
|
||||
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
|
||||
});
|
||||
|
||||
it('keeps the description when the imported workflow is edited', () => {
|
||||
const builder = workflow.fromJSON(describedWorkflowJSON('Pulls the CRM contacts'));
|
||||
builder.add(node({ type: 'n8n-nodes-base.set', version: 3, config: { name: 'B' } }));
|
||||
|
||||
const json = builder.toJSON();
|
||||
|
||||
expect(json.nodes.map((n) => n.name)).toContain('B');
|
||||
expect(json.nodeGroups![0].description).toBe('Pulls the CRM contacts');
|
||||
});
|
||||
|
||||
it('drops a description that the source JSON carries as a non-string', () => {
|
||||
const json = workflow.fromJSON(describedWorkflowJSON(42)).toJSON();
|
||||
|
||||
expect(json.nodeGroups![0]).not.toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('regenerateNodeIds()', () => {
|
||||
it('keeps group member ids consistent with the regenerated node ids (never dangling)', () => {
|
||||
const builder = buildGroupedWorkflow();
|
||||
@@ -87,7 +182,7 @@ describe('SDK node groups', () => {
|
||||
|
||||
it('resolves a grouped auto-renamed duplicate handle to the right node after regeneration', () => {
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
version: 1,
|
||||
config: { name: 'Start' },
|
||||
});
|
||||
@@ -210,7 +305,7 @@ describe('SDK node groups', () => {
|
||||
describe('existingGroupIdsByName (toJSON option)', () => {
|
||||
it('reuses an existing group id matched by name and derives one for a new group', () => {
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
version: 1,
|
||||
config: { name: 'Start' },
|
||||
});
|
||||
@@ -283,5 +378,39 @@ describe('SDK node groups', () => {
|
||||
expect(json2.nodeGroups![0].id).toBe(json1.nodeGroups![0].id);
|
||||
expect(json2.nodeGroups![0].nodeIds).toEqual(json1.nodeGroups![0].nodeIds);
|
||||
});
|
||||
|
||||
it('emits the description as the third argument and preserves it through a re-parse', () => {
|
||||
const builder = buildGroupedWorkflow({ description: 'Pulls the CRM contacts' });
|
||||
builder.regenerateNodeIds();
|
||||
|
||||
const code = generateWorkflowCode(builder.toJSON());
|
||||
expect(code).toContain(
|
||||
".group('Ingestion', [fetch_data, transform], { description: 'Pulls the CRM contacts' })",
|
||||
);
|
||||
|
||||
const rebuilt = parseWorkflowCodeToBuilder(code);
|
||||
rebuilt.regenerateNodeIds();
|
||||
|
||||
expect(rebuilt.toJSON().nodeGroups![0].description).toBe('Pulls the CRM contacts');
|
||||
});
|
||||
|
||||
it('escapes a quote in the description so the emitted code re-parses', () => {
|
||||
const builder = buildGroupedWorkflow({ description: "Bob's contacts" });
|
||||
builder.regenerateNodeIds();
|
||||
|
||||
const code = generateWorkflowCode(builder.toJSON());
|
||||
expect(code).toContain("{ description: 'Bob\\'s contacts' }");
|
||||
|
||||
const rebuilt = parseWorkflowCodeToBuilder(code);
|
||||
|
||||
expect(rebuilt.toJSON().nodeGroups![0].description).toBe("Bob's contacts");
|
||||
});
|
||||
|
||||
it('emits a two-argument .group() call when the group has no description', () => {
|
||||
const code = generateWorkflowCode(buildGroupedWorkflow().toJSON());
|
||||
|
||||
expect(code).toContain(".group('Ingestion', [fetch_data, transform])");
|
||||
expect(code).not.toContain('description:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GROUP_DESCRIPTION_MAX_LENGTH } from 'n8n-workflow';
|
||||
|
||||
import { NODE_GROUPS_REFERENCE, SDK_LANGUAGE_REFERENCE } from './sdk-language';
|
||||
import {
|
||||
SDK_METHODS,
|
||||
@@ -78,10 +80,19 @@ describe('NODE_GROUPS_REFERENCE', () => {
|
||||
});
|
||||
|
||||
it('explains how to declare a group', () => {
|
||||
// The worked example: .group(name, [members]) declared on the workflow.
|
||||
// The worked example: .group(name, [members], options?) declared on the workflow.
|
||||
expect(NODE_GROUPS_REFERENCE).toMatch(/\.group\('[^']+', \[/);
|
||||
});
|
||||
|
||||
it('documents the optional description and its length limit', () => {
|
||||
expect(NODE_GROUPS_REFERENCE).toContain('description:');
|
||||
expect(NODE_GROUPS_REFERENCE).toContain(`${GROUP_DESCRIPTION_MAX_LENGTH} characters`);
|
||||
});
|
||||
|
||||
it('tells an editing agent to keep existing descriptions', () => {
|
||||
expect(NODE_GROUPS_REFERENCE).toMatch(/keep the .+ and their descriptions\s+intact/is);
|
||||
});
|
||||
|
||||
it('states the single entry/exit boundary rule that grouping enforces', () => {
|
||||
// reason: 'invalid-subgraph' — grouping rejects a group with more than one
|
||||
// incoming or outgoing main connection (single entry/exit *boundary*).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Builder-facing SDK language reference, rendered from the interpreter's own
|
||||
* tables so guidance cannot drift from what the parser accepts.
|
||||
*/
|
||||
import { NODE_GROUPING_RULES } from 'n8n-workflow';
|
||||
import { GROUP_DESCRIPTION_MAX_LENGTH, NODE_GROUPING_RULES } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
SDK_METHODS,
|
||||
@@ -86,7 +86,7 @@ export const NODE_GROUPS_REFERENCE = `## Node groups
|
||||
|
||||
A node group is a named, visual grouping of nodes (a frame on the canvas). It is
|
||||
purely organisational — nothing about execution depends on it. Declare one with
|
||||
\`.group(name, members)\` on the workflow. Members are the node handles (the
|
||||
\`.group(name, members, options?)\` on the workflow. Members are the node handles (the
|
||||
\`const\` from \`node(...)\`) — the same way connections reference nodes:
|
||||
|
||||
\`\`\`typescript
|
||||
@@ -95,11 +95,16 @@ const transform = node({ /* ... name: 'Transform' */ });
|
||||
export default workflow('id', 'My workflow')
|
||||
.add(fetch)
|
||||
.to(transform)
|
||||
.group('Ingestion', [fetch, transform]);
|
||||
.group('Ingestion', [fetch, transform], {
|
||||
description: 'Pulls the CRM contacts and normalizes them',
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
When editing an existing workflow, **keep the \`.group(...)\` calls intact** unless
|
||||
the change is specifically about grouping.
|
||||
\`description\` is optional and shown when the group is collapsed. Keep it to one
|
||||
sentence — anything past ${GROUP_DESCRIPTION_MAX_LENGTH} characters is cut off.
|
||||
|
||||
When editing an existing workflow, **keep the \`.group(...)\` calls and their descriptions
|
||||
intact** unless the change is specifically about grouping.
|
||||
|
||||
An invalid group is rejected on save, so these following rules MUST be followed when
|
||||
creating or editing groups.
|
||||
|
||||
@@ -1024,6 +1024,26 @@ export interface ToJSONOptions {
|
||||
*/
|
||||
export type GroupMember = NodeInstance<string, string, unknown>;
|
||||
|
||||
/** Optional group settings passed as `.group()`'s third argument. */
|
||||
export type GroupOptions = {
|
||||
/**
|
||||
* Description shown when the group is collapsed on the canvas. Capped to
|
||||
* `GROUP_DESCRIPTION_MAX_LENGTH` characters on serialization; blank is
|
||||
* treated as no description.
|
||||
*/
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node group as authored: members are node handles, resolved to the emitted node
|
||||
* IDs only at serialization. `id` is present for a group carried in from JSON.
|
||||
*/
|
||||
export type AuthoredNodeGroup = GroupOptions & {
|
||||
id?: string;
|
||||
name: string;
|
||||
members: GroupMember[];
|
||||
};
|
||||
|
||||
export interface WorkflowBuilder {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
@@ -1103,6 +1123,8 @@ export interface WorkflowBuilder {
|
||||
* Members resolve to the emitted node IDs in `toJSON()`, so groups survive
|
||||
* `regenerateNodeIds()` like connections do. Chainable.
|
||||
*
|
||||
* Pass `{ description }` to add the text shown when the group is collapsed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fetch = node({ ... });
|
||||
@@ -1110,10 +1132,12 @@ export interface WorkflowBuilder {
|
||||
* workflow('id', 'Name')
|
||||
* .add(fetch)
|
||||
* .to(transform)
|
||||
* .group('Data ingestion', [fetch, transform]);
|
||||
* .group('Data ingestion', [fetch, transform], {
|
||||
* description: 'Pulls the CRM contacts and normalizes them',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
group(name: string, members: GroupMember[]): WorkflowBuilder;
|
||||
group(name: string, members: GroupMember[], options?: GroupOptions): WorkflowBuilder;
|
||||
|
||||
/**
|
||||
* Validate the workflow graph structure.
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
NodeInstance,
|
||||
ConnectionTarget,
|
||||
GraphNode,
|
||||
AuthoredNodeGroup,
|
||||
GroupMember,
|
||||
GroupOptions,
|
||||
IDataObject,
|
||||
NodeChain,
|
||||
GeneratePinDataOptions,
|
||||
@@ -30,6 +32,7 @@ import { jsonSerializer } from './workflow-builder/plugins/serializers';
|
||||
import type {
|
||||
PluginContext,
|
||||
MutablePluginContext,
|
||||
ResolvedNodeGroup,
|
||||
ValidationIssue,
|
||||
SerializerContext,
|
||||
} from './workflow-builder/plugins/types';
|
||||
@@ -58,7 +61,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
|
||||
private _dispatchedComposites = new WeakSet<object>();
|
||||
private static readonly MAX_BRANCH_DEPTH = 500;
|
||||
/** Node groups, carried by member node handle and resolved to IDs in toJSON(). */
|
||||
private _nodeGroups: Array<{ id?: string; name: string; members: GroupMember[] }>;
|
||||
private _nodeGroups: AuthoredNodeGroup[];
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
@@ -69,7 +72,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
|
||||
pinData?: Record<string, IDataObject[]>,
|
||||
meta?: { templateId?: string; instanceId?: string; [key: string]: unknown },
|
||||
registry?: PluginRegistry,
|
||||
nodeGroups?: Array<{ id?: string; name: string; members: GroupMember[] }>,
|
||||
nodeGroups?: AuthoredNodeGroup[],
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
@@ -80,9 +83,7 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
|
||||
this._pinData = pinData;
|
||||
this._meta = meta;
|
||||
this._registry = registry;
|
||||
this._nodeGroups = nodeGroups
|
||||
? nodeGroups.map((g) => ({ id: g.id, name: g.name, members: [...g.members] }))
|
||||
: [];
|
||||
this._nodeGroups = nodeGroups ? nodeGroups.map((g) => ({ ...g, members: [...g.members] })) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,8 +500,8 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
group(name: string, members: GroupMember[]): WorkflowBuilder {
|
||||
this._nodeGroups.push({ name, members: [...members] });
|
||||
group(name: string, members: GroupMember[], options?: GroupOptions): WorkflowBuilder {
|
||||
this._nodeGroups.push({ ...options, name, members: [...members] });
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -510,17 +511,17 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
|
||||
* _staleIdToKeyMap, exactly as connection targets do); the live instance under that
|
||||
* key holds the ID the serializer emits. Unresolvable members are dropped.
|
||||
*/
|
||||
private resolveNodeGroups(): Array<{ id?: string; name: string; memberIds: string[] }> {
|
||||
return this._nodeGroups.map((group) => {
|
||||
private resolveNodeGroups(): ResolvedNodeGroup[] {
|
||||
return this._nodeGroups.map(({ members, ...group }) => {
|
||||
const memberIds: string[] = [];
|
||||
for (const member of group.members) {
|
||||
for (const member of members) {
|
||||
const key = this.resolveTargetNodeName(member, this._staleIdToKeyMap);
|
||||
const id = key ? this._nodes.get(key)?.instance.id : undefined;
|
||||
if (id && !memberIds.includes(id)) {
|
||||
memberIds.push(id);
|
||||
}
|
||||
}
|
||||
return { id: group.id, name: group.name, memberIds };
|
||||
return { ...group, memberIds };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+13
-7
@@ -4,7 +4,7 @@
|
||||
* Serializes workflows to n8n's standard JSON format.
|
||||
*/
|
||||
|
||||
import { deepCopy, normalizeNodeShape } from 'n8n-workflow';
|
||||
import { deepCopy, normalizeGroupDescription, normalizeNodeShape } from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { foldLegacyErrorConnections } from '../../../types/base';
|
||||
@@ -280,14 +280,20 @@ export const jsonSerializer: SerializerPlugin<WorkflowJSON> = {
|
||||
if (ctx.nodeGroups && ctx.nodeGroups.length > 0) {
|
||||
const emittedIds = new Set(nodes.map((node) => node.id));
|
||||
|
||||
json.nodeGroups = ctx.nodeGroups.map((group) => ({
|
||||
id:
|
||||
json.nodeGroups = ctx.nodeGroups.map((group) => {
|
||||
const description = normalizeGroupDescription(group.description);
|
||||
const id =
|
||||
group.id ??
|
||||
ctx.existingGroupIdsByName?.get(group.name) ??
|
||||
generateDeterministicGroupId(ctx.workflowId, group.name),
|
||||
name: group.name,
|
||||
nodeIds: group.memberIds.filter((id) => emittedIds.has(id)),
|
||||
}));
|
||||
generateDeterministicGroupId(ctx.workflowId, group.name);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: group.name,
|
||||
nodeIds: group.memberIds.filter((memberId) => emittedIds.has(memberId)),
|
||||
...(description ? { description } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return json;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* WorkflowBuilder with custom validators, composite handlers, and serializers.
|
||||
*/
|
||||
|
||||
import type { GraphNode, NodeInstance, IDataObject } from '../../types/base';
|
||||
import type { AuthoredNodeGroup, GraphNode, NodeInstance, IDataObject } from '../../types/base';
|
||||
|
||||
/** An authored group with its members resolved to the node IDs the serializer emits. */
|
||||
export type ResolvedNodeGroup = Omit<AuthoredNodeGroup, 'members'> & { memberIds: string[] };
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions for Validators
|
||||
@@ -362,7 +365,7 @@ export interface SerializerContext extends PluginContext {
|
||||
* nodes carry. `id`, when present, is the source group ID (from fromJSON); the serializer
|
||||
* reuses it, otherwise assigns one.
|
||||
*/
|
||||
readonly nodeGroups?: ReadonlyArray<{ id?: string; name: string; memberIds: string[] }>;
|
||||
readonly nodeGroups?: readonly ResolvedNodeGroup[];
|
||||
|
||||
/**
|
||||
* Existing group IDs keyed by group name. When a group name matches, the serializer
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
foldLegacyErrorConnections,
|
||||
normalizeConnections,
|
||||
generateUniqueName,
|
||||
type AuthoredNodeGroup,
|
||||
type WorkflowJSON,
|
||||
type NodeInstance,
|
||||
type GraphNode,
|
||||
@@ -31,12 +32,7 @@ export interface ParsedWorkflow {
|
||||
readonly pinData?: Record<string, IDataObject[]>;
|
||||
readonly meta?: { templateId?: string; instanceId?: string; [key: string]: unknown };
|
||||
/** Node groups reconstructed by mapping the JSON's member IDs back to node handles. */
|
||||
readonly nodeGroups?: Array<{
|
||||
/** Source group ID from the JSON. */
|
||||
id?: string;
|
||||
name: string;
|
||||
members: Array<NodeInstance<string, string, unknown>>;
|
||||
}>;
|
||||
readonly nodeGroups?: AuthoredNodeGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,10 +137,14 @@ export function parseWorkflowJSON(json: WorkflowJSON): ParsedWorkflow {
|
||||
for (const [sourceName, nodeConns] of Object.entries(connections)) {
|
||||
const mapKey = nameToKey.get(sourceName);
|
||||
const graphNode = mapKey ? nodes.get(mapKey) : undefined;
|
||||
if (!graphNode) continue;
|
||||
if (!graphNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [connType, outputs] of Object.entries(nodeConns)) {
|
||||
if (!outputs || !Array.isArray(outputs)) continue;
|
||||
if (!outputs || !Array.isArray(outputs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const typeMap =
|
||||
graphNode.connections.get(connType) ?? new Map<number, ConnectionTarget[]>();
|
||||
@@ -175,12 +175,11 @@ export function parseWorkflowJSON(json: WorkflowJSON): ParsedWorkflow {
|
||||
lastNode = name;
|
||||
}
|
||||
|
||||
// Rebuild groups by mapping each member ID back to its node handle (unresolvable IDs
|
||||
// are dropped). The group's `id` is carried through so a round-trip preserves it.
|
||||
const nodeGroups = json.nodeGroups?.length
|
||||
? json.nodeGroups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
members: group.nodeIds.flatMap((id) => {
|
||||
const instance = idToInstance.get(id);
|
||||
return instance !== undefined ? [instance] : [];
|
||||
|
||||
Reference in New Issue
Block a user