@cosmicdrift/kumiko-renderer-web 0.135.0 → 0.136.1

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.135.0",
3
+ "version": "0.136.1",
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.135.0",
20
- "@cosmicdrift/kumiko-headless": "0.135.0",
21
- "@cosmicdrift/kumiko-renderer": "0.135.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.136.1",
20
+ "@cosmicdrift/kumiko-headless": "0.136.1",
21
+ "@cosmicdrift/kumiko-renderer": "0.136.1",
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",
@@ -107,6 +107,9 @@ const richScreen: DashboardScreenDefinition = {
107
107
  label: "widgets:dashboard:kpi",
108
108
  query: "widgets:query:metrics:kpi",
109
109
  valueField: "value",
110
+ deltaField: "delta",
111
+ deltaDirectionField: "deltaDirection",
112
+ deltaToneField: "deltaTone",
110
113
  },
111
114
  {
112
115
  kind: "stat-group",
@@ -251,6 +254,54 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
251
254
  expect(screen.queryByTestId("custom-echo")).toBeNull();
252
255
  });
253
256
 
257
+ test("stat-Panel: Delta-Chip rendert nur wenn value+direction geliefert werden", async () => {
258
+ const dispatcher = createMockDispatcher({
259
+ query: (async (type: string) => {
260
+ if (type === "widgets:query:metrics:kpi") {
261
+ return {
262
+ isSuccess: true,
263
+ data: {
264
+ value: "92.753 €",
265
+ delta: "23 %",
266
+ deltaDirection: "down",
267
+ deltaTone: "positive",
268
+ },
269
+ };
270
+ }
271
+ return { isSuccess: true, data: {} };
272
+ }) as unknown as Dispatcher["query"],
273
+ });
274
+ render(
275
+ <DispatcherProvider dispatcher={dispatcher}>
276
+ <DashboardBodyProvider value={WebDashboardBody}>
277
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
278
+ </DashboardBodyProvider>
279
+ </DispatcherProvider>,
280
+ );
281
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
282
+ expect(screen.getByText("↓23 %")).toBeTruthy();
283
+ });
284
+
285
+ test("stat-Panel: fehlende deltaDirection unterdrückt den Chip (kein Crash)", async () => {
286
+ const dispatcher = createMockDispatcher({
287
+ query: (async (type: string) => {
288
+ if (type === "widgets:query:metrics:kpi") {
289
+ return { isSuccess: true, data: { value: "92.753 €" } };
290
+ }
291
+ return { isSuccess: true, data: {} };
292
+ }) as unknown as Dispatcher["query"],
293
+ });
294
+ render(
295
+ <DispatcherProvider dispatcher={dispatcher}>
296
+ <DashboardBodyProvider value={WebDashboardBody}>
297
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
298
+ </DashboardBodyProvider>
299
+ </DispatcherProvider>,
300
+ );
301
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
302
+ expect(screen.queryByText("23 %")).toBeNull();
303
+ });
304
+
254
305
  test("Filter-Wechsel refetcht Stat- UND Feed-Panel mit neuem Payload", async () => {
255
306
  const calls: { readonly type: string; readonly payload: unknown }[] = [];
256
307
  const dispatcher = createMockDispatcher({
@@ -7,6 +7,9 @@
7
7
  // Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
8
8
  // stat → flaches Record, valueField/subField/toneField zeigen auf
9
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 %").
10
13
  // stat-group → mehrere stat-Panels unter einem Sektions-Titel, jedes
11
14
  // Kind bleibt eine eigenständige Query.
12
15
  // chart → { points: { atMs, value | null }[], windowStartMs,
@@ -48,7 +51,7 @@ import { FeedList, type FeedRow } from "../widgets/feed-list";
48
51
  import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
49
52
  import { QueryTable } from "../widgets/query-table";
50
53
  import { SectionCard } from "../widgets/section-card";
51
- import { StatCard, type StatTone } from "../widgets/stat";
54
+ import { StatCard, type StatDelta, type StatTone } from "../widgets/stat";
52
55
  import { ErrorState, LoadingState } from "../widgets/states";
53
56
 
54
57
  const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
@@ -76,17 +79,34 @@ function StatPanelBody({
76
79
  const tone =
77
80
  typeof rawTone === "string" && STAT_TONES.has(rawTone) ? (rawTone as StatTone) : "default";
78
81
  const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
82
+ const delta = readDelta(panel, record);
79
83
  return (
80
84
  <StatCard
81
85
  label={label}
82
86
  value={String(record[panel.valueField] ?? "—")}
83
87
  tone={tone}
84
88
  {...(sub !== undefined && sub !== null && { sub: String(sub) })}
89
+ {...(delta !== undefined && { delta })}
85
90
  testId={`dashboard-panel-${panel.id}`}
86
91
  />
87
92
  );
88
93
  }
89
94
 
95
+ function readDelta(
96
+ panel: DashboardStatPanel,
97
+ record: Readonly<Record<string, unknown>>,
98
+ ): StatDelta | undefined {
99
+ if (panel.deltaField === undefined || panel.deltaDirectionField === undefined) return undefined;
100
+ const value = record[panel.deltaField];
101
+ const direction = record[panel.deltaDirectionField];
102
+ if (value === undefined || value === null) return undefined;
103
+ if (direction !== "up" && direction !== "down") return undefined;
104
+ const rawTone = panel.deltaToneField !== undefined ? record[panel.deltaToneField] : undefined;
105
+ const tone =
106
+ typeof rawTone === "string" && STAT_TONES.has(rawTone) ? (rawTone as StatTone) : undefined;
107
+ return { value: String(value), direction, ...(tone !== undefined && { tone }) };
108
+ }
109
+
90
110
  function StatGroupPanelBody({
91
111
  panel,
92
112
  label,
@@ -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
  }