@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.
- package/CHANGELOG.md +133 -0
- package/package.json +10 -9
- package/src/api.ts +6 -1
- package/src/components/CatalogConfigPage.tsx +337 -271
- package/src/components/CatalogPage.tsx +172 -11
- package/src/components/EnvironmentEditor.tsx +220 -0
- package/src/components/EnvironmentPreviewPicker.tsx +61 -0
- package/src/components/SystemDetailPage.tsx +47 -34
- package/src/components/SystemEditor.tsx +6 -0
- package/src/components/SystemEnvironmentsEditor.tsx +98 -0
- package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
- package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
- package/src/components/browse/CatalogGroupSection.tsx +165 -0
- package/src/components/browse/CatalogSystemRow.tsx +63 -0
- package/src/components/browse/browseState.logic.test.ts +125 -0
- package/src/components/browse/browseState.logic.ts +158 -0
- package/src/components/browse/filterEntities.logic.test.ts +479 -0
- package/src/components/browse/filterEntities.logic.ts +360 -0
- package/src/components/browse/healthRollup.logic.test.ts +126 -0
- package/src/components/browse/healthRollup.logic.ts +120 -0
- package/src/components/browse/healthStatuses.logic.test.ts +39 -0
- package/src/components/browse/healthStatuses.logic.ts +29 -0
- package/src/components/environment-fields.logic.test.ts +111 -0
- package/src/components/environment-fields.logic.ts +98 -0
- package/src/components/environment-preview.logic.test.ts +76 -0
- package/src/components/environment-preview.logic.ts +61 -0
- package/src/components/manage/AssignMenu.tsx +78 -0
- package/src/components/manage/EnvironmentsTab.tsx +230 -0
- package/src/components/manage/GroupsTab.tsx +274 -0
- package/src/components/manage/SystemsTab.tsx +430 -0
- package/src/hooks/useCatalogBrowseState.ts +107 -0
- package/src/hooks/useDebouncedValue.ts +21 -0
- package/src/index.tsx +32 -20
- package/src/utils/formatDate.logic.test.ts +44 -0
- package/src/utils/formatDate.logic.ts +27 -0
- package/src/utils/normalizeMetadata.logic.test.ts +67 -0
- package/src/utils/normalizeMetadata.logic.ts +53 -0
- package/src/components/DraggableSystem.tsx +0 -200
- package/src/components/DroppableGroup.tsx +0 -174
- 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
|
+
});
|