Skip to content

Commit e605b4f

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

5 files changed

Lines changed: 537 additions & 7 deletions

File tree

src/lib/i18n/messages/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@
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",

src/lib/tasks/actions.ts

Lines changed: 26 additions & 0 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
// ---------------------------------------------------------------------------

src/lib/ui/Inspector.svelte

Lines changed: 272 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@
1616

1717
<script lang="ts">
1818
import { t } from '$lib/i18n/index.svelte';
19-
import type { Node } from '$lib/tasks/model';
19+
import {
20+
addSwitchBranch,
21+
removeSwitchBranch,
22+
renameSwitchBranch,
23+
updateSwitchBranchCondition,
24+
updateSwitchBranchTarget,
25+
} from '$lib/tasks/actions';
26+
import type { NamedWorkflow, Node, SwitchNode } from '$lib/tasks/model';
2027
2128
import CommonFields from './inspector/CommonFields.svelte';
2229
import { getNodeEditor } from './node-editors/registry';
@@ -37,11 +44,14 @@
3744
// Navigation into subgraphs
3845
onenternode?: (nodeId: string) => void; // loop body
3946
onenterbranch?: (nodeId: string, branchId: string) => void; // switch/fork/try
40-
// Branch management (switch / fork)
47+
// Branch management (fork only — switch uses onupdate directly)
4148
onaddbranch?: (nodeId: string) => void;
4249
onremovebranch?: (nodeId: string, branchId: string) => void;
4350
// Try-specific: add catchGraph section
4451
onaddcatch?: (nodeId: string) => void;
52+
// Workflow list for switch branch target selection
53+
workflows?: NamedWorkflow[];
54+
currentWorkflowName?: string;
4555
}
4656
4757
let {
@@ -57,6 +67,8 @@
5767
onaddbranch,
5868
onremovebranch,
5969
onaddcatch,
70+
workflows = [],
71+
currentWorkflowName = '',
6072
}: Props = $props();
6173
6274
// ---------------------------------------------------------------------------
@@ -65,6 +77,16 @@
6577
6678
const NodeEditor = $derived(node ? getNodeEditor(node) : null);
6779
80+
// Narrowed switch node — used by the switch branch editing section.
81+
const switchNode = $derived(
82+
node !== null && node.type === 'switch' ? (node as SwitchNode) : null,
83+
);
84+
85+
// Available target workflows: all except the current one.
86+
const targetWorkflows = $derived(
87+
workflows.filter((wf) => wf.name !== currentWorkflowName),
88+
);
89+
6890
// ---------------------------------------------------------------------------
6991
// Helpers
7092
// ---------------------------------------------------------------------------
@@ -78,6 +100,14 @@
78100
if (!node) return;
79101
onupdate?.({ ...node, name: value });
80102
}
103+
104+
// ---------------------------------------------------------------------------
105+
// Switch branch helpers — call onupdate with the full updated SwitchNode.
106+
// ---------------------------------------------------------------------------
107+
108+
function handleSwitchUpdate(updated: SwitchNode): void {
109+
onupdate?.(updated);
110+
}
81111
</script>
82112

83113
<aside class="inspector">
@@ -114,12 +144,157 @@
114144
</section>
115145

116146
<!-- -------------------------------------------------------------------
117-
TaskSpecificSection: dynamically loaded editor from registry.
118-
For task nodes this replaces the "coming soon" hint.
119-
For structural nodes this supplements the branch/section navigation.
147+
Switch branch editing — full per-branch form with name, condition,
148+
target workflow, and inline validation.
120149
-------------------------------------------------------------------- -->
121150

122-
{#if node.type === 'switch' || node.type === 'fork'}
151+
{#if switchNode}
152+
{@const hasDefault = switchNode.branches.some(
153+
(b) => b.condition === undefined,
154+
)}
155+
<section class="inspector-branches">
156+
<h3 class="inspector-section-title">
157+
{t('inspector.switch.branches')}
158+
</h3>
159+
160+
{#each switchNode.branches as branch (branch.id)}
161+
{@const isDefault = branch.condition === undefined}
162+
{@const isDuplicate =
163+
switchNode.branches.filter((b) => b.label === branch.label).length >
164+
1}
165+
{@const emptyCondition = !isDefault && branch.condition === ''}
166+
{@const noTarget = !branch.thenWorkflowName}
167+
168+
<div class="switch-branch-card" data-testid="switch-branch-card">
169+
<div class="switch-branch-card-header">
170+
{#if isDefault}
171+
<span class="switch-default-badge"
172+
>{t('inspector.switch.defaultBranch')}</span
173+
>
174+
{:else}
175+
<span></span>
176+
{/if}
177+
{#if switchNode.branches.length > 1}
178+
<button
179+
class="branch-remove-btn"
180+
type="button"
181+
aria-label={t('inspector.removeBranch', {
182+
label: branch.label,
183+
})}
184+
onclick={() =>
185+
handleSwitchUpdate(
186+
removeSwitchBranch(switchNode, branch.id),
187+
)}
188+
>
189+
190+
</button>
191+
{/if}
192+
</div>
193+
194+
<label class="field-label" for="branch-name-{branch.id}">
195+
{t('inspector.switch.branchName')}
196+
</label>
197+
<input
198+
id="branch-name-{branch.id}"
199+
class="text-input"
200+
type="text"
201+
aria-label={t('inspector.switch.branchName')}
202+
value={branch.label}
203+
oninput={(e) =>
204+
handleSwitchUpdate(
205+
renameSwitchBranch(
206+
switchNode,
207+
branch.id,
208+
e.currentTarget.value,
209+
),
210+
)}
211+
/>
212+
{#if isDuplicate}
213+
<p class="field-warning">
214+
{t('inspector.switch.errorDuplicateName')}
215+
</p>
216+
{/if}
217+
218+
{#if !isDefault}
219+
<label class="field-label" for="branch-cond-{branch.id}">
220+
{t('inspector.switch.condition')}
221+
</label>
222+
<input
223+
id="branch-cond-{branch.id}"
224+
class="text-input text-input--mono"
225+
type="text"
226+
aria-label={t('inspector.switch.condition')}
227+
value={branch.condition ?? ''}
228+
oninput={(e) =>
229+
handleSwitchUpdate(
230+
updateSwitchBranchCondition(
231+
switchNode,
232+
branch.id,
233+
e.currentTarget.value,
234+
),
235+
)}
236+
/>
237+
{#if emptyCondition}
238+
<p class="field-warning">
239+
{t('inspector.switch.errorEmptyCondition')}
240+
</p>
241+
{/if}
242+
{/if}
243+
244+
<label class="field-label" for="branch-target-{branch.id}">
245+
{t('inspector.switch.targetWorkflow')}
246+
</label>
247+
<select
248+
id="branch-target-{branch.id}"
249+
class="select-input"
250+
aria-label={t('inspector.switch.targetWorkflow')}
251+
value={branch.thenWorkflowName ?? ''}
252+
onchange={(e) =>
253+
handleSwitchUpdate(
254+
updateSwitchBranchTarget(
255+
switchNode,
256+
branch.id,
257+
e.currentTarget.value || undefined,
258+
),
259+
)}
260+
>
261+
<option value="">—</option>
262+
{#each targetWorkflows as wf (wf.id)}
263+
<option value={wf.name}>{wf.name}</option>
264+
{/each}
265+
</select>
266+
{#if noTarget}
267+
<p class="field-warning">
268+
{t('inspector.switch.errorNoTarget')}
269+
</p>
270+
{/if}
271+
</div>
272+
{/each}
273+
274+
<div class="switch-add-btns">
275+
<button
276+
class="branch-add-btn"
277+
type="button"
278+
onclick={() =>
279+
handleSwitchUpdate(addSwitchBranch(switchNode, 'new-branch', ''))}
280+
>
281+
{t('inspector.switch.addBranch')}
282+
</button>
283+
{#if !hasDefault}
284+
<button
285+
class="branch-add-btn"
286+
type="button"
287+
onclick={() =>
288+
handleSwitchUpdate(
289+
addSwitchBranch(switchNode, 'default', undefined),
290+
)}
291+
>
292+
{t('inspector.switch.addDefaultBranch')}
293+
</button>
294+
{/if}
295+
</div>
296+
</section>
297+
{:else if node.type === 'fork'}
123298
<section class="inspector-branches">
124299
<h3 class="inspector-section-title">{t('inspector.branches')}</h3>
125300
<ul class="branch-list" role="list">
@@ -343,6 +518,97 @@
343518
letter-spacing: 0.04em;
344519
}
345520
521+
/* -------------------------------------------------------------------------
522+
Switch branch cards
523+
------------------------------------------------------------------------- */
524+
525+
.switch-branch-card {
526+
border: 1px solid #e5e7eb;
527+
border-radius: 6px;
528+
padding: 0.5rem 0.6rem;
529+
margin-bottom: 0.5rem;
530+
display: flex;
531+
flex-direction: column;
532+
gap: 0.2rem;
533+
}
534+
535+
.switch-branch-card-header {
536+
display: flex;
537+
align-items: center;
538+
justify-content: space-between;
539+
margin-bottom: 0.2rem;
540+
}
541+
542+
.switch-default-badge {
543+
font-size: 0.68rem;
544+
font-weight: 600;
545+
text-transform: uppercase;
546+
letter-spacing: 0.04em;
547+
color: #7c3aed;
548+
background: #f3f0ff;
549+
border-radius: 3px;
550+
padding: 1px 5px;
551+
}
552+
553+
.text-input {
554+
width: 100%;
555+
padding: 0.25rem 0.4rem;
556+
border: 1px solid #ddd;
557+
border-radius: 4px;
558+
font-size: 0.78rem;
559+
font-family: inherit;
560+
color: #111;
561+
background: #fff;
562+
box-sizing: border-box;
563+
margin-bottom: 0.2rem;
564+
}
565+
566+
.text-input--mono {
567+
font-family: monospace;
568+
}
569+
570+
.text-input:focus {
571+
outline: none;
572+
border-color: #1a56cc;
573+
box-shadow: 0 0 0 2px rgba(26, 86, 204, 0.15);
574+
}
575+
576+
.select-input {
577+
width: 100%;
578+
padding: 0.25rem 0.4rem;
579+
border: 1px solid #ddd;
580+
border-radius: 4px;
581+
font-size: 0.78rem;
582+
font-family: inherit;
583+
color: #111;
584+
background: #fff;
585+
box-sizing: border-box;
586+
margin-bottom: 0.2rem;
587+
}
588+
589+
.select-input:focus {
590+
outline: none;
591+
border-color: #1a56cc;
592+
box-shadow: 0 0 0 2px rgba(26, 86, 204, 0.15);
593+
}
594+
595+
.field-warning {
596+
margin: 0 0 0.2rem;
597+
font-size: 0.7rem;
598+
color: #b45309;
599+
}
600+
601+
.switch-add-btns {
602+
display: flex;
603+
flex-direction: column;
604+
gap: 0.375rem;
605+
margin-top: 0.25rem;
606+
}
607+
608+
/* -------------------------------------------------------------------------
609+
Fork branch list (unchanged)
610+
------------------------------------------------------------------------- */
611+
346612
.branch-list {
347613
list-style: none;
348614
margin: 0 0 0.5rem;

0 commit comments

Comments
 (0)