@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,98 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ Label,
4
+ Checkbox,
5
+ useToast,
6
+ } from "@checkstack/ui";
7
+ import {
8
+ usePluginClient,
9
+ useApi,
10
+ accessApiRef,
11
+ } from "@checkstack/frontend-api";
12
+ import { CatalogApi, catalogAccess } from "@checkstack/catalog-common";
13
+ import { extractErrorMessage } from "@checkstack/common";
14
+ import { toggleSelectedId } from "./environment-fields.logic";
15
+
16
+ interface Props {
17
+ systemId: string;
18
+ }
19
+
20
+ /**
21
+ * Per-system environment multi-select picker. Lists every instance-wide
22
+ * environment with a checkbox; toggling drives the desired-set
23
+ * `setSystemEnvironments` mutation (add/prune diffed server-side).
24
+ */
25
+ export const SystemEnvironmentsEditor: React.FC<Props> = ({ systemId }) => {
26
+ const catalogClient = usePluginClient(CatalogApi);
27
+ const accessApi = useApi(accessApiRef);
28
+ const toast = useToast();
29
+
30
+ const { allowed: canManage } = accessApi.useAccess(
31
+ catalogAccess.environment.manage,
32
+ );
33
+
34
+ const { data: environments = [], isLoading: environmentsLoading } =
35
+ catalogClient.listEnvironments.useQuery({});
36
+
37
+ const {
38
+ data: assigned = [],
39
+ isLoading: assignedLoading,
40
+ refetch: refetchAssigned,
41
+ } = catalogClient.getSystemEnvironments.useQuery({ systemId });
42
+
43
+ const [selected, setSelected] = useState<string[]>([]);
44
+
45
+ useEffect(() => {
46
+ setSelected(assigned.map((environment) => environment.id));
47
+ }, [assigned]);
48
+
49
+ const setMutation = catalogClient.setSystemEnvironments.useMutation({
50
+ onSuccess: () => {
51
+ void refetchAssigned();
52
+ },
53
+ onError: (error) => {
54
+ toast.error(
55
+ extractErrorMessage(error, "Failed to update environments"),
56
+ );
57
+ // Re-sync from the server on failure.
58
+ setSelected(assigned.map((environment) => environment.id));
59
+ },
60
+ });
61
+
62
+ const handleToggle = (environmentId: string) => {
63
+ const next = toggleSelectedId({ selected, id: environmentId });
64
+ setSelected(next);
65
+ setMutation.mutate({ systemId, environmentIds: next });
66
+ };
67
+
68
+ const loading = environmentsLoading || assignedLoading;
69
+
70
+ return (
71
+ <div className="space-y-2">
72
+ <Label>Environments</Label>
73
+ <p className="text-xs text-muted-foreground">
74
+ Instance-wide environments this system belongs to.
75
+ </p>
76
+ {loading ? (
77
+ <p className="text-sm text-muted-foreground py-2">Loading...</p>
78
+ ) : environments.length === 0 ? (
79
+ <p className="text-sm text-muted-foreground py-2">
80
+ No environments defined yet.
81
+ </p>
82
+ ) : (
83
+ <div className="space-y-2">
84
+ {environments.map((environment) => (
85
+ <div key={environment.id} className="flex items-center gap-2">
86
+ <Checkbox
87
+ checked={selected.includes(environment.id)}
88
+ disabled={!canManage || setMutation.isPending}
89
+ onCheckedChange={() => handleToggle(environment.id)}
90
+ />
91
+ <span className="text-sm">{environment.name}</span>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ };
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { ExtensionSlot } from "@checkstack/frontend-api";
3
+ import {
4
+ CatalogBrowseHealthSlot,
5
+ type CatalogHealthStatuses,
6
+ } from "@checkstack/catalog-common";
7
+
8
+ export interface CatalogBrowseHealthProps {
9
+ /** Every system id currently in the browse view (the bulk-fetch input). */
10
+ systemIds: string[];
11
+ /** Receives the resolved per-system statuses from the slot filler. */
12
+ onStatuses: (statuses: CatalogHealthStatuses) => void;
13
+ }
14
+
15
+ /**
16
+ * Headless boundary that renders the optional `CatalogBrowseHealthSlot`. A health
17
+ * provider plugin (e.g. healthcheck-frontend) fills the slot to bulk-fetch system
18
+ * health and report it via `onStatuses`; the browse page feeds that DATA into its
19
+ * group rollups + health filter (see `healthRollup.logic`). When the slot is
20
+ * unfilled, `ExtensionSlot` renders nothing and no statuses are reported — the
21
+ * page then shows counts only and disables the health filter.
22
+ *
23
+ * Catalog only CONSUMES the slot here; all cross-plugin coupling lives on the
24
+ * filler side, so catalog stays decoupled from any health provider.
25
+ */
26
+ export const CatalogBrowseHealth: React.FC<CatalogBrowseHealthProps> = ({
27
+ systemIds,
28
+ onStatuses,
29
+ }) => {
30
+ return (
31
+ <ExtensionSlot
32
+ slot={CatalogBrowseHealthSlot}
33
+ context={{ systemIds, onStatuses }}
34
+ />
35
+ );
36
+ };
@@ -0,0 +1,173 @@
1
+ import React from "react";
2
+ import {
3
+ Input,
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ Tabs,
10
+ } from "@checkstack/ui";
11
+ import { Search, Rows3, Rows2 } from "lucide-react";
12
+ import type { Group } from "@checkstack/catalog-common";
13
+ import type {
14
+ Density,
15
+ HealthFilter,
16
+ } from "./browseState.logic";
17
+ import {
18
+ UNGROUPED_ID,
19
+ HealthFilterSchema,
20
+ DensitySchema,
21
+ } from "./browseState.logic";
22
+
23
+ /**
24
+ * Sentinel "all" value for the `Select` filters. Radix `Select` items cannot
25
+ * carry an empty-string value, so a non-empty token stands in for "no filter".
26
+ */
27
+ const ALL = "__all__";
28
+
29
+ const DENSITY_TABS = [
30
+ { id: "comfortable", label: "Comfortable", icon: <Rows3 className="h-4 w-4" /> },
31
+ { id: "compact", label: "Compact", icon: <Rows2 className="h-4 w-4" /> },
32
+ ];
33
+
34
+ const HEALTH_OPTIONS: { value: HealthFilter; label: string }[] = [
35
+ { value: "all", label: "All health" },
36
+ { value: "healthy", label: "Healthy" },
37
+ { value: "degraded", label: "Degraded" },
38
+ { value: "unhealthy", label: "Unhealthy" },
39
+ { value: "unknown", label: "Unknown" },
40
+ ];
41
+
42
+ export interface CatalogBrowseToolbarProps {
43
+ query: string;
44
+ onQueryChange: (query: string) => void;
45
+ group: string | null;
46
+ onGroupChange: (group: string | null) => void;
47
+ groups: Group[];
48
+ health: HealthFilter;
49
+ onHealthChange: (health: HealthFilter) => void;
50
+ /** `true` once the health slot is filled (Phase 4); disables the filter otherwise. */
51
+ healthEnabled?: boolean;
52
+ tag: string | null;
53
+ onTagChange: (tag: string | null) => void;
54
+ tagOptions: string[];
55
+ /**
56
+ * Density control. Optional: surfaces that render density-aware rows (browse)
57
+ * pass both; surfaces whose rows are not density-aware (the management lists)
58
+ * omit them, hiding the toggle rather than showing a dead control.
59
+ */
60
+ density?: Density;
61
+ onDensityChange?: (density: Density) => void;
62
+ }
63
+
64
+ /**
65
+ * Shared browse/manage toolbar: search input + group/health/tag filters + a
66
+ * comfortable/compact density toggle. Pure controlled component — all state is
67
+ * lifted to the page via `useCatalogBrowseState`.
68
+ */
69
+ export const CatalogBrowseToolbar: React.FC<CatalogBrowseToolbarProps> = ({
70
+ query,
71
+ onQueryChange,
72
+ group,
73
+ onGroupChange,
74
+ groups,
75
+ health,
76
+ onHealthChange,
77
+ healthEnabled = false,
78
+ tag,
79
+ onTagChange,
80
+ tagOptions,
81
+ density,
82
+ onDensityChange,
83
+ }) => {
84
+ return (
85
+ <div className="flex flex-col gap-3 md:flex-row md:flex-wrap md:items-center">
86
+ <div className="relative flex-1 min-w-[12rem]">
87
+ <Search
88
+ className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
89
+ aria-hidden="true"
90
+ />
91
+ <Input
92
+ type="search"
93
+ value={query}
94
+ onChange={(e) => onQueryChange(e.target.value)}
95
+ placeholder="Search systems and groups"
96
+ aria-label="Search systems and groups"
97
+ className="pl-9"
98
+ />
99
+ </div>
100
+
101
+ <Select
102
+ value={group ?? ALL}
103
+ onValueChange={(value) => onGroupChange(value === ALL ? null : value)}
104
+ >
105
+ <SelectTrigger className="md:w-44" aria-label="Filter by group">
106
+ <SelectValue placeholder="All groups" />
107
+ </SelectTrigger>
108
+ <SelectContent>
109
+ <SelectItem value={ALL}>All groups</SelectItem>
110
+ {groups.map((g) => (
111
+ <SelectItem key={g.id} value={g.id}>
112
+ {g.name}
113
+ </SelectItem>
114
+ ))}
115
+ <SelectItem value={UNGROUPED_ID}>Ungrouped</SelectItem>
116
+ </SelectContent>
117
+ </Select>
118
+
119
+ <Select
120
+ value={health}
121
+ onValueChange={(value) => onHealthChange(HealthFilterSchema.parse(value))}
122
+ disabled={!healthEnabled}
123
+ >
124
+ <SelectTrigger
125
+ className="md:w-40"
126
+ aria-label="Filter by health"
127
+ title={
128
+ healthEnabled
129
+ ? undefined
130
+ : "Health filtering becomes available once a health source is installed"
131
+ }
132
+ >
133
+ <SelectValue placeholder="All health" />
134
+ </SelectTrigger>
135
+ <SelectContent>
136
+ {HEALTH_OPTIONS.map((option) => (
137
+ <SelectItem key={option.value} value={option.value}>
138
+ {option.label}
139
+ </SelectItem>
140
+ ))}
141
+ </SelectContent>
142
+ </Select>
143
+
144
+ {tagOptions.length > 0 && (
145
+ <Select
146
+ value={tag ?? ALL}
147
+ onValueChange={(value) => onTagChange(value === ALL ? null : value)}
148
+ >
149
+ <SelectTrigger className="md:w-44" aria-label="Filter by tag">
150
+ <SelectValue placeholder="All tags" />
151
+ </SelectTrigger>
152
+ <SelectContent>
153
+ <SelectItem value={ALL}>All tags</SelectItem>
154
+ {tagOptions.map((option) => (
155
+ <SelectItem key={option} value={option}>
156
+ {option}
157
+ </SelectItem>
158
+ ))}
159
+ </SelectContent>
160
+ </Select>
161
+ )}
162
+
163
+ {density !== undefined && onDensityChange && (
164
+ <Tabs
165
+ items={DENSITY_TABS}
166
+ activeTab={density}
167
+ onTabChange={(id) => onDensityChange(DensitySchema.parse(id))}
168
+ className="md:w-auto"
169
+ />
170
+ )}
171
+ </div>
172
+ );
173
+ };
@@ -0,0 +1,165 @@
1
+ import React from "react";
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ Badge,
6
+ cn,
7
+ usePerformance,
8
+ } from "@checkstack/ui";
9
+ import {
10
+ ChevronDown,
11
+ FolderTree,
12
+ Layers,
13
+ CheckCircle2,
14
+ AlertTriangle,
15
+ XCircle,
16
+ } from "lucide-react";
17
+ import { useApi } from "@checkstack/frontend-api";
18
+ import { authApiRef } from "@checkstack/auth-frontend/api";
19
+ import { NotificationSubscriptionsManager } from "@checkstack/notification-frontend";
20
+ import { catalogGroupTarget } from "@checkstack/catalog-common";
21
+ import type { GroupSection } from "./filterEntities.logic";
22
+ import type { GroupHealthRollup } from "./healthRollup.logic";
23
+ import type { Density } from "./browseState.logic";
24
+ import { CatalogSystemRow } from "./CatalogSystemRow";
25
+
26
+ export interface CatalogGroupSectionProps {
27
+ section: GroupSection;
28
+ density: Density;
29
+ onToggle: (id: string, open: boolean) => void;
30
+ }
31
+
32
+ /**
33
+ * Group health rollup pill, derived from the status DATA (`section.rollup`), not
34
+ * from rendered badges. Renders nothing when no health source reported data
35
+ * (`hasData` false) so the header falls back to the count-only `Badge`. Always
36
+ * pairs colour with an icon AND a text label so it never relies on colour alone
37
+ * (a11y, plan §7).
38
+ */
39
+ const HealthRollupBadge: React.FC<{ rollup: GroupHealthRollup }> = ({
40
+ rollup,
41
+ }) => {
42
+ if (!rollup.hasData) return null;
43
+
44
+ if (rollup.allHealthy) {
45
+ return (
46
+ <Badge variant="success" className="gap-1">
47
+ <CheckCircle2 className="h-3 w-3" aria-hidden="true" />
48
+ All healthy
49
+ </Badge>
50
+ );
51
+ }
52
+
53
+ if (rollup.unhealthy > 0) {
54
+ return (
55
+ <Badge variant="destructive" className="gap-1">
56
+ <XCircle className="h-3 w-3" aria-hidden="true" />
57
+ {rollup.unhealthy} unhealthy
58
+ {rollup.degraded > 0 ? `, ${rollup.degraded} degraded` : ""}
59
+ </Badge>
60
+ );
61
+ }
62
+
63
+ if (rollup.degraded > 0) {
64
+ return (
65
+ <Badge variant="warning" className="gap-1">
66
+ <AlertTriangle className="h-3 w-3" aria-hidden="true" />
67
+ {rollup.degraded} degraded
68
+ </Badge>
69
+ );
70
+ }
71
+
72
+ // Has some data but no degraded/unhealthy and not all-healthy (mixed
73
+ // healthy + unknown) — no rollup pill, count-only header is the honest signal.
74
+ return null;
75
+ };
76
+
77
+ /**
78
+ * One collapsible group (or the synthetic "Ungrouped") section. The header is
79
+ * a real `<button>` with `aria-expanded` so keyboard + screen reader users can
80
+ * toggle it. We intentionally do NOT set `aria-controls`: the body is unmounted
81
+ * when collapsed (`{section.open && <CardContent…>}`), so a referenced id would
82
+ * dangle; `aria-expanded` alone is the correct contract for a custom disclosure
83
+ * whose controlled region is removed from the DOM. The header shows a member
84
+ * count and, when a `CatalogBrowseHealthSlot` filler reported data, a health
85
+ * rollup `Badge` derived from that DATA (not from rendered per-system badges).
86
+ *
87
+ * Collapsed sections render only their header — not their member rows — so the
88
+ * mounted-node count stays bounded even with hundreds of systems (plan §3.4).
89
+ */
90
+ export const CatalogGroupSection: React.FC<CatalogGroupSectionProps> = ({
91
+ section,
92
+ density,
93
+ onToggle,
94
+ }) => {
95
+ const { isLowPower } = usePerformance();
96
+ const authApi = useApi(authApiRef);
97
+ const { data: session } = authApi.useSession();
98
+ const Icon = section.isUngrouped ? Layers : FolderTree;
99
+
100
+ // The synthetic "Ungrouped" bucket is not a real group, so it has no group
101
+ // resource to subscribe to.
102
+ const canSubscribe = !section.isUngrouped && !!session;
103
+
104
+ return (
105
+ <Card>
106
+ <div className="flex items-center">
107
+ <button
108
+ type="button"
109
+ onClick={() => onToggle(section.id, !section.open)}
110
+ aria-expanded={section.open}
111
+ className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-tl-lg"
112
+ >
113
+ <ChevronDown
114
+ className={cn(
115
+ "h-4 w-4 shrink-0 text-muted-foreground",
116
+ !isLowPower && "transition-transform",
117
+ section.open ? "rotate-0" : "-rotate-90",
118
+ )}
119
+ aria-hidden="true"
120
+ />
121
+ <Icon
122
+ className="h-4 w-4 shrink-0 text-muted-foreground"
123
+ aria-hidden="true"
124
+ />
125
+ <span className="min-w-0 flex-1 truncate font-semibold text-foreground">
126
+ {section.name}
127
+ </span>
128
+ <HealthRollupBadge rollup={section.rollup} />
129
+ <Badge variant="secondary">
130
+ {section.totalCount}{" "}
131
+ {section.totalCount === 1 ? "system" : "systems"}
132
+ </Badge>
133
+ </button>
134
+ {canSubscribe && (
135
+ <div className="shrink-0 pr-2 pl-1">
136
+ <NotificationSubscriptionsManager
137
+ target={catalogGroupTarget}
138
+ resource={{ groupId: section.id, groupName: section.name }}
139
+ />
140
+ </div>
141
+ )}
142
+ </div>
143
+
144
+ {section.open && (
145
+ <CardContent
146
+ className={cn("pt-0", density === "compact" ? "space-y-0.5" : "space-y-1")}
147
+ >
148
+ {section.systems.length === 0 ? (
149
+ <p className="px-3 py-2 text-xs text-muted-foreground">
150
+ No systems match the current filters.
151
+ </p>
152
+ ) : (
153
+ section.systems.map((system) => (
154
+ <CatalogSystemRow
155
+ key={system.id}
156
+ system={system}
157
+ density={density}
158
+ />
159
+ ))
160
+ )}
161
+ </CardContent>
162
+ )}
163
+ </Card>
164
+ );
165
+ };
@@ -0,0 +1,63 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { ExtensionSlot } from "@checkstack/frontend-api";
4
+ import { resolveRoute } from "@checkstack/common";
5
+ import {
6
+ catalogRoutes,
7
+ SystemStateBadgesSlot,
8
+ type System,
9
+ } from "@checkstack/catalog-common";
10
+ import { cn } from "@checkstack/ui";
11
+ import { ChevronRight } from "lucide-react";
12
+ import type { Density } from "./browseState.logic";
13
+
14
+ export interface CatalogSystemRowProps {
15
+ system: System;
16
+ density: Density;
17
+ }
18
+
19
+ /**
20
+ * One browse row for a single system: name (links to the system detail page),
21
+ * the `SystemStateBadgesSlot` (health/maintenance/incident badges contributed
22
+ * by other plugins — exactly the decoupled mechanism `SystemDetailPage` uses),
23
+ * and a density-aware description. Compact density drops the inline
24
+ * description to a `title` tooltip and tightens spacing.
25
+ */
26
+ export const CatalogSystemRow: React.FC<CatalogSystemRowProps> = ({
27
+ system,
28
+ density,
29
+ }) => {
30
+ const isCompact = density === "compact";
31
+ const description = system.description?.trim();
32
+
33
+ return (
34
+ <Link
35
+ to={resolveRoute(catalogRoutes.routes.systemDetail, {
36
+ systemId: system.id,
37
+ })}
38
+ className={cn(
39
+ "group flex items-center gap-3 rounded-md border border-transparent px-3 transition-colors hover:border-border hover:bg-muted/50",
40
+ isCompact ? "py-1.5" : "py-2.5",
41
+ )}
42
+ title={isCompact && description ? description : undefined}
43
+ >
44
+ <div className="min-w-0 flex-1">
45
+ <span className="block truncate text-sm font-medium text-foreground">
46
+ {system.name}
47
+ </span>
48
+ {!isCompact && description && (
49
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
50
+ {description}
51
+ </p>
52
+ )}
53
+ </div>
54
+ <div className="flex shrink-0 items-center gap-1">
55
+ <ExtensionSlot slot={SystemStateBadgesSlot} context={{ system }} />
56
+ </div>
57
+ <ChevronRight
58
+ className="h-4 w-4 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground"
59
+ aria-hidden="true"
60
+ />
61
+ </Link>
62
+ );
63
+ };
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ parseBrowseState,
4
+ serializeBrowseState,
5
+ parseOpenParam,
6
+ serializeOpenParam,
7
+ DEFAULT_BROWSE_STATE,
8
+ BROWSE_PARAM,
9
+ } from "./browseState.logic";
10
+
11
+ /** Build a `params.get`-style reader from a plain record. */
12
+ function reader(record: Record<string, string>) {
13
+ return { get: (key: string): string | null => record[key] ?? null };
14
+ }
15
+
16
+ describe("parseBrowseState", () => {
17
+ test("returns defaults for empty params", () => {
18
+ expect(parseBrowseState(reader({}))).toEqual(DEFAULT_BROWSE_STATE);
19
+ });
20
+
21
+ test("parses all params", () => {
22
+ const state = parseBrowseState(
23
+ reader({
24
+ [BROWSE_PARAM.query]: "checkout",
25
+ [BROWSE_PARAM.group]: "payments",
26
+ [BROWSE_PARAM.health]: "degraded",
27
+ [BROWSE_PARAM.tag]: "team=payments",
28
+ [BROWSE_PARAM.density]: "compact",
29
+ [BROWSE_PARAM.open]: "payments,-platform",
30
+ }),
31
+ );
32
+ expect(state).toEqual({
33
+ query: "checkout",
34
+ group: "payments",
35
+ health: "degraded",
36
+ tag: "team=payments",
37
+ density: "compact",
38
+ open: { payments: true, platform: false },
39
+ });
40
+ });
41
+
42
+ test("falls back to default for invalid health/density enums", () => {
43
+ const state = parseBrowseState(
44
+ reader({ [BROWSE_PARAM.health]: "bogus", [BROWSE_PARAM.density]: "huge" }),
45
+ );
46
+ expect(state.health).toBe("all");
47
+ expect(state.density).toBe("comfortable");
48
+ });
49
+
50
+ test("treats empty group/tag strings as null", () => {
51
+ const state = parseBrowseState(
52
+ reader({ [BROWSE_PARAM.group]: "", [BROWSE_PARAM.tag]: "" }),
53
+ );
54
+ expect(state.group).toBeNull();
55
+ expect(state.tag).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("open param round-trip", () => {
60
+ test("parseOpenParam handles forced-open and forced-closed", () => {
61
+ expect(parseOpenParam("a,-b,c")).toEqual({ a: true, b: false, c: true });
62
+ });
63
+
64
+ test("parseOpenParam ignores blank/empty tokens", () => {
65
+ expect(parseOpenParam(" , ,-")).toEqual({});
66
+ expect(parseOpenParam(null)).toEqual({});
67
+ expect(parseOpenParam("")).toEqual({});
68
+ });
69
+
70
+ test("serializeOpenParam is sorted and deterministic", () => {
71
+ expect(serializeOpenParam({ c: true, a: false, b: true })).toBe("-a,b,c");
72
+ });
73
+
74
+ test("open map survives a full round-trip", () => {
75
+ const open = { payments: true, platform: false, infra: true };
76
+ expect(parseOpenParam(serializeOpenParam(open))).toEqual(open);
77
+ });
78
+ });
79
+
80
+ describe("serializeBrowseState", () => {
81
+ test("default state produces all-empty (no params)", () => {
82
+ const out = serializeBrowseState(DEFAULT_BROWSE_STATE);
83
+ expect(out).toEqual({
84
+ [BROWSE_PARAM.query]: "",
85
+ [BROWSE_PARAM.group]: "",
86
+ [BROWSE_PARAM.health]: "",
87
+ [BROWSE_PARAM.tag]: "",
88
+ [BROWSE_PARAM.density]: "",
89
+ [BROWSE_PARAM.open]: "",
90
+ });
91
+ });
92
+
93
+ test("non-default values are emitted", () => {
94
+ const out = serializeBrowseState({
95
+ query: "api",
96
+ group: "g1",
97
+ health: "unhealthy",
98
+ tag: "tier=1",
99
+ density: "compact",
100
+ open: { g1: true },
101
+ });
102
+ expect(out[BROWSE_PARAM.query]).toBe("api");
103
+ expect(out[BROWSE_PARAM.group]).toBe("g1");
104
+ expect(out[BROWSE_PARAM.health]).toBe("unhealthy");
105
+ expect(out[BROWSE_PARAM.tag]).toBe("tier=1");
106
+ expect(out[BROWSE_PARAM.density]).toBe("compact");
107
+ expect(out[BROWSE_PARAM.open]).toBe("g1");
108
+ });
109
+
110
+ test("parse(serialize(state)) round-trips a non-default state", () => {
111
+ const state = {
112
+ query: "checkout",
113
+ group: "payments",
114
+ health: "degraded" as const,
115
+ tag: "team=payments",
116
+ density: "compact" as const,
117
+ open: { payments: true, platform: false },
118
+ };
119
+ const serialized = serializeBrowseState(state);
120
+ const filled = Object.fromEntries(
121
+ Object.entries(serialized).filter(([, v]) => v.length > 0),
122
+ );
123
+ expect(parseBrowseState(reader(filled))).toEqual(state);
124
+ });
125
+ });