@checkstack/automation-frontend 0.12.6 → 0.12.8

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,149 @@
1
1
  # @checkstack/automation-frontend
2
2
 
3
+ ## 0.12.8
4
+
5
+ ### Patch Changes
6
+
7
+ - be74b01: Migrate the automation surfaces onto the shared filter bar, and dedupe useDebouncedValue
8
+
9
+ Follows the native `DataTable` facet API with the first wave of migrations.
10
+
11
+ - `DataTableFacet` gains `kind: "select" | "pills"`. A segmented pill row is the
12
+ right control for two or three short options a reader benefits from seeing at
13
+ a glance, and several surfaces had independently built one - so the shared bar
14
+ renders that variant rather than forcing every list into a dropdown. Both
15
+ variants share one state, sentinel and URL round-trip, and the pills set
16
+ `aria-pressed`, which two of the hand-rolled groups they replace had omitted.
17
+ - `parsedFacetValue` reads a facet's selection back as a domain value by parsing
18
+ it against the schema that defines it. Facet state is stringly-typed because
19
+ it round-trips through the URL, but a server-side filter needs the narrow union
20
+ its query input declares; parsing rather than casting means a stale link
21
+ degrades to unconstrained instead of smuggling an unknown value into a request.
22
+ - The automation list and run-history pages drop their hand-rolled status pill
23
+ rows for the shared bar. Their filters now persist to the URL, so a link to
24
+ "the failed runs of this automation" reopens filtered. The run-history table
25
+ also gains the `surface={false}` it was missing, fixing a panel-in-panel.
26
+ - `useDebouncedValue` had been copied verbatim into six plugin packages, each
27
+ with a comment noting no shared version existed. All six now import the one in
28
+ `@checkstack/ui` and the copies are deleted.
29
+
30
+ - be74b01: Move every table's filters into the table itself
31
+
32
+ The earlier migration unified how filter controls are BUILT but left several
33
+ rendering above their table as a detached bar, justified by the filtering
34
+ running server-side. That justification was wrong: where the narrowing runs says
35
+ nothing about where the control belongs, and a bar floating above a card reads
36
+ as unrelated to the list under it.
37
+
38
+ Now in the table's own bar:
39
+
40
+ - **Incidents** and **maintenances** - the Status column declares `filterValue`,
41
+ so the control sits with the column it filters. The selection still narrows
42
+ the list query, which is what actually reduces the fetch; the column filter
43
+ re-applying it over already-scoped rows is a harmless no-op.
44
+ - **Automation run history** - same, with the status pills.
45
+ - **Health-check list** - search, strategy and status move onto their columns.
46
+ The assigned-system control has no row to read (selecting a system swaps the
47
+ data source, which is what makes the catalog's per-system link work without
48
+ health-check grants), so it rides in as a control-only facet.
49
+ - **Health-check drawer** - the run-status control moves into the runs table.
50
+
51
+ `DataTable`'s `facets` now accepts a control WITHOUT a row accessor, rendered but
52
+ not applied. That is what lets a server-applied dimension stay in the table's bar
53
+ instead of forcing a second bar onto the page.
54
+
55
+ Fixes a trap the move exposed: with server-side filtering an empty `data` means
56
+ either "none exist" or "none match", and three of these pages rendered their
57
+ onboarding empty state either way - automation's run history replaced the whole
58
+ table, taking the filter controls with it, so a filter matching nothing could not
59
+ be cleared. Each now suppresses its `emptyState` while a filter is active and
60
+ offers a "no matches, clear filters" state instead.
61
+
62
+ Three surfaces deliberately keep an external bar, each narrowing more than one
63
+ list: the catalog toolbar (a browse grid plus three manage tabs), the automation
64
+ list (one table per accordion group), and the health-check drawer's source
65
+ control (it scopes the charts as well as the runs). The history detail page's
66
+ list is not a `DataTable` at all.
67
+
68
+ - be74b01: Let you search the incident, maintenance and automation lists
69
+
70
+ These three management lists had their search box switched off, on the
71
+ assumption that you find a record by status rather than by typing its name. That
72
+ was wrong: every one of them shows a title or name column, and none of the
73
+ underlying queries paginate, so the full set is already in the browser and there
74
+ was nothing to gain by withholding search.
75
+
76
+ - **Incidents** gain a search box and a **severity filter**. Severity had a
77
+ column and a sort but no filter, so "show me the criticals" needed reading the
78
+ whole table. Its options are ordered by impact, matching how the column sorts;
79
+ deriving them would have sorted alphabetically as critical / major / minor.
80
+ - **Maintenances** gain a search box.
81
+ - **Automations** gain a search box. It is page-level rather than table-level
82
+ because that list renders one table per group - a table-owned box would only
83
+ ever search its own group, so a match inside a collapsed group could never
84
+ surface. Filtering ahead of the grouping also makes groups with no match
85
+ disappear instead of leaving a wall of empty accordions.
86
+
87
+ All three searches match the title/name AND the second line the row renders (a
88
+ description, or the group label), so a search matches the words you can actually
89
+ see on the row.
90
+
91
+ - Updated dependencies [be74b01]
92
+ - Updated dependencies [be5c907]
93
+ - Updated dependencies [be74b01]
94
+ - Updated dependencies [be74b01]
95
+ - Updated dependencies [be74b01]
96
+ - Updated dependencies [be74b01]
97
+ - Updated dependencies [be74b01]
98
+ - Updated dependencies [be74b01]
99
+ - Updated dependencies [be74b01]
100
+ - Updated dependencies [be74b01]
101
+ - Updated dependencies [be74b01]
102
+ - Updated dependencies [be74b01]
103
+ - Updated dependencies [be74b01]
104
+ - Updated dependencies [be74b01]
105
+ - @checkstack/ui@1.30.0
106
+ - @checkstack/auth-frontend@0.15.0
107
+ - @checkstack/script-packages-frontend@0.4.18
108
+ - @checkstack/gitops-frontend@0.7.9
109
+ - @checkstack/secrets-frontend@0.3.17
110
+ - @checkstack/auth-common@0.16.0
111
+ - @checkstack/frontend-api@0.17.0
112
+ - @checkstack/catalog-common@2.8.1
113
+
114
+ ## 0.12.7
115
+
116
+ ### Patch Changes
117
+
118
+ - 6c8b36b: Annotate two deliberate effect-based state mirrors with the
119
+ `checkstack/no-state-seed-in-effect` lint rule: the automation edit page's
120
+ YAML-tab mirror of the visual editor's `definition`, and the theme toggle's
121
+ mirror of the global resolved theme. Both are one-way mirrors of values the user
122
+ never edits directly, so they are safe exceptions to the rule. Comment-only - no
123
+ runtime behavior change.
124
+ - Updated dependencies [6c8b36b]
125
+ - Updated dependencies [6c8b36b]
126
+ - Updated dependencies [6c8b36b]
127
+ - Updated dependencies [6c8b36b]
128
+ - Updated dependencies [6c8b36b]
129
+ - Updated dependencies [6c8b36b]
130
+ - Updated dependencies [6c8b36b]
131
+ - Updated dependencies [6c8b36b]
132
+ - @checkstack/ui@1.29.0
133
+ - @checkstack/auth-common@0.15.0
134
+ - @checkstack/auth-frontend@0.14.0
135
+ - @checkstack/catalog-common@2.8.0
136
+ - @checkstack/frontend-api@0.16.1
137
+ - @checkstack/gitops-frontend@0.7.8
138
+ - @checkstack/secrets-frontend@0.3.16
139
+ - @checkstack/common@0.23.0
140
+ - @checkstack/script-packages-frontend@0.4.17
141
+ - @checkstack/ai-common@0.6.7
142
+ - @checkstack/automation-common@0.10.2
143
+ - @checkstack/integration-common@0.9.10
144
+ - @checkstack/template-engine@0.4.12
145
+ - @checkstack/signal-frontend@0.3.7
146
+
3
147
  ## 0.12.6
4
148
 
5
149
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/automation-frontend",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -16,20 +16,20 @@
16
16
  "lint:code": "eslint . --max-warnings 0"
17
17
  },
18
18
  "dependencies": {
19
- "@checkstack/auth-common": "0.14.0",
20
- "@checkstack/auth-frontend": "0.13.6",
21
- "@checkstack/automation-common": "0.10.1",
22
- "@checkstack/ai-common": "0.6.6",
23
- "@checkstack/catalog-common": "2.7.3",
24
- "@checkstack/common": "0.22.0",
25
- "@checkstack/frontend-api": "0.16.0",
26
- "@checkstack/gitops-frontend": "0.7.7",
27
- "@checkstack/integration-common": "0.9.9",
28
- "@checkstack/script-packages-frontend": "0.4.16",
29
- "@checkstack/secrets-frontend": "0.3.15",
30
- "@checkstack/signal-frontend": "0.3.6",
31
- "@checkstack/template-engine": "0.4.11",
32
- "@checkstack/ui": "1.28.2",
19
+ "@checkstack/auth-common": "0.16.0",
20
+ "@checkstack/auth-frontend": "0.15.0",
21
+ "@checkstack/automation-common": "0.10.2",
22
+ "@checkstack/ai-common": "0.6.7",
23
+ "@checkstack/catalog-common": "2.8.1",
24
+ "@checkstack/common": "0.23.0",
25
+ "@checkstack/frontend-api": "0.17.0",
26
+ "@checkstack/gitops-frontend": "0.7.9",
27
+ "@checkstack/integration-common": "0.9.10",
28
+ "@checkstack/script-packages-frontend": "0.4.18",
29
+ "@checkstack/secrets-frontend": "0.3.17",
30
+ "@checkstack/signal-frontend": "0.3.7",
31
+ "@checkstack/template-engine": "0.4.12",
32
+ "@checkstack/ui": "1.30.0",
33
33
  "@dnd-kit/core": "^6.3.1",
34
34
  "@dnd-kit/sortable": "^8.0.0",
35
35
  "@dnd-kit/utilities": "^3.2.2",
@@ -43,6 +43,6 @@
43
43
  "typescript": "^5.0.0",
44
44
  "@types/react": "^19.0.0",
45
45
  "@checkstack/tsconfig": "0.0.7",
46
- "@checkstack/scripts": "0.7.5"
46
+ "@checkstack/scripts": "0.7.6"
47
47
  }
48
48
  }
@@ -250,6 +250,7 @@ const AutomationEditContent: React.FC = () => {
250
250
  // Keep the YAML mirror in sync with definition while the visual editor
251
251
  // is active — the YAML tab needs a non-stale starting point when the
252
252
  // operator switches over.
253
+ // eslint-disable-next-line checkstack/no-state-seed-in-effect -- deliberate live mirror of the visual editor's `definition` into the YAML tab's starting text; gated on the visual tab so it never overwrites in-progress YAML edits, and `definition` is local state (the actual query seeds already use useInitOnceForKey).
253
254
  React.useEffect(() => {
254
255
  if (tab === "visual") {
255
256
  setYamlText(stringifyYaml(definition));
@@ -13,6 +13,7 @@ import {
13
13
  automationAccess,
14
14
  automationRoutes,
15
15
  automationResourceTypes,
16
+ AutomationStatusSchema,
16
17
  type Automation,
17
18
  } from "@checkstack/automation-common";
18
19
  import {
@@ -25,7 +26,11 @@ import {
25
26
  Badge,
26
27
  Toggle,
27
28
  DataTable,
29
+ DataTableFilterBar,
30
+ useDataTableFilters,
31
+ parsedFacetValue,
28
32
  type DataTableColumn,
33
+ type DataTableFacet,
29
34
  RowActions,
30
35
  RowAction,
31
36
  LoadingSpinner,
@@ -42,7 +47,10 @@ import {
42
47
  } from "@checkstack/ui";
43
48
  import { resolveRoute } from "@checkstack/common";
44
49
  import { formatDistanceToNow } from "date-fns";
45
- import { groupAutomations } from "./automation-grouping";
50
+ import {
51
+ filterAutomationsByQuery,
52
+ groupAutomations,
53
+ } from "./automation-grouping";
46
54
 
47
55
  /**
48
56
  * Left status-accent class for an automation's enabled/disabled state. Reuses
@@ -67,6 +75,23 @@ const automationAccent = (status: Automation["status"]): string =>
67
75
  * Cache shape of the `listAutomations` loader: the paginated wrapper around the
68
76
  * automation rows. Used to type the optimistic snapshot/patch on the toggle.
69
77
  */
78
+ /**
79
+ * Enabled/disabled, as pills: two short options a reader benefits from seeing
80
+ * at a glance. `value` mirrors what the server filters on, so the facet stays
81
+ * meaningful if this list ever filters client-side.
82
+ */
83
+ const AUTOMATION_STATUS_FACET: DataTableFacet<Automation> = {
84
+ id: "status",
85
+ label: "Status",
86
+ anyLabel: "All",
87
+ kind: "pills",
88
+ options: [
89
+ { value: "enabled", label: "Enabled" },
90
+ { value: "disabled", label: "Disabled" },
91
+ ],
92
+ value: (automation) => automation.status,
93
+ };
94
+
70
95
  type AutomationsQueryData = {
71
96
  items: Automation[];
72
97
  };
@@ -93,9 +118,16 @@ const AutomationListContent: React.FC = () => {
93
118
  AutomationApi.contract.createAutomation,
94
119
  );
95
120
 
96
- const [statusFilter, setStatusFilter] = React.useState<
97
- "all" | "enabled" | "disabled"
98
- >("all");
121
+ // Filtering is server-side (it narrows the list query's input), so the table
122
+ // itself declares no facets - the shared filter bar drives the query. The
123
+ // state still lives in the URL, so a filtered list is shareable and survives
124
+ // opening an automation and coming back.
125
+ const filters = useDataTableFilters({ facetIds: [AUTOMATION_STATUS_FACET.id] });
126
+ const statusFilter = parsedFacetValue({
127
+ filters: filters.state,
128
+ facetId: AUTOMATION_STATUS_FACET.id,
129
+ schema: AutomationStatusSchema,
130
+ });
99
131
  const [deleteId, setDeleteId] = React.useState<string | undefined>();
100
132
 
101
133
  // The loader input depends on the status filter, so the cache key changes
@@ -106,7 +138,7 @@ const AutomationListContent: React.FC = () => {
106
138
  () => ({
107
139
  limit: 100,
108
140
  offset: 0,
109
- ...(statusFilter === "all" ? {} : { status: statusFilter }),
141
+ ...(statusFilter === undefined ? {} : { status: statusFilter }),
110
142
  }),
111
143
  [statusFilter],
112
144
  );
@@ -189,9 +221,21 @@ const AutomationListContent: React.FC = () => {
189
221
  });
190
222
 
191
223
  // Collapsible sections, sorted alphabetically with "Ungrouped" last.
224
+ // Search narrows BEFORE grouping, so a match inside a collapsed group still
225
+ // surfaces and groups left with nothing simply disappear. Debounced: a fast
226
+ // typist should not re-group a long list on every keystroke.
227
+ const matchedAutomations = React.useMemo(
228
+ () =>
229
+ filterAutomationsByQuery({
230
+ automations,
231
+ query: filters.debounced.query,
232
+ }),
233
+ [automations, filters.debounced.query],
234
+ );
235
+
192
236
  const groups = React.useMemo(
193
- () => groupAutomations({ automations }),
194
- [automations],
237
+ () => groupAutomations({ automations: matchedAutomations }),
238
+ [matchedAutomations],
195
239
  );
196
240
  // All sections expanded by default so nothing is hidden on first load.
197
241
  const allGroupKeys = React.useMemo(() => groups.map((g) => g.key), [groups]);
@@ -478,19 +522,15 @@ const AutomationListContent: React.FC = () => {
478
522
  <CardHeader className="border-b">
479
523
  <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
480
524
  <CardTitle className="text-base">All automations</CardTitle>
481
- <div className="flex items-center gap-1">
482
- {(["all", "enabled", "disabled"] as const).map((option) => (
483
- <Button
484
- key={option}
485
- size="sm"
486
- variant={statusFilter === option ? "primary" : "outline"}
487
- onClick={() => setStatusFilter(option)}
488
- className="capitalize"
489
- >
490
- {option}
491
- </Button>
492
- ))}
493
- </div>
525
+ <DataTableFilterBar
526
+ filters={filters.state}
527
+ onFiltersChange={filters.setState}
528
+ facets={[AUTOMATION_STATUS_FACET]}
529
+ // The list groups into several tables, so there is no single one
530
+ // to search; the status facet is the whole control here.
531
+ searchable={false}
532
+ className="md:justify-end"
533
+ />
494
534
  </div>
495
535
  </CardHeader>
496
536
  <CardContent className="p-0">
@@ -11,8 +11,9 @@ import {
11
11
  AutomationApi,
12
12
  automationAccess,
13
13
  automationRoutes,
14
+ RunStatusSchema,
14
15
  } from "@checkstack/automation-common";
15
- import type { RunStatus, AutomationRun } from "@checkstack/automation-common";
16
+ import type { AutomationRun } from "@checkstack/automation-common";
16
17
  import {
17
18
  PageLayout,
18
19
  Card,
@@ -21,7 +22,10 @@ import {
21
22
  CardTitle,
22
23
  Button,
23
24
  DataTable,
25
+ useDataTableFilters,
26
+ parsedFacetValue,
24
27
  type DataTableColumn,
28
+ type DataTableFacetOption,
25
29
  RowActions,
26
30
  RowAction,
27
31
  LoadingSpinner,
@@ -33,6 +37,20 @@ import { formatDistanceToNow } from "date-fns";
33
37
  import { RunStatusPill } from "./run-status-pill";
34
38
  import { formatDuration } from "./run-duration";
35
39
 
40
+ /**
41
+ * Run outcome, as pills: the whole point of this page is scanning for the runs
42
+ * that went wrong, so every outcome stays one click away rather than behind a
43
+ * dropdown. Options mirror `RunStatusSchema`, which is what the query filters on.
44
+ */
45
+ const RUN_STATUS_FACET_ID = "status";
46
+ const RUN_STATUS_OPTIONS: readonly DataTableFacetOption[] = [
47
+ { value: "running", label: "Running" },
48
+ { value: "success", label: "Success" },
49
+ { value: "failed", label: "Failed" },
50
+ { value: "cancelled", label: "Cancelled" },
51
+ { value: "waiting", label: "Waiting" },
52
+ ];
53
+
36
54
  /**
37
55
  * Run history for a single automation. Status filter pinned to the top;
38
56
  * rows link to the run detail page. We also surface a `← Back to
@@ -47,9 +65,15 @@ const RunsPageContent: React.FC = () => {
47
65
  const { allowed, loading: accessLoading } = accessApi.useAccess(
48
66
  automationAccess.read,
49
67
  );
50
- const [statusFilter, setStatusFilter] = React.useState<RunStatus | "all">(
51
- "all",
52
- );
68
+ // The Status column owns the control; the selection ALSO narrows the runs
69
+ // query, which is what reduces the fetch. URL-backed, so a link to "the failed
70
+ // runs of this automation" reopens filtered.
71
+ const filters = useDataTableFilters({ facetIds: [RUN_STATUS_FACET_ID] });
72
+ const statusFilter = parsedFacetValue({
73
+ filters: filters.state,
74
+ facetId: RUN_STATUS_FACET_ID,
75
+ schema: RunStatusSchema,
76
+ });
53
77
 
54
78
  const automationQuery = client.getAutomation.useQuery(
55
79
  { id: automationId ?? "" },
@@ -63,7 +87,7 @@ const RunsPageContent: React.FC = () => {
63
87
  {
64
88
  automationId: automationId ?? "",
65
89
  limit: 50,
66
- ...(statusFilter === "all" ? {} : { status: statusFilter }),
90
+ ...(statusFilter === undefined ? {} : { status: statusFilter }),
67
91
  },
68
92
  { enabled: Boolean(automationId) },
69
93
  );
@@ -75,6 +99,12 @@ const RunsPageContent: React.FC = () => {
75
99
  id: "status",
76
100
  header: "Status",
77
101
  sortValue: (run) => run.status,
102
+ filterValue: (run) => run.status,
103
+ filterOptions: RUN_STATUS_OPTIONS,
104
+ // Pills: the whole point of this page is scanning for the runs that went
105
+ // wrong, so every outcome stays one click away.
106
+ filterKind: "pills",
107
+ filterAnyLabel: "All",
78
108
  cell: (run) => <RunStatusPill status={run.status} />,
79
109
  },
80
110
  {
@@ -162,31 +192,7 @@ const RunsPageContent: React.FC = () => {
162
192
  >
163
193
  <Card>
164
194
  <CardHeader className="border-b">
165
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
166
- <CardTitle className="text-base">Runs</CardTitle>
167
- <div className="flex flex-wrap items-center gap-1">
168
- {(
169
- [
170
- "all",
171
- "running",
172
- "success",
173
- "failed",
174
- "cancelled",
175
- "waiting",
176
- ] as const
177
- ).map((option) => (
178
- <Button
179
- key={option}
180
- size="sm"
181
- variant={statusFilter === option ? "primary" : "outline"}
182
- onClick={() => setStatusFilter(option)}
183
- className="capitalize"
184
- >
185
- {option}
186
- </Button>
187
- ))}
188
- </div>
189
- </div>
195
+ <CardTitle className="text-base">Runs</CardTitle>
190
196
  </CardHeader>
191
197
  <CardContent className="p-0">
192
198
  {runsQuery.isLoading ? (
@@ -198,18 +204,48 @@ const RunsPageContent: React.FC = () => {
198
204
  error={runsQuery.error}
199
205
  onRetry={() => runsQuery.refetch()}
200
206
  />
201
- ) : runs.length === 0 ? (
202
- <EmptyState
203
- icon={<History className="h-8 w-8 text-muted-foreground" />}
204
- title="No runs match this filter"
205
- description="Manually trigger the automation from the edit page to generate a run."
206
- />
207
207
  ) : (
208
208
  <DataTable
209
209
  data={runs}
210
210
  columns={columns}
211
211
  getRowId={(run) => run.id}
212
+ // The Status column declares its own filter, so the pills sit in
213
+ // the table's bar. Runs are identified by time and outcome, not
214
+ // by a name worth typing, so there is no search box.
215
+ filters={filters.state}
216
+ onFiltersChange={filters.setState}
217
+ onClearFilters={filters.clear}
212
218
  searchable={false}
219
+ // The list arrives already narrowed by the server, so an empty
220
+ // `data` means either outcome. Suppressing `emptyState` while a
221
+ // filter is active is what lets the table tell them apart - and
222
+ // rendering both INSIDE the table keeps the pills on screen, so a
223
+ // filter that matches nothing can still be cleared.
224
+ emptyState={
225
+ filters.active ? undefined : (
226
+ <EmptyState
227
+ icon={<History className="h-8 w-8 text-muted-foreground" />}
228
+ title="No runs yet"
229
+ description="Manually trigger the automation from the edit page to generate a run."
230
+ />
231
+ )
232
+ }
233
+ noResultsState={
234
+ <EmptyState
235
+ icon={<History className="h-8 w-8 text-muted-foreground" />}
236
+ title="No runs match this filter"
237
+ description="No run of this automation has that outcome."
238
+ actions={
239
+ <Button variant="outline" onClick={filters.clear}>
240
+ Clear filter
241
+ </Button>
242
+ }
243
+ />
244
+ }
245
+ // Nested inside the page's opaque Card, so the default bg-card
246
+ // surface would create a panel-in-panel; the enclosing Card
247
+ // already provides the opaque background.
248
+ surface={false}
213
249
  renderMobileCard={(run) => (
214
250
  <div className="rounded-md border bg-surface p-4">
215
251
  <div className="flex items-start justify-between gap-2">
@@ -1,6 +1,7 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { describe, expect, it, test } from "bun:test";
2
2
  import type { Automation, AutomationDefinition } from "@checkstack/automation-common";
3
3
  import {
4
+ filterAutomationsByQuery,
4
5
  groupAutomations,
5
6
  UNGROUPED_LABEL,
6
7
  } from "./automation-grouping";
@@ -85,3 +86,39 @@ describe("groupAutomations", () => {
85
86
  expect(groups.some((g) => g.items.length === 0)).toBe(false);
86
87
  });
87
88
  });
89
+
90
+ describe("filterAutomationsByQuery", () => {
91
+ const rows = [
92
+ { name: "Nightly backup", group: "Ops" },
93
+ { name: "Deploy notifier", group: "Release" },
94
+ { name: "Cleanup", group: undefined },
95
+ ] as Automation[];
96
+
97
+ const names = (query: string) =>
98
+ filterAutomationsByQuery({ automations: rows, query }).map((a) => a.name);
99
+
100
+ test("matches the name, case-insensitively on a substring", () => {
101
+ expect(names("NIGHT")).toEqual(["Nightly backup"]);
102
+ });
103
+
104
+ test("matches the group label too", () => {
105
+ // The group is a heading the reader can see, so it is fair to search.
106
+ expect(names("release")).toEqual(["Deploy notifier"]);
107
+ });
108
+
109
+ test("an ungrouped automation is still searchable by name", () => {
110
+ expect(names("cleanup")).toEqual(["Cleanup"]);
111
+ });
112
+
113
+ test("an empty or whitespace query returns the same reference", () => {
114
+ // An idle search box must not re-group the list on every render.
115
+ expect(filterAutomationsByQuery({ automations: rows, query: "" })).toBe(rows);
116
+ expect(filterAutomationsByQuery({ automations: rows, query: " " })).toBe(
117
+ rows,
118
+ );
119
+ });
120
+
121
+ test("a query matching nothing yields an empty list, not every row", () => {
122
+ expect(names("nonexistent")).toEqual([]);
123
+ });
124
+ });
@@ -63,3 +63,32 @@ export function groupAutomations({
63
63
 
64
64
  return groups;
65
65
  }
66
+
67
+ /**
68
+ * Narrow a flat automation list by a free-text query, matched against the name
69
+ * and the group label - the two things the list actually shows above the fold.
70
+ *
71
+ * Search lives here rather than on the table because the list renders ONE table
72
+ * per group: a table-owned search box would filter its own group only, so a
73
+ * query could never surface a match sitting in a collapsed group. Filtering
74
+ * before `groupAutomations` also makes empty groups disappear, so the result
75
+ * reads as "here is what matched" instead of a wall of empty accordions.
76
+ *
77
+ * An empty or whitespace-only query returns the SAME array reference, so an
78
+ * idle search box costs no re-grouping.
79
+ */
80
+ export function filterAutomationsByQuery({
81
+ automations,
82
+ query,
83
+ }: {
84
+ automations: Automation[];
85
+ query: string;
86
+ }): Automation[] {
87
+ const needle = query.trim().toLowerCase();
88
+ if (!needle) return automations;
89
+ return automations.filter((automation) =>
90
+ `${automation.name} ${automation.group ?? ""}`
91
+ .toLowerCase()
92
+ .includes(needle),
93
+ );
94
+ }
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { cn } from "@checkstack/ui";
2
+ import { cn, pillToneStyles, StatusPill } from "@checkstack/ui";
3
3
  import type { RunStatus } from "@checkstack/automation-common";
4
4
 
5
5
  /**
@@ -21,36 +21,16 @@ export const RUN_STATUS_TONE: Record<RunStatus, RunStatusTone> = {
21
21
  };
22
22
 
23
23
  /**
24
- * Per-tone pill + dot + left-accent classes. Spelled out as full literal
25
- * strings (not interpolated) so Tailwind's JIT keeps them, driven by the
26
- * colorblind-safe status triad. Status reads as hue + dot + text label, never
27
- * color alone. The `accent` class drives the left status stripe on cards and
28
- * timeline rows so the stripe always matches this pill.
24
+ * Per-tone pill + dot + left-accent classes, taken from the shared
25
+ * `pillToneStyles` table rather than a private copy. The `accent` class drives
26
+ * the left status stripe on cards and timeline rows so the stripe always
27
+ * matches the pill.
29
28
  */
30
- export const RUN_TONE_STYLES: Record<
31
- RunStatusTone,
32
- { pill: string; dot: string; accent: string }
33
- > = {
34
- ok: {
35
- pill: "bg-status-ok/10 text-status-ok",
36
- dot: "bg-status-ok",
37
- accent: "bg-status-ok",
38
- },
39
- warn: {
40
- pill: "bg-status-warn/10 text-status-warn",
41
- dot: "bg-status-warn",
42
- accent: "bg-status-warn",
43
- },
44
- down: {
45
- pill: "bg-status-down/10 text-status-down",
46
- dot: "bg-status-down",
47
- accent: "bg-status-down",
48
- },
49
- unknown: {
50
- pill: "bg-status-unknown/10 text-status-unknown",
51
- dot: "bg-status-unknown",
52
- accent: "bg-status-unknown",
53
- },
29
+ export const RUN_TONE_STYLES: Pick<typeof pillToneStyles, RunStatusTone> = {
30
+ ok: pillToneStyles.ok,
31
+ warn: pillToneStyles.warn,
32
+ down: pillToneStyles.down,
33
+ unknown: pillToneStyles.unknown,
54
34
  };
55
35
 
56
36
  /**
@@ -61,18 +41,11 @@ export const RUN_TONE_STYLES: Record<
61
41
  export const RunStatusPill: React.FC<{
62
42
  status: RunStatus;
63
43
  className?: string;
64
- }> = ({ status, className }) => {
65
- const styles = RUN_TONE_STYLES[RUN_STATUS_TONE[status]];
66
- return (
67
- <span
68
- className={cn(
69
- "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium capitalize",
70
- styles.pill,
71
- className,
72
- )}
73
- >
74
- <span className={cn("size-1.5 rounded-full", styles.dot)} aria-hidden />
75
- {status}
76
- </span>
77
- );
78
- };
44
+ }> = ({ status, className }) => (
45
+ <StatusPill
46
+ tone={RUN_STATUS_TONE[status]}
47
+ className={cn("capitalize", className)}
48
+ >
49
+ {status}
50
+ </StatusPill>
51
+ );