@checkstack/automation-frontend 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +496 -0
  2. package/package.json +18 -13
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/components/RunAsServiceAccountPicker.tsx +170 -0
  5. package/src/editor/ActionEditor.tsx +180 -90
  6. package/src/editor/ActionListEditor.tsx +27 -1
  7. package/src/editor/AddActionDialog.tsx +15 -45
  8. package/src/editor/AddConditionDialog.tsx +86 -0
  9. package/src/editor/AddTriggerDialog.tsx +97 -0
  10. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  11. package/src/editor/ConditionEditor.tsx +359 -70
  12. package/src/editor/ConditionsEditor.tsx +113 -44
  13. package/src/editor/ItemSheet.tsx +51 -0
  14. package/src/editor/RunReplayPicker.tsx +97 -0
  15. package/src/editor/ScriptServicesBooter.tsx +57 -0
  16. package/src/editor/ScriptTestRenderer.tsx +150 -0
  17. package/src/editor/SystemEntityPicker.test.ts +37 -0
  18. package/src/editor/SystemEntityPicker.tsx +109 -0
  19. package/src/editor/TriggersEditor.tsx +345 -137
  20. package/src/editor/action-helpers.test.ts +107 -0
  21. package/src/editor/action-helpers.ts +72 -0
  22. package/src/editor/action-leaf-cards.tsx +105 -1
  23. package/src/editor/condition-kind.test.ts +126 -0
  24. package/src/editor/condition-kind.ts +130 -0
  25. package/src/editor/item-summary.test.ts +171 -0
  26. package/src/editor/item-summary.ts +210 -0
  27. package/src/editor/picker-dialog.tsx +156 -0
  28. package/src/editor/registry-context.tsx +9 -2
  29. package/src/editor/script-actions.test.ts +184 -0
  30. package/src/editor/script-actions.ts +146 -0
  31. package/src/editor/system-entity-picker.logic.ts +23 -0
  32. package/src/editor/template-completion.test.ts +22 -3
  33. package/src/editor/template-completion.ts +16 -8
  34. package/src/editor/template-helpers.ts +4 -0
  35. package/src/editor/trigger-helpers.test.ts +28 -0
  36. package/src/editor/trigger-helpers.ts +17 -0
  37. package/src/editor/useScriptDiagnostics.ts +108 -0
  38. package/src/index.tsx +26 -24
  39. package/src/pages/AutomationEditPage.tsx +116 -48
  40. package/src/pages/AutomationListPage.tsx +172 -123
  41. package/src/pages/automation-grouping.test.ts +87 -0
  42. package/src/pages/automation-grouping.ts +65 -0
  43. package/src/script-context.test.ts +142 -1
  44. package/src/script-context.ts +115 -0
  45. package/tsconfig.json +15 -0
  46. package/src/components/AutomationMenuItems.tsx +0 -37
@@ -4,43 +4,38 @@ import {
4
4
  Button,
5
5
  Card,
6
6
  CardContent,
7
+ DurationInput,
8
+ Input,
9
+ Label,
7
10
  Select,
8
11
  SelectContent,
12
+ SelectGroup,
9
13
  SelectItem,
14
+ SelectLabel,
15
+ SelectSeparator,
10
16
  SelectTrigger,
11
17
  SelectValue,
12
18
  TemplateValueInput,
19
+ TimeOfDayInput,
20
+ Toggle,
13
21
  VariablePicker,
22
+ type DurationValue,
14
23
  type TemplateCompletionProvider,
15
24
  type VariableNode,
16
25
  } from "@checkstack/ui";
17
- import type { ConditionInput } from "@checkstack/automation-common";
18
-
19
- type CombinatorKind = "expr" | "and" | "or" | "not";
20
-
21
- function kindOf(condition: ConditionInput): CombinatorKind {
22
- if (typeof condition === "string") return "expr";
23
- if ("and" in condition) return "and";
24
- if ("or" in condition) return "or";
25
- return "not";
26
- }
27
-
28
- function defaultForKind(kind: CombinatorKind): ConditionInput {
29
- switch (kind) {
30
- case "expr": {
31
- return "";
32
- }
33
- case "and": {
34
- return { and: [""] };
35
- }
36
- case "or": {
37
- return { or: [""] };
38
- }
39
- case "not": {
40
- return { not: "" };
41
- }
42
- }
43
- }
26
+ import type {
27
+ ConditionInput,
28
+ Duration,
29
+ NumericStateCondition,
30
+ StateCondition,
31
+ TimeCondition,
32
+ } from "@checkstack/automation-common";
33
+ import {
34
+ defaultForKind,
35
+ kindOf,
36
+ type ConditionKind,
37
+ } from "./condition-kind";
38
+ import { SystemEntityPicker } from "./SystemEntityPicker";
44
39
 
45
40
  export interface ConditionEditorProps {
46
41
  value: ConditionInput;
@@ -48,30 +43,39 @@ export interface ConditionEditorProps {
48
43
  variableNodes: VariableNode[];
49
44
  /**
50
45
  * Staged completion provider (expression mode) for the inline
51
- * expression input. Conditions are bare expressions — no `{{ }}`
52
- * wrapper — so this is the `expressionCompletion` from
46
+ * expression input. Conditions are bare expressions - no `{{ }}`
47
+ * wrapper - so this is the `expressionCompletion` from
53
48
  * `useVariableScope`. The hierarchical `variableNodes` powers the
54
49
  * explicit "fx" picker.
55
50
  */
56
51
  completionProvider: TemplateCompletionProvider;
57
- /** Render without the wrapping card — used when inlining inside an action card. */
52
+ /** Render without the wrapping card - used when inlining inside an action card. */
58
53
  bare?: boolean;
54
+ /**
55
+ * Suppress the inline kind `Select`. Used only for the top-level condition in
56
+ * the Conditions-section sheet, where the kind is chosen up front via
57
+ * `AddConditionDialog` (swap kind = delete + re-add, matching actions).
58
+ * Nested combinator children and the action `condition`-guard body do NOT
59
+ * set this, so their inline selector stays. The `expr` "fx" picker still
60
+ * renders when the kind is `expr`, even with the selector hidden.
61
+ */
62
+ hideKindSelector?: boolean;
59
63
  depth?: number;
60
64
  }
61
65
 
62
66
  /**
63
- * Recursive editor over the `ConditionInput` discriminated union
64
- * (`string | { and } | { or } | { not }`). Each level renders:
67
+ * Recursive editor over the `ConditionInput` union. Each level renders a
68
+ * kind selector plus the matching body:
65
69
  *
66
- * - A kind selector ("expression" | "and" | "or" | "not").
67
- * - The matching body:
68
- * expr → TemplateValueInput with `{{` autocomplete + the
69
- * VariablePicker "fx" trigger inline.
70
- * and / or → list of child conditions with add/remove buttons,
71
- * each child rendered through this same component.
72
- * not → single child condition.
70
+ * - expression - TemplateValueInput + the VariablePicker "fx" trigger.
71
+ * - and / or - list of child conditions (recursive).
72
+ * - not - single child condition.
73
+ * - numeric_state - value (path/template) + operator + threshold.
74
+ * - time - after / before (TimeOfDayInput) + weekday + timezone.
75
+ * - state - entity + status + optional dwell (DurationInput).
73
76
  *
74
- * No depth cap — operator can nest arbitrarily, mirroring the runtime.
77
+ * The raw expression stays the escape hatch for anything the structured
78
+ * variants don't cover. No depth cap - operator can nest arbitrarily.
75
79
  */
76
80
  export const ConditionEditor: React.FC<ConditionEditorProps> = ({
77
81
  value,
@@ -79,42 +83,69 @@ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
79
83
  variableNodes,
80
84
  completionProvider,
81
85
  bare,
86
+ hideKindSelector,
82
87
  depth = 0,
83
88
  }) => {
84
89
  const kind = kindOf(value);
85
90
 
86
91
  const body = (
87
92
  <div className="space-y-2">
88
- <div className="flex items-center gap-2">
89
- <Select
90
- value={kind}
91
- onValueChange={(next) =>
92
- onChange(defaultForKind(next as CombinatorKind))
93
- }
94
- >
95
- <SelectTrigger className="h-7 w-32 text-xs">
96
- <SelectValue />
97
- </SelectTrigger>
98
- <SelectContent>
99
- <SelectItem value="expr">expression</SelectItem>
100
- <SelectItem value="and">and</SelectItem>
101
- <SelectItem value="or">or</SelectItem>
102
- <SelectItem value="not">not</SelectItem>
103
- </SelectContent>
104
- </Select>
105
- {kind === "expr" && (
106
- <VariablePicker
107
- scope={variableNodes}
108
- onSelect={(path) => {
109
- // Conditions are bare expressions — insert the raw path,
110
- // not a `{{ … }}`-wrapped reference.
111
- const before = typeof value === "string" ? value : "";
112
- const sep = before.length > 0 && !before.endsWith(" ") ? " " : "";
113
- onChange(`${before}${sep}${path}`);
114
- }}
115
- />
116
- )}
117
- </div>
93
+ {/* The top row holds the kind selector and the `expr` "fx" picker. Render
94
+ it when EITHER is present; omit it entirely (e.g. hidden selector on a
95
+ non-expr top-level condition) so no empty row remains. */}
96
+ {(!hideKindSelector || kind === "expr") && (
97
+ <div className="flex items-center gap-2">
98
+ {!hideKindSelector && (
99
+ <Select
100
+ value={kind}
101
+ onValueChange={(next) =>
102
+ onChange(defaultForKind(next as ConditionKind))
103
+ }
104
+ >
105
+ <SelectTrigger className="h-7 w-40 text-xs">
106
+ <SelectValue />
107
+ </SelectTrigger>
108
+ <SelectContent>
109
+ {/* Structured kinds are the common case and lead the list. */}
110
+ <SelectGroup>
111
+ <SelectLabel>Structured</SelectLabel>
112
+ <SelectItem value="numeric_state">numeric state</SelectItem>
113
+ <SelectItem value="time">time of day</SelectItem>
114
+ <SelectItem value="state">system state</SelectItem>
115
+ </SelectGroup>
116
+ <SelectSeparator />
117
+ <SelectGroup>
118
+ <SelectLabel>Logical</SelectLabel>
119
+ <SelectItem value="and">and</SelectItem>
120
+ <SelectItem value="or">or</SelectItem>
121
+ <SelectItem value="not">not</SelectItem>
122
+ </SelectGroup>
123
+ <SelectSeparator />
124
+ {/* Raw expression stays the escape hatch for anything the
125
+ structured variants don't cover — de-emphasised at the
126
+ bottom, but still reachable. */}
127
+ <SelectGroup>
128
+ <SelectLabel>Advanced</SelectLabel>
129
+ <SelectItem value="expr">expression</SelectItem>
130
+ </SelectGroup>
131
+ </SelectContent>
132
+ </Select>
133
+ )}
134
+ {kind === "expr" && (
135
+ <VariablePicker
136
+ scope={variableNodes}
137
+ onSelect={(path) => {
138
+ // Conditions are bare expressions - insert the raw path,
139
+ // not a `{{ … }}`-wrapped reference.
140
+ const before = typeof value === "string" ? value : "";
141
+ const sep =
142
+ before.length > 0 && !before.endsWith(" ") ? " " : "";
143
+ onChange(`${before}${sep}${path}`);
144
+ }}
145
+ />
146
+ )}
147
+ </div>
148
+ )}
118
149
 
119
150
  {kind === "expr" && (
120
151
  <TemplateValueInput
@@ -125,6 +156,23 @@ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
125
156
  />
126
157
  )}
127
158
 
159
+ {kind === "numeric_state" && (
160
+ <NumericStateBody
161
+ value={value as NumericStateCondition}
162
+ onChange={onChange}
163
+ variableNodes={variableNodes}
164
+ completionProvider={completionProvider}
165
+ />
166
+ )}
167
+
168
+ {kind === "time" && (
169
+ <TimeBody value={value as TimeCondition} onChange={onChange} />
170
+ )}
171
+
172
+ {kind === "state" && (
173
+ <StateBody value={value as StateCondition} onChange={onChange} />
174
+ )}
175
+
128
176
  {(kind === "and" || kind === "or") && (
129
177
  <CombinatorList
130
178
  kind={kind}
@@ -167,6 +215,247 @@ export const ConditionEditor: React.FC<ConditionEditorProps> = ({
167
215
  );
168
216
  };
169
217
 
218
+ // ─── numeric_state ─────────────────────────────────────────────────────
219
+
220
+ type NumericOp = "above" | "below" | "between";
221
+
222
+ function numericOpOf(c: NumericStateCondition["numeric_state"]): NumericOp {
223
+ if (c.above !== undefined && c.below !== undefined) return "between";
224
+ if (c.below !== undefined) return "below";
225
+ return "above";
226
+ }
227
+
228
+ const NumericStateBody: React.FC<{
229
+ value: NumericStateCondition;
230
+ onChange: (next: ConditionInput) => void;
231
+ variableNodes: VariableNode[];
232
+ completionProvider: TemplateCompletionProvider;
233
+ }> = ({ value, onChange, variableNodes, completionProvider }) => {
234
+ const ns = value.numeric_state;
235
+ const op = numericOpOf(ns);
236
+ const patch = (next: Partial<NumericStateCondition["numeric_state"]>) =>
237
+ onChange({ numeric_state: { ...ns, ...next } });
238
+
239
+ return (
240
+ <div className="space-y-2 border-l border-border pl-3">
241
+ <div className="space-y-1">
242
+ <Label className="text-xs">Value (path or template)</Label>
243
+ <div className="flex items-center gap-2">
244
+ <div className="flex-1">
245
+ <TemplateValueInput
246
+ value={typeof ns.value === "string" ? ns.value : String(ns.value)}
247
+ onChange={(next) => patch({ value: next })}
248
+ placeholder="health.system.p95_latency_ms"
249
+ completionProvider={completionProvider}
250
+ />
251
+ </div>
252
+ <VariablePicker
253
+ scope={variableNodes}
254
+ onSelect={(path) => patch({ value: path })}
255
+ />
256
+ </div>
257
+ </div>
258
+ <div className="flex items-center gap-2">
259
+ <Select
260
+ value={op}
261
+ onValueChange={(next) => {
262
+ // Reset to a clean shape for the chosen operator.
263
+ if (next === "above") {
264
+ patch({ above: ns.above ?? 0, below: undefined });
265
+ } else if (next === "below") {
266
+ patch({ above: undefined, below: ns.below ?? 0 });
267
+ } else {
268
+ patch({ above: ns.above ?? 0, below: ns.below ?? 0 });
269
+ }
270
+ }}
271
+ >
272
+ <SelectTrigger className="h-8 w-32 text-xs">
273
+ <SelectValue />
274
+ </SelectTrigger>
275
+ <SelectContent>
276
+ <SelectItem value="above">above (&gt;)</SelectItem>
277
+ <SelectItem value="below">below (&lt;)</SelectItem>
278
+ <SelectItem value="between">between</SelectItem>
279
+ </SelectContent>
280
+ </Select>
281
+ {op !== "below" && (
282
+ <Input
283
+ type="number"
284
+ className="w-28"
285
+ placeholder="above"
286
+ value={ns.above ?? ""}
287
+ onChange={(event) =>
288
+ patch({
289
+ above:
290
+ event.target.value === ""
291
+ ? undefined
292
+ : Number(event.target.value),
293
+ })
294
+ }
295
+ />
296
+ )}
297
+ {op !== "above" && (
298
+ <Input
299
+ type="number"
300
+ className="w-28"
301
+ placeholder="below"
302
+ value={ns.below ?? ""}
303
+ onChange={(event) =>
304
+ patch({
305
+ below:
306
+ event.target.value === ""
307
+ ? undefined
308
+ : Number(event.target.value),
309
+ })
310
+ }
311
+ />
312
+ )}
313
+ </div>
314
+ </div>
315
+ );
316
+ };
317
+
318
+ // ─── time ──────────────────────────────────────────────────────────────
319
+
320
+ const WEEKDAYS: Array<{ value: number; label: string }> = [
321
+ { value: 0, label: "Sun" },
322
+ { value: 1, label: "Mon" },
323
+ { value: 2, label: "Tue" },
324
+ { value: 3, label: "Wed" },
325
+ { value: 4, label: "Thu" },
326
+ { value: 5, label: "Fri" },
327
+ { value: 6, label: "Sat" },
328
+ ];
329
+
330
+ const TimeBody: React.FC<{
331
+ value: TimeCondition;
332
+ onChange: (next: ConditionInput) => void;
333
+ }> = ({ value, onChange }) => {
334
+ const t = value.time;
335
+ const patch = (next: Partial<TimeCondition["time"]>) =>
336
+ onChange({ time: { ...t, ...next } });
337
+ const weekdays = t.weekday ?? [];
338
+
339
+ const toggleDay = (day: number) => {
340
+ const next = weekdays.includes(day)
341
+ ? weekdays.filter((d) => d !== day)
342
+ : [...weekdays, day].toSorted((a, b) => a - b);
343
+ patch({ weekday: next.length > 0 ? next : undefined });
344
+ };
345
+
346
+ return (
347
+ <div className="space-y-3 border-l border-border pl-3">
348
+ <div className="flex flex-wrap items-end gap-3">
349
+ <div className="space-y-1">
350
+ <Label className="text-xs">After</Label>
351
+ <TimeOfDayInput
352
+ value={t.after}
353
+ onChange={(next) => patch({ after: next })}
354
+ />
355
+ </div>
356
+ <div className="space-y-1">
357
+ <Label className="text-xs">Before</Label>
358
+ <TimeOfDayInput
359
+ value={t.before}
360
+ onChange={(next) => patch({ before: next })}
361
+ />
362
+ </div>
363
+ </div>
364
+ <div className="space-y-1">
365
+ <Label className="text-xs">Weekdays (any when none selected)</Label>
366
+ <div className="flex flex-wrap gap-1">
367
+ {WEEKDAYS.map((d) => {
368
+ const active = weekdays.includes(d.value);
369
+ return (
370
+ <Button
371
+ key={d.value}
372
+ type="button"
373
+ variant={active ? "primary" : "outline"}
374
+ size="sm"
375
+ className="h-7 w-11 text-xs"
376
+ onClick={() => toggleDay(d.value)}
377
+ aria-pressed={active}
378
+ >
379
+ {d.label}
380
+ </Button>
381
+ );
382
+ })}
383
+ </div>
384
+ </div>
385
+ <div className="space-y-1">
386
+ <Label className="text-xs">Timezone (IANA, defaults to UTC)</Label>
387
+ <Input
388
+ className="max-w-xs font-mono text-xs"
389
+ value={t.timezone ?? ""}
390
+ placeholder="Europe/Berlin"
391
+ onChange={(event) =>
392
+ patch({ timezone: event.target.value || undefined })
393
+ }
394
+ />
395
+ </div>
396
+ </div>
397
+ );
398
+ };
399
+
400
+ // ─── state ─────────────────────────────────────────────────────────────
401
+
402
+ const StateBody: React.FC<{
403
+ value: StateCondition;
404
+ onChange: (next: ConditionInput) => void;
405
+ }> = ({ value, onChange }) => {
406
+ const s = value.state;
407
+ const patch = (next: Partial<StateCondition["state"]>) =>
408
+ onChange({ state: { ...s, ...next } });
409
+ const hasDwell = s.for !== undefined;
410
+
411
+ return (
412
+ <div className="space-y-2 border-l border-border pl-3">
413
+ <div className="grid gap-2 sm:grid-cols-2">
414
+ <div className="space-y-1">
415
+ <Label className="text-xs">System (entity)</Label>
416
+ <SystemEntityPicker
417
+ value={s.entity}
418
+ onChange={(next) => patch({ entity: next })}
419
+ />
420
+ </div>
421
+ <div className="space-y-1">
422
+ <Label className="text-xs">Status</Label>
423
+ <Select
424
+ value={s.status}
425
+ onValueChange={(next) =>
426
+ patch({ status: next as StateCondition["state"]["status"] })
427
+ }
428
+ >
429
+ <SelectTrigger className="h-8 text-xs">
430
+ <SelectValue />
431
+ </SelectTrigger>
432
+ <SelectContent>
433
+ <SelectItem value="healthy">healthy</SelectItem>
434
+ <SelectItem value="degraded">degraded</SelectItem>
435
+ <SelectItem value="unhealthy">unhealthy</SelectItem>
436
+ </SelectContent>
437
+ </Select>
438
+ </div>
439
+ </div>
440
+ <div className="flex items-center gap-2">
441
+ <Toggle
442
+ checked={hasDwell}
443
+ onCheckedChange={(checked) =>
444
+ patch({ for: checked ? { minutes: 30 } : undefined })
445
+ }
446
+ />
447
+ <Label className="text-xs">Require held for a minimum duration</Label>
448
+ </div>
449
+ {hasDwell && (
450
+ <DurationInput
451
+ value={s.for as DurationValue | undefined}
452
+ onChange={(next) => patch({ for: (next as Duration) ?? undefined })}
453
+ />
454
+ )}
455
+ </div>
456
+ );
457
+ };
458
+
170
459
  const CombinatorList: React.FC<{
171
460
  kind: "and" | "or";
172
461
  children: ConditionInput[];
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
- import { Plus, Trash2 } from "lucide-react";
3
2
  import {
4
- Button,
3
+ ActionCard,
4
+ type ActionCardMenuItem,
5
5
  Card,
6
6
  CardContent,
7
7
  CardHeader,
@@ -10,7 +10,12 @@ import {
10
10
  type VariableNode,
11
11
  } from "@checkstack/ui";
12
12
  import type { ConditionInput } from "@checkstack/automation-common";
13
+ import { AddConditionDialog } from "./AddConditionDialog";
13
14
  import { ConditionEditor } from "./ConditionEditor";
15
+ import { defaultForKind } from "./condition-kind";
16
+ import { ItemSheet } from "./ItemSheet";
17
+ import { useConditionIssues } from "./editor-validation";
18
+ import { summarizeCondition } from "./item-summary";
14
19
 
15
20
  /**
16
21
  * Top-level pre-run conditions. Every condition in this list must pass
@@ -35,55 +40,119 @@ export const ConditionsEditor: React.FC<{
35
40
  }> = ({ value, onChange, variableNodes, completionProvider, disabled }) => (
36
41
  <Card>
37
42
  <CardHeader className="border-b">
38
- <div className="flex items-center justify-between">
39
- <CardTitle className="text-base">Conditions</CardTitle>
40
- <Button
41
- type="button"
42
- variant="outline"
43
- size="sm"
44
- onClick={() => onChange([...value, ""])}
45
- disabled={disabled}
46
- >
47
- <Plus className="mr-1 h-3 w-3" />
48
- Add condition
49
- </Button>
50
- </div>
43
+ <CardTitle className="text-base">Conditions</CardTitle>
51
44
  </CardHeader>
52
45
  <CardContent className="space-y-2 p-3">
53
- {value.length === 0 ? (
46
+ {value.length === 0 && (
54
47
  <p className="text-xs italic text-muted-foreground">
55
48
  No pre-run gating. Add a condition to require it before the
56
49
  actions run.
57
50
  </p>
58
- ) : (
59
- value.map((condition, index) => (
60
- <div key={index} className="flex items-start gap-2">
61
- <div className="flex-1">
62
- <ConditionEditor
63
- value={condition}
64
- onChange={(next) => {
65
- const list = [...value];
66
- list[index] = next;
67
- onChange(list);
68
- }}
69
- variableNodes={variableNodes}
70
- completionProvider={completionProvider}
71
- />
72
- </div>
73
- <Button
74
- type="button"
75
- variant="ghost"
76
- size="icon"
77
- className="h-7 w-7 text-destructive hover:bg-destructive/10"
78
- onClick={() => onChange(value.filter((_, i) => i !== index))}
79
- disabled={disabled}
80
- aria-label="Remove condition"
81
- >
82
- <Trash2 className="h-3 w-3" />
83
- </Button>
84
- </div>
85
- ))
86
51
  )}
52
+ {value.map((condition, index) => (
53
+ <ConditionCard
54
+ key={index}
55
+ index={index}
56
+ value={condition}
57
+ onChange={(next) => {
58
+ const list = [...value];
59
+ list[index] = next;
60
+ onChange(list);
61
+ }}
62
+ onRemove={() => onChange(value.filter((_, i) => i !== index))}
63
+ onDuplicate={() =>
64
+ // Conditions carry no id, so a structural clone (inserted
65
+ // directly after the original) is all that's needed.
66
+ onChange([
67
+ ...value.slice(0, index + 1),
68
+ structuredClone(condition),
69
+ ...value.slice(index + 1),
70
+ ])
71
+ }
72
+ variableNodes={variableNodes}
73
+ completionProvider={completionProvider}
74
+ disabled={disabled}
75
+ />
76
+ ))}
77
+ <AddConditionDialog
78
+ disabled={disabled}
79
+ onAdd={(kind) => onChange([...value, defaultForKind(kind)])}
80
+ />
87
81
  </CardContent>
88
82
  </Card>
89
83
  );
84
+
85
+ const ConditionCard: React.FC<{
86
+ index: number;
87
+ value: ConditionInput;
88
+ onChange: (next: ConditionInput) => void;
89
+ onRemove: () => void;
90
+ onDuplicate: () => void;
91
+ variableNodes: VariableNode[];
92
+ completionProvider: TemplateCompletionProvider;
93
+ disabled?: boolean;
94
+ }> = ({
95
+ index,
96
+ value,
97
+ onChange,
98
+ onRemove,
99
+ onDuplicate,
100
+ variableNodes,
101
+ completionProvider,
102
+ disabled,
103
+ }) => {
104
+ const issues = useConditionIssues(index);
105
+ const [sheetOpen, setSheetOpen] = React.useState(false);
106
+
107
+ React.useEffect(() => {
108
+ if (issues.length > 0) setSheetOpen(true);
109
+ }, [issues.length]);
110
+
111
+ const summary = summarizeCondition(value);
112
+ const title = `Condition ${index + 1}`;
113
+
114
+ const menuItems: ActionCardMenuItem[] = disabled
115
+ ? []
116
+ : [
117
+ { label: "Duplicate", icon: "Copy", onClick: onDuplicate },
118
+ {
119
+ label: "Delete",
120
+ icon: "Trash2",
121
+ onClick: onRemove,
122
+ variant: "destructive",
123
+ },
124
+ ];
125
+
126
+ return (
127
+ <>
128
+ <ActionCard
129
+ id={`condition-${index}`}
130
+ title={title}
131
+ summary={summary}
132
+ category="Condition"
133
+ icon="Funnel"
134
+ onOpenSheet={() => setSheetOpen(true)}
135
+ actions={menuItems}
136
+ errors={issues}
137
+ />
138
+ <ItemSheet
139
+ open={sheetOpen}
140
+ onOpenChange={setSheetOpen}
141
+ title={title}
142
+ description="Pre-run condition"
143
+ >
144
+ <ConditionEditor
145
+ value={value}
146
+ onChange={onChange}
147
+ variableNodes={variableNodes}
148
+ completionProvider={completionProvider}
149
+ bare
150
+ // Kind is chosen up front via AddConditionDialog; to change it the
151
+ // operator deletes + re-adds, matching how actions work. Nested
152
+ // clauses keep their inline selector (this prop isn't threaded down).
153
+ hideKindSelector
154
+ />
155
+ </ItemSheet>
156
+ </>
157
+ );
158
+ };