@checkstack/automation-frontend 0.2.0 → 0.3.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 (44) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/package.json +13 -9
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/editor/ActionEditor.tsx +180 -90
  5. package/src/editor/ActionListEditor.tsx +27 -1
  6. package/src/editor/AddActionDialog.tsx +15 -45
  7. package/src/editor/AddConditionDialog.tsx +86 -0
  8. package/src/editor/AddTriggerDialog.tsx +97 -0
  9. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  10. package/src/editor/ConditionEditor.tsx +359 -70
  11. package/src/editor/ConditionsEditor.tsx +113 -44
  12. package/src/editor/ItemSheet.tsx +51 -0
  13. package/src/editor/RunReplayPicker.tsx +97 -0
  14. package/src/editor/ScriptServicesBooter.tsx +53 -0
  15. package/src/editor/ScriptTestRenderer.tsx +150 -0
  16. package/src/editor/SystemEntityPicker.test.ts +37 -0
  17. package/src/editor/SystemEntityPicker.tsx +109 -0
  18. package/src/editor/TriggersEditor.tsx +345 -137
  19. package/src/editor/action-helpers.test.ts +107 -0
  20. package/src/editor/action-helpers.ts +72 -0
  21. package/src/editor/action-leaf-cards.tsx +98 -1
  22. package/src/editor/condition-kind.test.ts +126 -0
  23. package/src/editor/condition-kind.ts +130 -0
  24. package/src/editor/item-summary.test.ts +171 -0
  25. package/src/editor/item-summary.ts +210 -0
  26. package/src/editor/picker-dialog.tsx +156 -0
  27. package/src/editor/registry-context.tsx +9 -2
  28. package/src/editor/script-actions.test.ts +184 -0
  29. package/src/editor/script-actions.ts +146 -0
  30. package/src/editor/system-entity-picker.logic.ts +23 -0
  31. package/src/editor/template-completion.test.ts +22 -3
  32. package/src/editor/template-completion.ts +16 -8
  33. package/src/editor/template-helpers.ts +4 -0
  34. package/src/editor/trigger-helpers.test.ts +28 -0
  35. package/src/editor/trigger-helpers.ts +17 -0
  36. package/src/editor/useScriptDiagnostics.ts +108 -0
  37. package/src/index.tsx +2 -0
  38. package/src/pages/AutomationEditPage.tsx +95 -47
  39. package/src/pages/AutomationListPage.tsx +172 -123
  40. package/src/pages/automation-grouping.test.ts +86 -0
  41. package/src/pages/automation-grouping.ts +65 -0
  42. package/src/script-context.test.ts +142 -1
  43. package/src/script-context.ts +115 -0
  44. package/tsconfig.json +12 -0
@@ -46,40 +46,28 @@ 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";
58
60
 
59
61
  const STARTER_DEFINITION: AutomationDefinition = {
60
62
  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" }]),
63
+ // Start empty: the operator picks a trigger and adds actions via the Add
64
+ // dialogs (the empty-state hints guide them), rather than starting from a
65
+ // pre-filled trigger + action they then have to replace.
66
+ triggers: [],
64
67
  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
- ),
68
+ actions: [],
82
69
  mode: "single",
70
+ concurrency_scope: "automation",
83
71
  max_runs: 10,
84
72
  };
85
73
 
@@ -119,7 +107,21 @@ const AutomationEditContent: React.FC = () => {
119
107
  const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
120
108
  automationAccess.read,
121
109
  );
122
- const { allowed: canManage } = accessApi.useAccess(automationAccess.manage);
110
+ const { allowed: hasManageAccess } = accessApi.useAccess(
111
+ automationAccess.manage,
112
+ );
113
+
114
+ // GitOps provenance lock: when this automation is declaratively managed,
115
+ // disable manual edits + show a banner. `entityId` is the automation id
116
+ // (the GitOps reconciler stores it in provenance). New (unsaved)
117
+ // automations are never locked.
118
+ const { isLocked, provenance } = useProvenanceLock({
119
+ kind: "Automation",
120
+ entityId: isNew ? undefined : automationId,
121
+ });
122
+
123
+ // Effective edit permission: manage access AND not GitOps-locked.
124
+ const canManage = hasManageAccess && !isLocked;
123
125
 
124
126
  const loadQuery = client.getAutomation.useQuery(
125
127
  { id: automationId ?? "" },
@@ -129,7 +131,13 @@ const AutomationEditContent: React.FC = () => {
129
131
  // Top-level form state.
130
132
  const [name, setName] = React.useState("");
131
133
  const [description, setDescription] = React.useState("");
134
+ // Empty string means "Ungrouped" — sent as `null` on save to clear it.
135
+ const [group, setGroup] = React.useState("");
132
136
  const [statusEnabled, setStatusEnabled] = React.useState(true);
137
+
138
+ // Existing group values for the picker's "pick existing" suggestions.
139
+ const groupsQuery = client.listAutomationGroups.useQuery();
140
+ const groupSuggestions = groupsQuery.data?.groups ?? [];
133
141
  const [definition, setDefinition] =
134
142
  React.useState<AutomationDefinition>(STARTER_DEFINITION);
135
143
  const [yamlText, setYamlText] = React.useState<string>(() =>
@@ -150,11 +158,23 @@ const AutomationEditContent: React.FC = () => {
150
158
  loadQuery.isFetchedAfterMount ? loadQuery.data : undefined,
151
159
  loadQuery.data?.id,
152
160
  (a) => {
161
+ // Stored definitions (seeded defaults, GitOps, hand-written YAML) may
162
+ // carry triggers/actions without an `id`. The runtime derives those ids
163
+ // on the fly, but the editor must materialize them eagerly so they show
164
+ // immediately rather than appearing blank until the field is focused.
165
+ // Both helpers preserve existing ids and only fill blanks, so this is
166
+ // idempotent and matches how STARTER_DEFINITION is seeded.
167
+ const normalized: AutomationDefinition = {
168
+ ...a.definition,
169
+ triggers: assignDefaultTriggerIds(a.definition.triggers),
170
+ actions: assignDefaultIds(a.definition.actions, new Set()),
171
+ };
153
172
  setName(a.name);
154
173
  setDescription(a.description ?? "");
174
+ setGroup(a.group ?? "");
155
175
  setStatusEnabled(a.status === "enabled");
156
- setDefinition(a.definition);
157
- setYamlText(stringifyYaml(a.definition));
176
+ setDefinition(normalized);
177
+ setYamlText(stringifyYaml(normalized));
158
178
  },
159
179
  );
160
180
 
@@ -308,10 +328,15 @@ const AutomationEditContent: React.FC = () => {
308
328
  });
309
329
  if (!validation.valid) return;
310
330
 
331
+ // Empty input = Ungrouped. On create we omit it; on update we send `null`
332
+ // so an explicit clear round-trips (undefined would leave it unchanged).
333
+ const trimmedGroup = group.trim();
334
+
311
335
  if (isNew) {
312
336
  createMutation.mutate({
313
337
  name,
314
338
  description: description || undefined,
339
+ group: trimmedGroup || undefined,
315
340
  status: statusEnabled ? "enabled" : "disabled",
316
341
  definition: merged,
317
342
  });
@@ -320,6 +345,7 @@ const AutomationEditContent: React.FC = () => {
320
345
  id: automationId,
321
346
  name,
322
347
  description: description || undefined,
348
+ group: trimmedGroup || null,
323
349
  status: statusEnabled ? "enabled" : "disabled",
324
350
  definition: merged,
325
351
  });
@@ -431,7 +457,11 @@ const AutomationEditContent: React.FC = () => {
431
457
  ) : !isNew && loadQuery.isLoading ? (
432
458
  <LoadingSpinner />
433
459
  ) : (
434
- <div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
460
+ <div className="space-y-4">
461
+ {isLocked && provenance && (
462
+ <GitOpsLockBanner provenance={provenance} />
463
+ )}
464
+ <div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
435
465
  <Card>
436
466
  <CardHeader className="border-b">
437
467
  <CardTitle className="text-base">Metadata</CardTitle>
@@ -462,6 +492,19 @@ const AutomationEditContent: React.FC = () => {
462
492
  placeholder="Optional"
463
493
  />
464
494
  </div>
495
+ <div className="space-y-1">
496
+ <Label htmlFor="group">Group</Label>
497
+ <AutomationGroupCombobox
498
+ id="group"
499
+ value={group}
500
+ onValueChange={setGroup}
501
+ suggestions={groupSuggestions}
502
+ disabled={!canManage}
503
+ />
504
+ <p className="text-xs text-muted-foreground">
505
+ Optional. Organises the automations list into sections.
506
+ </p>
507
+ </div>
465
508
  <div className="flex items-center justify-between">
466
509
  <Label htmlFor="enabled">Enabled</Label>
467
510
  <Toggle
@@ -523,25 +566,29 @@ const AutomationEditContent: React.FC = () => {
523
566
  />
524
567
  </div>
525
568
  <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>
569
+ {unattributedIssues.length > 0 && (
570
+ <Alert variant="error" className="mb-2">
571
+ <AlertTitle>Definition issues</AlertTitle>
572
+ <AlertDescription>
573
+ <ul className="space-y-1 text-xs font-mono">
574
+ {unattributedIssues.map((issue, index) => (
575
+ <li key={index}>{issue}</li>
576
+ ))}
577
+ </ul>
578
+ </AlertDescription>
579
+ </Alert>
580
+ )}
581
+ {/* `AutomationDefinitionEditor` owns the `ValidationProvider`
582
+ (inside its registry provider) so it can merge these
583
+ structural issues with the live inline-script type issues it
584
+ computes. */}
585
+ <AutomationDefinitionEditor
586
+ value={definition}
587
+ onChange={setDefinition}
588
+ disabled={!canManage}
589
+ automationId={isNew ? undefined : automationId}
590
+ structuralIssues={validationErrors}
591
+ />
545
592
  </TabPanel>
546
593
  <TabPanel id="yaml" activeTab={tab}>
547
594
  <Card>
@@ -558,6 +605,7 @@ const AutomationEditContent: React.FC = () => {
558
605
  </Card>
559
606
  </TabPanel>
560
607
  </div>
608
+ </div>
561
609
  </div>
562
610
  )}
563
611
  </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,86 @@
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
+ status: "enabled",
30
+ definition: DEFINITION,
31
+ createdAt: new Date("2026-01-01T00:00:00Z"),
32
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
33
+ };
34
+ }
35
+
36
+ describe("groupAutomations", () => {
37
+ it("returns an empty list for no automations", () => {
38
+ expect(groupAutomations({ automations: [] })).toEqual([]);
39
+ });
40
+
41
+ it("puts automations with no group into the Ungrouped bucket", () => {
42
+ const groups = groupAutomations({
43
+ automations: [make({ id: "1" }), make({ id: "2", group: " " })],
44
+ });
45
+ expect(groups).toHaveLength(1);
46
+ expect(groups[0]?.label).toBe(UNGROUPED_LABEL);
47
+ expect(groups[0]?.ungrouped).toBe(true);
48
+ expect(groups[0]?.items.map((a) => a.id)).toEqual(["1", "2"]);
49
+ });
50
+
51
+ it("groups by group value and sorts named groups alphabetically", () => {
52
+ const groups = groupAutomations({
53
+ automations: [
54
+ make({ id: "1", group: "Zeta" }),
55
+ make({ id: "2", group: "alpha" }),
56
+ make({ id: "3", group: "Zeta" }),
57
+ ],
58
+ });
59
+ expect(groups.map((g) => g.label)).toEqual(["alpha", "Zeta"]);
60
+ expect(groups[1]?.items.map((a) => a.id)).toEqual(["1", "3"]);
61
+ });
62
+
63
+ it("always places the Ungrouped bucket last", () => {
64
+ const groups = groupAutomations({
65
+ automations: [
66
+ make({ id: "1" }),
67
+ make({ id: "2", group: "Networking" }),
68
+ make({ id: "3", group: "Alerting" }),
69
+ ],
70
+ });
71
+ expect(groups.map((g) => g.label)).toEqual([
72
+ "Alerting",
73
+ "Networking",
74
+ UNGROUPED_LABEL,
75
+ ]);
76
+ });
77
+
78
+ it("never emits empty groups", () => {
79
+ const groups = groupAutomations({
80
+ automations: [make({ id: "1", group: "Only" })],
81
+ });
82
+ expect(groups).toHaveLength(1);
83
+ expect(groups[0]?.label).toBe("Only");
84
+ expect(groups.some((g) => g.items.length === 0)).toBe(false);
85
+ });
86
+ });