@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,107 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
parseBrowseState,
|
|
5
|
+
serializeBrowseState,
|
|
6
|
+
type BrowseState,
|
|
7
|
+
type Density,
|
|
8
|
+
type HealthFilter,
|
|
9
|
+
} from "../components/browse/browseState.logic";
|
|
10
|
+
import { useDebouncedValue } from "./useDebouncedValue";
|
|
11
|
+
|
|
12
|
+
const QUERY_DEBOUNCE_MS = 150;
|
|
13
|
+
|
|
14
|
+
export interface CatalogBrowseStateApi {
|
|
15
|
+
/** Live state reflecting the URL (query updates immediately for the input). */
|
|
16
|
+
state: BrowseState;
|
|
17
|
+
/** Query debounced for filtering, so typing stays smooth on large lists. */
|
|
18
|
+
debouncedQuery: string;
|
|
19
|
+
setQuery: (query: string) => void;
|
|
20
|
+
setGroup: (group: string | null) => void;
|
|
21
|
+
setHealth: (health: HealthFilter) => void;
|
|
22
|
+
setTag: (tag: string | null) => void;
|
|
23
|
+
setDensity: (density: Density) => void;
|
|
24
|
+
/** Force a section's open/closed state (overrides the default policy). */
|
|
25
|
+
setSectionOpen: (id: string, open: boolean) => void;
|
|
26
|
+
/** Clear every filter (query/group/health/tag) but keep density + open. */
|
|
27
|
+
clearFilters: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bridge between the browse URL params and the page. All persistence logic
|
|
32
|
+
* lives in `browseState.logic.ts` (DOM-free, tested); this hook only wires it
|
|
33
|
+
* to `useSearchParams` and adds query debouncing.
|
|
34
|
+
*/
|
|
35
|
+
export function useCatalogBrowseState(): CatalogBrowseStateApi {
|
|
36
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
37
|
+
|
|
38
|
+
const state = useMemo(
|
|
39
|
+
() => parseBrowseState(searchParams),
|
|
40
|
+
[searchParams],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const debouncedQuery = useDebouncedValue(state.query, QUERY_DEBOUNCE_MS);
|
|
44
|
+
|
|
45
|
+
const commit = useCallback(
|
|
46
|
+
(next: BrowseState) => {
|
|
47
|
+
const serialized = serializeBrowseState(next);
|
|
48
|
+
setSearchParams(
|
|
49
|
+
(prev) => {
|
|
50
|
+
const updated = new URLSearchParams(prev);
|
|
51
|
+
for (const [key, value] of Object.entries(serialized)) {
|
|
52
|
+
if (value.length > 0) {
|
|
53
|
+
updated.set(key, value);
|
|
54
|
+
} else {
|
|
55
|
+
updated.delete(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return updated;
|
|
59
|
+
},
|
|
60
|
+
{ replace: true },
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
[setSearchParams],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const setQuery = useCallback(
|
|
67
|
+
(query: string) => commit({ ...state, query }),
|
|
68
|
+
[commit, state],
|
|
69
|
+
);
|
|
70
|
+
const setGroup = useCallback(
|
|
71
|
+
(group: string | null) => commit({ ...state, group }),
|
|
72
|
+
[commit, state],
|
|
73
|
+
);
|
|
74
|
+
const setHealth = useCallback(
|
|
75
|
+
(health: HealthFilter) => commit({ ...state, health }),
|
|
76
|
+
[commit, state],
|
|
77
|
+
);
|
|
78
|
+
const setTag = useCallback(
|
|
79
|
+
(tag: string | null) => commit({ ...state, tag }),
|
|
80
|
+
[commit, state],
|
|
81
|
+
);
|
|
82
|
+
const setDensity = useCallback(
|
|
83
|
+
(density: Density) => commit({ ...state, density }),
|
|
84
|
+
[commit, state],
|
|
85
|
+
);
|
|
86
|
+
const setSectionOpen = useCallback(
|
|
87
|
+
(id: string, open: boolean) =>
|
|
88
|
+
commit({ ...state, open: { ...state.open, [id]: open } }),
|
|
89
|
+
[commit, state],
|
|
90
|
+
);
|
|
91
|
+
const clearFilters = useCallback(
|
|
92
|
+
() => commit({ ...state, query: "", group: null, health: "all", tag: null }),
|
|
93
|
+
[commit, state],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
debouncedQuery,
|
|
99
|
+
setQuery,
|
|
100
|
+
setGroup,
|
|
101
|
+
setHealth,
|
|
102
|
+
setTag,
|
|
103
|
+
setDensity,
|
|
104
|
+
setSectionOpen,
|
|
105
|
+
clearFilters,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return `value` debounced by `delayMs`: the returned value only updates after
|
|
5
|
+
* `value` has stopped changing for `delayMs`. Used to throttle the live browse
|
|
6
|
+
* search so a fast typist doesn't re-filter a large catalog on every keystroke.
|
|
7
|
+
*
|
|
8
|
+
* Copied (not imported) from script-packages-frontend per the code-style guide —
|
|
9
|
+
* there is no shared `useDebounce` in `@checkstack/ui` yet, and adding one
|
|
10
|
+
* unilaterally is out of scope.
|
|
11
|
+
*/
|
|
12
|
+
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
13
|
+
const [debounced, setDebounced] = React.useState(value);
|
|
14
|
+
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
17
|
+
return () => clearTimeout(timer);
|
|
18
|
+
}, [value, delayMs]);
|
|
19
|
+
|
|
20
|
+
return debounced;
|
|
21
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
UserMenuItemsSlot,
|
|
3
|
-
createSlotExtension,
|
|
4
|
-
createFrontendPlugin,
|
|
5
|
-
} from "@checkstack/frontend-api";
|
|
1
|
+
import { createFrontendPlugin } from "@checkstack/frontend-api";
|
|
6
2
|
import {
|
|
7
3
|
catalogRoutes,
|
|
8
4
|
pluginMetadata,
|
|
@@ -12,11 +8,6 @@ import {
|
|
|
12
8
|
import { Server, FolderTree } from "lucide-react";
|
|
13
9
|
import { registerSubjectKind } from "@checkstack/notification-frontend";
|
|
14
10
|
|
|
15
|
-
import { CatalogPage } from "./components/CatalogPage";
|
|
16
|
-
import { CatalogConfigPage } from "./components/CatalogConfigPage";
|
|
17
|
-
import { CatalogUserMenuItems } from "./components/UserMenuItems";
|
|
18
|
-
import { SystemDetailPage } from "./components/SystemDetailPage";
|
|
19
|
-
|
|
20
11
|
// Notification subject kinds emitted by catalog (see catalog-common's
|
|
21
12
|
// `createSystemSubject` / `createGroupSubject`). Registered at module load
|
|
22
13
|
// so the notification bell + page render kind-appropriate icons.
|
|
@@ -36,25 +27,46 @@ export const catalogPlugin = createFrontendPlugin({
|
|
|
36
27
|
routes: [
|
|
37
28
|
{
|
|
38
29
|
route: catalogRoutes.routes.home,
|
|
39
|
-
|
|
30
|
+
load: () =>
|
|
31
|
+
import("./components/CatalogPage").then((m) => ({
|
|
32
|
+
default: m.CatalogPage,
|
|
33
|
+
})),
|
|
34
|
+
title: "Catalog",
|
|
35
|
+
nav: {
|
|
36
|
+
group: "Workspace",
|
|
37
|
+
icon: Server,
|
|
38
|
+
// Visible to anyone who can view systems in the catalog.
|
|
39
|
+
accessRule: catalogAccess.system.read,
|
|
40
|
+
},
|
|
40
41
|
},
|
|
41
42
|
{
|
|
42
43
|
route: catalogRoutes.routes.config,
|
|
43
|
-
|
|
44
|
+
load: () =>
|
|
45
|
+
import("./components/CatalogConfigPage").then((m) => ({
|
|
46
|
+
default: m.CatalogConfigPage,
|
|
47
|
+
})),
|
|
44
48
|
accessRule: catalogAccess.system.manage,
|
|
45
49
|
},
|
|
46
50
|
{
|
|
47
51
|
route: catalogRoutes.routes.systemDetail,
|
|
48
|
-
|
|
52
|
+
load: () =>
|
|
53
|
+
import("./components/SystemDetailPage").then((m) => ({
|
|
54
|
+
default: m.SystemDetailPage,
|
|
55
|
+
})),
|
|
49
56
|
},
|
|
50
57
|
],
|
|
51
|
-
extensions: [
|
|
52
|
-
createSlotExtension(UserMenuItemsSlot, {
|
|
53
|
-
id: "catalog.user-menu.items",
|
|
54
|
-
component: CatalogUserMenuItems,
|
|
55
|
-
metadata: { group: "Workspace" },
|
|
56
|
-
}),
|
|
57
|
-
],
|
|
58
|
+
extensions: [],
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
export * from "./api";
|
|
62
|
+
|
|
63
|
+
// Reusable "Preview as: <environment>" picker + its DOM-free helpers, so host
|
|
64
|
+
// plugins can let config authors preview `x-templatable` fields against a
|
|
65
|
+
// catalog environment's custom fields.
|
|
66
|
+
export { EnvironmentPreviewPicker } from "./components/EnvironmentPreviewPicker";
|
|
67
|
+
export {
|
|
68
|
+
toPreviewOptions,
|
|
69
|
+
environmentToPreviewFields,
|
|
70
|
+
findSelectedEnvironment,
|
|
71
|
+
type EnvironmentPreviewOption,
|
|
72
|
+
} from "./components/environment-preview.logic";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatDate } from "./formatDate.logic";
|
|
3
|
+
|
|
4
|
+
describe("formatDate", () => {
|
|
5
|
+
test("returns '' for null", () => {
|
|
6
|
+
expect(formatDate(null)).toBe("");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns '' for undefined", () => {
|
|
10
|
+
expect(formatDate(undefined)).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns '' for an invalid date string", () => {
|
|
14
|
+
expect(formatDate("not-a-date")).toBe("");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("returns '' for an invalid Date object", () => {
|
|
18
|
+
expect(formatDate(new Date("not-a-date"))).toBe("");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("does not embed 'en-US' locale in the output", () => {
|
|
22
|
+
// Run formatDate with a fixed date and verify the result does NOT contain
|
|
23
|
+
// a hardcoded locale marker. We can't assert the exact locale-formatted
|
|
24
|
+
// string since it depends on the runtime environment, but we can verify
|
|
25
|
+
// it produces a non-empty result and does not throw.
|
|
26
|
+
const result = formatDate(new Date("2025-01-05T00:00:00Z"));
|
|
27
|
+
expect(result.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("accepts a Date object and produces a non-empty string", () => {
|
|
31
|
+
const date = new Date(2025, 0, 5); // Jan 5, 2025 local time
|
|
32
|
+
expect(formatDate(date).length).toBeGreaterThan(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("accepts an ISO string and produces a non-empty string", () => {
|
|
36
|
+
expect(formatDate("2025-06-01T12:00:00.000Z").length).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a known fixed date produces output containing the year", () => {
|
|
40
|
+
const result = formatDate(new Date(2025, 5, 1)); // Jun 1, 2025
|
|
41
|
+
// The year should always appear regardless of locale
|
|
42
|
+
expect(result).toContain("2025");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date formatting helpers for catalog-frontend.
|
|
3
|
+
*
|
|
4
|
+
* Uses the browser/runtime locale (undefined) instead of a hardcoded "en-US"
|
|
5
|
+
* so dates render in the user's preferred locale.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Options used for human-readable catalog dates (e.g. "Jan 5, 2025"). */
|
|
9
|
+
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
10
|
+
year: "numeric",
|
|
11
|
+
month: "short",
|
|
12
|
+
day: "numeric",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format a date value as a short human-readable string using the runtime
|
|
17
|
+
* locale. Accepts a `Date` object or an ISO string.
|
|
18
|
+
*
|
|
19
|
+
* Returns an empty string for invalid or absent dates so the caller never
|
|
20
|
+
* shows "Invalid Date" in the UI.
|
|
21
|
+
*/
|
|
22
|
+
export function formatDate(value: Date | string | null | undefined): string {
|
|
23
|
+
if (!value) return "";
|
|
24
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
25
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
26
|
+
return date.toLocaleDateString(undefined, DATE_FORMAT_OPTIONS);
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { normalizeMetadata } from "./normalizeMetadata.logic";
|
|
3
|
+
|
|
4
|
+
describe("normalizeMetadata", () => {
|
|
5
|
+
test("returns [] for null", () => {
|
|
6
|
+
expect(normalizeMetadata(null)).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns [] for undefined", () => {
|
|
10
|
+
expect(normalizeMetadata(undefined)).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns [] for an empty object", () => {
|
|
14
|
+
expect(normalizeMetadata({})).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("renders a string value inline", () => {
|
|
18
|
+
expect(normalizeMetadata({ owner: "platform-team" })).toEqual([
|
|
19
|
+
{ key: "owner", displayValue: "platform-team" },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("renders a number value inline", () => {
|
|
24
|
+
expect(normalizeMetadata({ priority: 1 })).toEqual([
|
|
25
|
+
{ key: "priority", displayValue: "1" },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("renders a boolean value inline", () => {
|
|
30
|
+
expect(normalizeMetadata({ critical: true })).toEqual([
|
|
31
|
+
{ key: "critical", displayValue: "true" },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("renders null value as 'null'", () => {
|
|
36
|
+
expect(normalizeMetadata({ deprecated: null })).toEqual([
|
|
37
|
+
{ key: "deprecated", displayValue: "null" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("renders an object value as compact JSON", () => {
|
|
42
|
+
const result = normalizeMetadata({ links: { docs: "https://example.com" } });
|
|
43
|
+
expect(result).toEqual([
|
|
44
|
+
{ key: "links", displayValue: '{"docs":"https://example.com"}' },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("renders an array value as compact JSON", () => {
|
|
49
|
+
const result = normalizeMetadata({ tags: ["backend", "api"] });
|
|
50
|
+
expect(result).toEqual([
|
|
51
|
+
{ key: "tags", displayValue: '["backend","api"]' },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("handles multiple entries and preserves insertion order", () => {
|
|
56
|
+
const result = normalizeMetadata({
|
|
57
|
+
team: "payments",
|
|
58
|
+
version: 2,
|
|
59
|
+
active: false,
|
|
60
|
+
});
|
|
61
|
+
expect(result).toEqual([
|
|
62
|
+
{ key: "team", displayValue: "payments" },
|
|
63
|
+
{ key: "version", displayValue: "2" },
|
|
64
|
+
{ key: "active", displayValue: "false" },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Validated shape of a system's metadata field. */
|
|
4
|
+
export const MetadataSchema = z.record(z.string(), z.unknown());
|
|
5
|
+
|
|
6
|
+
/** A single displayable key/value pair derived from raw metadata. */
|
|
7
|
+
export interface MetadataEntry {
|
|
8
|
+
key: string;
|
|
9
|
+
/** Human-readable value string: primitives as-is, objects/arrays as compact JSON. */
|
|
10
|
+
displayValue: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalise a raw `Record<string, unknown>` metadata object into a flat list
|
|
15
|
+
* of `{ key, displayValue }` pairs suitable for key/value rendering.
|
|
16
|
+
*
|
|
17
|
+
* - Primitives (`string`, `number`, `boolean`) are displayed as their string
|
|
18
|
+
* representation.
|
|
19
|
+
* - `null` is displayed as `"null"`.
|
|
20
|
+
* - Objects and arrays are displayed as compact `JSON.stringify` output inside
|
|
21
|
+
* a `<code>` element, so structure is preserved without a raw JSON blob.
|
|
22
|
+
* - An absent or empty metadata object yields `[]`.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeMetadata(
|
|
25
|
+
meta: Record<string, unknown> | null | undefined,
|
|
26
|
+
): MetadataEntry[] {
|
|
27
|
+
if (!meta || typeof meta !== "object") return [];
|
|
28
|
+
|
|
29
|
+
const parsed = MetadataSchema.safeParse(meta);
|
|
30
|
+
if (!parsed.success) return [];
|
|
31
|
+
|
|
32
|
+
return Object.entries(parsed.data).map(([key, value]) => ({
|
|
33
|
+
key,
|
|
34
|
+
displayValue: valueToDisplay(value),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function valueToDisplay(value: unknown): string {
|
|
39
|
+
if (value === null) return "null";
|
|
40
|
+
if (value === undefined) return "";
|
|
41
|
+
if (
|
|
42
|
+
typeof value === "string" ||
|
|
43
|
+
typeof value === "number" ||
|
|
44
|
+
typeof value === "boolean"
|
|
45
|
+
) {
|
|
46
|
+
return String(value);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { useDraggable } from "@dnd-kit/core";
|
|
3
|
-
import { GripVertical, Edit, Trash2, FolderPlus } from "lucide-react";
|
|
4
|
-
import { Button } from "@checkstack/ui";
|
|
5
|
-
import { ExtensionSlot } from "@checkstack/frontend-api";
|
|
6
|
-
import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
|
|
7
|
-
import {
|
|
8
|
-
useProvenanceLock,
|
|
9
|
-
GitOpsSourceBadge,
|
|
10
|
-
} from "@checkstack/gitops-frontend";
|
|
11
|
-
import type { Group, System } from "../api";
|
|
12
|
-
|
|
13
|
-
interface DraggableSystemProps {
|
|
14
|
-
system: System;
|
|
15
|
-
groups: Group[];
|
|
16
|
-
assignedGroupIds: string[];
|
|
17
|
-
onEdit: (system: System) => void;
|
|
18
|
-
onDelete: (id: string) => void;
|
|
19
|
-
onAddToGroup: (systemId: string, groupId: string) => void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* A draggable system card for the Catalog Management page.
|
|
24
|
-
*
|
|
25
|
-
* Layout: two-row design so the system name is never truncated.
|
|
26
|
-
* Row 1: grip handle + full-width name + description
|
|
27
|
-
* Row 2 (footer): extension slot (Health Checks etc.) + action buttons
|
|
28
|
-
*
|
|
29
|
-
* On touch / small screens, the + button opens an inline group picker
|
|
30
|
-
* since drag-and-drop can be imprecise on small viewports.
|
|
31
|
-
*
|
|
32
|
-
* We intentionally do NOT apply the useDraggable transform to the element —
|
|
33
|
-
* DragOverlay handles the moving visual, keeping the original in-place.
|
|
34
|
-
*/
|
|
35
|
-
export const DraggableSystem = ({
|
|
36
|
-
system,
|
|
37
|
-
groups,
|
|
38
|
-
assignedGroupIds,
|
|
39
|
-
onEdit,
|
|
40
|
-
onDelete,
|
|
41
|
-
onAddToGroup,
|
|
42
|
-
}: DraggableSystemProps) => {
|
|
43
|
-
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
44
|
-
|
|
45
|
-
const { isLocked, provenance } = useProvenanceLock({
|
|
46
|
-
kind: "System",
|
|
47
|
-
entityId: system.id,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const { attributes, listeners, setNodeRef } = useDraggable({
|
|
51
|
-
id: system.id,
|
|
52
|
-
disabled: isLocked,
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const availableGroups = groups.filter((g) => !assignedGroupIds.includes(g.id));
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div
|
|
59
|
-
ref={setNodeRef}
|
|
60
|
-
className="bg-muted/30 rounded-lg border border-border transition-all duration-150"
|
|
61
|
-
>
|
|
62
|
-
{/* Main row: grip + name/description */}
|
|
63
|
-
<div className="flex items-start gap-2 p-3 pb-2">
|
|
64
|
-
{/* Grip handle — only this element triggers the drag.
|
|
65
|
-
When GitOps-locked, swap in the source badge so users can click
|
|
66
|
-
through to the file that owns this system. */}
|
|
67
|
-
{isLocked && provenance ? (
|
|
68
|
-
<div className="flex-shrink-0 mt-0.5">
|
|
69
|
-
<GitOpsSourceBadge provenance={provenance} />
|
|
70
|
-
</div>
|
|
71
|
-
) : (
|
|
72
|
-
<div
|
|
73
|
-
{...listeners}
|
|
74
|
-
{...attributes}
|
|
75
|
-
className="flex-shrink-0 mt-0.5 text-muted-foreground/40 touch-none cursor-grab active:cursor-grabbing hover:text-muted-foreground"
|
|
76
|
-
aria-label={`Drag ${system.name}`}
|
|
77
|
-
>
|
|
78
|
-
<GripVertical className="w-4 h-4" />
|
|
79
|
-
</div>
|
|
80
|
-
)}
|
|
81
|
-
|
|
82
|
-
{/* Name + description — gets all remaining width, never truncated */}
|
|
83
|
-
<div className="flex-1 min-w-0">
|
|
84
|
-
<p className="font-medium text-foreground leading-snug">
|
|
85
|
-
{system.name}
|
|
86
|
-
</p>
|
|
87
|
-
<p className="text-xs text-muted-foreground mt-0.5">
|
|
88
|
-
{system.description || "No description"}
|
|
89
|
-
</p>
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
|
|
93
|
-
{/* Footer row: unassigned badge (left) + all action buttons (right) */}
|
|
94
|
-
<div className="flex items-center justify-between gap-2 px-3 pb-2 pl-9">
|
|
95
|
-
{/* Left: unassigned badge */}
|
|
96
|
-
<div className="min-w-0">
|
|
97
|
-
{assignedGroupIds.length === 0 && (
|
|
98
|
-
<span className="flex-shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-warning/15 text-warning-foreground font-mono">
|
|
99
|
-
unassigned
|
|
100
|
-
</span>
|
|
101
|
-
)}
|
|
102
|
-
</div>
|
|
103
|
-
|
|
104
|
-
{/* Right: extension slot + action buttons, all grouped together */}
|
|
105
|
-
<div className="flex items-center gap-1 flex-shrink-0">
|
|
106
|
-
<ExtensionSlot
|
|
107
|
-
slot={CatalogSystemActionsSlot}
|
|
108
|
-
context={{ systemId: system.id, systemName: system.name }}
|
|
109
|
-
/>
|
|
110
|
-
|
|
111
|
-
{/* Group picker */}
|
|
112
|
-
<div className="relative">
|
|
113
|
-
<Button
|
|
114
|
-
variant="ghost"
|
|
115
|
-
size="sm"
|
|
116
|
-
className="h-7 w-7 p-0"
|
|
117
|
-
title={isLocked ? "Managed by GitOps" : `Add ${system.name} to a group`}
|
|
118
|
-
onClick={() => setIsPickerOpen((v) => !v)}
|
|
119
|
-
disabled={availableGroups.length === 0 || isLocked}
|
|
120
|
-
aria-expanded={isPickerOpen}
|
|
121
|
-
aria-haspopup="listbox"
|
|
122
|
-
aria-label={`Add ${system.name} to group`}
|
|
123
|
-
>
|
|
124
|
-
<FolderPlus className="w-3.5 h-3.5" />
|
|
125
|
-
</Button>
|
|
126
|
-
|
|
127
|
-
{isPickerOpen && availableGroups.length > 0 && (
|
|
128
|
-
<>
|
|
129
|
-
{/* Backdrop */}
|
|
130
|
-
<div
|
|
131
|
-
className="fixed inset-0 z-40"
|
|
132
|
-
onClick={() => setIsPickerOpen(false)}
|
|
133
|
-
/>
|
|
134
|
-
<div
|
|
135
|
-
role="listbox"
|
|
136
|
-
aria-label="Select a group"
|
|
137
|
-
className="absolute right-0 bottom-full mb-1 z-50 min-w-[160px] rounded-md border border-border bg-popover shadow-md py-1"
|
|
138
|
-
>
|
|
139
|
-
{availableGroups.map((group) => (
|
|
140
|
-
<button
|
|
141
|
-
key={group.id}
|
|
142
|
-
role="option"
|
|
143
|
-
aria-selected={false}
|
|
144
|
-
className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
|
|
145
|
-
onClick={() => {
|
|
146
|
-
onAddToGroup(system.id, group.id);
|
|
147
|
-
setIsPickerOpen(false);
|
|
148
|
-
}}
|
|
149
|
-
>
|
|
150
|
-
{group.name}
|
|
151
|
-
</button>
|
|
152
|
-
))}
|
|
153
|
-
</div>
|
|
154
|
-
</>
|
|
155
|
-
)}
|
|
156
|
-
</div>
|
|
157
|
-
|
|
158
|
-
<Button
|
|
159
|
-
variant="ghost"
|
|
160
|
-
size="sm"
|
|
161
|
-
className="h-7 w-7 p-0"
|
|
162
|
-
onClick={() => onEdit(system)}
|
|
163
|
-
disabled={isLocked}
|
|
164
|
-
title={isLocked ? "Managed by GitOps" : undefined}
|
|
165
|
-
aria-label={`Edit ${system.name}`}
|
|
166
|
-
>
|
|
167
|
-
<Edit className="w-3.5 h-3.5" />
|
|
168
|
-
</Button>
|
|
169
|
-
|
|
170
|
-
<Button
|
|
171
|
-
variant="ghost"
|
|
172
|
-
size="sm"
|
|
173
|
-
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-7 w-7 p-0"
|
|
174
|
-
onClick={() => onDelete(system.id)}
|
|
175
|
-
disabled={isLocked}
|
|
176
|
-
title={isLocked ? "Managed by GitOps" : undefined}
|
|
177
|
-
aria-label={`Delete ${system.name}`}
|
|
178
|
-
>
|
|
179
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
180
|
-
</Button>
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
|
-
);
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* A non-interactive ghost card shown in the DragOverlay while dragging.
|
|
189
|
-
*/
|
|
190
|
-
export const SystemDragOverlay = ({ system }: { system: System }) => (
|
|
191
|
-
<div className="flex items-center gap-2 p-3 bg-card rounded-lg border border-primary/50 shadow-lg cursor-grabbing w-full">
|
|
192
|
-
<GripVertical className="w-4 h-4 text-muted-foreground/40 flex-shrink-0" />
|
|
193
|
-
<div className="flex-1 min-w-0">
|
|
194
|
-
<p className="font-medium text-foreground">{system.name}</p>
|
|
195
|
-
<p className="text-xs text-muted-foreground">
|
|
196
|
-
{system.description || "No description"}
|
|
197
|
-
</p>
|
|
198
|
-
</div>
|
|
199
|
-
</div>
|
|
200
|
-
);
|