@cosmicdrift/kumiko-renderer-web 0.130.1 → 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__/nav-tree.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/layout/nav-tree.tsx +114 -25
- 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
|
+
});
|
|
@@ -241,6 +241,30 @@ describe("NavTree", () => {
|
|
|
241
241
|
expect(container.querySelectorAll("svg").length).toBe(2);
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
test("server, mail, download, rocket lösen auf ein Icon auf (Config-/SMTP-Nav)", () => {
|
|
245
|
+
// Config-Settings-Hub leitet Child-Nav-Icons aus dem mask.icon der
|
|
246
|
+
// ConfigKey ab (smtp-host="server", from="mail", subscription="rocket");
|
|
247
|
+
// fehlten sie in NAV_ICONS, rendert das Nav blank statt Icon.
|
|
248
|
+
const schema = {
|
|
249
|
+
featureName: "app",
|
|
250
|
+
entities: {},
|
|
251
|
+
screens: [
|
|
252
|
+
{ id: "a", type: "entityList", entity: "x", columns: [] },
|
|
253
|
+
{ id: "b", type: "entityList", entity: "x", columns: [] },
|
|
254
|
+
{ id: "c", type: "entityList", entity: "x", columns: [] },
|
|
255
|
+
{ id: "d", type: "entityList", entity: "x", columns: [] },
|
|
256
|
+
],
|
|
257
|
+
navs: [
|
|
258
|
+
{ id: "a", label: "SMTP", screen: "a", order: 10, icon: "server" },
|
|
259
|
+
{ id: "b", label: "From", screen: "b", order: 20, icon: "mail" },
|
|
260
|
+
{ id: "c", label: "Export", screen: "c", order: 30, icon: "download" },
|
|
261
|
+
{ id: "d", label: "Billing", screen: "d", order: 40, icon: "rocket" },
|
|
262
|
+
],
|
|
263
|
+
} as FeatureSchema;
|
|
264
|
+
const { container } = render(<NavTree schema={schema} />);
|
|
265
|
+
expect(container.querySelectorAll("svg").length).toBe(4);
|
|
266
|
+
});
|
|
267
|
+
|
|
244
268
|
test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
|
|
245
269
|
const schema = {
|
|
246
270
|
featureName: "app",
|
|
@@ -542,3 +566,65 @@ describe("NavTree dynamic provider nodes", () => {
|
|
|
542
566
|
expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
|
|
543
567
|
});
|
|
544
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("NavTree Suchfeld", () => {
|
|
571
|
+
// Das Suchfeld ist das einzige <input> im Baum; über die Rolle statt den
|
|
572
|
+
// i18n-Placeholder gefunden (i18n-Bundle resolvt im Worktree gegen den
|
|
573
|
+
// Haupt-Checkout, wo der neue Key noch fehlt — in CI greift er).
|
|
574
|
+
|
|
575
|
+
test("Filter blendet Nicht-Treffer aus, hält Treffer + deren Ancestors", () => {
|
|
576
|
+
render(<NavTree schema={makeSchema()} />);
|
|
577
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
578
|
+
|
|
579
|
+
fireEvent.change(input, { target: { value: "active" } });
|
|
580
|
+
|
|
581
|
+
// Treffer selbst sichtbar (case-insensitiv).
|
|
582
|
+
expect(screen.getByText("Active")).toBeTruthy();
|
|
583
|
+
// Nicht-Treffer-Geschwister weg.
|
|
584
|
+
expect(screen.queryByText("Backlog")).toBeNull();
|
|
585
|
+
// Ancestors bleiben, damit der Treffer erreichbar bleibt.
|
|
586
|
+
expect(screen.getByText("Items")).toBeTruthy();
|
|
587
|
+
expect(screen.getByText("Data")).toBeTruthy();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("Filter ohne Treffer leert den Baum; Leeren stellt alles wieder her", () => {
|
|
591
|
+
render(<NavTree schema={makeSchema()} />);
|
|
592
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
593
|
+
|
|
594
|
+
fireEvent.change(input, { target: { value: "zzz-nichts" } });
|
|
595
|
+
expect(screen.queryByText("Items")).toBeNull();
|
|
596
|
+
expect(screen.queryByText("Data")).toBeNull();
|
|
597
|
+
|
|
598
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
599
|
+
expect(screen.getByText("Backlog")).toBeTruthy();
|
|
600
|
+
expect(screen.getByText("Data")).toBeTruthy();
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("NavTree folder-open Icon", () => {
|
|
605
|
+
test("Folder-Knoten zeigt folder-open wenn expanded, folder wenn collapsed", async () => {
|
|
606
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
607
|
+
emit([{ label: "page", icon: "folder", children: [pageLeaf("hero")] }]);
|
|
608
|
+
return () => {};
|
|
609
|
+
};
|
|
610
|
+
let container: HTMLElement | undefined;
|
|
611
|
+
await act(async () => {
|
|
612
|
+
container = renderDynamic({
|
|
613
|
+
schema: dynamicSchema(),
|
|
614
|
+
providers: new Map([["cms:nav:content", provider]]),
|
|
615
|
+
}).container;
|
|
616
|
+
});
|
|
617
|
+
// Default-expanded → offener Ordner, kein geschlossener.
|
|
618
|
+
expect(container?.querySelector(".lucide-folder-open")).toBeTruthy();
|
|
619
|
+
expect(container?.querySelector(".lucide-folder")).toBeNull();
|
|
620
|
+
|
|
621
|
+
// Der einzige aria-gelabelte Chevron gehört dem "page"-Sub-Ordner
|
|
622
|
+
// (der Provider-Container toggelt inline, ohne aria-Label).
|
|
623
|
+
const chevron = screen.getByRole("button", { name: /Expand|Collapse/ });
|
|
624
|
+
await act(async () => {
|
|
625
|
+
fireEvent.click(chevron);
|
|
626
|
+
});
|
|
627
|
+
expect(container?.querySelector(".lucide-folder")).toBeTruthy();
|
|
628
|
+
expect(container?.querySelector(".lucide-folder-open")).toBeNull();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
@@ -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";
|