@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.130.2",
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.130.2",
20
- "@cosmicdrift/kumiko-headless": "0.130.2",
21
- "@cosmicdrift/kumiko-renderer": "0.130.2",
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
+ });
@@ -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
- <CustomScreensProvider value={customScreens}>
289
- <ColumnRenderersProvider value={columnRenderers}>
290
- <ExtensionSectionsProvider value={extensionSectionComponents}>
291
- <NavProvidersProvider value={navProviders} entities={navEntities}>
292
- <ResolversProvider resolvers={resolvers}>
293
- <ToastProvider>
294
- <UpdateChecker />
295
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
296
- </ToastProvider>
297
- </ResolversProvider>
298
- </NavProvidersProvider>
299
- </ExtensionSectionsProvider>
300
- </ColumnRenderersProvider>
301
- </CustomScreensProvider>
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";
@@ -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
+ });