@cosmicdrift/kumiko-renderer-web 0.130.2 → 0.132.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/__tests__/toast.test.tsx +9 -9
- 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 +48 -3
- package/src/primitives/toast.tsx +14 -9
- 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.132.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.132.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.132.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.132.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
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
//
|
|
2
2
|
// ToastProvider + useToast pinnt: toast() rendert Title+Description in
|
|
3
|
-
// einem Radix-Toast; mehrere toasts stapeln; Variant=
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// einem Radix-Toast; mehrere toasts stapeln; Variant=bad setzt die
|
|
4
|
+
// StatusTone-Klasse; useToast außerhalb des Providers ist no-op (kein
|
|
5
|
+
// crash); IDs sind kollisionsfrei auch bei zwei Calls im selben Tick
|
|
6
|
+
// (Counter-Race-Bug).
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import { act, fireEvent, screen } from "@testing-library/react";
|
|
@@ -49,7 +49,7 @@ describe("ToastProvider + useToast", () => {
|
|
|
49
49
|
options={[
|
|
50
50
|
{
|
|
51
51
|
title: "Konflikt",
|
|
52
|
-
variant: "
|
|
52
|
+
variant: "bad",
|
|
53
53
|
docsUrl: "https://docs.kumiko.rocks/errors/stale_state",
|
|
54
54
|
},
|
|
55
55
|
]}
|
|
@@ -88,18 +88,18 @@ describe("ToastProvider + useToast", () => {
|
|
|
88
88
|
expect(screen.queryByRole("link")).toBeNull();
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("variant=
|
|
91
|
+
test("variant=bad: setzt die status-bad StatusTone-Klasse auf den Root", () => {
|
|
92
92
|
render(
|
|
93
93
|
<ToastProvider>
|
|
94
|
-
<ToastTrigger options={[{ title: "Failed", variant: "
|
|
94
|
+
<ToastTrigger options={[{ title: "Failed", variant: "bad" }]} />
|
|
95
95
|
</ToastProvider>,
|
|
96
96
|
);
|
|
97
97
|
// Class-Mapping ist der Public-Vertrag mit Tailwind-Tokens. Wir
|
|
98
98
|
// suchen den nearest Ancestor des Title-Knotens dessen Klassen-
|
|
99
|
-
// String "
|
|
99
|
+
// String "status-bad" enthält — Radix-Toast.Root rendert in einem
|
|
100
100
|
// <li>, aber die genaue Role wechselt je nach priority/type.
|
|
101
101
|
let node: HTMLElement | null = screen.getByText("Failed");
|
|
102
|
-
while (node !== null && !node.className.includes("
|
|
102
|
+
while (node !== null && !node.className.includes("status-bad")) {
|
|
103
103
|
node = node.parentElement;
|
|
104
104
|
}
|
|
105
105
|
expect(node).not.toBeNull();
|
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>
|
|
@@ -773,7 +778,7 @@ function useRowActionTrigger(row: ListRowViewModel) {
|
|
|
773
778
|
toast({
|
|
774
779
|
title: t("kumiko.rowAction.failed"),
|
|
775
780
|
description: e instanceof Error ? e.message : String(e),
|
|
776
|
-
variant: "
|
|
781
|
+
variant: "bad",
|
|
777
782
|
...(docsUrl !== undefined && { docsUrl }),
|
|
778
783
|
});
|
|
779
784
|
} finally {
|
|
@@ -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/primitives/toast.tsx
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// kann. Kein Context-Boilerplate für Konsumenten — der Hook returned
|
|
4
4
|
// einen `toast()`-Trigger, mehr Public-API gibt's nicht.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// Auto-dismiss nach 5s, manuell schließbar via X-Button.
|
|
8
|
-
// Live-Region-Setup kommt komplett aus Radix.
|
|
6
|
+
// Variant = StatusTone (dieselbe Farb-Familie wie StatusBadge), Default
|
|
7
|
+
// "muted" (neutral). Auto-dismiss nach 5s, manuell schließbar via X-Button.
|
|
8
|
+
// Der ARIA-Live-Region-Setup kommt komplett aus Radix.
|
|
9
9
|
|
|
10
10
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
11
|
import * as Primitive from "@radix-ui/react-toast";
|
|
@@ -21,8 +21,9 @@ import {
|
|
|
21
21
|
useState,
|
|
22
22
|
} from "react";
|
|
23
23
|
import { cn } from "../lib/cn";
|
|
24
|
+
import type { StatusTone } from "../widgets/status-badge";
|
|
24
25
|
|
|
25
|
-
export type ToastVariant =
|
|
26
|
+
export type ToastVariant = StatusTone;
|
|
26
27
|
|
|
27
28
|
export type ToastOptions = {
|
|
28
29
|
readonly title: string;
|
|
@@ -111,6 +112,14 @@ const rootClass =
|
|
|
111
112
|
"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full " +
|
|
112
113
|
"data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full";
|
|
113
114
|
|
|
115
|
+
const TONE_CLASS: Record<StatusTone, string> = {
|
|
116
|
+
ok: "border-status-ok/30 bg-status-ok/10 text-status-ok",
|
|
117
|
+
warn: "border-status-warn/30 bg-status-warn/10 text-status-warn",
|
|
118
|
+
bad: "border-status-bad/30 bg-status-bad/10 text-status-bad",
|
|
119
|
+
critical: "border-status-critical/40 bg-status-critical/15 text-status-critical",
|
|
120
|
+
muted: "border bg-background text-foreground",
|
|
121
|
+
};
|
|
122
|
+
|
|
114
123
|
function ToastItem({
|
|
115
124
|
entry,
|
|
116
125
|
onClose,
|
|
@@ -120,10 +129,7 @@ function ToastItem({
|
|
|
120
129
|
}): ReactNode {
|
|
121
130
|
const t = useTranslation();
|
|
122
131
|
const learnMore = entry.docsLinkLabel ?? t("kumiko.toast.learn-more");
|
|
123
|
-
const variantClass =
|
|
124
|
-
entry.variant === "destructive"
|
|
125
|
-
? "destructive group border-destructive bg-destructive text-destructive-foreground"
|
|
126
|
-
: "border bg-background text-foreground";
|
|
132
|
+
const variantClass = TONE_CLASS[entry.variant ?? "muted"];
|
|
127
133
|
return (
|
|
128
134
|
<Primitive.Root
|
|
129
135
|
className={cn(rootClass, variantClass)}
|
|
@@ -159,7 +165,6 @@ function ToastItem({
|
|
|
159
165
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity",
|
|
160
166
|
"hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1",
|
|
161
167
|
"group-hover:opacity-100",
|
|
162
|
-
"group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50",
|
|
163
168
|
)}
|
|
164
169
|
aria-label={t("kumiko.dialog.close")}
|
|
165
170
|
>
|
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
|
* {
|