feat(FN-6880): insert add-ons as node subgraphs / optional groups (U5)
Surface all seven WORKFLOW_STEP_TEMPLATES add-ons in the editor's Built-in steps palette with two insert variants: as a single node (existing stepTemplateToNode) and as an optional-group-wrapped subgraph (optionalGroupFragmentIr + the existing insertFragment path, which already expands optional-group children). Also update the cross-package built-in failure-edge parity assertion for U6's workflow-step -> browser-verification optional-group migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep, WorkflowIrNodeKind } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflows,
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
emptyWorkflowLayout,
|
||||
copyIrWithFreshIds,
|
||||
insertFragment,
|
||||
optionalGroupFragmentIr,
|
||||
fragmentSeamConflicts,
|
||||
columnsOf,
|
||||
fieldsOf,
|
||||
@@ -1428,6 +1429,34 @@ function InnerEditor({
|
||||
[isBuiltin, addNode],
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:32:
|
||||
"Insert as optional group" (U5/R5): drop an add-on already wrapped in an `optional-group` container in
|
||||
one action, seeding the group's `defaultOn` from the template's `defaultOn`. Reuses `stepTemplateToNode`
|
||||
(KTD-5 — the catalog stays flat) to project the add-on to a prompt/script node, then `optionalGroupFragmentIr`
|
||||
to wrap it and the EXISTING `insertFragment` path to remap ids + expand the group's template child — so two
|
||||
inserts of the same add-on never collide. The group name carries the template name so the per-task toggle
|
||||
surfaces label it.
|
||||
*/
|
||||
const handleInsertStepTemplateAsOptionalGroup = useCallback(
|
||||
(tpl: WorkflowStepTemplate) => {
|
||||
if (isBuiltin) return;
|
||||
const { kind, config } = stepTemplateToNode(tpl);
|
||||
const fragmentIr = optionalGroupFragmentIr(
|
||||
{ kind: kind as WorkflowIrNodeKind, config },
|
||||
{ name: tpl.name, defaultOn: tpl.defaultOn ?? false },
|
||||
);
|
||||
const result = insertFragment(nodes, edges, fragmentIr, {
|
||||
x: 240,
|
||||
y: 200 + (nodes.length % 4) * 40,
|
||||
});
|
||||
setNodes(result.nodes);
|
||||
setEdges(result.edges);
|
||||
setSelectedNodeId(result.insertedNodeIds[0] ?? null);
|
||||
},
|
||||
[isBuiltin, nodes, edges, setNodes, setEdges],
|
||||
);
|
||||
|
||||
// U9/R8: insert a fragment definition's body into the active graph. Pre-validates
|
||||
// seam duplication via fragmentSeamConflicts; on conflict, surfaces a persistent
|
||||
// inline error inside the Templates section and does NOT insert. Otherwise
|
||||
@@ -2751,19 +2780,37 @@ function InnerEditor({
|
||||
{templateGroups.stepEntries.length > 0 && (
|
||||
<div className="wf-mobile-template-group">
|
||||
<h4>{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}</h4>
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-14:38: mobile mirrors the desktop two-variant insert (node / optional group). */}
|
||||
{templateGroups.stepEntries.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}`}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplate(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<div key={s.id} className="wf-mobile-template-option-row">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}`}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplate(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-mobile-template-option-optional"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}-optional-group`}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplateAsOptionalGroup",
|
||||
"Insert {{name}} as optional group",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplateAsOptionalGroup(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{t("workflowNodes.asOptionalGroup", "as optional group")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -3155,22 +3202,48 @@ function InnerEditor({
|
||||
{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}
|
||||
</h4>
|
||||
<div className="wf-templates-entries">
|
||||
{/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:36:
|
||||
Each built-in add-on surfaces TWO insert variants: the row inserts as a single node
|
||||
(today's behavior), and a small secondary "as optional group" affordance wraps it in
|
||||
an `optional-group` container (U5/R5). Both keep the established `wf-tpl-step-*` testid
|
||||
convention (the wrap variant suffixes `-optional-group`).
|
||||
*/}
|
||||
{templateGroups.stepEntries.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="wf-templates-entry"
|
||||
data-testid={`wf-tpl-step-${s.id}`}
|
||||
disabled={isBuiltin}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplate",
|
||||
"Insert template {{name}}",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => handleInsertStepTemplate(s)}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<div key={s.id} className="wf-templates-entry-row">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-templates-entry"
|
||||
data-testid={`wf-tpl-step-${s.id}`}
|
||||
disabled={isBuiltin}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplate",
|
||||
"Insert template {{name}}",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => handleInsertStepTemplate(s)}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-templates-entry-optional"
|
||||
data-testid={`wf-tpl-step-${s.id}-optional-group`}
|
||||
disabled={isBuiltin}
|
||||
title={t(
|
||||
"workflowNodes.insertAsOptionalGroup",
|
||||
"Insert as optional group",
|
||||
)}
|
||||
aria-label={t(
|
||||
"workflowNodes.insertTemplateAsOptionalGroup",
|
||||
"Insert {{name}} as optional group",
|
||||
{ name: s.name },
|
||||
)}
|
||||
onClick={() => handleInsertStepTemplateAsOptionalGroup(s)}
|
||||
>
|
||||
{t("workflowNodes.asOptionalGroup", "as optional group")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
|
||||
import { parseWorkflowIr, type WorkflowDefinition, type Settings } from "@fusion/core";
|
||||
import { parseWorkflowIr, WORKFLOW_STEP_TEMPLATES, type WorkflowDefinition, type Settings } from "@fusion/core";
|
||||
import type { Agent } from "../../api";
|
||||
import {
|
||||
irToFlow,
|
||||
@@ -386,12 +386,13 @@ describe("workflow-flow-mapping", () => {
|
||||
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
|
||||
const { edges } = edgeRenderableAssertion(builtinDef());
|
||||
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place.
|
||||
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
|
||||
"browser-verification",
|
||||
"execute",
|
||||
"merge-attempt",
|
||||
"planning",
|
||||
"review",
|
||||
"workflow-step",
|
||||
]);
|
||||
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
|
||||
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
|
||||
@@ -2893,11 +2894,13 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
|
||||
const filter = await screen.findByTestId("wf-template-filter");
|
||||
// All 8 step entries present pre-filter.
|
||||
expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(8);
|
||||
// All 8 step entries present pre-filter. Match only the primary "insert as
|
||||
// node" buttons, excluding the sibling "-optional-group" insert variant.
|
||||
const primaryStep = /^wf-tpl-step-(?!.*-optional-group$).*/;
|
||||
expect(screen.getAllByTestId(primaryStep).length).toBe(8);
|
||||
// Filter to "Step 3" → only that step survives.
|
||||
fireEvent.change(filter, { target: { value: "Step 3" } });
|
||||
await waitFor(() => expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(1));
|
||||
await waitFor(() => expect(screen.getAllByTestId(primaryStep).length).toBe(1));
|
||||
expect(screen.getByTestId("wf-tpl-step-s-3")).toBeInTheDocument();
|
||||
// Fragment (name "Lint fragment") no longer matches.
|
||||
expect(screen.queryByTestId("wf-tpl-fragment-WF-FRAG-A")).not.toBeInTheDocument();
|
||||
@@ -2936,6 +2939,123 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
expect(screen.getByTestId("wf-tpl-step-qa-check")).toBeDisabled();
|
||||
expect(screen.getByTestId("wf-tpl-plugin-acme-scan")).toBeDisabled();
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:50: All seven built-in add-ons must
|
||||
// surface in the palette and insert two ways — as a single node (today's
|
||||
// behavior, reusing stepTemplateToNode) and wrapped in an optional-group
|
||||
// container (reusing insertFragment). These tests pin U5/R5.
|
||||
it("surfaces all seven built-in add-ons in the palette", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
|
||||
// Every add-on id is present as a primary "insert as node" button AND offers
|
||||
// the "as optional group" sibling variant.
|
||||
for (const tpl of WORKFLOW_STEP_TEMPLATES) {
|
||||
expect(screen.getByTestId(`wf-tpl-step-${tpl.id}`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(`wf-tpl-step-${tpl.id}-optional-group`),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(WORKFLOW_STEP_TEMPLATES).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("inserts an add-on as a single node carrying its template config", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
|
||||
|
||||
const before = screen.queryAllByTestId("wf-node-prompt").length;
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-documentation-review"));
|
||||
await waitFor(
|
||||
() => expect(screen.queryAllByTestId("wf-node-prompt").length).toBe(before + 1),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const docTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "documentation-review")!;
|
||||
const inserted = ir.nodes.find((n) => n.config?.name === docTpl.name);
|
||||
expect(inserted).toBeTruthy();
|
||||
expect(inserted!.kind).toBe(docTpl.mode === "script" ? "script" : "prompt");
|
||||
});
|
||||
|
||||
it("inserts an add-on as an optional-group whose template holds the projected node and defaultOn matches", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
|
||||
// The wrapped add-on renders as a registered optional-group container.
|
||||
await waitFor(
|
||||
() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const secTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "security-audit")!;
|
||||
const group = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(group).toBeTruthy();
|
||||
expect(group!.config!.defaultOn).toBe(secTpl.defaultOn ?? false);
|
||||
const template = group!.config!.template as { nodes: { kind: string; config?: Record<string, unknown> }[] };
|
||||
expect(template.nodes).toHaveLength(1);
|
||||
expect(template.nodes[0].config?.name).toBe(secTpl.name);
|
||||
});
|
||||
|
||||
it("remaps ids when the same add-on subgraph is inserted twice (no collision)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
templates: WORKFLOW_STEP_TEMPLATES,
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
|
||||
await waitFor(
|
||||
() => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(1),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
|
||||
await waitFor(
|
||||
() => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(2),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { id: string; kind: string }[] } }).ir;
|
||||
const groupIds = ir.nodes.filter((n) => n.kind === "optional-group").map((n) => n.id);
|
||||
expect(groupIds).toHaveLength(2);
|
||||
expect(new Set(groupIds).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── U10: Design-with-AI editor affordances ──────────────────────────────────
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
irToFlow,
|
||||
flowToIr,
|
||||
insertFragment,
|
||||
optionalGroupFragmentIr,
|
||||
fragmentSeamConflicts,
|
||||
copyIrWithFreshIds,
|
||||
columnsOf,
|
||||
@@ -1436,6 +1437,38 @@ describe("insertFragment", () => {
|
||||
expect(template?.nodes).toHaveLength(2);
|
||||
expect(template?.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-14:55: optionalGroupFragmentIr wraps a
|
||||
// projected add-on node in an optional-group; insertFragment must expand its
|
||||
// template child and round-trip it via flowToIr, and two inserts must not collide.
|
||||
it("wraps an add-on node in an optional-group fragment that round-trips with defaultOn", () => {
|
||||
const fragmentIr = optionalGroupFragmentIr(
|
||||
{ kind: "prompt", config: { name: "Security Audit", prompt: "audit it" } },
|
||||
{ name: "Security Audit", defaultOn: true },
|
||||
);
|
||||
|
||||
const existing = irToFlow(u8ChainDef());
|
||||
const first = insertFragment(existing.nodes, existing.edges, fragmentIr, { x: 400, y: 200 });
|
||||
const second = insertFragment(first.nodes, first.edges, fragmentIr, { x: 700, y: 200 });
|
||||
|
||||
// Two optional-group containers, each with its template child expanded.
|
||||
const groups = second.nodes.filter((n) => n.data.kind === "optional-group");
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const g of groups) {
|
||||
expect(second.nodes.some((n) => n.parentId === g.id)).toBe(true);
|
||||
}
|
||||
// All ids disjoint across both inserts.
|
||||
const allIds = second.nodes.map((n) => n.id);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
|
||||
// Round-trip: the group carries defaultOn + a single-node template.
|
||||
const { ir: out } = flowToIr("wf", second.nodes, second.edges);
|
||||
const og = out.nodes.find((n) => n.kind === "optional-group")!;
|
||||
expect(og.config?.defaultOn).toBe(true);
|
||||
const template = (og.config as { template?: { nodes: { config?: Record<string, unknown> }[] } }).template;
|
||||
expect(template?.nodes).toHaveLength(1);
|
||||
expect(template?.nodes[0].config?.name).toBe("Security Audit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fragmentSeamConflicts", () => {
|
||||
|
||||
@@ -1250,6 +1250,52 @@ export function insertFragment(
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:30:
|
||||
"Insert as optional group" (U5/R5) wraps a single projected add-on node in an `optional-group`
|
||||
container so an author can drop e.g. "Security Audit (optional)" in one action. The wrapper is built
|
||||
as a v1-shaped fragment IR (start → optional-group → end) and handed to the EXISTING `insertFragment`
|
||||
path, which strips start/end, remaps the group id, and expands the group's `config.template` child as a
|
||||
`parentId` flow node — so no new insertion engine is needed and ids never collide across repeated inserts.
|
||||
KTD-5: the add-on catalog stays FLAT; projection to a node is done by the caller via `stepTemplateToNode`,
|
||||
and only the wrap-in-container step lives here.
|
||||
*/
|
||||
|
||||
/** Wrap a single projected add-on node in an `optional-group` fragment IR ready
|
||||
* for `insertFragment`. `defaultOn` seeds the group's per-task enable default
|
||||
* (from the source template's `defaultOn`). The group's `name` labels it in the
|
||||
* editor and the per-task toggle surfaces. The inner node uses a template-local
|
||||
* id; `insertFragment` remaps the group id and namespaces the child, so this id
|
||||
* need only be unique WITHIN the template. */
|
||||
export function optionalGroupFragmentIr(
|
||||
addOnNode: { kind: WorkflowIrNodeKind; config?: Record<string, unknown> },
|
||||
opts: { name?: string; defaultOn?: boolean },
|
||||
): WorkflowIr {
|
||||
const innerId = "addon";
|
||||
const optionalGroupId = "optional-group";
|
||||
const config: WorkflowOptionalGroupConfig & Record<string, unknown> = {
|
||||
defaultOn: opts.defaultOn ?? false,
|
||||
template: {
|
||||
nodes: [{ id: innerId, kind: addOnNode.kind, config: addOnNode.config }],
|
||||
edges: [],
|
||||
},
|
||||
};
|
||||
if (opts.name) config.name = opts.name;
|
||||
return {
|
||||
version: "v1",
|
||||
name: opts.name ?? "optional-group",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: optionalGroupId, kind: "optional-group", config },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: optionalGroupId, condition: "success" },
|
||||
{ from: optionalGroupId, to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Remap a template group's internal node ids + edges to fresh ids. Returns a
|
||||
* new template object; the original is untouched. Template-local ids are scoped
|
||||
* to the template, so a fresh local id space suffices (and keeps config compact
|
||||
|
||||
Reference in New Issue
Block a user