@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -0,0 +1,454 @@
1
+ // Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
2
+ // Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable, FeedList,
3
+ // ProgressList) in einem responsiven Grid. Registriert via
4
+ // DashboardBodyProvider in createKumikoApp — der KumikoScreen-Switch bleibt
5
+ // plattform-agnostisch.
6
+ //
7
+ // Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
8
+ // stat → flaches Record, valueField/subField/toneField zeigen auf
9
+ // anzeigefertige Werte (der Query-Handler formatiert).
10
+ // deltaField/deltaDirectionField(+deltaToneField) sind
11
+ // optional — nur wenn BEIDE Felder gesetzt sind UND geliefert
12
+ // werden, zeigt die Kachel einen Delta-Chip ("↓23 %").
13
+ // icon/accentColor sind statisch am Panel (keine Query-
14
+ // Felder) — icon über extensionSectionComponents wie bei
15
+ // custom-Panels, accentColor ein roher CSS-Farbwert.
16
+ // stat-group → mehrere stat-Panels unter einem Sektions-Titel, jedes
17
+ // Kind bleibt eine eigenständige Query.
18
+ // chart → { points: { atMs, value | null }[], windowStartMs,
19
+ // windowEndMs }
20
+ // list → paged envelope { rows, nextCursor, total? } wie
21
+ // projectionList.
22
+ // feed → { rows: { id, primary, trailing? }[] }
23
+ // progress-list → { rows: { id, label, value, fraction }[] }
24
+ // custom → keine Query — eine über extensionSectionComponents
25
+ // registrierte App-Komponente holt sich ihre Daten selbst.
26
+ //
27
+ // Screen-Filter (DashboardFilterDefinition): der gewählte Wert wird unter
28
+ // `filter.id` in JEDE Panel-Query gemerged. useQuery refetcht automatisch
29
+ // über seinen bestehenden payloadKey-Mechanismus — kein Sonderfall nötig.
30
+
31
+ import type {
32
+ DashboardChartPanel,
33
+ DashboardCustomPanel,
34
+ DashboardFeedPanel,
35
+ DashboardListPanel,
36
+ DashboardPanelDefinition,
37
+ DashboardProgressListPanel,
38
+ DashboardScreenDefinition,
39
+ DashboardStatGroupPanel,
40
+ DashboardStatPanel,
41
+ } from "@cosmicdrift/kumiko-framework/ui-types";
42
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
43
+ import {
44
+ type DashboardBodyProps,
45
+ extensionSectionName,
46
+ useExtensionSectionComponent,
47
+ usePrimitives,
48
+ useQuery,
49
+ useTranslation,
50
+ } from "@cosmicdrift/kumiko-renderer";
51
+ import { type ReactNode, useEffect, useState } from "react";
52
+ import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
53
+ import { FeedList, type FeedRow } from "../widgets/feed-list";
54
+ import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
55
+ import { QueryTable } from "../widgets/query-table";
56
+ import { SectionCard } from "../widgets/section-card";
57
+ import { StatCard, type StatDelta, type StatTone } from "../widgets/stat";
58
+ import { ErrorState, LoadingState } from "../widgets/states";
59
+
60
+ const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
61
+ const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
62
+ const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
63
+
64
+ function StatPanelBody({
65
+ panel,
66
+ label,
67
+ screenId,
68
+ filterParams,
69
+ }: {
70
+ readonly panel: DashboardStatPanel;
71
+ readonly label: string;
72
+ readonly screenId: string;
73
+ readonly filterParams: Readonly<Record<string, unknown>>;
74
+ }): ReactNode {
75
+ // Resolved HERE (not in a separate always-rendered child) so `icon` on
76
+ // <StatCard> is `undefined` — not a React element that renders empty —
77
+ // when the icon name isn't registered. StatCard gates its accent chip on
78
+ // `icon !== undefined`, so a resolved-but-hidden element used to leave a
79
+ // stray accent-colored chip next to the label.
80
+ const iconName = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
81
+ const Icon = useExtensionSectionComponent(iconName);
82
+ useEffect(() => {
83
+ if (panel.icon !== undefined && iconName !== undefined && Icon === undefined) {
84
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
85
+ console.warn(
86
+ `[kumiko] Dashboard stat-panel "${panel.id}" on screen "${screenId}" references icon ` +
87
+ `"${iconName}", which is not registered in clientFeatures.extensionSectionComponents.`,
88
+ );
89
+ }
90
+ }, [panel.icon, panel.id, iconName, Icon, screenId]);
91
+
92
+ const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
93
+ panel.query,
94
+ filterParams,
95
+ { live: true },
96
+ );
97
+ if (loading && data === null) return <LoadingState rows={2} />;
98
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
99
+ const record = data ?? {};
100
+ const rawTone = panel.toneField !== undefined ? record[panel.toneField] : undefined;
101
+ const tone =
102
+ typeof rawTone === "string" && STAT_TONES.has(rawTone) ? (rawTone as StatTone) : "default";
103
+ const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
104
+ const delta = readDelta(panel, record);
105
+ return (
106
+ <StatCard
107
+ icon={
108
+ Icon !== undefined ? (
109
+ <Icon
110
+ entityName={screenId}
111
+ entityId={null}
112
+ screenId={screenId}
113
+ filterParams={filterParams}
114
+ />
115
+ ) : undefined
116
+ }
117
+ label={label}
118
+ value={String(record[panel.valueField] ?? "—")}
119
+ tone={tone}
120
+ {...(Icon !== undefined && { accentColor: panel.accentColor })}
121
+ {...(sub !== undefined && sub !== null && { sub: String(sub) })}
122
+ {...(delta !== undefined && { delta })}
123
+ testId={`dashboard-panel-${panel.id}`}
124
+ />
125
+ );
126
+ }
127
+
128
+ function readDelta(
129
+ panel: DashboardStatPanel,
130
+ record: Readonly<Record<string, unknown>>,
131
+ ): StatDelta | undefined {
132
+ if (panel.deltaField === undefined || panel.deltaDirectionField === undefined) return undefined;
133
+ const value = record[panel.deltaField];
134
+ const direction = record[panel.deltaDirectionField];
135
+ if (value === undefined || value === null) return undefined;
136
+ if (direction !== "up" && direction !== "down") return undefined;
137
+ const rawTone = panel.deltaToneField !== undefined ? record[panel.deltaToneField] : undefined;
138
+ const tone =
139
+ typeof rawTone === "string" && STAT_TONES.has(rawTone) ? (rawTone as StatTone) : undefined;
140
+ return { value: String(value), direction, ...(tone !== undefined && { tone }) };
141
+ }
142
+
143
+ function StatGroupPanelBody({
144
+ panel,
145
+ label,
146
+ screenId,
147
+ filterParams,
148
+ }: {
149
+ readonly panel: DashboardStatGroupPanel;
150
+ readonly label: string;
151
+ readonly screenId: string;
152
+ readonly filterParams: Readonly<Record<string, unknown>>;
153
+ }): ReactNode {
154
+ const t = useTranslation();
155
+ return (
156
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
157
+ <section className="grid grid-cols-1 gap-3 sm:grid-cols-3">
158
+ {panel.stats.map((stat) => (
159
+ <StatPanelBody
160
+ key={stat.id}
161
+ panel={stat}
162
+ label={t(stat.label)}
163
+ screenId={screenId}
164
+ filterParams={filterParams}
165
+ />
166
+ ))}
167
+ </section>
168
+ </SectionCard>
169
+ );
170
+ }
171
+
172
+ type TimeseriesEnvelope = {
173
+ readonly points: readonly TimeseriesPoint[];
174
+ readonly windowStartMs: number;
175
+ readonly windowEndMs: number;
176
+ };
177
+
178
+ function ChartPanelBody({
179
+ panel,
180
+ label,
181
+ filterParams,
182
+ }: {
183
+ readonly panel: DashboardChartPanel;
184
+ readonly label: string;
185
+ readonly filterParams: Readonly<Record<string, unknown>>;
186
+ }): ReactNode {
187
+ const t = useTranslation();
188
+ const { data, error, loading, refetch } = useQuery<TimeseriesEnvelope>(
189
+ panel.query,
190
+ filterParams,
191
+ { live: true },
192
+ );
193
+ if (loading && data === null) return <LoadingState rows={3} />;
194
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
195
+ return (
196
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
197
+ <TimeseriesChart
198
+ points={data?.points ?? []}
199
+ windowStartMs={data?.windowStartMs ?? 0}
200
+ windowEndMs={data?.windowEndMs ?? 1}
201
+ ariaLabel={label}
202
+ emptyContent={t("kumiko.list.no-entries")}
203
+ />
204
+ </SectionCard>
205
+ );
206
+ }
207
+
208
+ function ListPanelBody({
209
+ panel,
210
+ label,
211
+ filterParams,
212
+ }: {
213
+ readonly panel: DashboardListPanel;
214
+ readonly label: string;
215
+ readonly filterParams: Readonly<Record<string, unknown>>;
216
+ }): ReactNode {
217
+ const t = useTranslation();
218
+ return (
219
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
220
+ <QueryTable<{ readonly rows: readonly Readonly<Record<string, unknown>>[] }>
221
+ query={panel.query}
222
+ payload={filterParams}
223
+ live
224
+ columns={panel.columns.map((c) => {
225
+ const normalized = normalizeListColumn(c);
226
+ return {
227
+ field: normalized.field,
228
+ label: t(normalized.label ?? normalized.field),
229
+ };
230
+ })}
231
+ rows={(data) => data.rows}
232
+ />
233
+ </SectionCard>
234
+ );
235
+ }
236
+
237
+ type FeedEnvelope = {
238
+ readonly rows: readonly { readonly primary: string; readonly trailing?: string }[];
239
+ };
240
+
241
+ function FeedPanelBody({
242
+ panel,
243
+ label,
244
+ filterParams,
245
+ }: {
246
+ readonly panel: DashboardFeedPanel;
247
+ readonly label: string;
248
+ readonly filterParams: Readonly<Record<string, unknown>>;
249
+ }): ReactNode {
250
+ const t = useTranslation();
251
+ const { data, error, loading, refetch } = useQuery<FeedEnvelope>(panel.query, filterParams, {
252
+ live: true,
253
+ });
254
+ if (loading && data === null) return <LoadingState rows={3} />;
255
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
256
+ const rows: readonly FeedRow[] = (data?.rows ?? []).map((row, i) => ({ id: String(i), ...row }));
257
+ return (
258
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
259
+ <FeedList rows={rows} emptyContent={t(panel.emptyLabel ?? "kumiko.list.no-entries")} />
260
+ </SectionCard>
261
+ );
262
+ }
263
+
264
+ type ProgressListEnvelope = {
265
+ readonly rows: readonly {
266
+ readonly label: string;
267
+ readonly value: string;
268
+ readonly fraction: number;
269
+ }[];
270
+ };
271
+
272
+ function ProgressListPanelBody({
273
+ panel,
274
+ label,
275
+ filterParams,
276
+ }: {
277
+ readonly panel: DashboardProgressListPanel;
278
+ readonly label: string;
279
+ readonly filterParams: Readonly<Record<string, unknown>>;
280
+ }): ReactNode {
281
+ const t = useTranslation();
282
+ const { data, error, loading, refetch } = useQuery<ProgressListEnvelope>(
283
+ panel.query,
284
+ filterParams,
285
+ { live: true },
286
+ );
287
+ if (loading && data === null) return <LoadingState rows={3} />;
288
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
289
+ const rows: readonly ProgressListRow[] = (data?.rows ?? []).map((row, i) => ({
290
+ id: String(i),
291
+ ...row,
292
+ }));
293
+ return (
294
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
295
+ <ProgressList rows={rows} emptyContent={t("kumiko.list.no-entries")} />
296
+ </SectionCard>
297
+ );
298
+ }
299
+
300
+ // Löst panel.component über dieselbe extensionSectionComponents-Registry auf
301
+ // wie entityEdit-Extension-Sections und List-Header-Slots — bleibt an seiner
302
+ // Array-Position statt in einen separaten Slot zu wandern (siehe Datei-Kopf-
303
+ // Kommentar zur Registry). Rendert nichts + dev-Warnung bei unregistriertem
304
+ // Namen, analog zu ListHeaderSlotMount in render-list.tsx.
305
+ function CustomPanelBody({
306
+ panel,
307
+ screenId,
308
+ filterParams,
309
+ }: {
310
+ readonly panel: DashboardCustomPanel;
311
+ readonly screenId: string;
312
+ readonly filterParams: Readonly<Record<string, unknown>>;
313
+ }): ReactNode {
314
+ const name = extensionSectionName(panel.component);
315
+ const Component = useExtensionSectionComponent(name);
316
+ useEffect(() => {
317
+ if (name !== undefined && Component === undefined) {
318
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
319
+ console.warn(
320
+ `[kumiko] Dashboard custom-panel "${panel.id}" on screen "${screenId}" references component ` +
321
+ `"${name}", which is not registered in clientFeatures.extensionSectionComponents — the panel renders nothing.`,
322
+ );
323
+ }
324
+ }, [name, Component, panel.id, screenId]);
325
+ if (Component === undefined) return null;
326
+ // Dashboard-Panels haben keine Entity — entityName trägt die screen.id,
327
+ // damit ExtensionSectionProps nicht extra für diesen einen Mount-Ort
328
+ // aufgeweicht werden muss (das bräche jede bestehende registrierte
329
+ // Section, die entityName als garantiert gesetzten string erwartet).
330
+ return (
331
+ <Component
332
+ entityName={screenId}
333
+ entityId={null}
334
+ screenId={screenId}
335
+ filterParams={filterParams}
336
+ />
337
+ );
338
+ }
339
+
340
+ // Screen-Filter: hält den gewählten Wert, rendert die Combobox, und liefert
341
+ // den Payload-Merge für jede Panel-Query. Statische `options` werden als
342
+ // i18n-Keys übersetzt; `optionsQuery`-Ergebnisse sind Server-Daten und werden
343
+ // unverändert übernommen.
344
+ function useFilterParams(screen: DashboardScreenDefinition): {
345
+ readonly params: Readonly<Record<string, unknown>>;
346
+ readonly picker: ReactNode;
347
+ } {
348
+ const { Field, Input } = usePrimitives();
349
+ const t = useTranslation();
350
+ const filter = screen.filter;
351
+ const [value, setValue] = useState("");
352
+ const optionsQueryResult = useQuery<{
353
+ readonly rows: readonly { readonly value: string; readonly label: string }[];
354
+ }>(filter?.optionsQuery ?? "", {}, { enabled: filter?.optionsQuery !== undefined });
355
+
356
+ if (filter === undefined) {
357
+ return { params: {}, picker: null };
358
+ }
359
+
360
+ const dynamicOptions =
361
+ filter.optionsQuery !== undefined
362
+ ? (optionsQueryResult.data?.rows ?? [])
363
+ : (filter.options ?? []).map((o) => ({ value: o.value, label: t(o.label) }));
364
+ const allLabel = t(filter.allLabel ?? "kumiko.dashboard.filter.all");
365
+ const options = [{ value: "", label: allLabel }, ...dynamicOptions];
366
+
367
+ const picker = (
368
+ <div className="max-w-xs">
369
+ <Field id={`dashboard-filter-${filter.id}`} label={t(filter.label)}>
370
+ <Input
371
+ kind="combobox"
372
+ id={`dashboard-filter-${filter.id}`}
373
+ name={filter.id}
374
+ options={options}
375
+ value={value}
376
+ onChange={setValue}
377
+ placeholder={filter.placeholder !== undefined ? t(filter.placeholder) : allLabel}
378
+ />
379
+ </Field>
380
+ </div>
381
+ );
382
+ const params = value === "" ? {} : { [filter.id]: value };
383
+ return { params, picker };
384
+ }
385
+
386
+ function panelSpanClassName(panel: DashboardPanelDefinition): string | undefined {
387
+ if (panel.kind === "stat") return undefined;
388
+ if (panel.kind === "feed" || panel.kind === "progress-list") return HALF_PANEL;
389
+ return WIDE_PANEL;
390
+ }
391
+
392
+ function PanelBody({
393
+ panel,
394
+ label,
395
+ screenId,
396
+ filterParams,
397
+ }: {
398
+ readonly panel: DashboardPanelDefinition;
399
+ readonly label: string;
400
+ readonly screenId: string;
401
+ readonly filterParams: Readonly<Record<string, unknown>>;
402
+ }): ReactNode {
403
+ if (panel.kind === "stat")
404
+ return (
405
+ <StatPanelBody panel={panel} label={label} screenId={screenId} filterParams={filterParams} />
406
+ );
407
+ if (panel.kind === "stat-group") {
408
+ return (
409
+ <StatGroupPanelBody
410
+ panel={panel}
411
+ label={label}
412
+ screenId={screenId}
413
+ filterParams={filterParams}
414
+ />
415
+ );
416
+ }
417
+ if (panel.kind === "chart")
418
+ return <ChartPanelBody panel={panel} label={label} filterParams={filterParams} />;
419
+ if (panel.kind === "list")
420
+ return <ListPanelBody panel={panel} label={label} filterParams={filterParams} />;
421
+ if (panel.kind === "feed")
422
+ return <FeedPanelBody panel={panel} label={label} filterParams={filterParams} />;
423
+ if (panel.kind === "progress-list") {
424
+ return <ProgressListPanelBody panel={panel} label={label} filterParams={filterParams} />;
425
+ }
426
+ return <CustomPanelBody panel={panel} screenId={screenId} filterParams={filterParams} />;
427
+ }
428
+
429
+ export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
430
+ const t = useTranslation();
431
+ const effectiveTranslate = translate ?? t;
432
+ const { params: filterParams, picker } = useFilterParams(screen);
433
+ return (
434
+ <div className="flex flex-col gap-4 p-6" data-testid={`dashboard-${screen.id}`}>
435
+ {picker}
436
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
437
+ {screen.panels.map((panel) => {
438
+ const label = panel.kind === "custom" ? "" : effectiveTranslate(panel.label);
439
+ const span = panelSpanClassName(panel);
440
+ return (
441
+ <div key={panel.id} className={span}>
442
+ <PanelBody
443
+ panel={panel}
444
+ label={label}
445
+ screenId={screen.id}
446
+ filterParams={filterParams}
447
+ />
448
+ </div>
449
+ );
450
+ })}
451
+ </div>
452
+ </div>
453
+ );
454
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  AppTokens,
12
12
  BannerProps,
13
13
  ButtonProps,
14
+ CardProps,
14
15
  ColorTokens,
15
16
  CorePrimitives,
16
17
  CoreTokens,
@@ -50,6 +51,7 @@ export type {
50
51
  UseFormResult,
51
52
  UseQueryOptions,
52
53
  UseQueryResult,
54
+ UserRolesProviderProps,
53
55
  WorkspaceSchema,
54
56
  } from "@cosmicdrift/kumiko-renderer";
55
57
  export {
@@ -68,6 +70,7 @@ export {
68
70
  RenderField,
69
71
  RenderList,
70
72
  TokensProvider,
73
+ UserRolesProvider,
71
74
  useDispatcher,
72
75
  useDispatcherStatus,
73
76
  useForm,
@@ -81,6 +84,7 @@ export {
81
84
  useTokenController,
82
85
  useTokens,
83
86
  useTranslation,
87
+ useUserRoles,
84
88
  } from "@cosmicdrift/kumiko-renderer";
85
89
  // --- Web-platform specifics ---
86
90
  export { createBrowserLocaleResolver } from "./app/browser-locale";
@@ -123,7 +127,12 @@ export type { WorkspaceSwitcherProps } from "./layout/workspace-switcher";
123
127
  export { WorkspaceSwitcher } from "./layout/workspace-switcher";
124
128
  export { cn } from "./lib/cn";
125
129
  export { postWithDownload } from "./lib/download";
126
- export { BareFormProvider, defaultPrimitives } from "./primitives";
130
+ export {
131
+ BareFormProvider,
132
+ DefaultCard as Card,
133
+ defaultPrimitives,
134
+ FormScreenShell,
135
+ } from "./primitives";
127
136
  export type { ActionMenuProps, MenuItemDef } from "./primitives/action-menu";
128
137
  export { ActionMenu } from "./primitives/action-menu";
129
138
  export {
@@ -136,6 +145,7 @@ export {
136
145
  DropdownMenuSeparator,
137
146
  DropdownMenuTrigger,
138
147
  } from "./primitives/dropdown-menu";
148
+ export { PageSection, Stack } from "./primitives/layout";
139
149
  export type { ToastOptions, ToastProviderProps, ToastVariant } from "./primitives/toast";
140
150
  export { ToastProvider, useToast } from "./primitives/toast";
141
151
  export type { CreateEventSourceLiveEventsOptions } from "./sse/live-events";
@@ -147,3 +157,67 @@ export {
147
157
  useBrowserTokensApi,
148
158
  } from "./tokens";
149
159
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
160
+ export type {
161
+ AiTextAreaProps,
162
+ AiTextFieldProps,
163
+ BooleanFieldProps,
164
+ ComparisonMetric,
165
+ DateFieldProps,
166
+ DrawerProps,
167
+ FeedRow,
168
+ FileFieldProps,
169
+ InfinityListProps,
170
+ NumberFieldProps,
171
+ ProgressListRow,
172
+ QueryTableColumn,
173
+ QueryTableProps,
174
+ RangeFieldProps,
175
+ ResultColumn,
176
+ SelectFieldProps,
177
+ StatDelta,
178
+ StatTone,
179
+ StatusBarEntry,
180
+ StatusTone,
181
+ TextareaFieldProps,
182
+ TextFieldProps,
183
+ TimeseriesPoint,
184
+ } from "./widgets";
185
+ export {
186
+ AiTextArea,
187
+ AiTextField,
188
+ BooleanField,
189
+ CollapsibleSection,
190
+ ComparisonTable,
191
+ DateField,
192
+ DetailList,
193
+ Drawer,
194
+ EmptyState,
195
+ ErrorState,
196
+ FeedList,
197
+ FileField,
198
+ InfinityList,
199
+ LoadingState,
200
+ MiniStat,
201
+ ModeSwitch,
202
+ MoneyField,
203
+ NumberField,
204
+ PercentField,
205
+ ProgressBar,
206
+ ProgressList,
207
+ QueryTable,
208
+ RangeField,
209
+ ResultPanel,
210
+ ResultTable,
211
+ SectionCard,
212
+ SelectField,
213
+ Sparkline,
214
+ STATUS_TONE_TEXT,
215
+ StatCard,
216
+ StatusBadge,
217
+ StatusBarChart,
218
+ smoothPath,
219
+ TextareaField,
220
+ TextField,
221
+ TimeseriesChart,
222
+ useDraft,
223
+ } from "./widgets";
@@ -9,13 +9,23 @@ import type {
9
9
  AppSchema,
10
10
  FeatureSchema,
11
11
  NavDefinition,
12
+ ScreenDefinition,
12
13
  } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
13
15
  import { buildNavRegistrySlice, buildNavRegistrySliceForApp } from "../nav-tree";
14
16
 
15
17
  function feature(navs: readonly NavDefinition[], featureName = "tasks"): FeatureSchema {
16
18
  return { featureName, entities: {}, screens: [], navs };
17
19
  }
18
20
 
21
+ function featureWithScreens(
22
+ navs: readonly NavDefinition[],
23
+ screens: readonly ScreenDefinition[],
24
+ featureName = "tasks",
25
+ ): FeatureSchema {
26
+ return { featureName, entities: {}, screens, navs };
27
+ }
28
+
19
29
  describe("buildNavRegistrySlice", () => {
20
30
  test("qualifiziert Nav-IDs zu feature:nav:id", () => {
21
31
  const slice = buildNavRegistrySlice(feature([{ id: "main", label: "Main" }]));
@@ -70,4 +80,99 @@ describe("buildNavRegistrySliceForApp", () => {
70
80
  const slice = buildNavRegistrySliceForApp(app);
71
81
  expect(slice.topLevel.map((n) => n.id)).toEqual(["shop:nav:catalog", "admin:nav:users"]);
72
82
  });
83
+
84
+ test("nav ohne eigene access erbt access vom referenzierten Screen (#1099)", () => {
85
+ const app: AppSchema = {
86
+ features: [
87
+ featureWithScreens(
88
+ [{ id: "members", label: "Members", screen: "members" }],
89
+ [
90
+ {
91
+ id: "members",
92
+ type: "custom",
93
+ renderer: { react: { __component: "Members" } },
94
+ access: { roles: ["Admin"] },
95
+ },
96
+ ],
97
+ ),
98
+ ],
99
+ };
100
+ const slice = buildNavRegistrySliceForApp(app);
101
+ expect(slice.topLevel[0]?.access).toEqual({ roles: ["Admin"] });
102
+ });
103
+
104
+ test("nav mit eigener access ignoriert die Screen-Access (expliziter Override gewinnt)", () => {
105
+ const app: AppSchema = {
106
+ features: [
107
+ featureWithScreens(
108
+ [
109
+ {
110
+ id: "members",
111
+ label: "Members",
112
+ screen: "members",
113
+ access: { roles: ["Admin", "Editor"] },
114
+ },
115
+ ],
116
+ [
117
+ {
118
+ id: "members",
119
+ type: "custom",
120
+ renderer: { react: { __component: "Members" } },
121
+ access: { roles: ["Admin"] },
122
+ },
123
+ ],
124
+ ),
125
+ ],
126
+ };
127
+ const slice = buildNavRegistrySliceForApp(app);
128
+ expect(slice.topLevel[0]?.access).toEqual({ roles: ["Admin", "Editor"] });
129
+ });
130
+
131
+ test("nav ohne eigene access bleibt ohne access, wenn der Ziel-Screen offen ist (kein Verstecken eines openToAll-Screens)", () => {
132
+ const app: AppSchema = {
133
+ features: [
134
+ featureWithScreens(
135
+ [{ id: "pub", label: "Pub", screen: "pub" }],
136
+ [
137
+ {
138
+ id: "pub",
139
+ type: "custom",
140
+ renderer: { react: { __component: "Pub" } },
141
+ },
142
+ ],
143
+ ),
144
+ ],
145
+ };
146
+ const slice = buildNavRegistrySliceForApp(app);
147
+ expect(slice.topLevel[0]?.access).toBeUndefined();
148
+ });
149
+
150
+ test("nav ohne screen bleibt ohne access (nichts zum Erben da)", () => {
151
+ const app: AppSchema = {
152
+ features: [feature([{ id: "group", label: "Group" }])],
153
+ };
154
+ const slice = buildNavRegistrySliceForApp(app);
155
+ expect(slice.topLevel[0]?.access).toBeUndefined();
156
+ });
157
+
158
+ test("end-to-end: resolveNavigation versteckt den Eintrag fuer eine Rolle, die den Ziel-Screen nicht sehen darf", () => {
159
+ const app: AppSchema = {
160
+ features: [
161
+ featureWithScreens(
162
+ [{ id: "members", label: "Members", screen: "members" }],
163
+ [
164
+ {
165
+ id: "members",
166
+ type: "custom",
167
+ renderer: { react: { __component: "Members" } },
168
+ access: { roles: ["Admin"] },
169
+ },
170
+ ],
171
+ ),
172
+ ],
173
+ };
174
+ const source = buildNavRegistrySliceForApp(app);
175
+ const tree = resolveNavigation({ source, user: { id: "u1", roles: ["Editor"] } });
176
+ expect(tree).toEqual([]);
177
+ });
73
178
  });