@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.
- package/package.json +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -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/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /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 {
|
|
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
|
});
|