@cosmicdrift/kumiko-renderer-web 0.133.0 → 0.135.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 +207 -1
- package/src/__tests__/workspace-shell.test.tsx +5 -37
- package/src/app/dashboard-body.tsx +273 -30
- package/src/index.ts +12 -0
- package/src/widgets/__tests__/form-kit.test.tsx +103 -0
- package/src/widgets/detail-list.tsx +19 -4
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +66 -0
- package/src/widgets/index.ts +5 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/result-panel.tsx +101 -0
- package/src/widgets/use-draft.ts +35 -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
|
@@ -1,40 +1,72 @@
|
|
|
1
1
|
// Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
|
|
2
|
-
// Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable
|
|
3
|
-
// einem responsiven Grid. Registriert via
|
|
4
|
-
// createKumikoApp — der KumikoScreen-Switch bleibt
|
|
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.
|
|
5
6
|
//
|
|
6
7
|
// Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
|
|
7
|
-
// stat
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// stat → flaches Record, valueField/subField/toneField zeigen auf
|
|
9
|
+
// anzeigefertige Werte (der Query-Handler formatiert).
|
|
10
|
+
// stat-group → mehrere stat-Panels unter einem Sektions-Titel, jedes
|
|
11
|
+
// Kind bleibt eine eigenständige Query.
|
|
12
|
+
// chart → { points: { atMs, value | null }[], windowStartMs,
|
|
13
|
+
// windowEndMs }
|
|
14
|
+
// list → paged envelope { rows, nextCursor, total? } wie
|
|
15
|
+
// projectionList.
|
|
16
|
+
// feed → { rows: { id, primary, trailing? }[] }
|
|
17
|
+
// progress-list → { rows: { id, label, value, fraction }[] }
|
|
18
|
+
// custom → keine Query — eine über extensionSectionComponents
|
|
19
|
+
// registrierte App-Komponente holt sich ihre Daten selbst.
|
|
20
|
+
//
|
|
21
|
+
// Screen-Filter (DashboardFilterDefinition): der gewählte Wert wird unter
|
|
22
|
+
// `filter.id` in JEDE Panel-Query gemerged. useQuery refetcht automatisch
|
|
23
|
+
// über seinen bestehenden payloadKey-Mechanismus — kein Sonderfall nötig.
|
|
11
24
|
|
|
12
25
|
import type {
|
|
13
26
|
DashboardChartPanel,
|
|
27
|
+
DashboardCustomPanel,
|
|
28
|
+
DashboardFeedPanel,
|
|
14
29
|
DashboardListPanel,
|
|
30
|
+
DashboardPanelDefinition,
|
|
31
|
+
DashboardProgressListPanel,
|
|
32
|
+
DashboardScreenDefinition,
|
|
33
|
+
DashboardStatGroupPanel,
|
|
15
34
|
DashboardStatPanel,
|
|
16
35
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
36
|
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
|
-
import {
|
|
19
|
-
|
|
37
|
+
import {
|
|
38
|
+
type DashboardBodyProps,
|
|
39
|
+
extensionSectionName,
|
|
40
|
+
useExtensionSectionComponent,
|
|
41
|
+
usePrimitives,
|
|
42
|
+
useQuery,
|
|
43
|
+
useTranslation,
|
|
44
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
45
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
20
46
|
import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
|
|
47
|
+
import { FeedList, type FeedRow } from "../widgets/feed-list";
|
|
48
|
+
import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
|
|
21
49
|
import { QueryTable } from "../widgets/query-table";
|
|
22
50
|
import { SectionCard } from "../widgets/section-card";
|
|
23
51
|
import { StatCard, type StatTone } from "../widgets/stat";
|
|
24
52
|
import { ErrorState, LoadingState } from "../widgets/states";
|
|
25
53
|
|
|
26
54
|
const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
|
|
55
|
+
const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
|
|
56
|
+
const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
|
|
27
57
|
|
|
28
58
|
function StatPanelBody({
|
|
29
59
|
panel,
|
|
30
60
|
label,
|
|
61
|
+
filterParams,
|
|
31
62
|
}: {
|
|
32
63
|
readonly panel: DashboardStatPanel;
|
|
33
64
|
readonly label: string;
|
|
65
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
34
66
|
}): ReactNode {
|
|
35
67
|
const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
|
|
36
68
|
panel.query,
|
|
37
|
-
|
|
69
|
+
filterParams,
|
|
38
70
|
{ live: true },
|
|
39
71
|
);
|
|
40
72
|
if (loading && data === null) return <LoadingState rows={2} />;
|
|
@@ -55,6 +87,32 @@ function StatPanelBody({
|
|
|
55
87
|
);
|
|
56
88
|
}
|
|
57
89
|
|
|
90
|
+
function StatGroupPanelBody({
|
|
91
|
+
panel,
|
|
92
|
+
label,
|
|
93
|
+
filterParams,
|
|
94
|
+
}: {
|
|
95
|
+
readonly panel: DashboardStatGroupPanel;
|
|
96
|
+
readonly label: string;
|
|
97
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
98
|
+
}): ReactNode {
|
|
99
|
+
const t = useTranslation();
|
|
100
|
+
return (
|
|
101
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
102
|
+
<section className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
103
|
+
{panel.stats.map((stat) => (
|
|
104
|
+
<StatPanelBody
|
|
105
|
+
key={stat.id}
|
|
106
|
+
panel={stat}
|
|
107
|
+
label={t(stat.label)}
|
|
108
|
+
filterParams={filterParams}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</section>
|
|
112
|
+
</SectionCard>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
58
116
|
type TimeseriesEnvelope = {
|
|
59
117
|
readonly points: readonly TimeseriesPoint[];
|
|
60
118
|
readonly windowStartMs: number;
|
|
@@ -64,14 +122,16 @@ type TimeseriesEnvelope = {
|
|
|
64
122
|
function ChartPanelBody({
|
|
65
123
|
panel,
|
|
66
124
|
label,
|
|
125
|
+
filterParams,
|
|
67
126
|
}: {
|
|
68
127
|
readonly panel: DashboardChartPanel;
|
|
69
128
|
readonly label: string;
|
|
129
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
70
130
|
}): ReactNode {
|
|
71
131
|
const t = useTranslation();
|
|
72
132
|
const { data, error, loading, refetch } = useQuery<TimeseriesEnvelope>(
|
|
73
133
|
panel.query,
|
|
74
|
-
|
|
134
|
+
filterParams,
|
|
75
135
|
{ live: true },
|
|
76
136
|
);
|
|
77
137
|
if (loading && data === null) return <LoadingState rows={3} />;
|
|
@@ -92,15 +152,18 @@ function ChartPanelBody({
|
|
|
92
152
|
function ListPanelBody({
|
|
93
153
|
panel,
|
|
94
154
|
label,
|
|
155
|
+
filterParams,
|
|
95
156
|
}: {
|
|
96
157
|
readonly panel: DashboardListPanel;
|
|
97
158
|
readonly label: string;
|
|
159
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
98
160
|
}): ReactNode {
|
|
99
161
|
const t = useTranslation();
|
|
100
162
|
return (
|
|
101
163
|
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
102
164
|
<QueryTable<{ readonly rows: readonly Readonly<Record<string, unknown>>[] }>
|
|
103
165
|
query={panel.query}
|
|
166
|
+
payload={filterParams}
|
|
104
167
|
live
|
|
105
168
|
columns={panel.columns.map((c) => {
|
|
106
169
|
const normalized = normalizeListColumn(c);
|
|
@@ -115,32 +178,212 @@ function ListPanelBody({
|
|
|
115
178
|
);
|
|
116
179
|
}
|
|
117
180
|
|
|
181
|
+
type FeedEnvelope = {
|
|
182
|
+
readonly rows: readonly { readonly primary: string; readonly trailing?: string }[];
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function FeedPanelBody({
|
|
186
|
+
panel,
|
|
187
|
+
label,
|
|
188
|
+
filterParams,
|
|
189
|
+
}: {
|
|
190
|
+
readonly panel: DashboardFeedPanel;
|
|
191
|
+
readonly label: string;
|
|
192
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
193
|
+
}): ReactNode {
|
|
194
|
+
const t = useTranslation();
|
|
195
|
+
const { data, error, loading, refetch } = useQuery<FeedEnvelope>(panel.query, filterParams, {
|
|
196
|
+
live: true,
|
|
197
|
+
});
|
|
198
|
+
if (loading && data === null) return <LoadingState rows={3} />;
|
|
199
|
+
if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
|
|
200
|
+
const rows: readonly FeedRow[] = (data?.rows ?? []).map((row, i) => ({ id: String(i), ...row }));
|
|
201
|
+
return (
|
|
202
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
203
|
+
<FeedList rows={rows} emptyContent={t(panel.emptyLabel ?? "kumiko.list.no-entries")} />
|
|
204
|
+
</SectionCard>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type ProgressListEnvelope = {
|
|
209
|
+
readonly rows: readonly {
|
|
210
|
+
readonly label: string;
|
|
211
|
+
readonly value: string;
|
|
212
|
+
readonly fraction: number;
|
|
213
|
+
}[];
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function ProgressListPanelBody({
|
|
217
|
+
panel,
|
|
218
|
+
label,
|
|
219
|
+
filterParams,
|
|
220
|
+
}: {
|
|
221
|
+
readonly panel: DashboardProgressListPanel;
|
|
222
|
+
readonly label: string;
|
|
223
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
224
|
+
}): ReactNode {
|
|
225
|
+
const t = useTranslation();
|
|
226
|
+
const { data, error, loading, refetch } = useQuery<ProgressListEnvelope>(
|
|
227
|
+
panel.query,
|
|
228
|
+
filterParams,
|
|
229
|
+
{ live: true },
|
|
230
|
+
);
|
|
231
|
+
if (loading && data === null) return <LoadingState rows={3} />;
|
|
232
|
+
if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
|
|
233
|
+
const rows: readonly ProgressListRow[] = (data?.rows ?? []).map((row, i) => ({
|
|
234
|
+
id: String(i),
|
|
235
|
+
...row,
|
|
236
|
+
}));
|
|
237
|
+
return (
|
|
238
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
239
|
+
<ProgressList rows={rows} emptyContent={t("kumiko.list.no-entries")} />
|
|
240
|
+
</SectionCard>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Löst panel.component über dieselbe extensionSectionComponents-Registry auf
|
|
245
|
+
// wie entityEdit-Extension-Sections und List-Header-Slots — bleibt an seiner
|
|
246
|
+
// Array-Position statt in einen separaten Slot zu wandern (siehe Datei-Kopf-
|
|
247
|
+
// Kommentar zur Registry). Rendert nichts + dev-Warnung bei unregistriertem
|
|
248
|
+
// Namen, analog zu ListHeaderSlotMount in render-list.tsx.
|
|
249
|
+
function CustomPanelBody({
|
|
250
|
+
panel,
|
|
251
|
+
screenId,
|
|
252
|
+
filterParams,
|
|
253
|
+
}: {
|
|
254
|
+
readonly panel: DashboardCustomPanel;
|
|
255
|
+
readonly screenId: string;
|
|
256
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
257
|
+
}): ReactNode {
|
|
258
|
+
const name = extensionSectionName(panel.component);
|
|
259
|
+
const Component = useExtensionSectionComponent(name);
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (name !== undefined && Component === undefined) {
|
|
262
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
263
|
+
console.warn(
|
|
264
|
+
`[kumiko] Dashboard custom-panel "${panel.id}" on screen "${screenId}" references component ` +
|
|
265
|
+
`"${name}", which is not registered in clientFeatures.extensionSectionComponents — the panel renders nothing.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}, [name, Component, panel.id, screenId]);
|
|
269
|
+
if (Component === undefined) return null;
|
|
270
|
+
// Dashboard-Panels haben keine Entity — entityName trägt die screen.id,
|
|
271
|
+
// damit ExtensionSectionProps nicht extra für diesen einen Mount-Ort
|
|
272
|
+
// aufgeweicht werden muss (das bräche jede bestehende registrierte
|
|
273
|
+
// Section, die entityName als garantiert gesetzten string erwartet).
|
|
274
|
+
return (
|
|
275
|
+
<Component
|
|
276
|
+
entityName={screenId}
|
|
277
|
+
entityId={null}
|
|
278
|
+
screenId={screenId}
|
|
279
|
+
filterParams={filterParams}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Screen-Filter: hält den gewählten Wert, rendert die Combobox, und liefert
|
|
285
|
+
// den Payload-Merge für jede Panel-Query. Statische `options` werden als
|
|
286
|
+
// i18n-Keys übersetzt; `optionsQuery`-Ergebnisse sind Server-Daten und werden
|
|
287
|
+
// unverändert übernommen.
|
|
288
|
+
function useFilterParams(screen: DashboardScreenDefinition): {
|
|
289
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
290
|
+
readonly picker: ReactNode;
|
|
291
|
+
} {
|
|
292
|
+
const { Field, Input } = usePrimitives();
|
|
293
|
+
const t = useTranslation();
|
|
294
|
+
const filter = screen.filter;
|
|
295
|
+
const [value, setValue] = useState("");
|
|
296
|
+
const optionsQueryResult = useQuery<{
|
|
297
|
+
readonly rows: readonly { readonly value: string; readonly label: string }[];
|
|
298
|
+
}>(filter?.optionsQuery ?? "", {}, { enabled: filter?.optionsQuery !== undefined });
|
|
299
|
+
|
|
300
|
+
if (filter === undefined) {
|
|
301
|
+
return { params: {}, picker: null };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const dynamicOptions =
|
|
305
|
+
filter.optionsQuery !== undefined
|
|
306
|
+
? (optionsQueryResult.data?.rows ?? [])
|
|
307
|
+
: (filter.options ?? []).map((o) => ({ value: o.value, label: t(o.label) }));
|
|
308
|
+
const allLabel = t(filter.allLabel ?? "kumiko.dashboard.filter.all");
|
|
309
|
+
const options = [{ value: "", label: allLabel }, ...dynamicOptions];
|
|
310
|
+
|
|
311
|
+
const picker = (
|
|
312
|
+
<div className="max-w-xs">
|
|
313
|
+
<Field id={`dashboard-filter-${filter.id}`} label={t(filter.label)}>
|
|
314
|
+
<Input
|
|
315
|
+
kind="combobox"
|
|
316
|
+
id={`dashboard-filter-${filter.id}`}
|
|
317
|
+
name={filter.id}
|
|
318
|
+
options={options}
|
|
319
|
+
value={value}
|
|
320
|
+
onChange={setValue}
|
|
321
|
+
placeholder={filter.placeholder !== undefined ? t(filter.placeholder) : allLabel}
|
|
322
|
+
/>
|
|
323
|
+
</Field>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
const params = value === "" ? {} : { [filter.id]: value };
|
|
327
|
+
return { params, picker };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function panelSpanClassName(panel: DashboardPanelDefinition): string | undefined {
|
|
331
|
+
if (panel.kind === "stat") return undefined;
|
|
332
|
+
if (panel.kind === "feed" || panel.kind === "progress-list") return HALF_PANEL;
|
|
333
|
+
return WIDE_PANEL;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function PanelBody({
|
|
337
|
+
panel,
|
|
338
|
+
label,
|
|
339
|
+
screenId,
|
|
340
|
+
filterParams,
|
|
341
|
+
}: {
|
|
342
|
+
readonly panel: DashboardPanelDefinition;
|
|
343
|
+
readonly label: string;
|
|
344
|
+
readonly screenId: string;
|
|
345
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
346
|
+
}): ReactNode {
|
|
347
|
+
if (panel.kind === "stat")
|
|
348
|
+
return <StatPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
349
|
+
if (panel.kind === "stat-group") {
|
|
350
|
+
return <StatGroupPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
351
|
+
}
|
|
352
|
+
if (panel.kind === "chart")
|
|
353
|
+
return <ChartPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
354
|
+
if (panel.kind === "list")
|
|
355
|
+
return <ListPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
356
|
+
if (panel.kind === "feed")
|
|
357
|
+
return <FeedPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
358
|
+
if (panel.kind === "progress-list") {
|
|
359
|
+
return <ProgressListPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
360
|
+
}
|
|
361
|
+
return <CustomPanelBody panel={panel} screenId={screenId} filterParams={filterParams} />;
|
|
362
|
+
}
|
|
363
|
+
|
|
118
364
|
export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
|
|
119
365
|
const t = useTranslation();
|
|
120
366
|
const effectiveTranslate = translate ?? t;
|
|
367
|
+
const { params: filterParams, picker } = useFilterParams(screen);
|
|
121
368
|
return (
|
|
122
|
-
<div
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (panel.kind === "stat") {
|
|
129
|
-
return <StatPanelBody key={panel.id} panel={panel} label={label} />;
|
|
130
|
-
}
|
|
131
|
-
if (panel.kind === "chart") {
|
|
369
|
+
<div className="flex flex-col gap-4 p-6" data-testid={`dashboard-${screen.id}`}>
|
|
370
|
+
{picker}
|
|
371
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
372
|
+
{screen.panels.map((panel) => {
|
|
373
|
+
const label = panel.kind === "custom" ? "" : effectiveTranslate(panel.label);
|
|
374
|
+
const span = panelSpanClassName(panel);
|
|
132
375
|
return (
|
|
133
|
-
<div key={panel.id} className=
|
|
134
|
-
<
|
|
376
|
+
<div key={panel.id} className={span}>
|
|
377
|
+
<PanelBody
|
|
378
|
+
panel={panel}
|
|
379
|
+
label={label}
|
|
380
|
+
screenId={screen.id}
|
|
381
|
+
filterParams={filterParams}
|
|
382
|
+
/>
|
|
135
383
|
</div>
|
|
136
384
|
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
<div key={panel.id} className="sm:col-span-2 lg:col-span-4">
|
|
140
|
-
<ListPanelBody panel={panel} label={label} />
|
|
141
|
-
</div>
|
|
142
|
-
);
|
|
143
|
-
})}
|
|
385
|
+
})}
|
|
386
|
+
</div>
|
|
144
387
|
</div>
|
|
145
388
|
);
|
|
146
389
|
}
|
package/src/index.ts
CHANGED
|
@@ -155,8 +155,12 @@ export {
|
|
|
155
155
|
} from "./tokens";
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
157
|
export type {
|
|
158
|
+
FeedRow,
|
|
159
|
+
NumberFieldProps,
|
|
160
|
+
ProgressListRow,
|
|
158
161
|
QueryTableColumn,
|
|
159
162
|
QueryTableProps,
|
|
163
|
+
ResultColumn,
|
|
160
164
|
StatDelta,
|
|
161
165
|
StatTone,
|
|
162
166
|
StatusBarEntry,
|
|
@@ -168,11 +172,18 @@ export {
|
|
|
168
172
|
DetailList,
|
|
169
173
|
EmptyState,
|
|
170
174
|
ErrorState,
|
|
175
|
+
FeedList,
|
|
171
176
|
LoadingState,
|
|
172
177
|
MiniStat,
|
|
173
178
|
ModeSwitch,
|
|
179
|
+
MoneyField,
|
|
180
|
+
NumberField,
|
|
181
|
+
PercentField,
|
|
174
182
|
ProgressBar,
|
|
183
|
+
ProgressList,
|
|
175
184
|
QueryTable,
|
|
185
|
+
ResultPanel,
|
|
186
|
+
ResultTable,
|
|
176
187
|
SectionCard,
|
|
177
188
|
Sparkline,
|
|
178
189
|
STATUS_TONE_TEXT,
|
|
@@ -181,4 +192,5 @@ export {
|
|
|
181
192
|
StatusBarChart,
|
|
182
193
|
smoothPath,
|
|
183
194
|
TimeseriesChart,
|
|
195
|
+
useDraft,
|
|
184
196
|
} from "./widgets";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
|
|
3
|
+
import { DetailList } from "../detail-list";
|
|
4
|
+
import { MoneyField, NumberField, PercentField } from "../form-fields";
|
|
5
|
+
import { ResultPanel, ResultTable } from "../result-panel";
|
|
6
|
+
import { useDraft } from "../use-draft";
|
|
7
|
+
|
|
8
|
+
describe("useDraft", () => {
|
|
9
|
+
test("field() liefert verdrahtete Props, onChange patcht den Draft", () => {
|
|
10
|
+
const { result } = renderHook(() => useDraft<{ sum: number | undefined }>({ sum: 100 }));
|
|
11
|
+
expect(result.current.field("sum")).toMatchObject({ id: "sum", name: "sum", value: 100 });
|
|
12
|
+
act(() => result.current.field("sum").onChange(250));
|
|
13
|
+
expect(result.current.draft.sum).toBe(250);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("reset stellt die Defaults wieder her", () => {
|
|
17
|
+
const { result } = renderHook(() => useDraft<{ a: number | undefined }>({ a: 1 }));
|
|
18
|
+
act(() => result.current.patch({ a: 9 }));
|
|
19
|
+
expect(result.current.draft.a).toBe(9);
|
|
20
|
+
act(() => result.current.reset());
|
|
21
|
+
expect(result.current.draft.a).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("NumberField", () => {
|
|
26
|
+
test("rendert Label und meldet Zahl bei Eingabe", () => {
|
|
27
|
+
const onChange = mock();
|
|
28
|
+
render(<NumberField label="Summe" id="sum" name="sum" value={300} onChange={onChange} />);
|
|
29
|
+
expect(screen.getByText("Summe")).toBeTruthy();
|
|
30
|
+
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "42" } });
|
|
31
|
+
expect(onChange).toHaveBeenCalledWith(42);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("leeres Feld meldet undefined", () => {
|
|
35
|
+
const onChange = mock();
|
|
36
|
+
render(<NumberField label="X" id="x" name="x" value={5} onChange={onChange} />);
|
|
37
|
+
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
|
|
38
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("MoneyField/PercentField zeigen ihre Einheit", () => {
|
|
42
|
+
const noop = (): void => {};
|
|
43
|
+
const { rerender } = render(
|
|
44
|
+
<MoneyField label="Betrag" id="b" name="b" value={1} onChange={noop} />,
|
|
45
|
+
);
|
|
46
|
+
expect(screen.getByText("€")).toBeTruthy();
|
|
47
|
+
rerender(<PercentField label="Zins" id="z" name="z" value={1} onChange={noop} />);
|
|
48
|
+
expect(screen.getByText("%")).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("DetailList emphasize", () => {
|
|
53
|
+
test("emphasize hebt Label und Wert hervor", () => {
|
|
54
|
+
render(
|
|
55
|
+
<DetailList
|
|
56
|
+
testId="dl"
|
|
57
|
+
rows={[
|
|
58
|
+
{ label: "Summe", value: "100" },
|
|
59
|
+
{ label: "Effektiv", value: "3,1 %", emphasize: true },
|
|
60
|
+
]}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByText("Effektiv").className).toContain("font-semibold");
|
|
64
|
+
expect(screen.getByText("Summe").className).not.toContain("font-semibold");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("ResultPanel", () => {
|
|
69
|
+
test("empty zeigt den Platzhalter, keine Liste", () => {
|
|
70
|
+
render(<ResultPanel title="Ergebnis" empty emptyText="Werte eingeben" />);
|
|
71
|
+
expect(screen.getByText("Werte eingeben")).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("gefüllt zeigt Kennzahlen und children", () => {
|
|
75
|
+
render(
|
|
76
|
+
<ResultPanel title="Ergebnis" rows={[{ label: "Rate", value: "890 €" }]}>
|
|
77
|
+
<span>extra</span>
|
|
78
|
+
</ResultPanel>,
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByText("Rate")).toBeTruthy();
|
|
81
|
+
expect(screen.getByText("extra")).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("ResultTable", () => {
|
|
86
|
+
test("rendert Header, Zeilen und rechtsbündige Zahlenspalte", () => {
|
|
87
|
+
render(
|
|
88
|
+
<ResultTable
|
|
89
|
+
testId="rt"
|
|
90
|
+
columns={[
|
|
91
|
+
{ header: "Tranche", cell: (r: { name: string; sum: string }) => r.name },
|
|
92
|
+
{ header: "Summe", align: "right", cell: (r) => r.sum },
|
|
93
|
+
]}
|
|
94
|
+
rows={[{ name: "Bank", sum: "300.000 €" }]}
|
|
95
|
+
rowKey={(r) => r.name}
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
expect(screen.getByText("Tranche")).toBeTruthy();
|
|
99
|
+
expect(screen.getByText("Bank")).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("Summe").className).toContain("text-right");
|
|
101
|
+
expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
2
3
|
|
|
3
4
|
/** Read-only Schlüssel-Wert-Liste für Detail-Masken (Label links gedimmt,
|
|
4
|
-
* Wert rechts). Wert ist ReactNode → Badges/Chips möglich.
|
|
5
|
+
* Wert rechts). Wert ist ReactNode → Badges/Chips möglich. `emphasize` hebt
|
|
6
|
+
* eine Zeile hervor (z.B. das Endergebnis in einem Rechner). */
|
|
5
7
|
export function DetailList({
|
|
6
8
|
rows,
|
|
7
9
|
testId,
|
|
8
10
|
}: {
|
|
9
|
-
readonly rows: readonly {
|
|
11
|
+
readonly rows: readonly {
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly value: ReactNode;
|
|
14
|
+
readonly emphasize?: boolean;
|
|
15
|
+
}[];
|
|
10
16
|
readonly testId?: string;
|
|
11
17
|
}): ReactNode {
|
|
12
18
|
return (
|
|
@@ -16,8 +22,17 @@ export function DetailList({
|
|
|
16
22
|
key={row.label}
|
|
17
23
|
className="grid grid-cols-1 gap-0.5 py-2.5 sm:grid-cols-[200px_1fr] sm:gap-4"
|
|
18
24
|
>
|
|
19
|
-
<dt
|
|
20
|
-
|
|
25
|
+
<dt
|
|
26
|
+
className={cn(
|
|
27
|
+
"text-sm text-muted-foreground",
|
|
28
|
+
row.emphasize === true && "font-semibold text-foreground",
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{row.label}
|
|
32
|
+
</dt>
|
|
33
|
+
<dd className={cn("text-sm font-medium", row.emphasize === true && "font-semibold")}>
|
|
34
|
+
{row.value}
|
|
35
|
+
</dd>
|
|
21
36
|
</div>
|
|
22
37
|
))}
|
|
23
38
|
</dl>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FeedRow = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly primary: string;
|
|
6
|
+
readonly trailing?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Nicht-tabellarische Kurzliste (z.B. "nächste Termine") — Primary-Text +
|
|
10
|
+
* optionaler rechtsbündiger Trailing-Wert pro Zeile. */
|
|
11
|
+
export function FeedList({
|
|
12
|
+
rows,
|
|
13
|
+
emptyContent,
|
|
14
|
+
testId,
|
|
15
|
+
}: {
|
|
16
|
+
readonly rows: readonly FeedRow[];
|
|
17
|
+
readonly emptyContent?: ReactNode;
|
|
18
|
+
readonly testId?: string;
|
|
19
|
+
}): ReactNode {
|
|
20
|
+
if (rows.length === 0) {
|
|
21
|
+
return <div data-testid={testId}>{emptyContent}</div>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<ul data-testid={testId} className="flex max-h-64 flex-col overflow-y-auto pr-1 text-sm">
|
|
25
|
+
{rows.map((row) => (
|
|
26
|
+
<li key={row.id} className="flex justify-between gap-4 border-b py-1.5 last:border-b-0">
|
|
27
|
+
<span>{row.primary}</span>
|
|
28
|
+
{row.trailing !== undefined && (
|
|
29
|
+
<span className="tabular-nums text-muted-foreground">{row.trailing}</span>
|
|
30
|
+
)}
|
|
31
|
+
</li>
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface NumberFieldProps {
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly value: number | undefined;
|
|
9
|
+
readonly onChange: (v: number | undefined) => void;
|
|
10
|
+
readonly required?: boolean;
|
|
11
|
+
readonly disabled?: boolean;
|
|
12
|
+
/** Einheit rechts neben dem Label (z.B. "€", "%"). */
|
|
13
|
+
readonly unit?: string;
|
|
14
|
+
readonly testId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die
|
|
18
|
+
* wiederholte id/name/value/onChange-Verdrahtung ab. `value` darf `undefined`
|
|
19
|
+
* sein (leeres Feld), intern auf den `""`-Empty-State des Inputs gemappt. */
|
|
20
|
+
export function NumberField({
|
|
21
|
+
label,
|
|
22
|
+
id,
|
|
23
|
+
name,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
required,
|
|
27
|
+
disabled,
|
|
28
|
+
unit,
|
|
29
|
+
testId,
|
|
30
|
+
}: NumberFieldProps): ReactNode {
|
|
31
|
+
const { Field, Input } = usePrimitives();
|
|
32
|
+
return (
|
|
33
|
+
<Field
|
|
34
|
+
id={id}
|
|
35
|
+
label={label}
|
|
36
|
+
required={required}
|
|
37
|
+
labelAppendix={
|
|
38
|
+
unit !== undefined ? (
|
|
39
|
+
<span className="text-xs text-muted-foreground">{unit}</span>
|
|
40
|
+
) : undefined
|
|
41
|
+
}
|
|
42
|
+
testId={testId}
|
|
43
|
+
>
|
|
44
|
+
<Input
|
|
45
|
+
kind="number"
|
|
46
|
+
id={id}
|
|
47
|
+
name={name}
|
|
48
|
+
value={value ?? ""}
|
|
49
|
+
onChange={onChange}
|
|
50
|
+
required={required}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
/>
|
|
53
|
+
</Field>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Euro-Betrag in ganzen Einheiten (kein Cent-Integer wie Input kind:"money").
|
|
58
|
+
* Preset über NumberField mit "€"-Einheit. */
|
|
59
|
+
export function MoneyField(props: Omit<NumberFieldProps, "unit">): ReactNode {
|
|
60
|
+
return <NumberField {...props} unit="€" />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Prozentwert (Zins, Tilgung, Rendite). Preset über NumberField mit "%". */
|
|
64
|
+
export function PercentField(props: Omit<NumberFieldProps, "unit">): ReactNode {
|
|
65
|
+
return <NumberField {...props} unit="%" />;
|
|
66
|
+
}
|
package/src/widgets/index.ts
CHANGED
|
@@ -12,10 +12,15 @@ export {
|
|
|
12
12
|
} from "./charts";
|
|
13
13
|
export { CollapsibleSection } from "./collapsible-section";
|
|
14
14
|
export { DetailList } from "./detail-list";
|
|
15
|
+
export { FeedList, type FeedRow } from "./feed-list";
|
|
16
|
+
export { MoneyField, NumberField, type NumberFieldProps, PercentField } from "./form-fields";
|
|
15
17
|
export { ModeSwitch } from "./mode-switch";
|
|
16
18
|
export { ProgressBar } from "./progress-bar";
|
|
19
|
+
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
17
20
|
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
21
|
+
export { type ResultColumn, ResultPanel, ResultTable } from "./result-panel";
|
|
18
22
|
export { SectionCard } from "./section-card";
|
|
19
23
|
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
20
24
|
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
21
25
|
export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
|
|
26
|
+
export { useDraft } from "./use-draft";
|