Skip to content

Commit 2d9c236

Browse files
committed
feat: configure control flows tasks
Signed-off-by: Simon Emms <simon@simonemms.com>
1 parent 7baf885 commit 2d9c236

12 files changed

Lines changed: 957 additions & 85 deletions

File tree

src/lib/i18n/messages/en.json

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,22 @@
3737
"branches": "Branches",
3838
"removeBranch": "Remove branch {{label}}",
3939
"addBranch": "+ Add branch",
40+
"switch": {
41+
"branches": "Branches",
42+
"branchName": "Branch name",
43+
"condition": "Condition (when)",
44+
"targetWorkflow": "Target workflow",
45+
"addBranch": "+ Add branch",
46+
"addDefaultBranch": "+ Add default branch",
47+
"defaultBranch": "Default branch",
48+
"errorDuplicateName": "Duplicate branch name",
49+
"errorEmptyCondition": "Condition is required",
50+
"errorNoTarget": "Target workflow is required"
51+
},
4052
"sections": "Sections",
4153
"enterTryBody": "Enter try body",
4254
"enterCatchBlock": "Enter catch block",
4355
"addCatchBlock": "+ Add catch block",
44-
"enterLoopBody": "Enter loop body",
4556
"comingSoon": "Full editing UI coming soon.",
4657
"moveUp": "↑ Move up",
4758
"moveUpLabel": "Move task up",
@@ -76,11 +87,12 @@
7687
"competeHint": "First branch to finish wins"
7788
},
7889
"loop": {
79-
"title": "Loop configuration",
80-
"in": "Collection",
81-
"each": "Item variable",
82-
"at": "Index variable",
83-
"while": "Break condition"
90+
"configuration": "Loop configuration",
91+
"collection": "Collection",
92+
"itemVariable": "Item variable",
93+
"indexVariable": "Index variable",
94+
"breakCondition": "Break condition",
95+
"enterBody": "Enter loop body"
8496
},
8597
"callGrpc": {
8698
"title": "Call gRPC",
@@ -349,7 +361,7 @@
349361
"switch": "Switch",
350362
"fork": "Fork",
351363
"try": "Try / Catch",
352-
"loop": "Loop"
364+
"loop": "For Loop"
353365
},
354366
"sidebar": {
355367
"document": "Document",
@@ -372,7 +384,20 @@
372384
"canvas": {
373385
"ariaLabel": "Workflow canvas",
374386
"tryBody": "try body",
375-
"catchBlock": "catch block",
376-
"loopBody": "body"
387+
"catchBlock": "catch block"
388+
},
389+
"node": {
390+
"loop": {
391+
"for": "for",
392+
"body": "body",
393+
"bodyEmpty": "body (empty)",
394+
"bodyCount": "body ({{count}} tasks)"
395+
}
396+
},
397+
"validation": {
398+
"loop": {
399+
"collectionRequired": "Collection is required",
400+
"invalidIdentifier": "Must be a valid identifier (letters, digits, underscores; cannot start with a digit)"
401+
}
377402
}
378403
}

src/lib/tasks/actions.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,32 @@ export function renameSwitchBranch(
461461
};
462462
}
463463

464+
export function updateSwitchBranchCondition(
465+
node: SwitchNode,
466+
branchId: string,
467+
condition: string | undefined,
468+
): SwitchNode {
469+
return {
470+
...node,
471+
branches: node.branches.map((b) =>
472+
b.id === branchId ? { ...b, condition } : b,
473+
),
474+
};
475+
}
476+
477+
export function updateSwitchBranchTarget(
478+
node: SwitchNode,
479+
branchId: string,
480+
thenWorkflowName: string | undefined,
481+
): SwitchNode {
482+
return {
483+
...node,
484+
branches: node.branches.map((b) =>
485+
b.id === branchId ? { ...b, thenWorkflowName } : b,
486+
),
487+
};
488+
}
489+
464490
// ---------------------------------------------------------------------------
465491
// ForkNode
466492
// ---------------------------------------------------------------------------
@@ -593,7 +619,7 @@ export function insertNode(
593619
// Resolve the FlowGraph at the given GraphPath. Throws on invalid paths.
594620
//
595621
// Segment consumption rules per node type:
596-
// loop → 1 segment (nodeId → bodyGraph)
622+
// loop → 2 segments (nodeId + 'body' → bodyGraph)
597623
// switch → 2 segments (nodeId + branchId → branch.graph)
598624
// fork → 2 segments (nodeId + branchId → branch.graph)
599625
// try → 2 segments (nodeId + 'tryGraph'|'catchGraph' → section)
@@ -615,13 +641,7 @@ export function getGraphAtPath(file: WorkflowFile, path: GraphPath): FlowGraph {
615641
throw new Error(`Node ${nodeId} is a task and has no sub-graph`);
616642
}
617643

618-
if (node.type === 'loop') {
619-
graph = node.bodyGraph;
620-
i += 1;
621-
continue;
622-
}
623-
624-
// switch, fork, try — consume one additional segment for the sub-graph id
644+
// switch, fork, try, loop — consume one additional segment for the sub-graph id
625645
i += 1;
626646
if (i >= path.segments.length) {
627647
throw new Error(
@@ -630,6 +650,17 @@ export function getGraphAtPath(file: WorkflowFile, path: GraphPath): FlowGraph {
630650
}
631651
const subId = path.segments[i]!;
632652

653+
if (node.type === 'loop') {
654+
if (subId !== 'body') {
655+
throw new Error(
656+
`Expected "body" after loop node ${nodeId}, got "${subId}"`,
657+
);
658+
}
659+
graph = node.bodyGraph;
660+
i += 1;
661+
continue;
662+
}
663+
633664
if (node.type === 'switch') {
634665
const branch = node.branches.find((b) => b.id === subId);
635666
if (!branch) {
@@ -693,17 +724,7 @@ function applyTransformAt(
693724
const node = graph.nodes[nodeId];
694725
if (!node) throw new Error(`Node ${nodeId} not found at segment ${i}`);
695726

696-
if (node.type === 'loop') {
697-
const newBody = applyTransformAt(
698-
node.bodyGraph,
699-
segments,
700-
i + 1,
701-
transform,
702-
);
703-
return replaceNode(graph, { ...node, bodyGraph: newBody });
704-
}
705-
706-
// switch, fork, try — next segment identifies which sub-graph to descend into
727+
// switch, fork, try, loop — next segment identifies which sub-graph to descend into
707728
const subIndex = i + 1;
708729
if (subIndex >= segments.length) {
709730
throw new Error(
@@ -756,6 +777,17 @@ function applyTransformAt(
756777
}
757778
}
758779

780+
if (node.type === 'loop') {
781+
// subId is the 'body' literal — descend into bodyGraph.
782+
const newBody = applyTransformAt(
783+
node.bodyGraph,
784+
segments,
785+
subIndex + 1,
786+
transform,
787+
);
788+
return replaceNode(graph, { ...node, bodyGraph: newBody });
789+
}
790+
759791
throw new Error(
760792
`Node ${nodeId} (type: ${node.type}) cannot be navigated into`,
761793
);

src/lib/tasks/registry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,9 @@ export const TASK_REGISTRY: readonly TaskDefinition[] = [
254254
id: nid,
255255
type: 'loop',
256256
name: 'loop',
257-
in: '$.',
257+
in: '${ $input.items }',
258+
each: 'item',
259+
at: 'index',
258260
bodyGraph: emptyGraph(),
259261
metadata: { [ZIGFLOW_ID_KEY]: nid },
260262
};

src/lib/ui/Canvas.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@
9898
ROW_HEADER + rows * ROW_HEIGHT + ROW_PADDING,
9999
);
100100
}
101-
// loop: header(28) + body row(22) + padding(8) = 58 ≈ 60
101+
// loop: header(28) + expression line(22) + body row(22) + padding(8) = 80
102+
if (node.type === 'loop') {
103+
return ROW_HEADER + ROW_HEIGHT + ROW_HEIGHT + ROW_PADDING;
104+
}
102105
return NODE_HEIGHT_TASK;
103106
}
104107
@@ -133,6 +136,8 @@
133136
nodeType: string;
134137
typeLabel: string;
135138
navRows?: NavRow[];
139+
// Loop nodes only: the collection expression shown under the header.
140+
loopExpression?: string;
136141
};
137142
138143
type SFNode = {
@@ -177,7 +182,12 @@
177182
return rows;
178183
}
179184
if (node.type === 'loop') {
180-
return [{ id: 'body', label: t('canvas.loopBody'), kind: 'enter' }];
185+
const count = Object.keys(node.bodyGraph.nodes).length;
186+
const label =
187+
count === 0
188+
? t('node.loop.bodyEmpty')
189+
: t('node.loop.bodyCount', { count });
190+
return [{ id: 'body', label, kind: 'enter' }];
181191
}
182192
return [];
183193
}
@@ -188,6 +198,7 @@
188198
nodeType: node.type,
189199
typeLabel: nodeTypeLabel(node.type),
190200
navRows: node.type !== 'task' ? buildNavRows(node) : undefined,
201+
loopExpression: node.type === 'loop' ? node.in : undefined,
191202
};
192203
}
193204

src/lib/ui/FlowNode.svelte

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
-->
3131

3232
<script lang="ts">
33+
import { t } from '$lib/i18n/index.svelte';
3334
import { Handle, Position } from '@xyflow/svelte';
3435
import { getContext } from 'svelte';
3536
@@ -53,6 +54,8 @@
5354
nodeType: string; // 'task' | 'switch' | 'fork' | 'try' | 'loop'
5455
typeLabel: string;
5556
navRows?: NavRow[];
57+
// Loop nodes only: the 'in' collection expression shown under the header.
58+
loopExpression?: string;
5659
};
5760
5861
interface Props {
@@ -110,6 +113,15 @@
110113
<span class="flow-node-name">{data.label}</span>
111114
</div>
112115

116+
{#if data.loopExpression !== undefined}
117+
<div class="flow-node-loop-expr">
118+
<span class="flow-node-loop-for" aria-hidden="true"
119+
>{t('node.loop.for')}</span
120+
>
121+
<span class="flow-node-loop-expr-val">{data.loopExpression}</span>
122+
</div>
123+
{/if}
124+
113125
{#if data.navRows && data.navRows.length > 0}
114126
<ul class="flow-node-rows" role="list">
115127
{#each data.navRows as row (row.id)}
@@ -267,4 +279,38 @@
267279
overflow: hidden;
268280
text-overflow: ellipsis;
269281
}
282+
283+
/* -------------------------------------------------------------------------
284+
Loop expression line
285+
------------------------------------------------------------------------- */
286+
287+
.flow-node-loop-expr {
288+
height: 22px;
289+
flex-shrink: 0;
290+
display: flex;
291+
align-items: center;
292+
gap: 0.3rem;
293+
padding: 0 0.5rem;
294+
border-bottom: 1px solid #eee;
295+
overflow: hidden;
296+
}
297+
298+
.flow-node-loop-for {
299+
font-size: 0.62rem;
300+
font-weight: 700;
301+
text-transform: lowercase;
302+
letter-spacing: 0.04em;
303+
color: var(--accent);
304+
flex-shrink: 0;
305+
}
306+
307+
.flow-node-loop-expr-val {
308+
font-size: 0.68rem;
309+
font-family: monospace;
310+
color: #444;
311+
white-space: nowrap;
312+
overflow: hidden;
313+
text-overflow: ellipsis;
314+
min-width: 0;
315+
}
270316
</style>

0 commit comments

Comments
 (0)