@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
@@ -46,40 +46,29 @@ import {
46
46
  TabPanel,
47
47
  } from "@checkstack/ui";
48
48
  import { extractErrorMessage, resolveRoute } from "@checkstack/common";
49
+ import {
50
+ GitOpsLockBanner,
51
+ useProvenanceLock,
52
+ } from "@checkstack/gitops-frontend";
49
53
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
50
54
  import { AutomationDefinitionEditor } from "../editor/AutomationDefinitionEditor";
51
55
  import { assignDefaultIds } from "../editor/action-helpers";
52
56
  import { assignDefaultTriggerIds } from "../editor/trigger-helpers";
53
57
  import { computeYamlMarkers } from "../editor/yaml-markers";
54
- import {
55
- ValidationProvider,
56
- partitionIssues,
57
- } from "../editor/editor-validation";
58
+ import { partitionIssues } from "../editor/editor-validation";
59
+ import { AutomationGroupCombobox } from "../components/AutomationGroupCombobox";
60
+ import { RunAsServiceAccountPicker } from "../components/RunAsServiceAccountPicker";
58
61
 
59
62
  const STARTER_DEFINITION: AutomationDefinition = {
60
63
  name: "New Automation",
61
- // Seed the starter trigger's id the same way actions are seeded, so it is
62
- // shown (and referenceable as `trigger.id`) immediately.
63
- triggers: assignDefaultTriggerIds([{ event: "incident.created" }]),
64
+ // Start empty: the operator picks a trigger and adds actions via the Add
65
+ // dialogs (the empty-state hints guide them), rather than starting from a
66
+ // pre-filled trigger + action they then have to replace.
67
+ triggers: [],
64
68
  conditions: [],
65
- // Run the seeded starter action through the same default-id assignment the
66
- // "Add step" path uses, so its `id` is filled in (and shown) immediately
67
- // rather than appearing blank until the field is focused.
68
- actions: assignDefaultIds(
69
- [
70
- {
71
- action: "automation.log",
72
- config: {
73
- message: "Incident {{ trigger.payload.title }} fired",
74
- level: "info",
75
- },
76
- enabled: true,
77
- continue_on_error: false,
78
- },
79
- ],
80
- new Set(),
81
- ),
69
+ actions: [],
82
70
  mode: "single",
71
+ concurrency_scope: "automation",
83
72
  max_runs: 10,
84
73
  };
85
74
 
@@ -119,7 +108,21 @@ const AutomationEditContent: React.FC = () => {
119
108
  const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
120
109
  automationAccess.read,
121
110
  );
122
- const { allowed: canManage } = accessApi.useAccess(automationAccess.manage);
111
+ const { allowed: hasManageAccess } = accessApi.useAccess(
112
+ automationAccess.manage,
113
+ );
114
+
115
+ // GitOps provenance lock: when this automation is declaratively managed,
116
+ // disable manual edits + show a banner. `entityId` is the automation id
117
+ // (the GitOps reconciler stores it in provenance). New (unsaved)
118
+ // automations are never locked.
119
+ const { isLocked, provenance } = useProvenanceLock({
120
+ kind: "Automation",
121
+ entityId: isNew ? undefined : automationId,
122
+ });
123
+
124
+ // Effective edit permission: manage access AND not GitOps-locked.
125
+ const canManage = hasManageAccess && !isLocked;
123
126
 
124
127
  const loadQuery = client.getAutomation.useQuery(
125
128
  { id: automationId ?? "" },
@@ -129,7 +132,16 @@ const AutomationEditContent: React.FC = () => {
129
132
  // Top-level form state.
130
133
  const [name, setName] = React.useState("");
131
134
  const [description, setDescription] = React.useState("");
135
+ // Empty string means "Ungrouped" — sent as `null` on save to clear it.
136
+ const [group, setGroup] = React.useState("");
137
+ // Service account (Application id) the automation runs as. Empty means
138
+ // "not chosen yet" — save is blocked until one is selected.
139
+ const [runAsApplicationId, setRunAsApplicationId] = React.useState("");
132
140
  const [statusEnabled, setStatusEnabled] = React.useState(true);
141
+
142
+ // Existing group values for the picker's "pick existing" suggestions.
143
+ const groupsQuery = client.listAutomationGroups.useQuery();
144
+ const groupSuggestions = groupsQuery.data?.groups ?? [];
133
145
  const [definition, setDefinition] =
134
146
  React.useState<AutomationDefinition>(STARTER_DEFINITION);
135
147
  const [yamlText, setYamlText] = React.useState<string>(() =>
@@ -150,11 +162,24 @@ const AutomationEditContent: React.FC = () => {
150
162
  loadQuery.isFetchedAfterMount ? loadQuery.data : undefined,
151
163
  loadQuery.data?.id,
152
164
  (a) => {
165
+ // Stored definitions (seeded defaults, GitOps, hand-written YAML) may
166
+ // carry triggers/actions without an `id`. The runtime derives those ids
167
+ // on the fly, but the editor must materialize them eagerly so they show
168
+ // immediately rather than appearing blank until the field is focused.
169
+ // Both helpers preserve existing ids and only fill blanks, so this is
170
+ // idempotent and matches how STARTER_DEFINITION is seeded.
171
+ const normalized: AutomationDefinition = {
172
+ ...a.definition,
173
+ triggers: assignDefaultTriggerIds(a.definition.triggers),
174
+ actions: assignDefaultIds(a.definition.actions, new Set()),
175
+ };
153
176
  setName(a.name);
154
177
  setDescription(a.description ?? "");
178
+ setGroup(a.group ?? "");
179
+ setRunAsApplicationId(a.runAs ?? "");
155
180
  setStatusEnabled(a.status === "enabled");
156
- setDefinition(a.definition);
157
- setYamlText(stringifyYaml(a.definition));
181
+ setDefinition(normalized);
182
+ setYamlText(stringifyYaml(normalized));
158
183
  },
159
184
  );
160
185
 
@@ -308,10 +333,16 @@ const AutomationEditContent: React.FC = () => {
308
333
  });
309
334
  if (!validation.valid) return;
310
335
 
336
+ // Empty input = Ungrouped. On create we omit it; on update we send `null`
337
+ // so an explicit clear round-trips (undefined would leave it unchanged).
338
+ const trimmedGroup = group.trim();
339
+
311
340
  if (isNew) {
312
341
  createMutation.mutate({
313
342
  name,
314
343
  description: description || undefined,
344
+ group: trimmedGroup || undefined,
345
+ runAs: runAsApplicationId,
315
346
  status: statusEnabled ? "enabled" : "disabled",
316
347
  definition: merged,
317
348
  });
@@ -320,6 +351,8 @@ const AutomationEditContent: React.FC = () => {
320
351
  id: automationId,
321
352
  name,
322
353
  description: description || undefined,
354
+ group: trimmedGroup || null,
355
+ runAs: runAsApplicationId,
323
356
  status: statusEnabled ? "enabled" : "disabled",
324
357
  definition: merged,
325
358
  });
@@ -367,8 +400,15 @@ const AutomationEditContent: React.FC = () => {
367
400
  // "Input validation failed" toast. Validate it here so the Name field can
368
401
  // surface the error and Save can be disabled instead.
369
402
  const nameError = name.trim().length === 0 ? "Name is required" : undefined;
403
+ // A service account is required: the automation runs with its permissions,
404
+ // so the backend rejects a save without one. Block save + hint inline.
405
+ const runAsError =
406
+ runAsApplicationId.trim().length === 0
407
+ ? "A service account is required"
408
+ : undefined;
370
409
  const isSaving = createMutation.isPending || updateMutation.isPending;
371
- const canSave = !nameError && validationErrors.length === 0 && !isSaving;
410
+ const canSave =
411
+ !nameError && !runAsError && validationErrors.length === 0 && !isSaving;
372
412
 
373
413
  return (
374
414
  <PageLayout
@@ -431,7 +471,11 @@ const AutomationEditContent: React.FC = () => {
431
471
  ) : !isNew && loadQuery.isLoading ? (
432
472
  <LoadingSpinner />
433
473
  ) : (
434
- <div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
474
+ <div className="space-y-4">
475
+ {isLocked && provenance && (
476
+ <GitOpsLockBanner provenance={provenance} />
477
+ )}
478
+ <div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
435
479
  <Card>
436
480
  <CardHeader className="border-b">
437
481
  <CardTitle className="text-base">Metadata</CardTitle>
@@ -462,6 +506,25 @@ const AutomationEditContent: React.FC = () => {
462
506
  placeholder="Optional"
463
507
  />
464
508
  </div>
509
+ <div className="space-y-1">
510
+ <Label htmlFor="group">Group</Label>
511
+ <AutomationGroupCombobox
512
+ id="group"
513
+ value={group}
514
+ onValueChange={setGroup}
515
+ suggestions={groupSuggestions}
516
+ disabled={!canManage}
517
+ />
518
+ <p className="text-xs text-muted-foreground">
519
+ Optional. Organises the automations list into sections.
520
+ </p>
521
+ </div>
522
+ <RunAsServiceAccountPicker
523
+ value={runAsApplicationId}
524
+ onValueChange={setRunAsApplicationId}
525
+ disabled={!canManage}
526
+ showError={!!runAsError}
527
+ />
465
528
  <div className="flex items-center justify-between">
466
529
  <Label htmlFor="enabled">Enabled</Label>
467
530
  <Toggle
@@ -523,25 +586,29 @@ const AutomationEditContent: React.FC = () => {
523
586
  />
524
587
  </div>
525
588
  <TabPanel id="visual" activeTab={tab}>
526
- <ValidationProvider issues={validationErrors}>
527
- {unattributedIssues.length > 0 && (
528
- <Alert variant="error" className="mb-2">
529
- <AlertTitle>Definition issues</AlertTitle>
530
- <AlertDescription>
531
- <ul className="space-y-1 text-xs font-mono">
532
- {unattributedIssues.map((issue, index) => (
533
- <li key={index}>{issue}</li>
534
- ))}
535
- </ul>
536
- </AlertDescription>
537
- </Alert>
538
- )}
539
- <AutomationDefinitionEditor
540
- value={definition}
541
- onChange={setDefinition}
542
- disabled={!canManage}
543
- />
544
- </ValidationProvider>
589
+ {unattributedIssues.length > 0 && (
590
+ <Alert variant="error" className="mb-2">
591
+ <AlertTitle>Definition issues</AlertTitle>
592
+ <AlertDescription>
593
+ <ul className="space-y-1 text-xs font-mono">
594
+ {unattributedIssues.map((issue, index) => (
595
+ <li key={index}>{issue}</li>
596
+ ))}
597
+ </ul>
598
+ </AlertDescription>
599
+ </Alert>
600
+ )}
601
+ {/* `AutomationDefinitionEditor` owns the `ValidationProvider`
602
+ (inside its registry provider) so it can merge these
603
+ structural issues with the live inline-script type issues it
604
+ computes. */}
605
+ <AutomationDefinitionEditor
606
+ value={definition}
607
+ onChange={setDefinition}
608
+ disabled={!canManage}
609
+ automationId={isNew ? undefined : automationId}
610
+ structuralIssues={validationErrors}
611
+ />
545
612
  </TabPanel>
546
613
  <TabPanel id="yaml" activeTab={tab}>
547
614
  <Card>
@@ -558,6 +625,7 @@ const AutomationEditContent: React.FC = () => {
558
625
  </Card>
559
626
  </TabPanel>
560
627
  </div>
628
+ </div>
561
629
  </div>
562
630
  )}
563
631
  </PageLayout>
@@ -11,6 +11,7 @@ import {
11
11
  AutomationApi,
12
12
  automationAccess,
13
13
  automationRoutes,
14
+ type Automation,
14
15
  } from "@checkstack/automation-common";
15
16
  import {
16
17
  PageLayout,
@@ -31,10 +32,15 @@ import {
31
32
  QueryErrorState,
32
33
  EmptyState,
33
34
  ConfirmationModal,
35
+ Accordion,
36
+ AccordionItem,
37
+ AccordionTrigger,
38
+ AccordionContent,
34
39
  useToast,
35
40
  } from "@checkstack/ui";
36
41
  import { extractErrorMessage, resolveRoute } from "@checkstack/common";
37
42
  import { formatDistanceToNow } from "date-fns";
43
+ import { groupAutomations } from "./automation-grouping";
38
44
 
39
45
  /**
40
46
  * Lists every automation the operator can see, with quick enable / disable
@@ -85,9 +91,123 @@ const AutomationListContent: React.FC = () => {
85
91
  onError: (error) => toast.error(extractErrorMessage(error)),
86
92
  });
87
93
 
88
- const automations = query.data?.items ?? [];
94
+ const items = query.data?.items;
95
+ const automations = React.useMemo(() => items ?? [], [items]);
89
96
  const isEmpty = !query.isLoading && automations.length === 0;
90
97
 
98
+ // Collapsible sections, sorted alphabetically with "Ungrouped" last.
99
+ const groups = React.useMemo(
100
+ () => groupAutomations({ automations }),
101
+ [automations],
102
+ );
103
+ // All sections expanded by default so nothing is hidden on first load.
104
+ const allGroupKeys = React.useMemo(() => groups.map((g) => g.key), [groups]);
105
+
106
+ const renderRow = (automation: Automation) => (
107
+ <TableRow
108
+ key={automation.id}
109
+ className="cursor-pointer hover:bg-accent/40"
110
+ onClick={() =>
111
+ navigate(
112
+ resolveRoute(automationRoutes.routes.edit, {
113
+ automationId: automation.id,
114
+ }),
115
+ )
116
+ }
117
+ >
118
+ <TableCell onClick={(e) => e.stopPropagation()}>
119
+ {canManage ? (
120
+ <Toggle
121
+ checked={automation.status === "enabled"}
122
+ onCheckedChange={(enabled) =>
123
+ toggleMutation.mutate({
124
+ id: automation.id,
125
+ enabled,
126
+ })
127
+ }
128
+ aria-label={
129
+ automation.status === "enabled"
130
+ ? "Disable automation"
131
+ : "Enable automation"
132
+ }
133
+ />
134
+ ) : (
135
+ <Badge
136
+ variant={
137
+ automation.status === "enabled" ? "success" : "outline"
138
+ }
139
+ >
140
+ {automation.status}
141
+ </Badge>
142
+ )}
143
+ </TableCell>
144
+ <TableCell>
145
+ <div className="flex flex-col">
146
+ <span className="font-medium">{automation.name}</span>
147
+ {automation.description && (
148
+ <span className="text-xs text-muted-foreground">
149
+ {automation.description}
150
+ </span>
151
+ )}
152
+ </div>
153
+ </TableCell>
154
+ <TableCell>
155
+ <div className="flex flex-wrap gap-1">
156
+ {automation.definition.triggers.slice(0, 3).map((trigger, index) => (
157
+ <Badge
158
+ key={`${trigger.event}-${index}`}
159
+ variant="outline"
160
+ className="text-[10px] font-mono"
161
+ >
162
+ {trigger.event}
163
+ </Badge>
164
+ ))}
165
+ {automation.definition.triggers.length > 3 && (
166
+ <Badge variant="outline" className="text-[10px]">
167
+ +{automation.definition.triggers.length - 3}
168
+ </Badge>
169
+ )}
170
+ </div>
171
+ </TableCell>
172
+ <TableCell>
173
+ <Badge variant="secondary" className="text-[10px]">
174
+ {automation.definition.mode}
175
+ </Badge>
176
+ </TableCell>
177
+ <TableCell>
178
+ <span className="text-xs text-muted-foreground">
179
+ {formatDistanceToNow(new Date(automation.updatedAt), {
180
+ addSuffix: true,
181
+ })}
182
+ </span>
183
+ </TableCell>
184
+ <TableCell onClick={(e) => e.stopPropagation()} className="text-right">
185
+ <div className="flex justify-end gap-1">
186
+ <Link
187
+ to={resolveRoute(automationRoutes.routes.runs, {
188
+ automationId: automation.id,
189
+ })}
190
+ >
191
+ <Button variant="ghost" size="sm">
192
+ Runs
193
+ </Button>
194
+ </Link>
195
+ {canManage && (
196
+ <Button
197
+ variant="ghost"
198
+ size="icon"
199
+ className="h-8 w-8 text-destructive hover:bg-destructive/10"
200
+ onClick={() => setDeleteId(automation.id)}
201
+ aria-label="Delete automation"
202
+ >
203
+ <Trash2 className="h-4 w-4" />
204
+ </Button>
205
+ )}
206
+ </div>
207
+ </TableCell>
208
+ </TableRow>
209
+ );
210
+
91
211
  return (
92
212
  <PageLayout
93
213
  title="Automations"
@@ -154,131 +274,60 @@ const AutomationListContent: React.FC = () => {
154
274
  }
155
275
  />
156
276
  ) : (
157
- <Table>
158
- <TableHeader>
159
- <TableRow>
160
- <TableHead className="w-8" />
161
- <TableHead>Name</TableHead>
162
- <TableHead>Triggers</TableHead>
163
- <TableHead>Mode</TableHead>
164
- <TableHead>Updated</TableHead>
165
- <TableHead className="w-24 text-right">Actions</TableHead>
166
- </TableRow>
167
- </TableHeader>
168
- <TableBody>
169
- {automations.map((automation) => (
170
- <TableRow
171
- key={automation.id}
172
- className="cursor-pointer hover:bg-accent/40"
173
- onClick={() =>
174
- navigate(
175
- resolveRoute(automationRoutes.routes.edit, {
176
- automationId: automation.id,
177
- }),
178
- )
179
- }
180
- >
181
- <TableCell onClick={(e) => e.stopPropagation()}>
182
- {canManage ? (
183
- <Toggle
184
- checked={automation.status === "enabled"}
185
- onCheckedChange={(enabled) =>
186
- toggleMutation.mutate({
187
- id: automation.id,
188
- enabled,
189
- })
190
- }
191
- aria-label={
192
- automation.status === "enabled"
193
- ? "Disable automation"
194
- : "Enable automation"
195
- }
196
- />
197
- ) : (
198
- <Badge
199
- variant={
200
- automation.status === "enabled"
201
- ? "success"
202
- : "outline"
203
- }
204
- >
205
- {automation.status}
206
- </Badge>
207
- )}
208
- </TableCell>
209
- <TableCell>
210
- <div className="flex flex-col">
211
- <span className="font-medium">{automation.name}</span>
212
- {automation.description && (
213
- <span className="text-xs text-muted-foreground">
214
- {automation.description}
215
- </span>
216
- )}
217
- </div>
218
- </TableCell>
219
- <TableCell>
220
- <div className="flex flex-wrap gap-1">
221
- {automation.definition.triggers
222
- .slice(0, 3)
223
- .map((trigger, index) => (
224
- <Badge
225
- key={`${trigger.event}-${index}`}
226
- variant="outline"
227
- className="text-[10px] font-mono"
228
- >
229
- {trigger.event}
230
- </Badge>
231
- ))}
232
- {automation.definition.triggers.length > 3 && (
233
- <Badge variant="outline" className="text-[10px]">
234
- +{automation.definition.triggers.length - 3}
235
- </Badge>
236
- )}
237
- </div>
238
- </TableCell>
239
- <TableCell>
277
+ <Accordion
278
+ // Re-key on the group set so newly-appearing sections (after a
279
+ // status-filter change) start expanded rather than collapsed.
280
+ key={allGroupKeys.join("|")}
281
+ type="multiple"
282
+ defaultValue={allGroupKeys}
283
+ className="w-full"
284
+ >
285
+ {groups.map((group) => (
286
+ <AccordionItem
287
+ key={group.key}
288
+ value={group.key}
289
+ className="last:border-b-0"
290
+ >
291
+ <AccordionTrigger className="px-4">
292
+ <span className="flex items-center gap-2">
293
+ <span
294
+ className={
295
+ group.ungrouped
296
+ ? "text-muted-foreground"
297
+ : undefined
298
+ }
299
+ >
300
+ {group.label}
301
+ </span>
240
302
  <Badge variant="secondary" className="text-[10px]">
241
- {automation.definition.mode}
303
+ {group.items.length}
242
304
  </Badge>
243
- </TableCell>
244
- <TableCell>
245
- <span className="text-xs text-muted-foreground">
246
- {formatDistanceToNow(new Date(automation.updatedAt), {
247
- addSuffix: true,
248
- })}
249
- </span>
250
- </TableCell>
251
- <TableCell
252
- onClick={(e) => e.stopPropagation()}
253
- className="text-right"
254
- >
255
- <div className="flex justify-end gap-1">
256
- <Link
257
- to={resolveRoute(automationRoutes.routes.runs, {
258
- automationId: automation.id,
259
- })}
260
- >
261
- <Button variant="ghost" size="sm">
262
- Runs
263
- </Button>
264
- </Link>
265
- {canManage && (
266
- <Button
267
- variant="ghost"
268
- size="icon"
269
- className="h-8 w-8 text-destructive hover:bg-destructive/10"
270
- onClick={() => setDeleteId(automation.id)}
271
- aria-label="Delete automation"
272
- >
273
- <Trash2 className="h-4 w-4" />
274
- </Button>
305
+ </span>
306
+ </AccordionTrigger>
307
+ <AccordionContent className="px-0 pb-0">
308
+ <Table>
309
+ <TableHeader>
310
+ <TableRow>
311
+ <TableHead className="w-8" />
312
+ <TableHead>Name</TableHead>
313
+ <TableHead>Triggers</TableHead>
314
+ <TableHead>Mode</TableHead>
315
+ <TableHead>Updated</TableHead>
316
+ <TableHead className="w-24 text-right">
317
+ Actions
318
+ </TableHead>
319
+ </TableRow>
320
+ </TableHeader>
321
+ <TableBody>
322
+ {group.items.map((automation) =>
323
+ renderRow(automation),
275
324
  )}
276
- </div>
277
- </TableCell>
278
- </TableRow>
279
- ))}
280
- </TableBody>
281
- </Table>
325
+ </TableBody>
326
+ </Table>
327
+ </AccordionContent>
328
+ </AccordionItem>
329
+ ))}
330
+ </Accordion>
282
331
  )}
283
332
  </CardContent>
284
333
  </Card>
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { Automation, AutomationDefinition } from "@checkstack/automation-common";
3
+ import {
4
+ groupAutomations,
5
+ UNGROUPED_LABEL,
6
+ } from "./automation-grouping";
7
+
8
+ const DEFINITION: AutomationDefinition = {
9
+ name: "n",
10
+ triggers: [{ event: "incident.incident.created" }],
11
+ conditions: [],
12
+ actions: [],
13
+ mode: "single",
14
+ concurrency_scope: "automation",
15
+ max_runs: 10,
16
+ };
17
+
18
+ function make({
19
+ id,
20
+ group,
21
+ }: {
22
+ id: string;
23
+ group?: string;
24
+ }): Automation {
25
+ return {
26
+ id,
27
+ name: `Automation ${id}`,
28
+ group,
29
+ runAs: null,
30
+ status: "enabled",
31
+ definition: DEFINITION,
32
+ createdAt: new Date("2026-01-01T00:00:00Z"),
33
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
34
+ };
35
+ }
36
+
37
+ describe("groupAutomations", () => {
38
+ it("returns an empty list for no automations", () => {
39
+ expect(groupAutomations({ automations: [] })).toEqual([]);
40
+ });
41
+
42
+ it("puts automations with no group into the Ungrouped bucket", () => {
43
+ const groups = groupAutomations({
44
+ automations: [make({ id: "1" }), make({ id: "2", group: " " })],
45
+ });
46
+ expect(groups).toHaveLength(1);
47
+ expect(groups[0]?.label).toBe(UNGROUPED_LABEL);
48
+ expect(groups[0]?.ungrouped).toBe(true);
49
+ expect(groups[0]?.items.map((a) => a.id)).toEqual(["1", "2"]);
50
+ });
51
+
52
+ it("groups by group value and sorts named groups alphabetically", () => {
53
+ const groups = groupAutomations({
54
+ automations: [
55
+ make({ id: "1", group: "Zeta" }),
56
+ make({ id: "2", group: "alpha" }),
57
+ make({ id: "3", group: "Zeta" }),
58
+ ],
59
+ });
60
+ expect(groups.map((g) => g.label)).toEqual(["alpha", "Zeta"]);
61
+ expect(groups[1]?.items.map((a) => a.id)).toEqual(["1", "3"]);
62
+ });
63
+
64
+ it("always places the Ungrouped bucket last", () => {
65
+ const groups = groupAutomations({
66
+ automations: [
67
+ make({ id: "1" }),
68
+ make({ id: "2", group: "Networking" }),
69
+ make({ id: "3", group: "Alerting" }),
70
+ ],
71
+ });
72
+ expect(groups.map((g) => g.label)).toEqual([
73
+ "Alerting",
74
+ "Networking",
75
+ UNGROUPED_LABEL,
76
+ ]);
77
+ });
78
+
79
+ it("never emits empty groups", () => {
80
+ const groups = groupAutomations({
81
+ automations: [make({ id: "1", group: "Only" })],
82
+ });
83
+ expect(groups).toHaveLength(1);
84
+ expect(groups[0]?.label).toBe("Only");
85
+ expect(groups.some((g) => g.items.length === 0)).toBe(false);
86
+ });
87
+ });