@cosmicdrift/kumiko-renderer-web 0.226.0 → 0.228.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.226.0",
3
+ "version": "0.228.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.226.0",
20
- "@cosmicdrift/kumiko-headless": "0.226.0",
21
- "@cosmicdrift/kumiko-renderer": "0.226.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.228.0",
20
+ "@cosmicdrift/kumiko-headless": "0.228.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.228.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",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.226.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.228.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -6,7 +6,14 @@ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
6
6
  import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
7
7
  import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
8
8
  import userEvent from "@testing-library/user-event";
9
- import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
9
+ import { type ReactNode, useState } from "react";
10
+ import {
11
+ createMockDispatcher,
12
+ render,
13
+ renderWithPrimitivesOverride,
14
+ screen,
15
+ waitFor,
16
+ } from "./test-utils";
10
17
 
11
18
  const baseScreen: ProjectionDetailScreenDefinition = {
12
19
  id: "rent-detail",
@@ -124,6 +131,57 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
124
131
  });
125
132
  });
126
133
 
134
+ describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
135
+ const metricsScreen: ProjectionDetailScreenDefinition = {
136
+ ...baseScreen,
137
+ metrics: ["balance"],
138
+ };
139
+
140
+ test("goes through the Metric primitive, not naked Text, when one is registered", async () => {
141
+ const dispatcher = dispatcherReturning(rowData);
142
+
143
+ render(
144
+ <DispatcherProvider dispatcher={dispatcher}>
145
+ <KumikoScreen
146
+ schema={schemaFor(metricsScreen)}
147
+ qn="rentals:screen:rent-detail"
148
+ entityId="rent-1"
149
+ />
150
+ </DispatcherProvider>,
151
+ );
152
+
153
+ await waitFor(() => screen.getByTestId("render-edit-form"));
154
+ // Only the Metric primitive assigns a testid to the tile itself — the
155
+ // naked-Text fallback below only labels the -label/-value nodes. This
156
+ // is red against the pre-fix code (naked Text, no tile testid).
157
+ expect(screen.getByTestId("kumiko-screen-projection-detail-metric-balance")).toBeTruthy();
158
+ expect(
159
+ screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
160
+ ).toBe("120");
161
+ });
162
+
163
+ test("falls back to plain label/value Text when no Metric primitive is registered", async () => {
164
+ const dispatcher = dispatcherReturning(rowData);
165
+
166
+ renderWithPrimitivesOverride(
167
+ <DispatcherProvider dispatcher={dispatcher}>
168
+ <KumikoScreen
169
+ schema={schemaFor(metricsScreen)}
170
+ qn="rentals:screen:rent-detail"
171
+ entityId="rent-1"
172
+ />
173
+ </DispatcherProvider>,
174
+ { Metric: undefined },
175
+ );
176
+
177
+ await waitFor(() => screen.getByTestId("render-edit-form"));
178
+ expect(
179
+ screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
180
+ ).toBe("120");
181
+ expect(screen.queryByTestId("kumiko-screen-projection-detail-metric-balance")).toBeNull();
182
+ });
183
+ });
184
+
127
185
  describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
128
186
  const tabsScreen: ProjectionDetailScreenDefinition = {
129
187
  ...baseScreen,
@@ -167,6 +225,92 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
167
225
  };
168
226
  }
169
227
 
228
+ // Reactive nav stub — navWithTab's searchParams is fixed at mount, so it
229
+ // can't exercise a real "click a tab, watch the active section change"
230
+ // flow. Only used by the tab-switch test below.
231
+ function StatefulTabNav({ children }: { readonly children: ReactNode }): ReactNode {
232
+ const [tab, setTab] = useState<string | undefined>(undefined);
233
+ const navApi: NavApi = {
234
+ route: undefined,
235
+ navigate: () => {},
236
+ replace: () => {},
237
+ hrefFor: () => "",
238
+ searchParams: tab !== undefined ? { tab } : {},
239
+ setSearchParams: (updates) => {
240
+ const next = updates["tab"];
241
+ setTab(next === null || next === undefined ? undefined : next);
242
+ },
243
+ };
244
+ return <NavProvider value={navApi}>{children}</NavProvider>;
245
+ }
246
+
247
+ test("switching tabs does not remount the body or refire the detail query", async () => {
248
+ const dispatcher = dispatcherReturning(rowData);
249
+ const user = userEvent.setup();
250
+
251
+ render(
252
+ <StatefulTabNav>
253
+ <DispatcherProvider dispatcher={dispatcher}>
254
+ <KumikoScreen
255
+ schema={schemaFor(tabsScreen)}
256
+ qn="rentals:screen:rent-detail"
257
+ entityId="rent-1"
258
+ />
259
+ </DispatcherProvider>
260
+ </StatefulTabNav>,
261
+ );
262
+
263
+ await waitFor(() => screen.getByTestId("render-edit-form"));
264
+ const detailCallsBefore = dispatcher.calls.filter(
265
+ (c) => c.type === "rentals:query:rent:detail",
266
+ ).length;
267
+ expect(detailCallsBefore).toBe(1);
268
+
269
+ const trigger = await waitFor(() =>
270
+ screen.getByTestId("kumiko-screen-projection-detail-tabs-payments"),
271
+ );
272
+ await user.click(trigger);
273
+ await waitFor(() =>
274
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
275
+ );
276
+
277
+ // A `key` on the record identifier alone (fw#2518 fix) must not also
278
+ // depend on the active tab — folding tab into the key would remount the
279
+ // body on every tab click and refire this query. The pre-fix "fires
280
+ // exactly one query" test above only checks first render and would not
281
+ // have caught that regression.
282
+ const detailCallsAfter = dispatcher.calls.filter(
283
+ (c) => c.type === "rentals:query:rent:detail",
284
+ ).length;
285
+ expect(detailCallsAfter).toBe(detailCallsBefore);
286
+ });
287
+
288
+ test("tabs mode suppresses the form's own redundant title, not just section titles", async () => {
289
+ const dispatcher = dispatcherReturning(rowData);
290
+ const { navApi } = navWithTab(undefined);
291
+
292
+ render(
293
+ <NavProvider value={navApi}>
294
+ <DispatcherProvider dispatcher={dispatcher}>
295
+ <KumikoScreen
296
+ schema={schemaFor(tabsScreen)}
297
+ qn="rentals:screen:rent-detail"
298
+ entityId="rent-1"
299
+ />
300
+ </DispatcherProvider>
301
+ </NavProvider>,
302
+ );
303
+
304
+ await waitFor(() => screen.getByTestId("render-edit-form"));
305
+ // hideSectionTitles used to only blank out each Section's own title —
306
+ // RenderEdit's own Form-level title (screen id/i18n fallback) rendered
307
+ // unconditionally regardless, duplicating the active tab's label
308
+ // whenever the two happened to read the same ("Mietvertrag" above the
309
+ // table, matching the already-visible tab). This asserts the Form-level
310
+ // title is gone too, not just the section ones.
311
+ expect(screen.queryByTestId("render-edit-form-title")).toBeNull();
312
+ });
313
+
170
314
  test("fires exactly one query on first render — the inactive tabs' relatedList queries never fire", async () => {
171
315
  const dispatcher = dispatcherReturning(rowData);
172
316
  const { navApi } = navWithTab(undefined);
@@ -301,5 +445,55 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
301
445
  expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true),
302
446
  );
303
447
  expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
448
+ // Contrast for the tabs-mode "form title suppressed" test above — outside
449
+ // tabs mode (hideSectionTitles unset) the form's own title still renders.
450
+ expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
451
+ });
452
+ });
453
+
454
+ describe("KumikoScreen / projectionDetail — switching records remounts the body (fw#2518)", () => {
455
+ test("record A's fields are gone from the DOM after switching to record B", async () => {
456
+ const dataById: Record<string, Readonly<Record<string, unknown>>> = {
457
+ "rent-1": { description: "Loft 4B" },
458
+ "rent-2": { description: "Warehouse 9" },
459
+ };
460
+ const query = (async (type: string, payload: unknown) => {
461
+ if (type === "rentals:query:rent:detail") {
462
+ const id = (payload as { id?: string }).id ?? "";
463
+ return { isSuccess: true, data: dataById[id] ?? {} };
464
+ }
465
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
466
+ }) as unknown as Dispatcher["query"];
467
+ const dispatcher = createMockDispatcher({ query });
468
+
469
+ const { rerender } = render(
470
+ <DispatcherProvider dispatcher={dispatcher}>
471
+ <KumikoScreen
472
+ schema={schemaFor(baseScreen)}
473
+ qn="rentals:screen:rent-detail"
474
+ entityId="rent-1"
475
+ />
476
+ </DispatcherProvider>,
477
+ );
478
+
479
+ await waitFor(() => screen.getByText("Loft 4B"));
480
+
481
+ rerender(
482
+ <DispatcherProvider dispatcher={dispatcher}>
483
+ <KumikoScreen
484
+ schema={schemaFor(baseScreen)}
485
+ qn="rentals:screen:rent-detail"
486
+ entityId="rent-2"
487
+ />
488
+ </DispatcherProvider>,
489
+ );
490
+
491
+ // Without the key fix, React keeps the old ProjectionDetailBody instance
492
+ // mounted and briefly renders record A's fields until the new query
493
+ // resolves. Asserting only the ABSENCE of "Loft 4B" would pass on flaky
494
+ // timing even without the fix, so this also waits for record B's own
495
+ // value to confirm the remount actually completed.
496
+ await waitFor(() => screen.getByText("Warehouse 9"));
497
+ expect(screen.queryByText("Loft 4B")).toBeNull();
304
498
  });
305
499
  });
@@ -9,7 +9,12 @@ import { describe, expect, test } from "bun:test";
9
9
  import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
10
10
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
11
11
  import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
12
- import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
12
+ import {
13
+ DispatcherProvider,
14
+ ExtensionSectionsProvider,
15
+ KumikoScreen,
16
+ NavProvider,
17
+ } from "@cosmicdrift/kumiko-renderer";
13
18
  import { act, createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
14
19
 
15
20
  const detailScreen: ProjectionDetailScreenDefinition = {
@@ -470,3 +475,141 @@ describe("KumikoScreen / projectionDetail relatedList section (fw#2166)", () =>
470
475
  expect(navigated).toBeUndefined();
471
476
  });
472
477
  });
478
+
479
+ // solon#264: a hand-written NotesSection block had no declarative equivalent
480
+ // on projectionDetail. Extension sections without contributesToFormSubmit
481
+ // (self-persisting, e.g. via their own dispatcher writes) are now allowed.
482
+ describe("KumikoScreen / projectionDetail extension section (solon#264)", () => {
483
+ const SessionNotes = ({
484
+ entityName,
485
+ entityId,
486
+ }: {
487
+ entityName: string;
488
+ entityId: string | null;
489
+ }) => (
490
+ <div data-testid="session-notes">
491
+ {entityName}:{entityId ?? "(none)"}
492
+ </div>
493
+ );
494
+
495
+ test("mounts and receives the route entityId and the section's declared entityName, no submit button", async () => {
496
+ const extensionScreen: ProjectionDetailScreenDefinition = {
497
+ ...detailScreen,
498
+ layout: {
499
+ sections: [
500
+ ...detailScreen.layout.sections,
501
+ {
502
+ kind: "extension",
503
+ title: "Notes",
504
+ component: { react: { __component: "SessionNotes" } },
505
+ entityName: "user-session",
506
+ },
507
+ ],
508
+ },
509
+ };
510
+ const extensionSchema: FeatureSchema = {
511
+ featureName: "sessions",
512
+ entities: {},
513
+ screens: [extensionScreen],
514
+ };
515
+ const dispatcher: Dispatcher = createMockDispatcher({
516
+ query: (async () => ({
517
+ isSuccess: true,
518
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
519
+ })) as unknown as Dispatcher["query"],
520
+ });
521
+
522
+ render(
523
+ <DispatcherProvider dispatcher={dispatcher}>
524
+ <ExtensionSectionsProvider value={{ SessionNotes }}>
525
+ <KumikoScreen
526
+ schema={extensionSchema}
527
+ qn="sessions:screen:session-detail"
528
+ entityId="sess-1"
529
+ />
530
+ </ExtensionSectionsProvider>
531
+ </DispatcherProvider>,
532
+ );
533
+
534
+ await waitFor(() => screen.getByTestId("render-edit-form"));
535
+ // Declared entityName ("user-session") must win over the shim's internal
536
+ // placeholder entity name — otherwise a self-persisting section like
537
+ // NotesSection would filter/write against the wrong domain entity.
538
+ expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
539
+ expect(screen.queryByTestId("render-edit-submit")).toBeNull();
540
+ });
541
+
542
+ test("layout.mode: 'tabs' — mounts only when its tab is active, not on first render of another tab", async () => {
543
+ const tabsExtensionScreen: ProjectionDetailScreenDefinition = {
544
+ ...detailScreen,
545
+ layout: {
546
+ mode: "tabs",
547
+ sections: [
548
+ { id: "overview", title: "Session", fields: ["userId"] },
549
+ {
550
+ id: "notes",
551
+ kind: "extension",
552
+ title: "Notes",
553
+ component: { react: { __component: "SessionNotes" } },
554
+ entityName: "user-session",
555
+ },
556
+ ],
557
+ },
558
+ };
559
+ const tabsExtensionSchema: FeatureSchema = {
560
+ featureName: "sessions",
561
+ entities: {},
562
+ screens: [tabsExtensionScreen],
563
+ };
564
+ const dispatcher: Dispatcher = createMockDispatcher({
565
+ query: (async () => ({
566
+ isSuccess: true,
567
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
568
+ })) as unknown as Dispatcher["query"],
569
+ });
570
+ function navWithTab(tab: string | undefined): NavApi {
571
+ return {
572
+ route: undefined,
573
+ navigate: () => {},
574
+ replace: () => {},
575
+ hrefFor: () => "",
576
+ searchParams: tab !== undefined ? { tab } : {},
577
+ setSearchParams: () => {},
578
+ };
579
+ }
580
+
581
+ const { unmount } = render(
582
+ <NavProvider value={navWithTab(undefined)}>
583
+ <DispatcherProvider dispatcher={dispatcher}>
584
+ <ExtensionSectionsProvider value={{ SessionNotes }}>
585
+ <KumikoScreen
586
+ schema={tabsExtensionSchema}
587
+ qn="sessions:screen:session-detail"
588
+ entityId="sess-1"
589
+ />
590
+ </ExtensionSectionsProvider>
591
+ </DispatcherProvider>
592
+ </NavProvider>,
593
+ );
594
+ await waitFor(() => screen.getByTestId("render-edit-form"));
595
+ expect(screen.queryByTestId("session-notes")).toBeNull();
596
+ unmount();
597
+
598
+ render(
599
+ <NavProvider value={navWithTab("notes")}>
600
+ <DispatcherProvider dispatcher={dispatcher}>
601
+ <ExtensionSectionsProvider value={{ SessionNotes }}>
602
+ <KumikoScreen
603
+ schema={tabsExtensionSchema}
604
+ qn="sessions:screen:session-detail"
605
+ entityId="sess-1"
606
+ />
607
+ </ExtensionSectionsProvider>
608
+ </DispatcherProvider>
609
+ </NavProvider>,
610
+ );
611
+ await waitFor(() => screen.getByTestId("session-notes"));
612
+ expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
613
+ expect(screen.queryByTestId("field-userId")).toBeNull();
614
+ });
615
+ });
@@ -105,6 +105,7 @@ import { EmbeddedListInput } from "./embedded-list-input";
105
105
  import { FileUploadInput } from "./file-upload";
106
106
  import { DefaultLightbox } from "./lightbox";
107
107
  import { LocatedTimestampInput } from "./located-timestamp-input";
108
+ import { DefaultMetric } from "./metric";
108
109
  import { DefaultModal } from "./modal";
109
110
  import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
110
111
  import { DefaultStatusBadge } from "./status-badge";
@@ -1625,6 +1626,7 @@ function DefaultForm({
1625
1626
  testId,
1626
1627
  width,
1627
1628
  stickyActions,
1629
+ headerRegion,
1628
1630
  }: FormProps): ReactNode {
1629
1631
  // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
1630
1632
  // der Container trägt Card/Titel selbst, sonst Card-in-Card.
@@ -1664,6 +1666,9 @@ function DefaultForm({
1664
1666
  className="flex flex-col w-full"
1665
1667
  >
1666
1668
  <FormScreenShell {...(width !== undefined && { maxWidth: width })}>
1669
+ {headerRegion !== undefined && (
1670
+ <div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
1671
+ )}
1667
1672
  <div className={cn(cardSurface(), "overflow-hidden")}>
1668
1673
  {(title !== undefined || subtitle !== undefined) && (
1669
1674
  <div className="px-6 pb-2 pt-5">
@@ -1856,6 +1861,16 @@ function DefaultSection({
1856
1861
  }
1857
1862
 
1858
1863
  function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNode {
1864
+ // "auto": content-sized items in a wrapping row (e.g. a metrics band of
1865
+ // self-sized tiles) instead of N equal-width, container-stretched tracks.
1866
+ // maxRows/scrolling don't apply — the row just wraps.
1867
+ if (columns === "auto") {
1868
+ return (
1869
+ <div data-testid={testId} className="flex flex-wrap gap-4">
1870
+ {children}
1871
+ </div>
1872
+ );
1873
+ }
1859
1874
  // Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
1860
1875
  // die Author-deklarierte Spaltenzahl. Inline-style schreibt
1861
1876
  // CSS-Variable; Tailwind-Klasse liest die Variable mit
@@ -2098,4 +2113,5 @@ export const defaultPrimitives: CorePrimitives = {
2098
2113
  WizardStepGroup: DefaultWizardStepGroup,
2099
2114
  Tabs: DefaultTabs,
2100
2115
  StatusBadge: DefaultStatusBadge,
2116
+ Metric: DefaultMetric,
2101
2117
  };
@@ -0,0 +1,17 @@
1
+ import type { MetricProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ import { MiniStat } from "../widgets/stat";
4
+
5
+ export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode {
6
+ return (
7
+ <MiniStat
8
+ label={label}
9
+ value={value}
10
+ testId={testId}
11
+ {...(testId !== undefined && {
12
+ labelTestId: `${testId}-label`,
13
+ valueTestId: `${testId}-value`,
14
+ })}
15
+ />
16
+ );
17
+ }
@@ -157,12 +157,16 @@ export function MiniStat({
157
157
  tone = "default",
158
158
  emphasize = false,
159
159
  testId,
160
+ labelTestId,
161
+ valueTestId,
160
162
  }: {
161
163
  readonly label: string;
162
164
  readonly value: string;
163
165
  readonly tone?: StatTone;
164
166
  readonly emphasize?: boolean;
165
167
  readonly testId?: string;
168
+ readonly labelTestId?: string;
169
+ readonly valueTestId?: string;
166
170
  }): ReactNode {
167
171
  const { Card } = usePrimitives();
168
172
  return (
@@ -171,13 +175,16 @@ export function MiniStat({
171
175
  className={cn("p-3", emphasize && "ring-1 ring-primary/30")}
172
176
  testId={testId}
173
177
  >
174
- <div className="text-xs text-muted-foreground">{label}</div>
178
+ <div className="text-xs text-muted-foreground" data-testid={labelTestId}>
179
+ {label}
180
+ </div>
175
181
  <div
176
182
  className={cn(
177
183
  "mt-0.5 font-semibold tabular-nums",
178
184
  TONE_VALUE[tone],
179
185
  emphasize ? "text-lg" : "text-sm",
180
186
  )}
187
+ data-testid={valueTestId}
181
188
  >
182
189
  {value}
183
190
  </div>