@checkstack/catalog-frontend 0.10.7 → 0.11.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 (40) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/package.json +10 -9
  3. package/src/api.ts +6 -1
  4. package/src/components/CatalogConfigPage.tsx +337 -271
  5. package/src/components/CatalogPage.tsx +172 -11
  6. package/src/components/EnvironmentEditor.tsx +220 -0
  7. package/src/components/EnvironmentPreviewPicker.tsx +61 -0
  8. package/src/components/SystemDetailPage.tsx +47 -34
  9. package/src/components/SystemEditor.tsx +6 -0
  10. package/src/components/SystemEnvironmentsEditor.tsx +98 -0
  11. package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
  12. package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
  13. package/src/components/browse/CatalogGroupSection.tsx +165 -0
  14. package/src/components/browse/CatalogSystemRow.tsx +63 -0
  15. package/src/components/browse/browseState.logic.test.ts +125 -0
  16. package/src/components/browse/browseState.logic.ts +158 -0
  17. package/src/components/browse/filterEntities.logic.test.ts +479 -0
  18. package/src/components/browse/filterEntities.logic.ts +360 -0
  19. package/src/components/browse/healthRollup.logic.test.ts +126 -0
  20. package/src/components/browse/healthRollup.logic.ts +120 -0
  21. package/src/components/browse/healthStatuses.logic.test.ts +39 -0
  22. package/src/components/browse/healthStatuses.logic.ts +29 -0
  23. package/src/components/environment-fields.logic.test.ts +111 -0
  24. package/src/components/environment-fields.logic.ts +98 -0
  25. package/src/components/environment-preview.logic.test.ts +76 -0
  26. package/src/components/environment-preview.logic.ts +61 -0
  27. package/src/components/manage/AssignMenu.tsx +78 -0
  28. package/src/components/manage/EnvironmentsTab.tsx +230 -0
  29. package/src/components/manage/GroupsTab.tsx +274 -0
  30. package/src/components/manage/SystemsTab.tsx +430 -0
  31. package/src/hooks/useCatalogBrowseState.ts +107 -0
  32. package/src/hooks/useDebouncedValue.ts +21 -0
  33. package/src/index.tsx +32 -20
  34. package/src/utils/formatDate.logic.test.ts +44 -0
  35. package/src/utils/formatDate.logic.ts +27 -0
  36. package/src/utils/normalizeMetadata.logic.test.ts +67 -0
  37. package/src/utils/normalizeMetadata.logic.ts +53 -0
  38. package/src/components/DraggableSystem.tsx +0 -200
  39. package/src/components/DroppableGroup.tsx +0 -174
  40. package/src/components/UserMenuItems.tsx +0 -31
@@ -0,0 +1,158 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Browse-view URL/view state — DOM-free serialise/parse logic for the catalog
5
+ * browse page. All browse state (search query, filters, density, which groups
6
+ * are open) is ephemeral UI state held in URL params so a shared link reopens
7
+ * the same view. This module owns the single source of truth for the param
8
+ * names and their defaults; the React hook (`useCatalogBrowseState`) is a thin
9
+ * wrapper over these pure functions.
10
+ */
11
+
12
+ /** URL param keys used by the browse view. Centralised to avoid typos. */
13
+ export const BROWSE_PARAM = {
14
+ query: "q",
15
+ group: "group",
16
+ health: "health",
17
+ tag: "tag",
18
+ density: "density",
19
+ open: "open",
20
+ } as const;
21
+
22
+ /**
23
+ * Health filter values. Phase 2 ships "counts only" (no health data), so the
24
+ * filter is parsed and round-tripped but only `"all"` is actionable until the
25
+ * Phase 4 health slot lands. Modelling it now keeps the URL contract stable.
26
+ */
27
+ export const HealthFilterSchema = z.enum([
28
+ "all",
29
+ "healthy",
30
+ "degraded",
31
+ "unhealthy",
32
+ "unknown",
33
+ ]);
34
+ export type HealthFilter = z.infer<typeof HealthFilterSchema>;
35
+
36
+ /** Row density. `comfortable` shows descriptions inline; `compact` is dense. */
37
+ export const DensitySchema = z.enum(["comfortable", "compact"]);
38
+ export type Density = z.infer<typeof DensitySchema>;
39
+
40
+ /** The fully-parsed browse view state. */
41
+ export interface BrowseState {
42
+ /** Free-text search over system + group name and description. */
43
+ query: string;
44
+ /** Narrow to a single group id, or `null` for "all groups". */
45
+ group: string | null;
46
+ /** Health filter (Phase 4); always `"all"` is meaningful in Phase 2. */
47
+ health: HealthFilter;
48
+ /** Metadata tag/value filter token, or `null` for none. */
49
+ tag: string | null;
50
+ /** Row density. */
51
+ density: Density;
52
+ /**
53
+ * Explicitly-toggled group sections, by id. A value of `true` means the user
54
+ * forced-open, `false` means forced-closed. Groups absent from the map fall
55
+ * back to the computed default-open policy.
56
+ */
57
+ open: Record<string, boolean>;
58
+ }
59
+
60
+ /** Default state when no params are present. */
61
+ export const DEFAULT_BROWSE_STATE: BrowseState = {
62
+ query: "",
63
+ group: null,
64
+ health: "all",
65
+ tag: null,
66
+ density: "comfortable",
67
+ open: {},
68
+ };
69
+
70
+ /**
71
+ * Synthetic id for the "Ungrouped" section so it can participate in the same
72
+ * open/closed URL state as real groups. Chosen to never collide with a real
73
+ * group id (group ids are opaque server-generated strings, never this token).
74
+ */
75
+ export const UNGROUPED_ID = "__ungrouped__";
76
+
77
+ /**
78
+ * Parse browse state from a `URLSearchParams`-like reader. Invalid or absent
79
+ * values fall back to defaults so a hand-edited URL never throws.
80
+ */
81
+ export function parseBrowseState(params: {
82
+ get: (key: string) => string | null;
83
+ }): BrowseState {
84
+ const query = params.get(BROWSE_PARAM.query) ?? "";
85
+ const group = params.get(BROWSE_PARAM.group);
86
+ const tag = params.get(BROWSE_PARAM.tag);
87
+
88
+ const healthRaw = params.get(BROWSE_PARAM.health);
89
+ const health = HealthFilterSchema.safeParse(healthRaw);
90
+
91
+ const densityRaw = params.get(BROWSE_PARAM.density);
92
+ const density = DensitySchema.safeParse(densityRaw);
93
+
94
+ const openRaw = params.get(BROWSE_PARAM.open);
95
+
96
+ return {
97
+ query,
98
+ group: group && group.length > 0 ? group : null,
99
+ health: health.success ? health.data : DEFAULT_BROWSE_STATE.health,
100
+ tag: tag && tag.length > 0 ? tag : null,
101
+ density: density.success ? density.data : DEFAULT_BROWSE_STATE.density,
102
+ open: parseOpenParam(openRaw),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Parse the `?open=` param. Encoding: a comma-separated list of group ids,
108
+ * each optionally prefixed with `-` to mean "force-closed". E.g.
109
+ * `?open=payments,-platform` → payments forced open, platform forced closed.
110
+ */
111
+ export function parseOpenParam(
112
+ value: string | null | undefined,
113
+ ): Record<string, boolean> {
114
+ if (!value) return {};
115
+ const result: Record<string, boolean> = {};
116
+ for (const rawToken of value.split(",")) {
117
+ const token = rawToken.trim();
118
+ if (token.length === 0) continue;
119
+ if (token.startsWith("-")) {
120
+ const id = token.slice(1);
121
+ if (id.length > 0) result[id] = false;
122
+ } else {
123
+ result[token] = true;
124
+ }
125
+ }
126
+ return result;
127
+ }
128
+
129
+ /**
130
+ * Serialise the `open` map back to the `?open=` token form. Stable, sorted
131
+ * order so URLs are deterministic (good for testing + shareable links).
132
+ */
133
+ export function serializeOpenParam(open: Record<string, boolean>): string {
134
+ return Object.keys(open)
135
+ .toSorted()
136
+ .map((id) => (open[id] ? id : `-${id}`))
137
+ .join(",");
138
+ }
139
+
140
+ /**
141
+ * Compute the param mutations to apply for a given browse state. Returns a map
142
+ * of param key → value where an empty string means "delete this param" (so the
143
+ * URL stays clean and a default state produces no params at all). The React
144
+ * hook applies these against the live `URLSearchParams`.
145
+ */
146
+ export function serializeBrowseState(state: BrowseState): Record<string, string> {
147
+ const openSerialized = serializeOpenParam(state.open);
148
+ return {
149
+ [BROWSE_PARAM.query]: state.query,
150
+ [BROWSE_PARAM.group]: state.group ?? "",
151
+ [BROWSE_PARAM.health]:
152
+ state.health === DEFAULT_BROWSE_STATE.health ? "" : state.health,
153
+ [BROWSE_PARAM.tag]: state.tag ?? "",
154
+ [BROWSE_PARAM.density]:
155
+ state.density === DEFAULT_BROWSE_STATE.density ? "" : state.density,
156
+ [BROWSE_PARAM.open]: openSerialized,
157
+ };
158
+ }
@@ -0,0 +1,479 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { System, Group } from "@checkstack/catalog-common";
3
+ import {
4
+ buildBrowseModel,
5
+ filterManagementLists,
6
+ systemTags,
7
+ collectTagOptions,
8
+ } from "./filterEntities.logic";
9
+ import {
10
+ DEFAULT_BROWSE_STATE,
11
+ UNGROUPED_ID,
12
+ type BrowseState,
13
+ } from "./browseState.logic";
14
+
15
+ const NOW = new Date("2026-01-01T00:00:00Z");
16
+
17
+ function makeSystem(props: Partial<System> & { id: string; name: string }): System {
18
+ return {
19
+ description: null,
20
+ metadata: null,
21
+ createdAt: NOW,
22
+ updatedAt: NOW,
23
+ ...props,
24
+ };
25
+ }
26
+
27
+ function makeGroup(props: Partial<Group> & { id: string; name: string }): Group {
28
+ return {
29
+ systemIds: [],
30
+ metadata: null,
31
+ createdAt: NOW,
32
+ updatedAt: NOW,
33
+ ...props,
34
+ };
35
+ }
36
+
37
+ function state(overrides: Partial<BrowseState> = {}): BrowseState {
38
+ return { ...DEFAULT_BROWSE_STATE, ...overrides };
39
+ }
40
+
41
+ const checkout = makeSystem({
42
+ id: "s-checkout",
43
+ name: "checkout-api",
44
+ description: "Handles payments checkout",
45
+ metadata: { team: "payments", tier: 1 },
46
+ });
47
+ const ledger = makeSystem({ id: "s-ledger", name: "ledger-worker" });
48
+ const auth = makeSystem({ id: "s-auth", name: "auth-service" });
49
+ const orphan = makeSystem({ id: "s-orphan", name: "lonely-system" });
50
+
51
+ const payments = makeGroup({
52
+ id: "g-payments",
53
+ name: "Payments",
54
+ systemIds: ["s-checkout", "s-ledger"],
55
+ });
56
+ const platform = makeGroup({
57
+ id: "g-platform",
58
+ name: "Platform",
59
+ systemIds: ["s-auth", "s-checkout"], // checkout is in two groups
60
+ });
61
+
62
+ const ALL_SYSTEMS = [checkout, ledger, auth, orphan];
63
+ const ALL_GROUPS = [payments, platform];
64
+
65
+ describe("systemTags", () => {
66
+ test("emits bare-key and key=value tokens for string-coerced metadata", () => {
67
+ expect(systemTags(checkout)).toEqual([
68
+ "team",
69
+ "team=payments",
70
+ "tier",
71
+ "tier=1",
72
+ ]);
73
+ });
74
+
75
+ test("returns [] for systems without metadata", () => {
76
+ expect(systemTags(ledger)).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe("collectTagOptions", () => {
81
+ test("collects distinct key=value tokens, sorted, excluding bare keys", () => {
82
+ expect(collectTagOptions(ALL_SYSTEMS)).toEqual([
83
+ "team=payments",
84
+ "tier=1",
85
+ ]);
86
+ });
87
+
88
+ test("returns [] when no system has string metadata", () => {
89
+ expect(collectTagOptions([ledger, auth])).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("buildBrowseModel — partitioning", () => {
94
+ test("empty catalog is flagged", () => {
95
+ const model = buildBrowseModel({ systems: [], groups: [], state: state() });
96
+ expect(model.isEmptyCatalog).toBe(true);
97
+ expect(model.sections).toEqual([]);
98
+ });
99
+
100
+ test("partitions systems into group sections plus a synthetic ungrouped section", () => {
101
+ const model = buildBrowseModel({
102
+ systems: ALL_SYSTEMS,
103
+ groups: ALL_GROUPS,
104
+ state: state(),
105
+ });
106
+ const ids = model.sections.map((s) => s.id);
107
+ expect(ids).toEqual(["g-payments", "g-platform", UNGROUPED_ID]);
108
+
109
+ const ungrouped = model.sections.find((s) => s.isUngrouped);
110
+ expect(ungrouped?.systems.map((s) => s.id)).toEqual(["s-orphan"]);
111
+ });
112
+
113
+ test("a system in multiple groups appears under each", () => {
114
+ const model = buildBrowseModel({
115
+ systems: ALL_SYSTEMS,
116
+ groups: ALL_GROUPS,
117
+ state: state(),
118
+ });
119
+ const paymentsSection = model.sections.find((s) => s.id === "g-payments");
120
+ const platformSection = model.sections.find((s) => s.id === "g-platform");
121
+ expect(paymentsSection?.systems.map((s) => s.id)).toContain("s-checkout");
122
+ expect(platformSection?.systems.map((s) => s.id)).toContain("s-checkout");
123
+ });
124
+
125
+ test("header count reflects total members, not filtered survivors", () => {
126
+ const model = buildBrowseModel({
127
+ systems: ALL_SYSTEMS,
128
+ groups: ALL_GROUPS,
129
+ state: state({ query: "ledger" }),
130
+ });
131
+ const paymentsSection = model.sections.find((s) => s.id === "g-payments");
132
+ expect(paymentsSection?.totalCount).toBe(2);
133
+ expect(paymentsSection?.systems.map((s) => s.id)).toEqual(["s-ledger"]);
134
+ });
135
+
136
+ test("no ungrouped section when every system belongs to a group", () => {
137
+ const everyGrouped = makeGroup({
138
+ id: "g-all",
139
+ name: "All",
140
+ systemIds: ["s-checkout", "s-ledger", "s-auth", "s-orphan"],
141
+ });
142
+ const model = buildBrowseModel({
143
+ systems: ALL_SYSTEMS,
144
+ groups: [everyGrouped],
145
+ state: state(),
146
+ });
147
+ expect(model.sections.some((s) => s.isUngrouped)).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("buildBrowseModel — search", () => {
152
+ test("matches name case-insensitively", () => {
153
+ const model = buildBrowseModel({
154
+ systems: ALL_SYSTEMS,
155
+ groups: ALL_GROUPS,
156
+ state: state({ query: "CHECKOUT" }),
157
+ });
158
+ const matched = model.sections.flatMap((s) => s.systems.map((x) => x.id));
159
+ expect(matched).toContain("s-checkout");
160
+ expect(matched).not.toContain("s-auth");
161
+ });
162
+
163
+ test("matches description", () => {
164
+ const model = buildBrowseModel({
165
+ systems: ALL_SYSTEMS,
166
+ groups: ALL_GROUPS,
167
+ state: state({ query: "Handles payments" }),
168
+ });
169
+ const matched = model.sections.flatMap((s) => s.systems.map((x) => x.id));
170
+ expect(matched).toEqual(["s-checkout", "s-checkout"]); // both groups it's in
171
+ });
172
+
173
+ test("groups with zero matches drop out entirely under an active query", () => {
174
+ const model = buildBrowseModel({
175
+ systems: ALL_SYSTEMS,
176
+ groups: ALL_GROUPS,
177
+ state: state({ query: "checkout" }),
178
+ });
179
+ // Payments + Platform both contain checkout; ungrouped (orphan) drops.
180
+ expect(model.sections.map((s) => s.id)).toEqual(["g-payments", "g-platform"]);
181
+ });
182
+
183
+ test("a matching section auto-expands under query", () => {
184
+ const model = buildBrowseModel({
185
+ systems: ALL_SYSTEMS,
186
+ groups: ALL_GROUPS,
187
+ state: state({ query: "checkout", open: {} }),
188
+ });
189
+ expect(model.sections.every((s) => s.open)).toBe(true);
190
+ });
191
+
192
+ test("an explicit forced-closed override beats search auto-expand", () => {
193
+ const model = buildBrowseModel({
194
+ systems: ALL_SYSTEMS,
195
+ groups: ALL_GROUPS,
196
+ state: state({ query: "checkout", open: { "g-payments": false } }),
197
+ });
198
+ const paymentsSection = model.sections.find((s) => s.id === "g-payments");
199
+ expect(paymentsSection?.open).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("buildBrowseModel — filters compose (AND)", () => {
204
+ test("tag + query compose", () => {
205
+ const model = buildBrowseModel({
206
+ systems: ALL_SYSTEMS,
207
+ groups: ALL_GROUPS,
208
+ state: state({ query: "checkout", tag: "team=payments" }),
209
+ });
210
+ const matched = model.sections.flatMap((s) => s.systems.map((x) => x.id));
211
+ expect(new Set(matched)).toEqual(new Set(["s-checkout"]));
212
+ });
213
+
214
+ test("tag filter excludes systems without the tag", () => {
215
+ const model = buildBrowseModel({
216
+ systems: ALL_SYSTEMS,
217
+ groups: ALL_GROUPS,
218
+ state: state({ tag: "team=payments" }),
219
+ });
220
+ const matched = model.sections.flatMap((s) => s.systems.map((x) => x.id));
221
+ expect(matched).not.toContain("s-ledger");
222
+ expect(matched).not.toContain("s-auth");
223
+ });
224
+
225
+ test("group filter narrows to one section", () => {
226
+ const model = buildBrowseModel({
227
+ systems: ALL_SYSTEMS,
228
+ groups: ALL_GROUPS,
229
+ state: state({ group: "g-payments" }),
230
+ });
231
+ expect(model.sections.map((s) => s.id)).toEqual(["g-payments"]);
232
+ });
233
+
234
+ test("group filter can select the synthetic ungrouped section", () => {
235
+ const model = buildBrowseModel({
236
+ systems: ALL_SYSTEMS,
237
+ groups: ALL_GROUPS,
238
+ state: state({ group: UNGROUPED_ID }),
239
+ });
240
+ expect(model.sections.map((s) => s.id)).toEqual([UNGROUPED_ID]);
241
+ });
242
+ });
243
+
244
+ describe("buildBrowseModel — health rollups + filter", () => {
245
+ test("group rollup is derived from status DATA, over ALL members pre-filter", () => {
246
+ const model = buildBrowseModel({
247
+ systems: ALL_SYSTEMS,
248
+ groups: ALL_GROUPS,
249
+ state: state(),
250
+ statuses: {
251
+ "s-checkout": "healthy",
252
+ "s-ledger": "degraded",
253
+ "s-auth": "healthy",
254
+ },
255
+ });
256
+ const payments = model.sections.find((s) => s.id === "g-payments");
257
+ expect(payments?.rollup.hasData).toBe(true);
258
+ expect(payments?.rollup.allHealthy).toBe(false);
259
+ expect(payments?.rollup.degraded).toBe(1);
260
+ expect(payments?.rollup.worst).toBe("degraded");
261
+ });
262
+
263
+ test("all-healthy group derived from data (healthy members emit no badge)", () => {
264
+ const model = buildBrowseModel({
265
+ systems: [auth, checkout],
266
+ groups: [platform],
267
+ state: state(),
268
+ statuses: { "s-auth": "healthy", "s-checkout": "healthy" },
269
+ });
270
+ const platformSection = model.sections.find((s) => s.id === "g-platform");
271
+ expect(platformSection?.rollup.allHealthy).toBe(true);
272
+ });
273
+
274
+ test("no statuses → rollup hasData false (counts only), no crash", () => {
275
+ const model = buildBrowseModel({
276
+ systems: ALL_SYSTEMS,
277
+ groups: ALL_GROUPS,
278
+ state: state(),
279
+ });
280
+ expect(model.sections.every((s) => s.rollup.hasData === false)).toBe(true);
281
+ });
282
+
283
+ test("all-healthy group starts collapsed; non-healthy group starts expanded", () => {
284
+ const model = buildBrowseModel({
285
+ systems: ALL_SYSTEMS,
286
+ groups: ALL_GROUPS,
287
+ state: state(),
288
+ statuses: {
289
+ "s-checkout": "healthy",
290
+ "s-ledger": "healthy",
291
+ "s-auth": "healthy",
292
+ },
293
+ });
294
+ // payments = {checkout, ledger} both healthy → collapsed.
295
+ expect(model.sections.find((s) => s.id === "g-payments")?.open).toBe(false);
296
+ // ungrouped = {orphan} has no data → not allHealthy → expanded.
297
+ expect(model.sections.find((s) => s.id === UNGROUPED_ID)?.open).toBe(true);
298
+ });
299
+
300
+ test("URL open override beats the collapse-all-healthy default", () => {
301
+ const model = buildBrowseModel({
302
+ systems: ALL_SYSTEMS,
303
+ groups: ALL_GROUPS,
304
+ state: state({ open: { "g-payments": true } }),
305
+ statuses: { "s-checkout": "healthy", "s-ledger": "healthy" },
306
+ });
307
+ expect(model.sections.find((s) => s.id === "g-payments")?.open).toBe(true);
308
+ });
309
+
310
+ test("health filter hides healthy rows; unknown matches no-data systems", () => {
311
+ const degraded = buildBrowseModel({
312
+ systems: ALL_SYSTEMS,
313
+ groups: ALL_GROUPS,
314
+ state: state({ health: "degraded" }),
315
+ statuses: { "s-checkout": "degraded", "s-ledger": "healthy" },
316
+ });
317
+ const matched = degraded.sections.flatMap((s) =>
318
+ s.systems.map((x) => x.id),
319
+ );
320
+ expect(new Set(matched)).toEqual(new Set(["s-checkout"]));
321
+
322
+ const unknown = buildBrowseModel({
323
+ systems: ALL_SYSTEMS,
324
+ groups: ALL_GROUPS,
325
+ state: state({ health: "unknown" }),
326
+ statuses: { "s-checkout": "degraded", "s-ledger": "healthy" },
327
+ });
328
+ const unknownMatched = unknown.sections.flatMap((s) =>
329
+ s.systems.map((x) => x.id),
330
+ );
331
+ // s-auth + s-orphan have no reported status → unknown.
332
+ expect(unknownMatched).toContain("s-auth");
333
+ expect(unknownMatched).toContain("s-orphan");
334
+ expect(unknownMatched).not.toContain("s-checkout");
335
+ });
336
+ });
337
+
338
+ describe("buildBrowseModel — empty states", () => {
339
+ test("filtered-to-empty sets isFilteredEmpty (not isEmptyCatalog)", () => {
340
+ const model = buildBrowseModel({
341
+ systems: ALL_SYSTEMS,
342
+ groups: ALL_GROUPS,
343
+ state: state({ query: "nonexistent-zzz" }),
344
+ });
345
+ expect(model.isEmptyCatalog).toBe(false);
346
+ expect(model.isFilteredEmpty).toBe(true);
347
+ });
348
+
349
+ test("no filters + populated catalog is neither empty nor filtered-empty", () => {
350
+ const model = buildBrowseModel({
351
+ systems: ALL_SYSTEMS,
352
+ groups: ALL_GROUPS,
353
+ state: state(),
354
+ });
355
+ expect(model.isEmptyCatalog).toBe(false);
356
+ expect(model.isFilteredEmpty).toBe(false);
357
+ });
358
+ });
359
+
360
+ describe("filterManagementLists", () => {
361
+ test("no filters returns all systems and groups unchanged", () => {
362
+ const model = filterManagementLists({
363
+ systems: ALL_SYSTEMS,
364
+ groups: ALL_GROUPS,
365
+ state: state(),
366
+ });
367
+ expect(model.systems.map((s) => s.id)).toEqual([
368
+ "s-checkout",
369
+ "s-ledger",
370
+ "s-auth",
371
+ "s-orphan",
372
+ ]);
373
+ expect(model.groups.map((g) => g.id)).toEqual(["g-payments", "g-platform"]);
374
+ expect(model.isEmptyCatalog).toBe(false);
375
+ });
376
+
377
+ test("empty catalog is flagged", () => {
378
+ const model = filterManagementLists({ systems: [], groups: [], state: state() });
379
+ expect(model.isEmptyCatalog).toBe(true);
380
+ expect(model.systems).toEqual([]);
381
+ expect(model.groups).toEqual([]);
382
+ });
383
+
384
+ test("query filters systems by name and keeps groups that contain a match", () => {
385
+ const model = filterManagementLists({
386
+ systems: ALL_SYSTEMS,
387
+ groups: ALL_GROUPS,
388
+ state: state({ query: "checkout" }),
389
+ });
390
+ expect(model.systems.map((s) => s.id)).toEqual(["s-checkout"]);
391
+ // checkout is in both payments + platform, so both groups survive.
392
+ expect(model.groups.map((g) => g.id)).toEqual(["g-payments", "g-platform"]);
393
+ });
394
+
395
+ test("query matches a group's own name even with no matching member", () => {
396
+ const model = filterManagementLists({
397
+ systems: ALL_SYSTEMS,
398
+ groups: ALL_GROUPS,
399
+ state: state({ query: "Platform" }),
400
+ });
401
+ expect(model.groups.map((g) => g.id)).toContain("g-platform");
402
+ });
403
+
404
+ test("tag filter narrows the systems list", () => {
405
+ const model = filterManagementLists({
406
+ systems: ALL_SYSTEMS,
407
+ groups: ALL_GROUPS,
408
+ state: state({ tag: "team=payments" }),
409
+ });
410
+ expect(model.systems.map((s) => s.id)).toEqual(["s-checkout"]);
411
+ });
412
+
413
+ test("group filter narrows systems to members and to the one group", () => {
414
+ const model = filterManagementLists({
415
+ systems: ALL_SYSTEMS,
416
+ groups: ALL_GROUPS,
417
+ state: state({ group: "g-payments" }),
418
+ });
419
+ expect(model.systems.map((s) => s.id)).toEqual(["s-checkout", "s-ledger"]);
420
+ expect(model.groups.map((g) => g.id)).toEqual(["g-payments"]);
421
+ });
422
+
423
+ test("ungrouped group filter shows only ungrouped systems and no real group", () => {
424
+ const model = filterManagementLists({
425
+ systems: ALL_SYSTEMS,
426
+ groups: ALL_GROUPS,
427
+ state: state({ group: UNGROUPED_ID }),
428
+ });
429
+ expect(model.systems.map((s) => s.id)).toEqual(["s-orphan"]);
430
+ expect(model.groups).toEqual([]);
431
+ });
432
+
433
+ test("filtered-to-empty yields empty lists, not isEmptyCatalog", () => {
434
+ const model = filterManagementLists({
435
+ systems: ALL_SYSTEMS,
436
+ groups: ALL_GROUPS,
437
+ state: state({ query: "nonexistent-zzz" }),
438
+ });
439
+ expect(model.isEmptyCatalog).toBe(false);
440
+ expect(model.systems).toEqual([]);
441
+ expect(model.groups).toEqual([]);
442
+ });
443
+
444
+ test("health filter narrows systems by reported status; unknown matches no-data", () => {
445
+ const statuses = { "s-checkout": "degraded", "s-ledger": "healthy" } as const;
446
+ const degradedOnly = filterManagementLists({
447
+ systems: ALL_SYSTEMS,
448
+ groups: ALL_GROUPS,
449
+ state: state({ health: "degraded" }),
450
+ statuses,
451
+ });
452
+ expect(degradedOnly.systems.map((s) => s.id)).toEqual(["s-checkout"]);
453
+
454
+ const unknownOnly = filterManagementLists({
455
+ systems: ALL_SYSTEMS,
456
+ groups: ALL_GROUPS,
457
+ state: state({ health: "unknown" }),
458
+ statuses,
459
+ });
460
+ // s-auth and s-orphan have no reported status → unknown.
461
+ expect(unknownOnly.systems.map((s) => s.id)).toEqual(["s-auth", "s-orphan"]);
462
+ });
463
+
464
+ test("clearing query restores all (no stale filtering)", () => {
465
+ const filtered = filterManagementLists({
466
+ systems: ALL_SYSTEMS,
467
+ groups: ALL_GROUPS,
468
+ state: state({ query: "checkout" }),
469
+ });
470
+ expect(filtered.systems.length).toBe(1);
471
+ const cleared = filterManagementLists({
472
+ systems: ALL_SYSTEMS,
473
+ groups: ALL_GROUPS,
474
+ state: state({ query: "" }),
475
+ });
476
+ expect(cleared.systems.length).toBe(ALL_SYSTEMS.length);
477
+ expect(cleared.groups.length).toBe(ALL_GROUPS.length);
478
+ });
479
+ });