@cosmicdrift/kumiko-renderer-web 0.136.0 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.136.0",
3
+ "version": "0.137.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.136.0",
20
- "@cosmicdrift/kumiko-headless": "0.136.0",
21
- "@cosmicdrift/kumiko-renderer": "0.136.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.137.0",
20
+ "@cosmicdrift/kumiko-headless": "0.137.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.137.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 <StatPanelBody panel={panel} label={label} filterParams={filterParams} />;
408
+ return (
409
+ <StatPanelBody panel={panel} label={label} screenId={screenId} filterParams={filterParams} />
410
+ );
369
411
  if (panel.kind === "stat-group") {
370
- return <StatGroupPanelBody panel={panel} label={label} filterParams={filterParams} />;
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} />;
@@ -38,14 +38,16 @@ describe("NumberField", () => {
38
38
  expect(onChange).toHaveBeenCalledWith(undefined);
39
39
  });
40
40
 
41
- test("MoneyField/PercentField zeigen ihre Einheit", () => {
41
+ test("MoneyField/PercentField rendern als beschriftetes Zahlenfeld ohne Einheit-Badge", () => {
42
42
  const noop = (): void => {};
43
43
  const { rerender } = render(
44
- <MoneyField label="Betrag" id="b" name="b" value={1} onChange={noop} />,
44
+ <MoneyField label="Betrag (€)" id="b" name="b" value={1} onChange={noop} />,
45
45
  );
46
- expect(screen.getByText("€")).toBeTruthy();
47
- rerender(<PercentField label="Zins" id="z" name="z" value={1} onChange={noop} />);
48
- expect(screen.getByText("%")).toBeTruthy();
46
+ expect(screen.getByText("Betrag ()")).toBeTruthy();
47
+ expect(screen.getByRole("spinbutton")).toBeTruthy();
48
+ expect(screen.queryByText("")).toBeNull(); // Einheit lebt im Label, kein separates Badge
49
+ rerender(<PercentField label="Zins (%)" id="z" name="z" value={1} onChange={noop} />);
50
+ expect(screen.queryByText("%")).toBeNull();
49
51
  });
50
52
  });
51
53
 
@@ -9,14 +9,13 @@ export interface NumberFieldProps {
9
9
  readonly onChange: (v: number | undefined) => void;
10
10
  readonly required?: boolean;
11
11
  readonly disabled?: boolean;
12
- /** Einheit rechts neben dem Label (z.B. "€", "%"). */
13
- readonly unit?: string;
14
12
  readonly testId?: string;
15
13
  }
16
14
 
17
15
  /** Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die
18
16
  * wiederholte id/name/value/onChange-Verdrahtung ab. `value` darf `undefined`
19
- * sein (leeres Feld), intern auf den `""`-Empty-State des Inputs gemappt. */
17
+ * sein (leeres Feld), intern auf den `""`-Empty-State des Inputs gemappt.
18
+ * Einheiten (€/%) gehören ins Label (`t("…Summe (€)")`), nicht als Badge. */
20
19
  export function NumberField({
21
20
  label,
22
21
  id,
@@ -25,22 +24,11 @@ export function NumberField({
25
24
  onChange,
26
25
  required,
27
26
  disabled,
28
- unit,
29
27
  testId,
30
28
  }: NumberFieldProps): ReactNode {
31
29
  const { Field, Input } = usePrimitives();
32
30
  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
- >
31
+ <Field id={id} label={label} required={required} testId={testId}>
44
32
  <Input
45
33
  kind="number"
46
34
  id={id}
@@ -54,13 +42,13 @@ export function NumberField({
54
42
  );
55
43
  }
56
44
 
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="€" />;
45
+ // MoneyField/PercentField markieren die Feld-Absicht am Call-Site (lesbarer als
46
+ // NumberField überall) und sind der Ort, an dem später geld-/prozent-spezifische
47
+ // Formatierung andocken kann. Aktuell rendern sie identisch zu NumberField.
48
+ export function MoneyField(props: NumberFieldProps): ReactNode {
49
+ return <NumberField {...props} />;
61
50
  }
62
51
 
63
- /** Prozentwert (Zins, Tilgung, Rendite). Preset über NumberField mit "%". */
64
- export function PercentField(props: Omit<NumberFieldProps, "unit">): ReactNode {
65
- return <NumberField {...props} unit="%" />;
52
+ export function PercentField(props: NumberFieldProps): ReactNode {
53
+ return <NumberField {...props} />;
66
54
  }