@cosmicdrift/kumiko-renderer-web 0.130.2 → 0.131.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/package.json +4 -4
- package/src/__tests__/dashboard-screen.test.tsx +86 -0
- package/src/__tests__/projection-list-actions.test.tsx +89 -0
- package/src/app/create-app.tsx +18 -14
- package/src/app/dashboard-body.tsx +146 -0
- package/src/index.ts +28 -0
- package/src/primitives/index.tsx +47 -2
- package/src/styles.css +14 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/widgets.test.tsx +229 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +25 -0
- package/src/widgets/index.ts +21 -0
- package/src/widgets/mode-switch.tsx +40 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.131.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.131.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.131.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.131.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import {
|
|
6
|
+
DashboardBodyProvider,
|
|
7
|
+
DispatcherProvider,
|
|
8
|
+
KumikoScreen,
|
|
9
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
import { WebDashboardBody } from "../app/dashboard-body";
|
|
11
|
+
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
12
|
+
|
|
13
|
+
const dashboardScreen: DashboardScreenDefinition = {
|
|
14
|
+
id: "overview",
|
|
15
|
+
type: "dashboard",
|
|
16
|
+
panels: [
|
|
17
|
+
{
|
|
18
|
+
kind: "stat",
|
|
19
|
+
id: "uptime",
|
|
20
|
+
label: "status:dashboard:uptime",
|
|
21
|
+
query: "status:query:monitor:uptime-stat",
|
|
22
|
+
valueField: "value",
|
|
23
|
+
subField: "sub",
|
|
24
|
+
toneField: "tone",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: "list",
|
|
28
|
+
id: "latest",
|
|
29
|
+
label: "status:dashboard:latest",
|
|
30
|
+
query: "status:query:incident:latest",
|
|
31
|
+
columns: [{ field: "name", label: "status:dashboard:col-name" }],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const schema: FeatureSchema = {
|
|
37
|
+
featureName: "status",
|
|
38
|
+
entities: {},
|
|
39
|
+
screens: [dashboardScreen],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function makeDispatcher(): Dispatcher {
|
|
43
|
+
return createMockDispatcher({
|
|
44
|
+
query: (async (type: string) => {
|
|
45
|
+
if (type === "status:query:monitor:uptime-stat") {
|
|
46
|
+
return {
|
|
47
|
+
isSuccess: true,
|
|
48
|
+
data: { value: "99,98 %", sub: "letzte 90 Tage", tone: "positive" },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
isSuccess: true,
|
|
53
|
+
data: { rows: [{ id: "i1", name: "API-Ausfall" }], nextCursor: null },
|
|
54
|
+
};
|
|
55
|
+
}) as unknown as Dispatcher["query"],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("KumikoScreen dashboard", () => {
|
|
60
|
+
test("ohne registrierten Body → Placeholder-Banner", () => {
|
|
61
|
+
render(
|
|
62
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
63
|
+
<KumikoScreen schema={schema} qn="status:screen:overview" />
|
|
64
|
+
</DispatcherProvider>,
|
|
65
|
+
);
|
|
66
|
+
expect(screen.getByTestId("kumiko-screen-dashboard-placeholder")).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("WebDashboardBody rendert Stat- und List-Panels aus den Queries", async () => {
|
|
70
|
+
render(
|
|
71
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
72
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
73
|
+
<KumikoScreen schema={schema} qn="status:screen:overview" />
|
|
74
|
+
</DashboardBodyProvider>
|
|
75
|
+
</DispatcherProvider>,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
await waitFor(() => expect(screen.getByText("99,98 %")).toBeTruthy());
|
|
79
|
+
expect(screen.getByTestId("dashboard-overview")).toBeTruthy();
|
|
80
|
+
// Stat-Panel: Label (Key-Fallback), Wert, Sub-Zeile aus dem Query-Record.
|
|
81
|
+
expect(screen.getByText("status:dashboard:uptime")).toBeTruthy();
|
|
82
|
+
expect(screen.getByText("letzte 90 Tage")).toBeTruthy();
|
|
83
|
+
// List-Panel: Row aus der paged envelope.
|
|
84
|
+
await waitFor(() => expect(screen.getByText("API-Ausfall")).toBeTruthy());
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
7
|
+
|
|
8
|
+
// writeHandler-Row/Toolbar-Actions auf projectionList — der entityList-
|
|
9
|
+
// Dispatch-Pfad gilt jetzt auch hier (vorher v1: nur navigate).
|
|
10
|
+
|
|
11
|
+
const projectionScreen: ProjectionListScreenDefinition = {
|
|
12
|
+
id: "maintenance-list",
|
|
13
|
+
type: "projectionList",
|
|
14
|
+
query: "status:query:maintenance:upcoming",
|
|
15
|
+
columns: [{ field: "name", label: "status:col:name" }],
|
|
16
|
+
rowActions: [
|
|
17
|
+
{
|
|
18
|
+
kind: "writeHandler",
|
|
19
|
+
id: "start",
|
|
20
|
+
label: "status:action:start",
|
|
21
|
+
handler: "status:write:maintenance:start",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
toolbarActions: [
|
|
25
|
+
{
|
|
26
|
+
kind: "writeHandler",
|
|
27
|
+
id: "sync",
|
|
28
|
+
label: "status:action:sync",
|
|
29
|
+
handler: "status:write:maintenance:sync",
|
|
30
|
+
payload: { source: "manual" },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const schema: FeatureSchema = {
|
|
36
|
+
featureName: "status",
|
|
37
|
+
entities: {},
|
|
38
|
+
screens: [projectionScreen],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
|
|
42
|
+
return {
|
|
43
|
+
...createMockDispatcher({
|
|
44
|
+
query: (async () => ({
|
|
45
|
+
isSuccess: true,
|
|
46
|
+
data: { rows: [{ id: "m1", name: "DB-Upgrade" }], nextCursor: null },
|
|
47
|
+
})) as unknown as Dispatcher["query"],
|
|
48
|
+
}),
|
|
49
|
+
write,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("projectionList writeHandler-Actions", () => {
|
|
54
|
+
test("Row-Action dispatcht den Handler mit Default-Payload {id}", async () => {
|
|
55
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
56
|
+
isSuccess: true,
|
|
57
|
+
data: {},
|
|
58
|
+
}));
|
|
59
|
+
render(
|
|
60
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
61
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
62
|
+
</DispatcherProvider>,
|
|
63
|
+
);
|
|
64
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
65
|
+
|
|
66
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
|
|
67
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
68
|
+
expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:start");
|
|
69
|
+
expect(write.mock.calls[0]?.[1]).toEqual({ id: "m1" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Toolbar-Action dispatcht den Handler mit deklariertem Payload", async () => {
|
|
73
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
74
|
+
isSuccess: true,
|
|
75
|
+
data: {},
|
|
76
|
+
}));
|
|
77
|
+
render(
|
|
78
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
79
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
80
|
+
</DispatcherProvider>,
|
|
81
|
+
);
|
|
82
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
83
|
+
|
|
84
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
|
|
85
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
86
|
+
expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:sync");
|
|
87
|
+
expect(write.mock.calls[0]?.[1]).toEqual({ source: "manual" });
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/app/create-app.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ColumnRendererComponent,
|
|
12
12
|
ColumnRenderersProvider,
|
|
13
13
|
CustomScreensProvider,
|
|
14
|
+
DashboardBodyProvider,
|
|
14
15
|
DispatcherProvider,
|
|
15
16
|
type ExtensionSectionComponent,
|
|
16
17
|
ExtensionSectionsProvider,
|
|
@@ -38,6 +39,7 @@ import { useBrowserTokensApi } from "../tokens";
|
|
|
38
39
|
import { UpdateChecker } from "../version/update-checker";
|
|
39
40
|
import { createBrowserLocaleResolver } from "./browser-locale";
|
|
40
41
|
import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
|
|
42
|
+
import { WebDashboardBody } from "./dashboard-body";
|
|
41
43
|
import { useBrowserNavApi } from "./nav";
|
|
42
44
|
import { NavProvidersProvider } from "./nav-providers-context";
|
|
43
45
|
import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
|
|
@@ -285,20 +287,22 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
285
287
|
<PrimitivesProvider value={primitives}>
|
|
286
288
|
<DispatcherProvider dispatcher={dispatcher}>
|
|
287
289
|
<LiveEventsProvider value={liveEvents}>
|
|
288
|
-
<
|
|
289
|
-
<
|
|
290
|
-
<
|
|
291
|
-
<
|
|
292
|
-
<
|
|
293
|
-
<
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
290
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
291
|
+
<CustomScreensProvider value={customScreens}>
|
|
292
|
+
<ColumnRenderersProvider value={columnRenderers}>
|
|
293
|
+
<ExtensionSectionsProvider value={extensionSectionComponents}>
|
|
294
|
+
<NavProvidersProvider value={navProviders} entities={navEntities}>
|
|
295
|
+
<ResolversProvider resolvers={resolvers}>
|
|
296
|
+
<ToastProvider>
|
|
297
|
+
<UpdateChecker />
|
|
298
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
299
|
+
</ToastProvider>
|
|
300
|
+
</ResolversProvider>
|
|
301
|
+
</NavProvidersProvider>
|
|
302
|
+
</ExtensionSectionsProvider>
|
|
303
|
+
</ColumnRenderersProvider>
|
|
304
|
+
</CustomScreensProvider>
|
|
305
|
+
</DashboardBodyProvider>
|
|
302
306
|
</LiveEventsProvider>
|
|
303
307
|
</DispatcherProvider>
|
|
304
308
|
</PrimitivesProvider>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
|
|
2
|
+
// Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable) in
|
|
3
|
+
// einem responsiven Grid. Registriert via DashboardBodyProvider in
|
|
4
|
+
// createKumikoApp — der KumikoScreen-Switch bleibt plattform-agnostisch.
|
|
5
|
+
//
|
|
6
|
+
// Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
|
|
7
|
+
// stat → flaches Record, valueField/subField/toneField zeigen auf
|
|
8
|
+
// anzeigefertige Werte (der Query-Handler formatiert).
|
|
9
|
+
// chart → { points: { atMs, value | null }[], windowStartMs, windowEndMs }
|
|
10
|
+
// list → paged envelope { rows, nextCursor, total? } wie projectionList.
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
DashboardChartPanel,
|
|
14
|
+
DashboardListPanel,
|
|
15
|
+
DashboardStatPanel,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
|
+
import { type DashboardBodyProps, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
|
|
21
|
+
import { QueryTable } from "../widgets/query-table";
|
|
22
|
+
import { SectionCard } from "../widgets/section-card";
|
|
23
|
+
import { StatCard, type StatTone } from "../widgets/stat";
|
|
24
|
+
import { ErrorState, LoadingState } from "../widgets/states";
|
|
25
|
+
|
|
26
|
+
const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
|
|
27
|
+
|
|
28
|
+
function StatPanelBody({
|
|
29
|
+
panel,
|
|
30
|
+
label,
|
|
31
|
+
}: {
|
|
32
|
+
readonly panel: DashboardStatPanel;
|
|
33
|
+
readonly label: string;
|
|
34
|
+
}): ReactNode {
|
|
35
|
+
const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
|
|
36
|
+
panel.query,
|
|
37
|
+
{},
|
|
38
|
+
{ live: true },
|
|
39
|
+
);
|
|
40
|
+
if (loading && data === null) return <LoadingState rows={2} />;
|
|
41
|
+
if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
|
|
42
|
+
const record = data ?? {};
|
|
43
|
+
const rawTone = panel.toneField !== undefined ? record[panel.toneField] : undefined;
|
|
44
|
+
const tone =
|
|
45
|
+
typeof rawTone === "string" && STAT_TONES.has(rawTone) ? (rawTone as StatTone) : "default";
|
|
46
|
+
const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
|
|
47
|
+
return (
|
|
48
|
+
<StatCard
|
|
49
|
+
label={label}
|
|
50
|
+
value={String(record[panel.valueField] ?? "—")}
|
|
51
|
+
tone={tone}
|
|
52
|
+
{...(sub !== undefined && sub !== null && { sub: String(sub) })}
|
|
53
|
+
testId={`dashboard-panel-${panel.id}`}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type TimeseriesEnvelope = {
|
|
59
|
+
readonly points: readonly TimeseriesPoint[];
|
|
60
|
+
readonly windowStartMs: number;
|
|
61
|
+
readonly windowEndMs: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function ChartPanelBody({
|
|
65
|
+
panel,
|
|
66
|
+
label,
|
|
67
|
+
}: {
|
|
68
|
+
readonly panel: DashboardChartPanel;
|
|
69
|
+
readonly label: string;
|
|
70
|
+
}): ReactNode {
|
|
71
|
+
const t = useTranslation();
|
|
72
|
+
const { data, error, loading, refetch } = useQuery<TimeseriesEnvelope>(
|
|
73
|
+
panel.query,
|
|
74
|
+
{},
|
|
75
|
+
{ live: true },
|
|
76
|
+
);
|
|
77
|
+
if (loading && data === null) return <LoadingState rows={3} />;
|
|
78
|
+
if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
|
|
79
|
+
return (
|
|
80
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
81
|
+
<TimeseriesChart
|
|
82
|
+
points={data?.points ?? []}
|
|
83
|
+
windowStartMs={data?.windowStartMs ?? 0}
|
|
84
|
+
windowEndMs={data?.windowEndMs ?? 1}
|
|
85
|
+
ariaLabel={label}
|
|
86
|
+
emptyContent={t("kumiko.list.no-entries")}
|
|
87
|
+
/>
|
|
88
|
+
</SectionCard>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ListPanelBody({
|
|
93
|
+
panel,
|
|
94
|
+
label,
|
|
95
|
+
}: {
|
|
96
|
+
readonly panel: DashboardListPanel;
|
|
97
|
+
readonly label: string;
|
|
98
|
+
}): ReactNode {
|
|
99
|
+
const t = useTranslation();
|
|
100
|
+
return (
|
|
101
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
102
|
+
<QueryTable<{ readonly rows: readonly Readonly<Record<string, unknown>>[] }>
|
|
103
|
+
query={panel.query}
|
|
104
|
+
live
|
|
105
|
+
columns={panel.columns.map((c) => {
|
|
106
|
+
const normalized = normalizeListColumn(c);
|
|
107
|
+
return {
|
|
108
|
+
field: normalized.field,
|
|
109
|
+
label: t(normalized.label ?? normalized.field),
|
|
110
|
+
};
|
|
111
|
+
})}
|
|
112
|
+
rows={(data) => data.rows}
|
|
113
|
+
/>
|
|
114
|
+
</SectionCard>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
|
|
119
|
+
const t = useTranslation();
|
|
120
|
+
const effectiveTranslate = translate ?? t;
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className="grid gap-4 p-6 sm:grid-cols-2 lg:grid-cols-4"
|
|
124
|
+
data-testid={`dashboard-${screen.id}`}
|
|
125
|
+
>
|
|
126
|
+
{screen.panels.map((panel) => {
|
|
127
|
+
const label = effectiveTranslate(panel.label);
|
|
128
|
+
if (panel.kind === "stat") {
|
|
129
|
+
return <StatPanelBody key={panel.id} panel={panel} label={label} />;
|
|
130
|
+
}
|
|
131
|
+
if (panel.kind === "chart") {
|
|
132
|
+
return (
|
|
133
|
+
<div key={panel.id} className="sm:col-span-2 lg:col-span-4">
|
|
134
|
+
<ChartPanelBody panel={panel} label={label} />
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return (
|
|
139
|
+
<div key={panel.id} className="sm:col-span-2 lg:col-span-4">
|
|
140
|
+
<ListPanelBody panel={panel} label={label} />
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -154,3 +154,31 @@ export {
|
|
|
154
154
|
useBrowserTokensApi,
|
|
155
155
|
} from "./tokens";
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
|
+
export type {
|
|
158
|
+
QueryTableColumn,
|
|
159
|
+
QueryTableProps,
|
|
160
|
+
StatDelta,
|
|
161
|
+
StatTone,
|
|
162
|
+
StatusBarEntry,
|
|
163
|
+
StatusTone,
|
|
164
|
+
TimeseriesPoint,
|
|
165
|
+
} from "./widgets";
|
|
166
|
+
export {
|
|
167
|
+
CollapsibleSection,
|
|
168
|
+
DetailList,
|
|
169
|
+
EmptyState,
|
|
170
|
+
ErrorState,
|
|
171
|
+
LoadingState,
|
|
172
|
+
MiniStat,
|
|
173
|
+
ModeSwitch,
|
|
174
|
+
ProgressBar,
|
|
175
|
+
QueryTable,
|
|
176
|
+
SectionCard,
|
|
177
|
+
Sparkline,
|
|
178
|
+
STATUS_TONE_TEXT,
|
|
179
|
+
StatCard,
|
|
180
|
+
StatusBadge,
|
|
181
|
+
StatusBarChart,
|
|
182
|
+
smoothPath,
|
|
183
|
+
TimeseriesChart,
|
|
184
|
+
} from "./widgets";
|
package/src/primitives/index.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type GridProps,
|
|
30
30
|
type HeadingProps,
|
|
31
31
|
type InputProps,
|
|
32
|
+
type LinkProps,
|
|
32
33
|
type SectionProps,
|
|
33
34
|
type TextProps,
|
|
34
35
|
useColumnRenderer,
|
|
@@ -59,7 +60,7 @@ import {
|
|
|
59
60
|
} from "react";
|
|
60
61
|
import { cn } from "../lib/cn";
|
|
61
62
|
import { Badge } from "../ui/badge";
|
|
62
|
-
import { Button as UiButton } from "../ui/button";
|
|
63
|
+
import { buttonVariants, Button as UiButton } from "../ui/button";
|
|
63
64
|
import { Checkbox } from "../ui/checkbox";
|
|
64
65
|
import { Input as UiInput } from "../ui/input";
|
|
65
66
|
import { Label as UiLabel } from "../ui/label";
|
|
@@ -102,11 +103,12 @@ const cardFooterBorder = "border-t bg-muted/30";
|
|
|
102
103
|
|
|
103
104
|
// Contract-Variant → shadcn-Variant: secondary war schon immer der
|
|
104
105
|
// bordered-bg-background-Look = shadcns `outline`. primary→default,
|
|
105
|
-
// danger→destructive.
|
|
106
|
+
// danger→destructive, link→link (kein BG, underline on hover).
|
|
106
107
|
const BUTTON_VARIANT = {
|
|
107
108
|
primary: "default",
|
|
108
109
|
secondary: "outline",
|
|
109
110
|
danger: "destructive",
|
|
111
|
+
link: "link",
|
|
110
112
|
} as const;
|
|
111
113
|
|
|
112
114
|
function DefaultButton({
|
|
@@ -126,6 +128,9 @@ function DefaultButton({
|
|
|
126
128
|
data-testid={testId}
|
|
127
129
|
data-loading={loading === true ? "true" : undefined}
|
|
128
130
|
variant={BUTTON_VARIANT[variant]}
|
|
131
|
+
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner),
|
|
132
|
+
// nicht als gepolsterte Buttonfläche.
|
|
133
|
+
className={variant === "link" ? "h-auto px-0 py-0" : undefined}
|
|
129
134
|
>
|
|
130
135
|
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
131
136
|
</UiButton>
|
|
@@ -1590,11 +1595,50 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
|
|
|
1590
1595
|
{children}
|
|
1591
1596
|
</span>
|
|
1592
1597
|
);
|
|
1598
|
+
case "muted":
|
|
1599
|
+
return (
|
|
1600
|
+
<span data-testid={testId} className="text-sm text-muted-foreground">
|
|
1601
|
+
{children}
|
|
1602
|
+
</span>
|
|
1603
|
+
);
|
|
1593
1604
|
default:
|
|
1594
1605
|
return <span data-testid={testId}>{children}</span>;
|
|
1595
1606
|
}
|
|
1596
1607
|
}
|
|
1597
1608
|
|
|
1609
|
+
// ---- Link (anchor mit Button-/Muted-Optik) ----
|
|
1610
|
+
|
|
1611
|
+
// `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
|
|
1612
|
+
// der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
|
|
1613
|
+
// authButtonClass), `muted` der dezente Sekundär-Link (ehem.
|
|
1614
|
+
// authMutedLinkClass).
|
|
1615
|
+
function DefaultLink({
|
|
1616
|
+
href,
|
|
1617
|
+
variant = "default",
|
|
1618
|
+
target,
|
|
1619
|
+
className,
|
|
1620
|
+
children,
|
|
1621
|
+
testId,
|
|
1622
|
+
}: LinkProps): ReactNode {
|
|
1623
|
+
const variantClass =
|
|
1624
|
+
variant === "button"
|
|
1625
|
+
? buttonVariants({ variant: "default" })
|
|
1626
|
+
: variant === "muted"
|
|
1627
|
+
? "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
1628
|
+
: "text-primary underline-offset-4 hover:underline";
|
|
1629
|
+
return (
|
|
1630
|
+
<a
|
|
1631
|
+
href={href}
|
|
1632
|
+
target={target}
|
|
1633
|
+
rel={target === "_blank" ? "noreferrer" : undefined}
|
|
1634
|
+
data-testid={testId}
|
|
1635
|
+
className={cn(variantClass, className)}
|
|
1636
|
+
>
|
|
1637
|
+
{children}
|
|
1638
|
+
</a>
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1598
1642
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1599
1643
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1600
1644
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -1685,4 +1729,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1685
1729
|
Lightbox: DefaultLightbox,
|
|
1686
1730
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1687
1731
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1732
|
+
Link: DefaultLink,
|
|
1688
1733
|
};
|
package/src/styles.css
CHANGED
|
@@ -80,6 +80,14 @@
|
|
|
80
80
|
--radius-md: calc(var(--radius) - 2px);
|
|
81
81
|
--radius-lg: var(--radius);
|
|
82
82
|
--radius-xl: calc(var(--radius) + 4px);
|
|
83
|
+
|
|
84
|
+
/* Semantische Status-Farben (Widgets: StatusBadge, Charts, StatCard-Tones).
|
|
85
|
+
@theme generiert die Utilities (text-status-ok, bg-status-warn/10, …).
|
|
86
|
+
Dark-Werte; Light-Overrides unten. Apps überschreiben in ihrer styles.css. */
|
|
87
|
+
--color-status-ok: #22c55e;
|
|
88
|
+
--color-status-warn: #f59e0b;
|
|
89
|
+
--color-status-bad: #ef4444;
|
|
90
|
+
--color-status-critical: #f87171;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
/* Card-Chrome-Maße — Framework-Defaults. Eine App überschreibt selektiv in
|
|
@@ -132,6 +140,12 @@
|
|
|
132
140
|
--color-sidebar-accent-foreground: hsl(0 0% 9%);
|
|
133
141
|
--color-sidebar-border: hsl(0 0% 89.8%);
|
|
134
142
|
--color-sidebar-ring: hsl(0 0% 63%);
|
|
143
|
+
|
|
144
|
+
/* Status-Farben Light — kräftiger/dunkler als die Dark-Werte. */
|
|
145
|
+
--color-status-ok: #16a34a;
|
|
146
|
+
--color-status-warn: #d97706;
|
|
147
|
+
--color-status-bad: #dc2626;
|
|
148
|
+
--color-status-critical: #991b1b;
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
* {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createMockDispatcher,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from "../../__tests__/test-utils";
|
|
12
|
+
import { QueryTable } from "../query-table";
|
|
13
|
+
|
|
14
|
+
function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
15
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COLUMNS = [
|
|
19
|
+
{ field: "name", label: "Name" },
|
|
20
|
+
{ field: "plan", label: "Plan" },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
describe("QueryTable", () => {
|
|
24
|
+
test("rendert Query-Result als DataTable-Rows", async () => {
|
|
25
|
+
const dispatcher = createMockDispatcher({
|
|
26
|
+
query: (async () => ({
|
|
27
|
+
isSuccess: true,
|
|
28
|
+
data: [
|
|
29
|
+
{ id: "t1", name: "Acme", plan: "pro" },
|
|
30
|
+
{ id: "t2", name: "Beta GmbH", plan: "free" },
|
|
31
|
+
],
|
|
32
|
+
})) as unknown as Dispatcher["query"],
|
|
33
|
+
});
|
|
34
|
+
renderWithDispatcher(
|
|
35
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
36
|
+
dispatcher,
|
|
37
|
+
);
|
|
38
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
39
|
+
expect(screen.getByText("Beta GmbH")).toBeTruthy();
|
|
40
|
+
expect(screen.getByText("Name")).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Row-Click liefert die geklickte Row", async () => {
|
|
44
|
+
const onRowClick = mock((_row: { id: string }) => {});
|
|
45
|
+
const dispatcher = createMockDispatcher({
|
|
46
|
+
query: (async () => ({
|
|
47
|
+
isSuccess: true,
|
|
48
|
+
data: [{ id: "t1", name: "Acme", plan: "pro" }],
|
|
49
|
+
})) as unknown as Dispatcher["query"],
|
|
50
|
+
});
|
|
51
|
+
renderWithDispatcher(
|
|
52
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} onRowClick={onRowClick} />,
|
|
53
|
+
dispatcher,
|
|
54
|
+
);
|
|
55
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
56
|
+
fireEvent.click(screen.getByText("Acme"));
|
|
57
|
+
expect(onRowClick).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(onRowClick.mock.calls[0]?.[0]?.id).toBe("t1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Fehler → ErrorState, Retry refetcht", async () => {
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const dispatcher = createMockDispatcher({
|
|
64
|
+
query: (async () => {
|
|
65
|
+
calls += 1;
|
|
66
|
+
if (calls === 1) {
|
|
67
|
+
return {
|
|
68
|
+
isSuccess: false,
|
|
69
|
+
error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { isSuccess: true, data: [{ id: "t1", name: "Acme", plan: "pro" }] };
|
|
73
|
+
}) as unknown as Dispatcher["query"],
|
|
74
|
+
});
|
|
75
|
+
renderWithDispatcher(
|
|
76
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
77
|
+
dispatcher,
|
|
78
|
+
);
|
|
79
|
+
await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
|
|
80
|
+
fireEvent.click(screen.getByRole("button"));
|
|
81
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
82
|
+
expect(calls).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("leeres Result rendert Empty-State", async () => {
|
|
86
|
+
const dispatcher = createMockDispatcher({
|
|
87
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
88
|
+
});
|
|
89
|
+
const { container } = renderWithDispatcher(
|
|
90
|
+
<QueryTable
|
|
91
|
+
query="tenant:query:tenant:list"
|
|
92
|
+
columns={COLUMNS}
|
|
93
|
+
emptyState={<span>Keine Tenants</span>}
|
|
94
|
+
/>,
|
|
95
|
+
dispatcher,
|
|
96
|
+
);
|
|
97
|
+
await waitFor(() => expect(screen.getByText("Keine Tenants")).toBeTruthy());
|
|
98
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rows-Selector zieht Rows aus verschachteltem Result", async () => {
|
|
102
|
+
const dispatcher = createMockDispatcher({
|
|
103
|
+
query: (async () => ({
|
|
104
|
+
isSuccess: true,
|
|
105
|
+
data: { items: [{ id: "x", name: "Nested", plan: "pro" }], total: 1 },
|
|
106
|
+
})) as unknown as Dispatcher["query"],
|
|
107
|
+
});
|
|
108
|
+
renderWithDispatcher(
|
|
109
|
+
<QueryTable<{ items: readonly Record<string, unknown>[]; total: number }>
|
|
110
|
+
query="tenant:query:tenant:list"
|
|
111
|
+
columns={COLUMNS}
|
|
112
|
+
rows={(data) => data.items}
|
|
113
|
+
/>,
|
|
114
|
+
dispatcher,
|
|
115
|
+
);
|
|
116
|
+
await waitFor(() => expect(screen.getByText("Nested")).toBeTruthy());
|
|
117
|
+
});
|
|
118
|
+
});
|