@cosmicdrift/kumiko-renderer-web 0.270.0 → 0.273.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.270.0",
3
+ "version": "0.273.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.270.0",
20
- "@cosmicdrift/kumiko-headless": "0.270.0",
21
- "@cosmicdrift/kumiko-renderer": "0.270.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.273.0",
20
+ "@cosmicdrift/kumiko-headless": "0.273.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.273.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",
@@ -66,7 +66,7 @@
66
66
  "@types/react-dom": "^19.2.3",
67
67
  "jsdom": "^29.1.1",
68
68
  "tailwindcss": "^4.3.0",
69
- "@cosmicdrift/kumiko-locale-de": "0.270.0"
69
+ "@cosmicdrift/kumiko-locale-de": "0.273.0"
70
70
  },
71
71
  "repository": {
72
72
  "type": "git",
@@ -948,7 +948,8 @@ describe("KumikoScreen", () => {
948
948
  await waitFor(() => expect(navigateCalls.length).toBe(1));
949
949
  expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
950
950
  // params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
951
- expect(searchParamUpdates).toEqual([{ taskId: "r1" }]);
951
+ // returnTo: "task-list" — this KumikoScreen has no outer host, so it becomes its own.
952
+ expect(searchParamUpdates).toEqual([{ taskId: "r1", returnTo: "task-list" }]);
952
953
  // Reihenfolge-Pin: erst navigate, dann setSearchParams.
953
954
  expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
954
955
  });
@@ -1072,7 +1073,9 @@ describe("KumikoScreen", () => {
1072
1073
  await user.click(screen.getByTestId("row-r1-action-edit"));
1073
1074
  await waitFor(() => expect(navigateCalls.length).toBe(1));
1074
1075
  expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "r1" });
1075
- expect(searchParamUpdates).toEqual([]);
1076
+ // No declared params, but returnTo still attaches — the id itself never
1077
+ // leaks into search params (that's the point above).
1078
+ expect(searchParamUpdates).toEqual([{ returnTo: "task-list" }]);
1076
1079
  });
1077
1080
 
1078
1081
  // JSON-Schema-Fall (window.__KUMIKO_SCHEMA__): Declarative entityId: "id"
@@ -1195,7 +1198,7 @@ describe("KumikoScreen", () => {
1195
1198
  expect(navigateCalls[0]).toEqual({ screenId: "task-approve" });
1196
1199
  });
1197
1200
 
1198
- test("entityList rowActions kind=navigate ohne params: setSearchParams wird NICHT gerufen", async () => {
1201
+ test("entityList rowActions kind=navigate ohne eigene params: setSearchParams trägt nur returnTo", async () => {
1199
1202
  const navigateCalls: { screenId: string }[] = [];
1200
1203
  const searchParamUpdates: Record<string, string | null>[] = [];
1201
1204
  const memoryNav = {
@@ -1241,7 +1244,8 @@ describe("KumikoScreen", () => {
1241
1244
 
1242
1245
  await user.click(screen.getByTestId("row-r1-action-view"));
1243
1246
  await waitFor(() => expect(navigateCalls.length).toBe(1));
1244
- expect(searchParamUpdates).toEqual([]);
1247
+ // No declared params, but returnTo still attaches.
1248
+ expect(searchParamUpdates).toEqual([{ returnTo: "task-list" }]);
1245
1249
  });
1246
1250
 
1247
1251
  // toolbarActions: Schema-Form (kind: navigate | writeHandler) →
@@ -401,6 +401,101 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
401
401
  });
402
402
  });
403
403
 
404
+ describe("KumikoScreen / projectionDetail — header actions sit in the title row", () => {
405
+ const headerActionsScreen: ProjectionDetailScreenDefinition = {
406
+ ...baseScreen,
407
+ header: { title: "tenantName", subtitle: "address", status: "state" },
408
+ metrics: ["balance"],
409
+ actions: [
410
+ {
411
+ kind: "navigate",
412
+ id: "open-tenant",
413
+ label: "actions.openTenant",
414
+ entity: "tenant",
415
+ entityId: "tenantId",
416
+ },
417
+ ],
418
+ };
419
+
420
+ function nearestCommonAncestor(a: Element, b: Element): Element {
421
+ const ancestorsOfA = new Set<Element>();
422
+ for (let el: Element | null = a; el !== null; el = el.parentElement) ancestorsOfA.add(el);
423
+ for (let el: Element | null = b; el !== null; el = el.parentElement) {
424
+ if (ancestorsOfA.has(el)) return el;
425
+ }
426
+ throw new Error("title and action share no common ancestor");
427
+ }
428
+
429
+ test("title and action buttons share a header container that excludes the metrics band", async () => {
430
+ const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
431
+
432
+ render(
433
+ <DispatcherProvider dispatcher={dispatcher}>
434
+ <KumikoScreen
435
+ schema={schemaFor(headerActionsScreen)}
436
+ qn="rentals:screen:rent-detail"
437
+ entityId="rent-1"
438
+ />
439
+ </DispatcherProvider>,
440
+ );
441
+
442
+ await waitFor(() => screen.getByTestId("render-edit-form"));
443
+ const title = screen.getByTestId("kumiko-screen-projection-detail-title");
444
+ const actionButton = screen.getByTestId("render-edit-action-open-tenant");
445
+ const metricsBand = screen.getByTestId("kumiko-screen-projection-detail-metrics");
446
+
447
+ const headerContainer = nearestCommonAncestor(title, actionButton);
448
+ expect(headerContainer.contains(metricsBand)).toBe(false);
449
+ });
450
+
451
+ test("the metrics band follows the subtitle and the header row in DOM order", async () => {
452
+ const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
453
+
454
+ render(
455
+ <DispatcherProvider dispatcher={dispatcher}>
456
+ <KumikoScreen
457
+ schema={schemaFor(headerActionsScreen)}
458
+ qn="rentals:screen:rent-detail"
459
+ entityId="rent-1"
460
+ />
461
+ </DispatcherProvider>,
462
+ );
463
+
464
+ await waitFor(() => screen.getByTestId("render-edit-form"));
465
+ const title = screen.getByTestId("kumiko-screen-projection-detail-title");
466
+ const actionButton = screen.getByTestId("render-edit-action-open-tenant");
467
+ const subtitle = screen.getByTestId("kumiko-screen-projection-detail-subtitle");
468
+ const metricsBand = screen.getByTestId("kumiko-screen-projection-detail-metrics");
469
+ const headerContainer = nearestCommonAncestor(title, actionButton);
470
+
471
+ expect(
472
+ (subtitle.compareDocumentPosition(metricsBand) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
473
+ ).toBe(true);
474
+ expect(
475
+ (headerContainer.compareDocumentPosition(metricsBand) & Node.DOCUMENT_POSITION_FOLLOWING) !==
476
+ 0,
477
+ ).toBe(true);
478
+ });
479
+
480
+ test("the title + status-badge container is vertically centered", async () => {
481
+ const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
482
+
483
+ render(
484
+ <DispatcherProvider dispatcher={dispatcher}>
485
+ <KumikoScreen
486
+ schema={schemaFor(headerActionsScreen)}
487
+ qn="rentals:screen:rent-detail"
488
+ entityId="rent-1"
489
+ />
490
+ </DispatcherProvider>,
491
+ );
492
+
493
+ await waitFor(() => screen.getByTestId("render-edit-form"));
494
+ const title = screen.getByTestId("kumiko-screen-projection-detail-title");
495
+ expect(title.parentElement?.className).toContain("items-center");
496
+ });
497
+ });
498
+
404
499
  describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
405
500
  const metricsScreen: ProjectionDetailScreenDefinition = {
406
501
  ...baseScreen,
@@ -633,7 +633,7 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
633
633
  expect(screen.queryByTestId("field-userId")).toBeNull();
634
634
  });
635
635
 
636
- test("layout.mode: 'tabs' with a header — exactly one card renders, the head (bedienkonzept A1)", async () => {
636
+ test("layout.mode: 'tabs' with a header — the head card plus one card for the active tab's fields", async () => {
637
637
  const tabsHeaderScreen: ProjectionDetailScreenDefinition = {
638
638
  ...detailScreen,
639
639
  header: { title: "userId" },
@@ -679,10 +679,9 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
679
679
  );
680
680
 
681
681
  await waitFor(() => screen.getByTestId("render-edit-form"));
682
- // Bedienkonzept A1: on a tabbed Akte there is exactly one card, the head
683
- // tab content sits on the page background. Checked via the card's
684
- // structural marker, not a Tailwind class string.
685
- expect(container.querySelectorAll('[data-slot="card"]')).toHaveLength(1);
682
+ // Head card plus one titled card for the fields tab checked via the
683
+ // card's structural marker, not a Tailwind class string.
684
+ expect(container.querySelectorAll('[data-slot="card"]')).toHaveLength(2);
686
685
  });
687
686
 
688
687
  test("layout.mode: 'tabs' with countField — tab label carries the record's count, sections without it stay unchanged", async () => {
@@ -204,10 +204,9 @@ describe("RenderEdit", () => {
204
204
 
205
205
  // Fall 2: tabs layout sets hideSectionTitles (kumiko-screen.tsx's
206
206
  // ProjectionDetailBody, the only real caller) to blank out each section's
207
- // OWN title — the active tab already names it. That gate used to also
208
- // swallow the screen-level subtitle (screen.description), which has
209
- // nothing to do with section titles and must survive tabs mode.
210
- test("tabs layout (hideSectionTitles) keeps the section title hidden but still shows the screen's own subtitle", () => {
207
+ // OWN title — the active tab already names it. The head card also carries
208
+ // screen.description as its subtitle, so it must not render again here.
209
+ test("tabs layout (hideSectionTitles) keeps the section title hidden and drops the screen's own subtitle", () => {
211
210
  const entity = {
212
211
  fields: { email: { type: "text", required: true } },
213
212
  } as unknown as EntityDefinition;
@@ -236,9 +235,93 @@ describe("RenderEdit", () => {
236
235
 
237
236
  expect(screen.queryByTestId("render-edit-form-title")).toBeNull();
238
237
  expect(screen.queryByTestId("section-Contact-title")).toBeNull();
239
- expect(screen.getByTestId("render-edit-form-subtitle").textContent).toBe(
240
- "Everything about this order in one place.",
238
+ expect(screen.queryByTestId("render-edit-form-subtitle")).toBeNull();
239
+ });
240
+
241
+ // S2: a fields-kind section's `groups` splits it into its own titled cards
242
+ // (tabs mode only — the branch that renders `groups` at all lives behind
243
+ // `hideSectionTitles`) instead of one flat grid.
244
+ test("a section with two groups renders two titled cards in tabs mode, each with its own fields", () => {
245
+ const entity = {
246
+ fields: {
247
+ email: { type: "text", required: true },
248
+ plan: { type: "text" },
249
+ },
250
+ } as unknown as EntityDefinition;
251
+ const screenDef: EntityEditScreenDefinition = {
252
+ id: "orders:screen:order-detail",
253
+ type: "entityEdit",
254
+ entity: "order",
255
+ layout: {
256
+ mode: "tabs",
257
+ sections: [
258
+ {
259
+ id: "overview",
260
+ title: "Overview",
261
+ fields: [],
262
+ groups: [
263
+ { title: "Contact", fields: [{ field: "email" }] },
264
+ { title: "Billing", fields: [{ field: "plan" }] },
265
+ ],
266
+ },
267
+ ],
268
+ },
269
+ };
270
+ render(
271
+ <DispatcherProvider dispatcher={makeDispatcher()}>
272
+ <RenderEdit
273
+ screen={screenDef}
274
+ entity={entity}
275
+ featureName="orders"
276
+ initial={{ email: "", plan: "" } as never}
277
+ writeCommand="order:create"
278
+ hideSectionTitles
279
+ />
280
+ </DispatcherProvider>,
241
281
  );
282
+
283
+ const contactCard = screen.getByTestId("section-Overview-group-0");
284
+ const billingCard = screen.getByTestId("section-Overview-group-1");
285
+ expect(contactCard.querySelector("h3")?.textContent).toBe("Contact");
286
+ expect(billingCard.querySelector("h3")?.textContent).toBe("Billing");
287
+ expect(within(contactCard).getByTestId("field-email")).toBeTruthy();
288
+ expect(within(billingCard).getByTestId("field-plan")).toBeTruthy();
289
+ expect(within(contactCard).queryByTestId("field-plan")).toBeNull();
290
+
291
+ // Default columns per group is 2 (--grid-cols CSS var), unless overridden.
292
+ const contactGrid = contactCard.querySelector(".grid") as HTMLElement | null;
293
+ expect(contactGrid?.style.getPropertyValue("--grid-cols")).toBe("repeat(2, minmax(0, 1fr))");
294
+ });
295
+
296
+ test("a tabs-mode section without groups renders a single card", () => {
297
+ const entity = {
298
+ fields: { email: { type: "text", required: true } },
299
+ } as unknown as EntityDefinition;
300
+ const screenDef: EntityEditScreenDefinition = {
301
+ id: "orders:screen:order-detail",
302
+ type: "entityEdit",
303
+ entity: "order",
304
+ layout: {
305
+ mode: "tabs",
306
+ sections: [{ id: "overview", title: "Overview", fields: [{ field: "email" }] }],
307
+ },
308
+ };
309
+ render(
310
+ <DispatcherProvider dispatcher={makeDispatcher()}>
311
+ <RenderEdit
312
+ screen={screenDef}
313
+ entity={entity}
314
+ featureName="orders"
315
+ initial={{ email: "" } as never}
316
+ writeCommand="order:create"
317
+ hideSectionTitles
318
+ />
319
+ </DispatcherProvider>,
320
+ );
321
+
322
+ expect(screen.getByTestId("section-Overview")).toBeTruthy();
323
+ expect(screen.queryByTestId("section-Overview-group-0")).toBeNull();
324
+ expect(within(screen.getByTestId("section-Overview")).getByTestId("field-email")).toBeTruthy();
242
325
  });
243
326
 
244
327
  // End-to-end-Routing: ein `type:"locatedTimestamp"`-Entity-Feld muss durch
@@ -0,0 +1,144 @@
1
+ // Split out of return-to.test.tsx: the singleton create->update path fires
2
+ // fireEvent.change immediately followed by a click right after an async load
3
+ // settles — the #457 shared single-process happy-dom pattern. Isolated the
4
+ // same way as kumiko-screen-singleton-entity-edit.test.tsx (bunfig.ci-dom.toml,
5
+ // pathIgnorePatterns in bunfig.dom.toml).
6
+ import { describe, expect, mock, test } from "bun:test";
7
+ import type {
8
+ DashboardScreenDefinition,
9
+ EntityDefinition,
10
+ EntityEditScreenDefinition,
11
+ FeatureSchema,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
14
+ import {
15
+ AppFeaturesProvider,
16
+ DashboardBodyProvider,
17
+ DispatcherProvider,
18
+ KumikoScreen,
19
+ NavProvider,
20
+ qualifyScreenId,
21
+ useNav,
22
+ } from "@cosmicdrift/kumiko-renderer";
23
+ import type { ReactNode } from "react";
24
+ import { WebDashboardBody } from "../app/dashboard-body";
25
+ import { useBrowserNavApi } from "../app/nav";
26
+ import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
27
+
28
+ const settingEntity: EntityDefinition = {
29
+ fields: { title: { type: "text", required: false, searchable: false, sortable: false } },
30
+ };
31
+
32
+ const singletonScreen: EntityEditScreenDefinition = {
33
+ id: "settings-edit",
34
+ type: "entityEdit",
35
+ entity: "setting",
36
+ singleton: true,
37
+ layout: { sections: [{ fields: ["title"] }] },
38
+ };
39
+
40
+ const dashboardScreen: DashboardScreenDefinition = {
41
+ id: "home",
42
+ type: "dashboard",
43
+ panels: [{ kind: "screen", id: "singleton", screen: "settings-edit" }],
44
+ };
45
+
46
+ const schema: FeatureSchema = {
47
+ featureName: "settingsfeat",
48
+ entities: { setting: settingEntity },
49
+ screens: [dashboardScreen, singletonScreen],
50
+ };
51
+
52
+ function RouterScreen({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
53
+ const nav = useNav();
54
+ if (nav.route === undefined) return null;
55
+ const qn = qualifyScreenId(appSchema.featureName, nav.route.screenId);
56
+ return (
57
+ <KumikoScreen
58
+ schema={appSchema}
59
+ qn={qn}
60
+ {...(nav.route.entityId !== undefined && { entityId: nav.route.entityId })}
61
+ />
62
+ );
63
+ }
64
+
65
+ function BrowserNavRoot({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
66
+ const nav = useBrowserNavApi({ hasWorkspaces: false });
67
+ return (
68
+ <NavProvider value={nav}>
69
+ <RouterScreen appSchema={appSchema} />
70
+ </NavProvider>
71
+ );
72
+ }
73
+
74
+ function App({
75
+ appSchema,
76
+ dispatcher,
77
+ }: {
78
+ readonly appSchema: FeatureSchema;
79
+ readonly dispatcher: Dispatcher;
80
+ }): ReactNode {
81
+ return (
82
+ <DispatcherProvider dispatcher={dispatcher}>
83
+ <AppFeaturesProvider features={[appSchema]}>
84
+ <DashboardBodyProvider value={WebDashboardBody}>
85
+ <BrowserNavRoot appSchema={appSchema} />
86
+ </DashboardBodyProvider>
87
+ </AppFeaturesProvider>
88
+ </DispatcherProvider>
89
+ );
90
+ }
91
+
92
+ describe("returnTo — singleton entityEdit ignores it", () => {
93
+ test("reached with ?returnTo=home, saving stays on the singleton screen", async () => {
94
+ let created = false;
95
+ const write = mock(async (type: string) => {
96
+ if (type === "settingsfeat:write:setting:create") {
97
+ created = true;
98
+ return { isSuccess: true, data: { id: "s1" } };
99
+ }
100
+ return { isSuccess: true, data: {} };
101
+ });
102
+ const query = mock(async (type: string) => {
103
+ if (type === "settingsfeat:query:setting:list") {
104
+ return created
105
+ ? { isSuccess: true, data: { rows: [{ id: "s1", title: "Saved title" }] } }
106
+ : { isSuccess: true, data: { rows: [], nextCursor: null } };
107
+ }
108
+ if (type === "settingsfeat:query:setting:detail") {
109
+ return {
110
+ isSuccess: true,
111
+ data: { id: "s1", version: 1, title: "Saved title" },
112
+ };
113
+ }
114
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
115
+ });
116
+ const dispatcher = createMockDispatcher({
117
+ write: write as unknown as Dispatcher["write"],
118
+ query: query as unknown as Dispatcher["query"],
119
+ });
120
+
121
+ window.history.replaceState(null, "", "/settings-edit?returnTo=home");
122
+ render(<App appSchema={schema} dispatcher={dispatcher} />);
123
+
124
+ await waitFor(() => expect(screen.getByTestId("field-title")).toBeTruthy());
125
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
126
+ fireEvent.change(titleInput, { target: { value: "Saved title" } });
127
+ await waitFor(() => {
128
+ const button = screen.getByTestId("render-edit-submit") as HTMLButtonElement;
129
+ expect(button.disabled).toBe(false);
130
+ });
131
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
132
+
133
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
134
+ // The singleton wrapper flips to the update form of the newly created
135
+ // record instead of navigating anywhere — returnTo must not interfere.
136
+ await waitFor(() => {
137
+ const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
138
+ expect(input.value).toBe("Saved title");
139
+ });
140
+ expect(window.location.pathname).toBe("/settings-edit");
141
+
142
+ window.history.replaceState(null, "", "/");
143
+ });
144
+ });
@@ -0,0 +1,551 @@
1
+ // Exercises the real navigation stack end to end (real useBrowserNavApi),
2
+ // asserting on window.location instead of a captured NavTarget.
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+ import type {
5
+ DashboardScreenDefinition,
6
+ EntityDefinition,
7
+ FeatureSchema,
8
+ ScreenDefinition,
9
+ } from "@cosmicdrift/kumiko-framework/ui-types";
10
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
11
+ import {
12
+ AppFeaturesProvider,
13
+ DashboardBodyProvider,
14
+ DispatcherProvider,
15
+ KumikoScreen,
16
+ NavProvider,
17
+ qualifyScreenId,
18
+ UserRolesProvider,
19
+ useNav,
20
+ } from "@cosmicdrift/kumiko-renderer";
21
+ import userEvent from "@testing-library/user-event";
22
+ import type { ReactNode } from "react";
23
+ import { WebDashboardBody } from "../app/dashboard-body";
24
+ import { useBrowserNavApi } from "../app/nav";
25
+ import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
26
+
27
+ const tokenEntity: EntityDefinition = {
28
+ fields: { name: { type: "text", required: false, searchable: false, sortable: false } },
29
+ };
30
+
31
+ const dashboardScreen: DashboardScreenDefinition = {
32
+ id: "settings",
33
+ type: "dashboard",
34
+ panels: [
35
+ { kind: "screen", id: "list", screen: "token-list" },
36
+ { kind: "screen", id: "stats", screen: "token-stats" },
37
+ ],
38
+ };
39
+
40
+ const listScreen: ScreenDefinition = {
41
+ id: "token-list",
42
+ type: "entityList",
43
+ entity: "token",
44
+ columns: ["name"],
45
+ toolbarActions: [
46
+ { kind: "navigate", id: "create", label: "create", screen: "token-create" },
47
+ { kind: "navigate", id: "action", label: "action", screen: "token-action" },
48
+ ],
49
+ rowActions: [{ kind: "navigate", id: "open", label: "open", screen: "token-open" }],
50
+ };
51
+
52
+ const secretMintScreen: ScreenDefinition = {
53
+ id: "token-create",
54
+ type: "secretMint",
55
+ handler: "tokens:write:token-secret:mint",
56
+ fields: { label: { type: "text", required: false, searchable: false, sortable: false } },
57
+ layout: { sections: [{ fields: ["label"] }] },
58
+ reveal: { fields: [{ field: "secret", label: "secret" }] },
59
+ redirect: "token-list",
60
+ cancelTarget: "token-list",
61
+ };
62
+
63
+ const tokenActionScreen: ScreenDefinition = {
64
+ id: "token-action",
65
+ type: "actionForm",
66
+ handler: "tokens:write:token:action",
67
+ fields: {},
68
+ layout: { sections: [] },
69
+ redirect: "token-list",
70
+ };
71
+
72
+ const tokenOpenScreen: ScreenDefinition = {
73
+ id: "token-open",
74
+ type: "actionForm",
75
+ handler: "tokens:write:token:open",
76
+ fields: {},
77
+ layout: { sections: [] },
78
+ redirect: { screen: "token-edit", idFrom: "id" },
79
+ };
80
+
81
+ const tokenEditScreen: ScreenDefinition = {
82
+ id: "token-edit",
83
+ type: "entityEdit",
84
+ entity: "token",
85
+ layout: { sections: [{ fields: ["name"] }] },
86
+ };
87
+
88
+ // A second, distinct record screen for the same entity/id — lets returnTo
89
+ // name a record screen that is NOT the one being deleted (delete-guard test).
90
+ // Also carries a relatedList section for the host-threading test (18/19
91
+ // cover entityList/projectionList; this one covers relatedList).
92
+ const tokenDetailScreen: ScreenDefinition = {
93
+ id: "token-detail",
94
+ type: "projectionDetail",
95
+ query: "tokens:query:token:detail-view",
96
+ layout: {
97
+ sections: [
98
+ { fields: ["name"] },
99
+ {
100
+ kind: "relatedList",
101
+ title: "Related",
102
+ query: "tokens:query:token:related",
103
+ columns: ["name"],
104
+ toolbarActions: [
105
+ {
106
+ kind: "navigate",
107
+ id: "related-action",
108
+ label: "related-action",
109
+ screen: "token-action",
110
+ },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ };
116
+
117
+ const tokenStatsScreen: ScreenDefinition = {
118
+ id: "token-stats",
119
+ type: "projectionList",
120
+ query: "tokens:query:token:stats",
121
+ columns: [{ field: "name", label: "name" }],
122
+ toolbarActions: [
123
+ { kind: "navigate", id: "stats-action", label: "stats-action", screen: "token-action" },
124
+ ],
125
+ rowActions: [
126
+ { kind: "navigate", id: "stats-row-action", label: "stats-row-action", screen: "token-action" },
127
+ ],
128
+ };
129
+
130
+ const adminOnlyScreen: ScreenDefinition = {
131
+ id: "admin-only",
132
+ type: "custom",
133
+ renderer: { react: "stub" },
134
+ access: { roles: ["Admin"] },
135
+ };
136
+
137
+ const schema: FeatureSchema = {
138
+ featureName: "tokens",
139
+ entities: { token: tokenEntity },
140
+ screens: [
141
+ dashboardScreen,
142
+ listScreen,
143
+ secretMintScreen,
144
+ tokenActionScreen,
145
+ tokenOpenScreen,
146
+ tokenEditScreen,
147
+ tokenDetailScreen,
148
+ tokenStatsScreen,
149
+ adminOnlyScreen,
150
+ ],
151
+ };
152
+
153
+ function makeDispatcher(): Dispatcher {
154
+ return createMockDispatcher({
155
+ query: (async (type: string) => {
156
+ if (type === "tokens:query:token:list") {
157
+ return {
158
+ isSuccess: true,
159
+ data: { rows: [{ id: "tok-1", name: "Token 1" }], nextCursor: null, total: 1 },
160
+ };
161
+ }
162
+ // Distinct row data from the entityList above — both panels render on
163
+ // the same dashboard, and duplicate text/ids would break `getByText`/
164
+ // `getByTestId` uniqueness in the other tests.
165
+ if (type === "tokens:query:token:stats") {
166
+ return {
167
+ isSuccess: true,
168
+ data: { rows: [{ id: "stat-1", name: "Stat A" }], nextCursor: null, total: 1 },
169
+ };
170
+ }
171
+ if (type === "tokens:query:token:detail") {
172
+ return { isSuccess: true, data: { id: "tok-1", version: 1, name: "Token 1" } };
173
+ }
174
+ if (type === "tokens:query:token:related") {
175
+ return {
176
+ isSuccess: true,
177
+ data: { rows: [{ id: "rel-1", name: "Related A" }], nextCursor: null },
178
+ };
179
+ }
180
+ return { isSuccess: true, data: {} };
181
+ }) as unknown as Dispatcher["query"],
182
+ write: (async (command: string) => {
183
+ if (command === "tokens:write:token-secret:mint") {
184
+ return { isSuccess: true, data: { secret: "shh-secret" } };
185
+ }
186
+ if (command === "tokens:write:token:open") {
187
+ return { isSuccess: true, data: { id: "tok-1" } };
188
+ }
189
+ if (command === "tokens:write:token:create") {
190
+ return { isSuccess: true, data: { id: "new-tok" } };
191
+ }
192
+ return { isSuccess: true, data: {} };
193
+ }) as unknown as Dispatcher["write"],
194
+ });
195
+ }
196
+
197
+ function RouterScreen({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
198
+ const nav = useNav();
199
+ if (nav.route === undefined) return null;
200
+ const qn = qualifyScreenId(appSchema.featureName, nav.route.screenId);
201
+ return (
202
+ <KumikoScreen
203
+ schema={appSchema}
204
+ qn={qn}
205
+ {...(nav.route.entityId !== undefined && { entityId: nav.route.entityId })}
206
+ />
207
+ );
208
+ }
209
+
210
+ function BrowserNavRoot({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
211
+ const nav = useBrowserNavApi({ hasWorkspaces: false });
212
+ return (
213
+ <NavProvider value={nav}>
214
+ <RouterScreen appSchema={appSchema} />
215
+ </NavProvider>
216
+ );
217
+ }
218
+
219
+ function App({
220
+ appSchema,
221
+ dispatcher,
222
+ }: {
223
+ readonly appSchema: FeatureSchema;
224
+ readonly dispatcher: Dispatcher;
225
+ }): ReactNode {
226
+ return (
227
+ <DispatcherProvider dispatcher={dispatcher}>
228
+ <AppFeaturesProvider features={[appSchema]}>
229
+ <UserRolesProvider roles={["User"]}>
230
+ <DashboardBodyProvider value={WebDashboardBody}>
231
+ <BrowserNavRoot appSchema={appSchema} />
232
+ </DashboardBodyProvider>
233
+ </UserRolesProvider>
234
+ </AppFeaturesProvider>
235
+ </DispatcherProvider>
236
+ );
237
+ }
238
+
239
+ function renderApp(): void {
240
+ render(<App appSchema={schema} dispatcher={makeDispatcher()} />);
241
+ }
242
+
243
+ async function waitForEnabled(testId: string): Promise<HTMLButtonElement> {
244
+ const btn = screen.getByTestId(testId) as HTMLButtonElement;
245
+ await waitFor(() => expect(btn.disabled).toBe(false));
246
+ return btn;
247
+ }
248
+
249
+ describe("returnTo", () => {
250
+ afterEach(() => {
251
+ window.history.replaceState(null, "", "/");
252
+ });
253
+
254
+ test("1. embedded list toolbar create sets returnTo=settings; cancel returns to settings", async () => {
255
+ const user = userEvent.setup();
256
+ window.history.replaceState(null, "", "/settings");
257
+ renderApp();
258
+
259
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
260
+ await user.click(screen.getByTestId("render-list-toolbar-action-create"));
261
+
262
+ expect(window.location.pathname).toBe("/token-create");
263
+ expect(window.location.search).toBe("?returnTo=settings");
264
+
265
+ await user.click(screen.getByTestId("render-edit-cancel"));
266
+ expect(window.location.pathname).toBe("/settings");
267
+ });
268
+
269
+ test("2. embedded list toolbar create → fill + submit reveals the secret → confirm returns to settings", async () => {
270
+ const user = userEvent.setup();
271
+ window.history.replaceState(null, "", "/settings");
272
+ renderApp();
273
+
274
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
275
+ await user.click(screen.getByTestId("render-list-toolbar-action-create"));
276
+ expect(window.location.pathname).toBe("/token-create");
277
+
278
+ const input = screen.getByTestId("field-label").querySelector("input") as HTMLInputElement;
279
+ fireEvent.change(input, { target: { value: "My token" } });
280
+ const submitBtn = await waitForEnabled("render-edit-submit");
281
+ await user.click(submitBtn);
282
+
283
+ await waitFor(() =>
284
+ expect(screen.getByTestId("kumiko-screen-secret-mint-reveal")).toBeTruthy(),
285
+ );
286
+ await user.click(screen.getByTestId("kumiko-screen-secret-mint-confirm"));
287
+
288
+ expect(window.location.pathname).toBe("/settings");
289
+ });
290
+
291
+ test("3. embedded list toolbar action → submit returns to settings", async () => {
292
+ const user = userEvent.setup();
293
+ window.history.replaceState(null, "", "/settings");
294
+ renderApp();
295
+
296
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
297
+ await user.click(screen.getByTestId("render-list-toolbar-action-action"));
298
+ expect(window.location.pathname).toBe("/token-action");
299
+ expect(window.location.search).toBe("?returnTo=settings");
300
+
301
+ const submitBtn = await waitForEnabled("render-edit-submit");
302
+ await user.click(submitBtn);
303
+
304
+ await waitFor(() => expect(window.location.pathname).toBe("/settings"));
305
+ });
306
+
307
+ test("3b. embedded list toolbar action → cancel returns to settings", async () => {
308
+ const user = userEvent.setup();
309
+ window.history.replaceState(null, "", "/settings");
310
+ renderApp();
311
+
312
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
313
+ await user.click(screen.getByTestId("render-list-toolbar-action-action"));
314
+ expect(window.location.pathname).toBe("/token-action");
315
+
316
+ await user.click(screen.getByTestId("render-edit-cancel"));
317
+ expect(window.location.pathname).toBe("/settings");
318
+ });
319
+
320
+ test("4. + New (default create) on embedded list → token-edit create → save returns to settings", async () => {
321
+ const user = userEvent.setup();
322
+ window.history.replaceState(null, "", "/settings");
323
+ renderApp();
324
+
325
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
326
+ await user.click(screen.getByTestId("render-list-create"));
327
+
328
+ expect(window.location.pathname).toBe("/token-edit");
329
+ expect(window.location.search).toBe("?returnTo=settings");
330
+
331
+ const input = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
332
+ fireEvent.change(input, { target: { value: "New Token" } });
333
+ const submitBtn = await waitForEnabled("render-edit-submit");
334
+ await user.click(submitBtn);
335
+
336
+ await waitFor(() => expect(window.location.pathname).toBe("/settings"));
337
+ });
338
+
339
+ test("5. row action with a record-targeting redirect lands on token-edit/<id>, not settings", async () => {
340
+ const user = userEvent.setup();
341
+ window.history.replaceState(null, "", "/settings");
342
+ renderApp();
343
+
344
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
345
+ await user.click(screen.getByTestId("row-tok-1-action-open"));
346
+
347
+ expect(window.location.pathname).toBe("/token-open");
348
+ expect(window.location.search).toBe("?returnTo=settings");
349
+
350
+ const submitBtn = await waitForEnabled("render-edit-submit");
351
+ await user.click(submitBtn);
352
+
353
+ await waitFor(() => expect(window.location.pathname).toBe("/token-edit/tok-1"));
354
+ });
355
+
356
+ test("6. direct navigation to token-create (no returnTo) → cancel falls back to cancelTarget", async () => {
357
+ const user = userEvent.setup();
358
+ window.history.replaceState(null, "", "/token-create");
359
+ renderApp();
360
+
361
+ const cancelBtn = await screen.findByTestId("render-edit-cancel");
362
+ await user.click(cancelBtn);
363
+ expect(window.location.pathname).toBe("/token-list");
364
+ });
365
+
366
+ test.each([
367
+ "unknown-screen",
368
+ "//evil.com",
369
+ "https://evil.com",
370
+ "tokens:screen:settings",
371
+ "admin-only",
372
+ "token-create",
373
+ "settings/x/y",
374
+ ])("7. manipulated returnTo=%s falls back to cancelTarget", async (value) => {
375
+ const user = userEvent.setup();
376
+ window.history.replaceState(null, "", `/token-create?returnTo=${encodeURIComponent(value)}`);
377
+ renderApp();
378
+
379
+ const cancelBtn = await screen.findByTestId("render-edit-cancel");
380
+ await user.click(cancelBtn);
381
+ expect(window.location.pathname).toBe("/token-list");
382
+ });
383
+
384
+ test("8. non-embedded token-list toolbar create sets returnTo=token-list, cancel returns there", async () => {
385
+ const user = userEvent.setup();
386
+ window.history.replaceState(null, "", "/token-list");
387
+ renderApp();
388
+
389
+ await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
390
+ await user.click(screen.getByTestId("render-list-toolbar-action-create"));
391
+
392
+ expect(window.location.pathname).toBe("/token-create");
393
+ expect(window.location.search).toBe("?returnTo=token-list");
394
+
395
+ await user.click(screen.getByTestId("render-edit-cancel"));
396
+ expect(window.location.pathname).toBe("/token-list");
397
+ });
398
+
399
+ test("9. direct navigation to token-action (no returnTo) → submit falls back to its own redirect", async () => {
400
+ const user = userEvent.setup();
401
+ window.history.replaceState(null, "", "/token-action");
402
+ renderApp();
403
+
404
+ const submitBtn = await waitForEnabled("render-edit-submit");
405
+ await user.click(submitBtn);
406
+
407
+ await waitFor(() => expect(window.location.pathname).toBe("/token-list"));
408
+ });
409
+
410
+ test("10. direct navigation to token-action (no returnTo) → cancel falls back to its own redirect", async () => {
411
+ const user = userEvent.setup();
412
+ window.history.replaceState(null, "", "/token-action");
413
+ renderApp();
414
+
415
+ const cancelBtn = await screen.findByTestId("render-edit-cancel");
416
+ await user.click(cancelBtn);
417
+ expect(window.location.pathname).toBe("/token-list");
418
+ });
419
+
420
+ test("11. direct navigation to token-create (no returnTo) → submit → reveal → confirm falls back to redirect", async () => {
421
+ const user = userEvent.setup();
422
+ window.history.replaceState(null, "", "/token-create");
423
+ renderApp();
424
+
425
+ const input = await screen.findByTestId("field-label");
426
+ fireEvent.change(input.querySelector("input") as HTMLInputElement, {
427
+ target: { value: "My token" },
428
+ });
429
+ const submitBtn = await waitForEnabled("render-edit-submit");
430
+ await user.click(submitBtn);
431
+
432
+ await waitFor(() =>
433
+ expect(screen.getByTestId("kumiko-screen-secret-mint-reveal")).toBeTruthy(),
434
+ );
435
+ await user.click(screen.getByTestId("kumiko-screen-secret-mint-confirm"));
436
+
437
+ expect(window.location.pathname).toBe("/token-list");
438
+ });
439
+
440
+ test("12. direct navigation to token-edit create (no returnTo) → save falls back to the entity's list", async () => {
441
+ const user = userEvent.setup();
442
+ window.history.replaceState(null, "", "/token-edit");
443
+ renderApp();
444
+
445
+ const input = await screen.findByTestId("field-name");
446
+ fireEvent.change(input.querySelector("input") as HTMLInputElement, {
447
+ target: { value: "New Token" },
448
+ });
449
+ const submitBtn = await waitForEnabled("render-edit-submit");
450
+ await user.click(submitBtn);
451
+
452
+ await waitFor(() => expect(window.location.pathname).toBe("/token-list"));
453
+ });
454
+
455
+ test("13. entityEdit update with returnTo=settings → save returns to settings", async () => {
456
+ const user = userEvent.setup();
457
+ window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=settings");
458
+ renderApp();
459
+
460
+ const input = await screen.findByTestId("field-name");
461
+ await waitFor(() =>
462
+ expect((input.querySelector("input") as HTMLInputElement).value).toBe("Token 1"),
463
+ );
464
+ fireEvent.change(input.querySelector("input") as HTMLInputElement, {
465
+ target: { value: "Renamed" },
466
+ });
467
+ const submitBtn = await waitForEnabled("render-edit-submit");
468
+ await user.click(submitBtn);
469
+
470
+ await waitFor(() => expect(window.location.pathname).toBe("/settings"));
471
+ });
472
+
473
+ test("14. entityEdit update with returnTo=settings → cancel returns to settings", async () => {
474
+ const user = userEvent.setup();
475
+ window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=settings");
476
+ renderApp();
477
+
478
+ const cancelBtn = await screen.findByTestId("render-edit-cancel");
479
+ await user.click(cancelBtn);
480
+ expect(window.location.pathname).toBe("/settings");
481
+ });
482
+
483
+ test("15. entityEdit update with returnTo=settings → delete returns to settings", async () => {
484
+ const user = userEvent.setup();
485
+ window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=settings");
486
+ renderApp();
487
+
488
+ await screen.findByTestId("field-name");
489
+ await user.click(screen.getByTestId("render-edit-delete"));
490
+ await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
491
+
492
+ await waitFor(() => expect(window.location.pathname).toBe("/settings"));
493
+ });
494
+
495
+ test("16. delete-guard: returnTo pointing at the just-deleted record falls back to the list", async () => {
496
+ const user = userEvent.setup();
497
+ window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=token-detail/tok-1");
498
+ renderApp();
499
+
500
+ await screen.findByTestId("field-name");
501
+ await user.click(screen.getByTestId("render-edit-delete"));
502
+ await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
503
+
504
+ await waitFor(() => expect(window.location.pathname).toBe("/token-list"));
505
+ });
506
+
507
+ test("17. manipulated entityId in the real URL (returnTo=token-edit/%2e%2e) → cancel falls back to cancelTarget", async () => {
508
+ const user = userEvent.setup();
509
+ window.history.replaceState(null, "", "/token-create?returnTo=token-edit/%2e%2e");
510
+ renderApp();
511
+
512
+ const cancelBtn = await screen.findByTestId("render-edit-cancel");
513
+ await user.click(cancelBtn);
514
+ expect(window.location.pathname).toBe("/token-list");
515
+ });
516
+
517
+ test("18. projectionList embedded in the dashboard: toolbar navigate carries returnTo=settings", async () => {
518
+ const user = userEvent.setup();
519
+ window.history.replaceState(null, "", "/settings");
520
+ renderApp();
521
+
522
+ await user.click(await screen.findByTestId("render-list-toolbar-action-stats-action"));
523
+
524
+ expect(window.location.pathname).toBe("/token-action");
525
+ expect(window.location.search).toBe("?returnTo=settings");
526
+ });
527
+
528
+ test("19. projectionList embedded in the dashboard: row action navigate carries returnTo=settings", async () => {
529
+ const user = userEvent.setup();
530
+ window.history.replaceState(null, "", "/settings");
531
+ renderApp();
532
+
533
+ await user.click(await screen.findByTestId("row-stat-1-action-stats-row-action"));
534
+
535
+ expect(window.location.pathname).toBe("/token-action");
536
+ expect(window.location.search).toBe("?returnTo=settings");
537
+ });
538
+
539
+ test("20. relatedList toolbar navigate (in a projectionDetail) sets returnTo=<detail>/<id> and its parentParam prefill", async () => {
540
+ const user = userEvent.setup();
541
+ window.history.replaceState(null, "", "/token-detail/tok-1");
542
+ renderApp();
543
+
544
+ await user.click(await screen.findByTestId("render-list-toolbar-action-related-action"));
545
+
546
+ expect(window.location.pathname).toBe("/token-action");
547
+ const params = new URLSearchParams(window.location.search);
548
+ expect(params.get("returnTo")).toBe("token-detail/tok-1");
549
+ expect(params.get("id")).toBe("tok-1");
550
+ });
551
+ });
@@ -29,10 +29,12 @@ import {
29
29
  mergeTranslations,
30
30
  type NavApi,
31
31
  NavProvider,
32
+ navigateWithReturnTo,
32
33
  PrimitivesProvider,
33
34
  type PrimitivesRegistry,
34
35
  type QualifiedContentCollection,
35
36
  qualifyScreenId,
37
+ type ReturnHost,
36
38
  TokensProvider,
37
39
  type TranslationsByLocale,
38
40
  toAppSchema,
@@ -638,14 +640,23 @@ function RoutedScreen({
638
640
  (s) => s.type === "entityEdit" && s.entity === entityName,
639
641
  );
640
642
  if (editScreen) {
643
+ // No ReturnHostProvider out here — derive the host from this memo's own qn/entityId.
644
+ const host: ReturnHost = {
645
+ screenId: lastSegment(qn),
646
+ ...(entityId !== undefined && { entityId }),
647
+ };
641
648
  // editScreen.id is already short form; lastSegment is a no-op
642
649
  // safety net here, kept for symmetry with the other call sites.
643
- nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
650
+ navigateWithReturnTo(
651
+ nav,
652
+ { screenId: lastSegment(editScreen.id), entityId: row.id },
653
+ host,
654
+ );
644
655
  return;
645
656
  }
646
657
  }
647
658
  };
648
- }, [app, activeScreen, onRowClick, nav]);
659
+ }, [app, activeScreen, onRowClick, nav, qn, entityId]);
649
660
 
650
661
  // Copy-link action (Issue #912) for entityEdit update screens. Builds
651
662
  // the absolute permalink URL from the current route + copies it —
@@ -1,4 +1,5 @@
1
1
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { resolveNavParentScreen } from "@cosmicdrift/kumiko-framework/ui-types";
2
3
  import { lastSegment } from "@cosmicdrift/kumiko-renderer";
3
4
 
4
5
  export type BreadcrumbCrumb = {
@@ -10,51 +11,13 @@ export function screenTitleKey(screenShortId: string): string {
10
11
  return `screen:${screenShortId}.title`;
11
12
  }
12
13
 
13
- // Every screen type carrying `listScreenId` declares it identically — reading
14
- // it here (instead of at each call site) keeps the switch the only place
15
- // that has to grow when a new screen type adopts the field.
16
- function explicitListScreenId(screen: ScreenDefinition): string | undefined {
17
- switch (screen.type) {
18
- case "custom":
19
- case "projectionDetail":
20
- case "entityEdit":
21
- case "actionForm":
22
- case "secretMint":
23
- return screen.listScreenId;
24
- default:
25
- return undefined;
26
- }
27
- }
28
-
29
- // Shared by the breadcrumb (this file) and NavTree's active-marker fallback
30
- // (nav-tree.tsx) — both need "which list screen does this detail belong to",
31
- // so this is the one place that answers it. An explicit `listScreenId` wins
32
- // over the heuristics below (rowAction target / same-entity entityList): a
33
- // screen author who declares it is opting out of the guess.
14
+ // Delegates to resolveNavParentScreen, supplying the feature-qualified-id
15
+ // normalization the client-side schema needs.
34
16
  function resolveParentScreen(
35
17
  screens: readonly ScreenDefinition[],
36
18
  detail: ScreenDefinition,
37
19
  ): ScreenDefinition | undefined {
38
- const detailScreenId = lastSegment(detail.id);
39
-
40
- const listFromExplicit = ((): ScreenDefinition | undefined => {
41
- const explicitId = explicitListScreenId(detail);
42
- return explicitId !== undefined
43
- ? screens.find((s) => lastSegment(s.id) === explicitId)
44
- : undefined;
45
- })();
46
-
47
- const listFromRowAction = screens.find((s) => {
48
- if (s.type !== "entityList") return false;
49
- return (s.rowActions ?? []).some((a) => a.kind === "navigate" && a.screen === detailScreenId);
50
- });
51
-
52
- const listFromEntity =
53
- detail.type === "entityEdit"
54
- ? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
55
- : undefined;
56
-
57
- return listFromExplicit ?? listFromRowAction ?? listFromEntity;
20
+ return resolveNavParentScreen(screens, detail, (s) => lastSegment(s.id));
58
21
  }
59
22
 
60
23
  /** The short id of {screenId}'s parent list screen, resolved via the same
@@ -1,4 +1,4 @@
1
- // kumiko-screen-akte-bedienkonzept: Ctrl/Cmd+Enter submits a textarea
1
+ // Ctrl/Cmd+Enter submits a textarea
2
2
  // (InputProps.onSubmitShortcut) instead of inserting a newline. Plain Enter
3
3
  // keeps inserting a newline — asserted here via the keydown event's
4
4
  // defaultPrevented state, since fireEvent.keyDown does not itself simulate
@@ -2289,9 +2289,8 @@ function FormSections({
2289
2289
  }
2290
2290
 
2291
2291
  // Title/subtitle block shared by DefaultForm's card and chromeless layouts —
2292
- // the caller-supplied subtitle (screen.description) is independent of
2293
- // hideSectionTitles (which only blanks per-section titles, see RenderEdit)
2294
- // and must keep rendering in tabs/chromeless mode, just without card chrome.
2292
+ // RenderEdit already withholds screen.description from `subtitle` in tabs
2293
+ // mode (chromeless), so whatever arrives here still renders, just without card chrome.
2295
2294
  function FormTitleBlock({
2296
2295
  title,
2297
2296
  subtitle,
@@ -2694,7 +2693,7 @@ function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNo
2694
2693
  // maxRows/scrolling don't apply — the row just wraps.
2695
2694
  if (columns === "auto") {
2696
2695
  return (
2697
- <div data-testid={testId} className="flex flex-wrap gap-4">
2696
+ <div data-testid={testId} className="flex flex-wrap items-center gap-4">
2698
2697
  {children}
2699
2698
  </div>
2700
2699
  );