@cosmicdrift/kumiko-renderer-web 0.136.1 → 0.138.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 +46 -0
- package/src/app/dashboard-body.tsx +51 -2
- package/src/index.ts +12 -0
- package/src/widgets/__tests__/form-kit.test.tsx +113 -2
- package/src/widgets/form-fields.tsx +189 -0
- package/src/widgets/index.ts +23 -2
- package/src/widgets/result-panel.tsx +68 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.138.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.138.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.138.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.138.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -110,6 +110,8 @@ const richScreen: DashboardScreenDefinition = {
|
|
|
110
110
|
deltaField: "delta",
|
|
111
111
|
deltaDirectionField: "deltaDirection",
|
|
112
112
|
deltaToneField: "deltaTone",
|
|
113
|
+
icon: { react: { __component: "rich-dashboard-kpi-icon" } },
|
|
114
|
+
accentColor: "#123456",
|
|
113
115
|
},
|
|
114
116
|
{
|
|
115
117
|
kind: "stat-group",
|
|
@@ -282,6 +284,50 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
|
282
284
|
expect(screen.getByText("↓23 %")).toBeTruthy();
|
|
283
285
|
});
|
|
284
286
|
|
|
287
|
+
test("stat-Panel: registriertes Icon rendert, unregistriertes rendert nichts (kein Crash)", async () => {
|
|
288
|
+
function KpiIcon(): ReactNode {
|
|
289
|
+
return <svg data-testid="kpi-icon" />;
|
|
290
|
+
}
|
|
291
|
+
const dispatcher = createMockDispatcher({
|
|
292
|
+
query: (async (type: string) => {
|
|
293
|
+
if (type === "widgets:query:metrics:kpi") {
|
|
294
|
+
return { isSuccess: true, data: { value: "92.753 €" } };
|
|
295
|
+
}
|
|
296
|
+
return { isSuccess: true, data: {} };
|
|
297
|
+
}) as unknown as Dispatcher["query"],
|
|
298
|
+
});
|
|
299
|
+
render(
|
|
300
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
301
|
+
<ExtensionSectionsProvider value={{ "rich-dashboard-kpi-icon": KpiIcon }}>
|
|
302
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
303
|
+
<KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
|
|
304
|
+
</DashboardBodyProvider>
|
|
305
|
+
</ExtensionSectionsProvider>
|
|
306
|
+
</DispatcherProvider>,
|
|
307
|
+
);
|
|
308
|
+
await waitFor(() => expect(screen.getByTestId("kpi-icon")).toBeTruthy());
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("stat-Panel: unregistriertes Icon rendert nichts, wirft nicht", async () => {
|
|
312
|
+
const dispatcher = createMockDispatcher({
|
|
313
|
+
query: (async (type: string) => {
|
|
314
|
+
if (type === "widgets:query:metrics:kpi") {
|
|
315
|
+
return { isSuccess: true, data: { value: "92.753 €" } };
|
|
316
|
+
}
|
|
317
|
+
return { isSuccess: true, data: {} };
|
|
318
|
+
}) as unknown as Dispatcher["query"],
|
|
319
|
+
});
|
|
320
|
+
render(
|
|
321
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
322
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
323
|
+
<KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
|
|
324
|
+
</DashboardBodyProvider>
|
|
325
|
+
</DispatcherProvider>,
|
|
326
|
+
);
|
|
327
|
+
await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
|
|
328
|
+
expect(screen.queryByTestId("kpi-icon")).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
285
331
|
test("stat-Panel: fehlende deltaDirection unterdrückt den Chip (kein Crash)", async () => {
|
|
286
332
|
const dispatcher = createMockDispatcher({
|
|
287
333
|
query: (async (type: string) => {
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// deltaField/deltaDirectionField(+deltaToneField) sind
|
|
11
11
|
// optional — nur wenn BEIDE Felder gesetzt sind UND geliefert
|
|
12
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.
|
|
13
16
|
// stat-group → mehrere stat-Panels unter einem Sektions-Titel, jedes
|
|
14
17
|
// Kind bleibt eine eigenständige Query.
|
|
15
18
|
// chart → { points: { atMs, value | null }[], windowStartMs,
|
|
@@ -58,13 +61,41 @@ const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"])
|
|
|
58
61
|
const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
|
|
59
62
|
const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
|
|
60
63
|
|
|
64
|
+
function StatPanelIcon({
|
|
65
|
+
panel,
|
|
66
|
+
screenId,
|
|
67
|
+
filterParams,
|
|
68
|
+
}: {
|
|
69
|
+
readonly panel: DashboardStatPanel;
|
|
70
|
+
readonly screenId: string;
|
|
71
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
72
|
+
}): ReactNode {
|
|
73
|
+
const name = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
|
|
74
|
+
const Icon = useExtensionSectionComponent(name);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (panel.icon !== undefined && name !== undefined && Icon === undefined) {
|
|
77
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
78
|
+
console.warn(
|
|
79
|
+
`[kumiko] Dashboard stat-panel "${panel.id}" on screen "${screenId}" references icon ` +
|
|
80
|
+
`"${name}", which is not registered in clientFeatures.extensionSectionComponents.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}, [panel.icon, panel.id, name, Icon, screenId]);
|
|
84
|
+
if (Icon === undefined) return null;
|
|
85
|
+
return (
|
|
86
|
+
<Icon entityName={screenId} entityId={null} screenId={screenId} filterParams={filterParams} />
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
function StatPanelBody({
|
|
62
91
|
panel,
|
|
63
92
|
label,
|
|
93
|
+
screenId,
|
|
64
94
|
filterParams,
|
|
65
95
|
}: {
|
|
66
96
|
readonly panel: DashboardStatPanel;
|
|
67
97
|
readonly label: string;
|
|
98
|
+
readonly screenId: string;
|
|
68
99
|
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
69
100
|
}): ReactNode {
|
|
70
101
|
const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
|
|
@@ -82,9 +113,15 @@ function StatPanelBody({
|
|
|
82
113
|
const delta = readDelta(panel, record);
|
|
83
114
|
return (
|
|
84
115
|
<StatCard
|
|
116
|
+
icon={
|
|
117
|
+
panel.icon !== undefined ? (
|
|
118
|
+
<StatPanelIcon panel={panel} screenId={screenId} filterParams={filterParams} />
|
|
119
|
+
) : undefined
|
|
120
|
+
}
|
|
85
121
|
label={label}
|
|
86
122
|
value={String(record[panel.valueField] ?? "—")}
|
|
87
123
|
tone={tone}
|
|
124
|
+
accentColor={panel.accentColor}
|
|
88
125
|
{...(sub !== undefined && sub !== null && { sub: String(sub) })}
|
|
89
126
|
{...(delta !== undefined && { delta })}
|
|
90
127
|
testId={`dashboard-panel-${panel.id}`}
|
|
@@ -110,10 +147,12 @@ function readDelta(
|
|
|
110
147
|
function StatGroupPanelBody({
|
|
111
148
|
panel,
|
|
112
149
|
label,
|
|
150
|
+
screenId,
|
|
113
151
|
filterParams,
|
|
114
152
|
}: {
|
|
115
153
|
readonly panel: DashboardStatGroupPanel;
|
|
116
154
|
readonly label: string;
|
|
155
|
+
readonly screenId: string;
|
|
117
156
|
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
118
157
|
}): ReactNode {
|
|
119
158
|
const t = useTranslation();
|
|
@@ -125,6 +164,7 @@ function StatGroupPanelBody({
|
|
|
125
164
|
key={stat.id}
|
|
126
165
|
panel={stat}
|
|
127
166
|
label={t(stat.label)}
|
|
167
|
+
screenId={screenId}
|
|
128
168
|
filterParams={filterParams}
|
|
129
169
|
/>
|
|
130
170
|
))}
|
|
@@ -365,9 +405,18 @@ function PanelBody({
|
|
|
365
405
|
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
366
406
|
}): ReactNode {
|
|
367
407
|
if (panel.kind === "stat")
|
|
368
|
-
return
|
|
408
|
+
return (
|
|
409
|
+
<StatPanelBody panel={panel} label={label} screenId={screenId} filterParams={filterParams} />
|
|
410
|
+
);
|
|
369
411
|
if (panel.kind === "stat-group") {
|
|
370
|
-
return
|
|
412
|
+
return (
|
|
413
|
+
<StatGroupPanelBody
|
|
414
|
+
panel={panel}
|
|
415
|
+
label={label}
|
|
416
|
+
screenId={screenId}
|
|
417
|
+
filterParams={filterParams}
|
|
418
|
+
/>
|
|
419
|
+
);
|
|
371
420
|
}
|
|
372
421
|
if (panel.kind === "chart")
|
|
373
422
|
return <ChartPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
package/src/index.ts
CHANGED
|
@@ -155,20 +155,29 @@ export {
|
|
|
155
155
|
} from "./tokens";
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
157
|
export type {
|
|
158
|
+
BooleanFieldProps,
|
|
159
|
+
ComparisonMetric,
|
|
160
|
+
DateFieldProps,
|
|
158
161
|
FeedRow,
|
|
159
162
|
NumberFieldProps,
|
|
160
163
|
ProgressListRow,
|
|
161
164
|
QueryTableColumn,
|
|
162
165
|
QueryTableProps,
|
|
163
166
|
ResultColumn,
|
|
167
|
+
SelectFieldProps,
|
|
164
168
|
StatDelta,
|
|
165
169
|
StatTone,
|
|
166
170
|
StatusBarEntry,
|
|
167
171
|
StatusTone,
|
|
172
|
+
TextareaFieldProps,
|
|
173
|
+
TextFieldProps,
|
|
168
174
|
TimeseriesPoint,
|
|
169
175
|
} from "./widgets";
|
|
170
176
|
export {
|
|
177
|
+
BooleanField,
|
|
171
178
|
CollapsibleSection,
|
|
179
|
+
ComparisonTable,
|
|
180
|
+
DateField,
|
|
172
181
|
DetailList,
|
|
173
182
|
EmptyState,
|
|
174
183
|
ErrorState,
|
|
@@ -185,12 +194,15 @@ export {
|
|
|
185
194
|
ResultPanel,
|
|
186
195
|
ResultTable,
|
|
187
196
|
SectionCard,
|
|
197
|
+
SelectField,
|
|
188
198
|
Sparkline,
|
|
189
199
|
STATUS_TONE_TEXT,
|
|
190
200
|
StatCard,
|
|
191
201
|
StatusBadge,
|
|
192
202
|
StatusBarChart,
|
|
193
203
|
smoothPath,
|
|
204
|
+
TextareaField,
|
|
205
|
+
TextField,
|
|
194
206
|
TimeseriesChart,
|
|
195
207
|
useDraft,
|
|
196
208
|
} from "./widgets";
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
|
|
3
3
|
import { DetailList } from "../detail-list";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
BooleanField,
|
|
6
|
+
DateField,
|
|
7
|
+
MoneyField,
|
|
8
|
+
NumberField,
|
|
9
|
+
PercentField,
|
|
10
|
+
SelectField,
|
|
11
|
+
TextareaField,
|
|
12
|
+
TextField,
|
|
13
|
+
} from "../form-fields";
|
|
14
|
+
import { ComparisonTable, ResultPanel, ResultTable } from "../result-panel";
|
|
6
15
|
import { useDraft } from "../use-draft";
|
|
7
16
|
|
|
8
17
|
describe("useDraft", () => {
|
|
@@ -103,3 +112,105 @@ describe("ResultTable", () => {
|
|
|
103
112
|
expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
|
|
104
113
|
});
|
|
105
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("Feld-Widgets (Select/Date/Text/Boolean/Textarea)", () => {
|
|
117
|
+
const noopStr = (): void => {};
|
|
118
|
+
|
|
119
|
+
test("SelectField rendert Label und den aktuell gewählten Wert", () => {
|
|
120
|
+
render(
|
|
121
|
+
<SelectField
|
|
122
|
+
label="Land"
|
|
123
|
+
id="l"
|
|
124
|
+
name="l"
|
|
125
|
+
value="NW"
|
|
126
|
+
onChange={noopStr}
|
|
127
|
+
options={[
|
|
128
|
+
{ value: "NW", label: "Nordrhein-Westfalen" },
|
|
129
|
+
{ value: "BY", label: "Bayern" },
|
|
130
|
+
]}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
expect(screen.getByText("Land")).toBeTruthy();
|
|
134
|
+
expect(screen.getByText("Nordrhein-Westfalen")).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("TextField meldet Eingabe", () => {
|
|
138
|
+
const onChange = mock();
|
|
139
|
+
render(<TextField label="Name" id="n" name="n" value="A" onChange={onChange} />);
|
|
140
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Neu" } });
|
|
141
|
+
expect(onChange).toHaveBeenCalledWith("Neu");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("DateField rendert mit Wert", () => {
|
|
145
|
+
render(
|
|
146
|
+
<DateField
|
|
147
|
+
label="Datum"
|
|
148
|
+
id="d"
|
|
149
|
+
name="d"
|
|
150
|
+
value="2026-07-10"
|
|
151
|
+
onChange={noopStr}
|
|
152
|
+
max="2030-01-01"
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
expect(screen.getByText("Datum")).toBeTruthy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("BooleanField meldet Umschalten", () => {
|
|
159
|
+
const onChange = mock();
|
|
160
|
+
render(<BooleanField label="Aktiv" id="b" name="b" value={false} onChange={onChange} />);
|
|
161
|
+
fireEvent.click(screen.getByRole("checkbox"));
|
|
162
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("TextareaField meldet Eingabe", () => {
|
|
166
|
+
const onChange = mock();
|
|
167
|
+
render(<TextareaField label="Notiz" id="t" name="t" value="" onChange={onChange} rows={3} />);
|
|
168
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Text" } });
|
|
169
|
+
expect(onChange).toHaveBeenCalledWith("Text");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("ResultPanel footer", () => {
|
|
174
|
+
test("rendert den footer-Slot", () => {
|
|
175
|
+
render(
|
|
176
|
+
<ResultPanel
|
|
177
|
+
title="R"
|
|
178
|
+
rows={[{ label: "X", value: "1" }]}
|
|
179
|
+
footer={<button type="button">Los</button>}
|
|
180
|
+
>
|
|
181
|
+
<span>body</span>
|
|
182
|
+
</ResultPanel>,
|
|
183
|
+
);
|
|
184
|
+
expect(screen.getByRole("button", { name: "Los" })).toBeTruthy();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("ComparisonTable", () => {
|
|
189
|
+
test("transponierte Matrix mit Best-Highlight je Kennzahl", () => {
|
|
190
|
+
const cols = [
|
|
191
|
+
{ name: "A", rate: 900 },
|
|
192
|
+
{ name: "B", rate: 850 },
|
|
193
|
+
];
|
|
194
|
+
render(
|
|
195
|
+
<ComparisonTable
|
|
196
|
+
testId="cmp"
|
|
197
|
+
columns={cols}
|
|
198
|
+
columnHeader={(c) => c.name}
|
|
199
|
+
columnKey={(c) => c.name}
|
|
200
|
+
metricLabel="Kennzahl"
|
|
201
|
+
metrics={[
|
|
202
|
+
{
|
|
203
|
+
label: "Rate",
|
|
204
|
+
value: (c: { name: string; rate: number }) => `${c.rate} €`,
|
|
205
|
+
bestIndex: (cs) => ((cs[0]?.rate ?? 0) <= (cs[1]?.rate ?? 0) ? 0 : 1),
|
|
206
|
+
},
|
|
207
|
+
]}
|
|
208
|
+
/>,
|
|
209
|
+
);
|
|
210
|
+
expect(screen.getByText("Kennzahl")).toBeTruthy();
|
|
211
|
+
expect(screen.getByText("A")).toBeTruthy();
|
|
212
|
+
// B (850) ist günstiger → hervorgehoben (font-semibold text-primary span)
|
|
213
|
+
expect(screen.getByText("850 €").className).toContain("text-primary");
|
|
214
|
+
expect(screen.getByText("900 €").className).not.toContain("text-primary");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -52,3 +52,192 @@ export function MoneyField(props: NumberFieldProps): ReactNode {
|
|
|
52
52
|
export function PercentField(props: NumberFieldProps): ReactNode {
|
|
53
53
|
return <NumberField {...props} />;
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
interface FieldBase {
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly required?: boolean;
|
|
61
|
+
readonly disabled?: boolean;
|
|
62
|
+
readonly testId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TextFieldProps extends FieldBase {
|
|
66
|
+
readonly value: string;
|
|
67
|
+
readonly onChange: (v: string) => void;
|
|
68
|
+
readonly placeholder?: string;
|
|
69
|
+
readonly autoComplete?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Textfeld = Field + Input(kind:"text"). */
|
|
73
|
+
export function TextField({
|
|
74
|
+
label,
|
|
75
|
+
id,
|
|
76
|
+
name,
|
|
77
|
+
value,
|
|
78
|
+
onChange,
|
|
79
|
+
required,
|
|
80
|
+
disabled,
|
|
81
|
+
placeholder,
|
|
82
|
+
autoComplete,
|
|
83
|
+
testId,
|
|
84
|
+
}: TextFieldProps): ReactNode {
|
|
85
|
+
const { Field, Input } = usePrimitives();
|
|
86
|
+
return (
|
|
87
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
88
|
+
<Input
|
|
89
|
+
kind="text"
|
|
90
|
+
id={id}
|
|
91
|
+
name={name}
|
|
92
|
+
value={value}
|
|
93
|
+
onChange={onChange}
|
|
94
|
+
required={required}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
{...(placeholder !== undefined && { placeholder })}
|
|
97
|
+
{...(autoComplete !== undefined && { autoComplete })}
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SelectFieldProps extends FieldBase {
|
|
104
|
+
readonly value: string;
|
|
105
|
+
readonly onChange: (v: string) => void;
|
|
106
|
+
readonly options:
|
|
107
|
+
| readonly string[]
|
|
108
|
+
| readonly { readonly value: string; readonly label: string }[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Auswahlfeld = Field + Input(kind:"select"). */
|
|
112
|
+
export function SelectField({
|
|
113
|
+
label,
|
|
114
|
+
id,
|
|
115
|
+
name,
|
|
116
|
+
value,
|
|
117
|
+
onChange,
|
|
118
|
+
options,
|
|
119
|
+
required,
|
|
120
|
+
disabled,
|
|
121
|
+
testId,
|
|
122
|
+
}: SelectFieldProps): ReactNode {
|
|
123
|
+
const { Field, Input } = usePrimitives();
|
|
124
|
+
return (
|
|
125
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
126
|
+
<Input
|
|
127
|
+
kind="select"
|
|
128
|
+
id={id}
|
|
129
|
+
name={name}
|
|
130
|
+
value={value}
|
|
131
|
+
onChange={onChange}
|
|
132
|
+
options={options}
|
|
133
|
+
required={required}
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
/>
|
|
136
|
+
</Field>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DateFieldProps extends FieldBase {
|
|
141
|
+
readonly value: string;
|
|
142
|
+
readonly onChange: (v: string | undefined) => void;
|
|
143
|
+
readonly min?: string;
|
|
144
|
+
readonly max?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Datumsfeld = Field + Input(kind:"date"), ISO yyyy-mm-dd. */
|
|
148
|
+
export function DateField({
|
|
149
|
+
label,
|
|
150
|
+
id,
|
|
151
|
+
name,
|
|
152
|
+
value,
|
|
153
|
+
onChange,
|
|
154
|
+
min,
|
|
155
|
+
max,
|
|
156
|
+
required,
|
|
157
|
+
disabled,
|
|
158
|
+
testId,
|
|
159
|
+
}: DateFieldProps): ReactNode {
|
|
160
|
+
const { Field, Input } = usePrimitives();
|
|
161
|
+
return (
|
|
162
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
163
|
+
<Input
|
|
164
|
+
kind="date"
|
|
165
|
+
id={id}
|
|
166
|
+
name={name}
|
|
167
|
+
value={value}
|
|
168
|
+
onChange={onChange}
|
|
169
|
+
required={required}
|
|
170
|
+
disabled={disabled}
|
|
171
|
+
{...(min !== undefined && { min })}
|
|
172
|
+
{...(max !== undefined && { max })}
|
|
173
|
+
/>
|
|
174
|
+
</Field>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface BooleanFieldProps extends FieldBase {
|
|
179
|
+
readonly value: boolean;
|
|
180
|
+
readonly onChange: (v: boolean) => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Checkbox/Switch = Field(layout:"inline") + Input(kind:"boolean"). */
|
|
184
|
+
export function BooleanField({
|
|
185
|
+
label,
|
|
186
|
+
id,
|
|
187
|
+
name,
|
|
188
|
+
value,
|
|
189
|
+
onChange,
|
|
190
|
+
required,
|
|
191
|
+
disabled,
|
|
192
|
+
testId,
|
|
193
|
+
}: BooleanFieldProps): ReactNode {
|
|
194
|
+
const { Field, Input } = usePrimitives();
|
|
195
|
+
return (
|
|
196
|
+
<Field id={id} label={label} required={required} layout="inline" testId={testId}>
|
|
197
|
+
<Input
|
|
198
|
+
kind="boolean"
|
|
199
|
+
id={id}
|
|
200
|
+
name={name}
|
|
201
|
+
value={value}
|
|
202
|
+
onChange={onChange}
|
|
203
|
+
required={required}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
/>
|
|
206
|
+
</Field>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface TextareaFieldProps extends FieldBase {
|
|
211
|
+
readonly value: string;
|
|
212
|
+
readonly onChange: (v: string) => void;
|
|
213
|
+
readonly rows?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Mehrzeiliges Textfeld = Field + Input(kind:"textarea"). */
|
|
217
|
+
export function TextareaField({
|
|
218
|
+
label,
|
|
219
|
+
id,
|
|
220
|
+
name,
|
|
221
|
+
value,
|
|
222
|
+
onChange,
|
|
223
|
+
rows,
|
|
224
|
+
required,
|
|
225
|
+
disabled,
|
|
226
|
+
testId,
|
|
227
|
+
}: TextareaFieldProps): ReactNode {
|
|
228
|
+
const { Field, Input } = usePrimitives();
|
|
229
|
+
return (
|
|
230
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
231
|
+
<Input
|
|
232
|
+
kind="textarea"
|
|
233
|
+
id={id}
|
|
234
|
+
name={name}
|
|
235
|
+
value={value}
|
|
236
|
+
onChange={onChange}
|
|
237
|
+
required={required}
|
|
238
|
+
disabled={disabled}
|
|
239
|
+
{...(rows !== undefined && { rows })}
|
|
240
|
+
/>
|
|
241
|
+
</Field>
|
|
242
|
+
);
|
|
243
|
+
}
|
package/src/widgets/index.ts
CHANGED
|
@@ -13,12 +13,33 @@ export {
|
|
|
13
13
|
export { CollapsibleSection } from "./collapsible-section";
|
|
14
14
|
export { DetailList } from "./detail-list";
|
|
15
15
|
export { FeedList, type FeedRow } from "./feed-list";
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
BooleanField,
|
|
18
|
+
type BooleanFieldProps,
|
|
19
|
+
DateField,
|
|
20
|
+
type DateFieldProps,
|
|
21
|
+
MoneyField,
|
|
22
|
+
NumberField,
|
|
23
|
+
type NumberFieldProps,
|
|
24
|
+
PercentField,
|
|
25
|
+
SelectField,
|
|
26
|
+
type SelectFieldProps,
|
|
27
|
+
TextareaField,
|
|
28
|
+
type TextareaFieldProps,
|
|
29
|
+
TextField,
|
|
30
|
+
type TextFieldProps,
|
|
31
|
+
} from "./form-fields";
|
|
17
32
|
export { ModeSwitch } from "./mode-switch";
|
|
18
33
|
export { ProgressBar } from "./progress-bar";
|
|
19
34
|
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
20
35
|
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
21
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
type ComparisonMetric,
|
|
38
|
+
ComparisonTable,
|
|
39
|
+
type ResultColumn,
|
|
40
|
+
ResultPanel,
|
|
41
|
+
ResultTable,
|
|
42
|
+
} from "./result-panel";
|
|
22
43
|
export { SectionCard } from "./section-card";
|
|
23
44
|
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
24
45
|
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
@@ -13,6 +13,7 @@ export function ResultPanel({
|
|
|
13
13
|
empty,
|
|
14
14
|
emptyText,
|
|
15
15
|
rows,
|
|
16
|
+
footer,
|
|
16
17
|
children,
|
|
17
18
|
testId,
|
|
18
19
|
}: {
|
|
@@ -25,12 +26,14 @@ export function ResultPanel({
|
|
|
25
26
|
readonly value: ReactNode;
|
|
26
27
|
readonly emphasize?: boolean;
|
|
27
28
|
}[];
|
|
29
|
+
/** Action-Slot am Karten-Fuß (z.B. „In Finanzierung übernehmen"). */
|
|
30
|
+
readonly footer?: ReactNode;
|
|
28
31
|
readonly children?: ReactNode;
|
|
29
32
|
readonly testId?: string;
|
|
30
33
|
}): ReactNode {
|
|
31
34
|
const { Banner } = usePrimitives();
|
|
32
35
|
return (
|
|
33
|
-
<SectionCard title={title} subtitle={subtitle} testId={testId}>
|
|
36
|
+
<SectionCard title={title} subtitle={subtitle} footer={footer} testId={testId}>
|
|
34
37
|
{empty === true ? (
|
|
35
38
|
<Banner variant="info" padded>
|
|
36
39
|
{emptyText}
|
|
@@ -99,3 +102,67 @@ export function ResultTable<Row>({
|
|
|
99
102
|
</div>
|
|
100
103
|
);
|
|
101
104
|
}
|
|
105
|
+
|
|
106
|
+
export interface ComparisonMetric<Col> {
|
|
107
|
+
readonly label: string;
|
|
108
|
+
readonly value: (col: Col, index: number) => ReactNode;
|
|
109
|
+
/** Index der besten Spalte für diese Zeile (hervorgehoben); -1 = keine. */
|
|
110
|
+
readonly bestIndex?: (cols: readonly Col[]) => number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Transponierte Vergleichstabelle: Zeile = Kennzahl, Spalte = Variante, je
|
|
114
|
+
* Kennzahl optional die beste Spalte hervorgehoben. Für Szenario-/Angebots-
|
|
115
|
+
* Vergleiche, wo ResultTable (Zeile=Datensatz) nicht passt. */
|
|
116
|
+
export function ComparisonTable<Col>({
|
|
117
|
+
columns,
|
|
118
|
+
columnHeader,
|
|
119
|
+
columnKey,
|
|
120
|
+
metrics,
|
|
121
|
+
metricLabel,
|
|
122
|
+
testId,
|
|
123
|
+
}: {
|
|
124
|
+
readonly columns: readonly Col[];
|
|
125
|
+
readonly columnHeader: (col: Col, index: number) => string;
|
|
126
|
+
readonly columnKey: (col: Col, index: number) => string;
|
|
127
|
+
readonly metrics: readonly ComparisonMetric<Col>[];
|
|
128
|
+
readonly metricLabel: string;
|
|
129
|
+
readonly testId?: string;
|
|
130
|
+
}): ReactNode {
|
|
131
|
+
return (
|
|
132
|
+
<div className="overflow-x-auto">
|
|
133
|
+
<table data-testid={testId} className="w-full min-w-[24rem] text-sm">
|
|
134
|
+
<thead>
|
|
135
|
+
<tr className="border-b text-left text-muted-foreground">
|
|
136
|
+
<th className="py-1.5 font-medium">{metricLabel}</th>
|
|
137
|
+
{columns.map((col, i) => (
|
|
138
|
+
<th key={columnKey(col, i)} className="py-1.5 text-right font-medium">
|
|
139
|
+
{columnHeader(col, i)}
|
|
140
|
+
</th>
|
|
141
|
+
))}
|
|
142
|
+
</tr>
|
|
143
|
+
</thead>
|
|
144
|
+
<tbody>
|
|
145
|
+
{metrics.map((metric) => {
|
|
146
|
+
const best = metric.bestIndex !== undefined ? metric.bestIndex(columns) : -1;
|
|
147
|
+
return (
|
|
148
|
+
<tr key={metric.label} className="border-b last:border-0">
|
|
149
|
+
<td className="py-1.5 text-muted-foreground">{metric.label}</td>
|
|
150
|
+
{columns.map((col, i) => (
|
|
151
|
+
<td key={columnKey(col, i)} className="py-1.5 text-right tabular-nums">
|
|
152
|
+
{i === best ? (
|
|
153
|
+
<span className="inline-block rounded bg-primary/10 px-2 py-0.5 font-semibold text-primary">
|
|
154
|
+
{metric.value(col, i)}
|
|
155
|
+
</span>
|
|
156
|
+
) : (
|
|
157
|
+
metric.value(col, i)
|
|
158
|
+
)}
|
|
159
|
+
</td>
|
|
160
|
+
))}
|
|
161
|
+
</tr>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</tbody>
|
|
165
|
+
</table>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|