@cosmicdrift/kumiko-renderer-web 0.269.2 → 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.269.2",
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.269.2",
20
- "@cosmicdrift/kumiko-headless": "0.269.2",
21
- "@cosmicdrift/kumiko-renderer": "0.269.2",
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.269.2"
69
+ "@cosmicdrift/kumiko-locale-de": "0.273.0"
70
70
  },
71
71
  "repository": {
72
72
  "type": "git",
@@ -183,7 +183,12 @@ describe("createKumikoApp", () => {
183
183
  const openFeature: FeatureSchema = {
184
184
  featureName: "tasks",
185
185
  entities: { task: taskEntity },
186
- screens: [{ ...listScreen, access: { openToAll: true } }],
186
+ screens: [
187
+ {
188
+ ...listScreen,
189
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
190
+ },
191
+ ],
187
192
  navs: [{ id: "task-list", label: "tasks:nav.task-list", screen: "tasks:screen:task-list" }],
188
193
  };
189
194
  const schema: AppSchema = { features: [restrictedFeature, openFeature] };
@@ -282,7 +287,7 @@ describe("createKumikoApp", () => {
282
287
  id: "privacy-center",
283
288
  type: "custom",
284
289
  renderer: { react: { __component: "PrivacyCenterScreen" } },
285
- access: { openToAll: true },
290
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
286
291
  dormant: true,
287
292
  },
288
293
  ],
@@ -539,7 +539,7 @@ const sessionsSchema: FeatureSchema = {
539
539
  type: "projectionList",
540
540
  query: "sessions:query:user-session:mine",
541
541
  columns: [{ field: "userAgent", label: "sessions.mine.col.userAgent" }],
542
- access: { openToAll: true },
542
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
543
543
  },
544
544
  {
545
545
  id: "session-list",
@@ -563,7 +563,7 @@ const mfaSchema: FeatureSchema = {
563
563
  layout: { sections: [] },
564
564
  submitLabel: "mfa.enable.start",
565
565
  reveal: { fields: [{ field: "recoveryCodes", label: "codes", display: "list" }] },
566
- access: { openToAll: true },
566
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
567
567
  },
568
568
  {
569
569
  id: "auth-mfa-disable",
@@ -572,7 +572,7 @@ const mfaSchema: FeatureSchema = {
572
572
  fields: { code: { type: "text", required: true } },
573
573
  layout: { sections: [{ fields: ["code"] }] },
574
574
  submitLabel: "mfa.disable.submit",
575
- access: { openToAll: true },
575
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
576
576
  },
577
577
  ],
578
578
  };
@@ -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
+ });
@@ -74,7 +74,7 @@ function ws(
74
74
  } = {},
75
75
  ): WorkspaceSchema {
76
76
  const access = options.openToAll
77
- ? ({ openToAll: true } as const)
77
+ ? ({ openToAll: { reason: "test handler callable by any signed-in test user" } } as const)
78
78
  : options.roles !== undefined
79
79
  ? ({ roles: options.roles } as const)
80
80
  : undefined;
@@ -121,9 +121,18 @@ describe("filterByAccess", () => {
121
121
 
122
122
  test("sorts by order then insertion order", () => {
123
123
  const list = [
124
- ws("c", { openToAll: true, order: 3 }),
125
- ws("a", { openToAll: true, order: 1 }),
126
- ws("b", { openToAll: true, order: 2 }),
124
+ ws("c", {
125
+ openToAll: true,
126
+ order: 3,
127
+ }),
128
+ ws("a", {
129
+ openToAll: true,
130
+ order: 1,
131
+ }),
132
+ ws("b", {
133
+ openToAll: true,
134
+ order: 2,
135
+ }),
127
136
  ws("d", { openToAll: true }), // no order — sorts last
128
137
  ];
129
138
  expect(filterByAccess(list, []).map((w) => w.definition.id)).toEqual(["a", "b", "c", "d"]);
@@ -133,7 +142,10 @@ describe("filterByAccess", () => {
133
142
  describe("resolveDefaultId", () => {
134
143
  const visible = [
135
144
  ws("admin", { openToAll: true }),
136
- ws("dispatch", { openToAll: true, isDefault: true }),
145
+ ws("dispatch", {
146
+ openToAll: true,
147
+ isDefault: true,
148
+ }),
137
149
  ws("driver", { openToAll: true }),
138
150
  ];
139
151
 
@@ -234,8 +246,14 @@ describe("WorkspaceSwitcher", () => {
234
246
  renderWithSidebar(
235
247
  <WorkspaceSwitcher
236
248
  workspaces={[
237
- ws("admin", { label: "Admin", openToAll: true }),
238
- ws("driver", { label: "Driver", openToAll: true }),
249
+ ws("admin", {
250
+ label: "Admin",
251
+ openToAll: true,
252
+ }),
253
+ ws("driver", {
254
+ label: "Driver",
255
+ openToAll: true,
256
+ }),
239
257
  ]}
240
258
  activeId="admin"
241
259
  onSelect={() => {}}
@@ -253,8 +271,14 @@ describe("WorkspaceSwitcher", () => {
253
271
  renderWithSidebar(
254
272
  <WorkspaceSwitcher
255
273
  workspaces={[
256
- ws("admin", { label: "Admin", openToAll: true }),
257
- ws("driver", { label: "Driver", openToAll: true }),
274
+ ws("admin", {
275
+ label: "Admin",
276
+ openToAll: true,
277
+ }),
278
+ ws("driver", {
279
+ label: "Driver",
280
+ openToAll: true,
281
+ }),
258
282
  ]}
259
283
  activeId="admin"
260
284
  onSelect={onSelect}
@@ -31,7 +31,12 @@ describe("firstOpenScreenQn", () => {
31
31
  const features: readonly FeatureSchema[] = [
32
32
  feature({
33
33
  featureName: "auth-mfa",
34
- screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
34
+ screens: [
35
+ customScreen({
36
+ id: "auth-mfa-enable",
37
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
38
+ }),
39
+ ],
35
40
  // No nav entry — this is auth-mfa's dormant custom-screen convention.
36
41
  }),
37
42
  feature({
@@ -58,7 +63,12 @@ describe("firstOpenScreenQn", () => {
58
63
  const features: readonly FeatureSchema[] = [
59
64
  feature({
60
65
  featureName: "auth-mfa",
61
- screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
66
+ screens: [
67
+ customScreen({
68
+ id: "auth-mfa-enable",
69
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
70
+ }),
71
+ ],
62
72
  }),
63
73
  ];
64
74
  expect(firstOpenScreenQn(features)).toBeUndefined();
@@ -1,5 +1,6 @@
1
1
  import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
2
2
  import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
3
4
  import type {
4
5
  Dispatcher,
5
6
  ListRowViewModel,
@@ -28,10 +29,12 @@ import {
28
29
  mergeTranslations,
29
30
  type NavApi,
30
31
  NavProvider,
32
+ navigateWithReturnTo,
31
33
  PrimitivesProvider,
32
34
  type PrimitivesRegistry,
33
35
  type QualifiedContentCollection,
34
36
  qualifyScreenId,
37
+ type ReturnHost,
35
38
  TokensProvider,
36
39
  type TranslationsByLocale,
37
40
  toAppSchema,
@@ -236,7 +239,7 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
236
239
  for (const feature of features) {
237
240
  const openScreen = feature.screens.find(
238
241
  (s) =>
239
- (s.access === undefined || "openToAll" in s.access) &&
242
+ (s.access === undefined || isOpenToAllGranted(s.access)) &&
240
243
  navScreenQns.has(qualifyScreenId(feature.featureName, s.id)),
241
244
  );
242
245
  if (openScreen !== undefined) return qualifyScreenId(feature.featureName, openScreen.id);
@@ -637,14 +640,23 @@ function RoutedScreen({
637
640
  (s) => s.type === "entityEdit" && s.entity === entityName,
638
641
  );
639
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
+ };
640
648
  // editScreen.id is already short form; lastSegment is a no-op
641
649
  // safety net here, kept for symmetry with the other call sites.
642
- nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
650
+ navigateWithReturnTo(
651
+ nav,
652
+ { screenId: lastSegment(editScreen.id), entityId: row.id },
653
+ host,
654
+ );
643
655
  return;
644
656
  }
645
657
  }
646
658
  };
647
- }, [app, activeScreen, onRowClick, nav]);
659
+ }, [app, activeScreen, onRowClick, nav, qn, entityId]);
648
660
 
649
661
  // Copy-link action (Issue #912) for entityEdit update screens. Builds
650
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
  );