@cosmicdrift/kumiko-renderer 0.319.0 → 0.321.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",
3
- "version": "0.319.0",
3
+ "version": "0.321.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.319.0",
19
- "@cosmicdrift/kumiko-headless": "0.319.0",
20
- "@cosmicdrift/kumiko-types": "0.319.0",
18
+ "@cosmicdrift/kumiko-framework": "0.321.0",
19
+ "@cosmicdrift/kumiko-headless": "0.321.0",
20
+ "@cosmicdrift/kumiko-types": "0.321.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.319.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.321.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -1,7 +1,37 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { FeatureSchema } from "../feature-schema";
4
- import { formatReturnTo, resolveReturnTarget, returnToParams, splitReturnTo } from "../return-to";
4
+ import type { NavApi, NavTarget } from "../nav";
5
+ import {
6
+ formatReturnTo,
7
+ navigateWithReturnTo,
8
+ resolveReturnTarget,
9
+ returnToParams,
10
+ splitReturnTo,
11
+ } from "../return-to";
12
+
13
+ // hrefFor maps entity ObjectTargets to their detail screen, mirroring resolveTarget.
14
+ function fakeNav(searchParams: Readonly<Record<string, string>> = {}): {
15
+ readonly nav: NavApi;
16
+ readonly navigateCalls: NavTarget[];
17
+ readonly setSearchParamsCalls: ReadonlyArray<Readonly<Record<string, string | null>>>;
18
+ } {
19
+ const navigateCalls: NavTarget[] = [];
20
+ const setSearchParamsCalls: Array<Readonly<Record<string, string | null>>> = [];
21
+ const hrefFor = (target: NavTarget): string =>
22
+ "screenId" in target
23
+ ? `/${[target.screenId, target.entityId].filter((s) => s !== undefined).join("/")}`
24
+ : `/vehicle-detail/${target.id}`;
25
+ const nav: NavApi = {
26
+ route: undefined,
27
+ navigate: (target) => navigateCalls.push(target),
28
+ replace: () => {},
29
+ hrefFor,
30
+ searchParams,
31
+ setSearchParams: (updates) => setSearchParamsCalls.push(updates),
32
+ };
33
+ return { nav, navigateCalls, setSearchParamsCalls };
34
+ }
5
35
 
6
36
  function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
7
37
  return { featureName, entities: {}, screens };
@@ -336,3 +366,31 @@ describe("formatReturnTo / splitReturnTo", () => {
336
366
  ).toEqual({ returnTo: "token-list?tab=keys" });
337
367
  });
338
368
  });
369
+
370
+ describe("navigateWithReturnTo — ObjectTarget", () => {
371
+ test("target resolves away from the host: returnTo carries the host's search-params snapshot", () => {
372
+ const { nav, navigateCalls, setSearchParamsCalls } = fakeNav({ tab: "active" });
373
+ navigateWithReturnTo(nav, { entity: "vehicle", id: "abc" }, { screenId: "vehicle-list" });
374
+
375
+ expect(navigateCalls).toEqual([{ entity: "vehicle", id: "abc" }]);
376
+ expect(setSearchParamsCalls).toEqual([{ returnTo: "vehicle-list?tab=active" }]);
377
+ });
378
+
379
+ test("target resolves to the host itself: no returnTo", () => {
380
+ const { nav, setSearchParamsCalls } = fakeNav();
381
+ navigateWithReturnTo(
382
+ nav,
383
+ { entity: "vehicle", id: "abc" },
384
+ { screenId: "vehicle-detail", entityId: "abc" },
385
+ );
386
+
387
+ expect(setSearchParamsCalls).toEqual([]);
388
+ });
389
+
390
+ test("no host: only params, no returnTo", () => {
391
+ const { nav, setSearchParamsCalls } = fakeNav();
392
+ navigateWithReturnTo(nav, { entity: "vehicle", id: "abc" }, undefined, { name: "Vehicle A" });
393
+
394
+ expect(setSearchParamsCalls).toEqual([{ name: "Vehicle A" }]);
395
+ });
396
+ });
@@ -1,10 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { RowActionNavigate } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type {
3
+ RelatedListToolbarAction,
4
+ RowActionNavigate,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
3
6
  import type { NavApi, NavTarget } from "../nav";
4
7
  import { RETURN_TO_PARAM, type ReturnHost } from "../return-to";
5
- import { buildRecordActions } from "../row-actions";
8
+ import {
9
+ buildProjectionToolbarActions,
10
+ buildRecordActions,
11
+ runProjectionRowNavigate,
12
+ } from "../row-actions";
6
13
 
7
- function recordingNav(): {
14
+ function recordingNav(hrefFor: (target: NavTarget) => string = () => ""): {
8
15
  readonly nav: NavApi;
9
16
  readonly navigateCalls: NavTarget[];
10
17
  readonly setSearchParamsCalls: ReadonlyArray<Readonly<Record<string, string | null>>>;
@@ -15,13 +22,19 @@ function recordingNav(): {
15
22
  route: undefined,
16
23
  navigate: (target) => navigateCalls.push(target),
17
24
  replace: () => {},
18
- hrefFor: () => "",
25
+ hrefFor,
19
26
  searchParams: {},
20
27
  setSearchParams: (updates) => setSearchParamsCalls.push(updates),
21
28
  };
22
29
  return { nav, navigateCalls, setSearchParamsCalls };
23
30
  }
24
31
 
32
+ // hrefFor maps entity ObjectTargets to their detail screen, mirroring resolveTarget.
33
+ const hrefForVehicleDetail = (target: NavTarget): string =>
34
+ "screenId" in target
35
+ ? `/${[target.screenId, target.entityId].filter((s) => s !== undefined).join("/")}`
36
+ : `/vehicle-detail/${target.id}`;
37
+
25
38
  const host: ReturnHost = { screenId: "vehicle-list" };
26
39
  const noOpenDrawer = (): void => {};
27
40
  const noOnWriteSuccess = async (): Promise<void> => {};
@@ -74,7 +87,7 @@ describe("buildRecordActions — RowActionNavigate.tab", () => {
74
87
  record: { id: "abc" },
75
88
  translate: (key) => key,
76
89
  nav,
77
- host,
90
+ host: undefined,
78
91
  dispatcher: undefined,
79
92
  openDrawer: noOpenDrawer,
80
93
  onWriteSuccess: noOnWriteSuccess,
@@ -85,6 +98,32 @@ describe("buildRecordActions — RowActionNavigate.tab", () => {
85
98
  expect(setSearchParamsCalls[0]).toEqual({ tab: "channels" });
86
99
  });
87
100
 
101
+ test("entity-target: carries returnTo when the target resolves away from the host", () => {
102
+ const { nav, navigateCalls, setSearchParamsCalls } = recordingNav(hrefForVehicleDetail);
103
+ const action: RowActionNavigate = {
104
+ kind: "navigate",
105
+ id: "view-channels",
106
+ label: "app.actions.viewChannels",
107
+ entity: "vehicle",
108
+ entityId: "id",
109
+ tab: "channels",
110
+ };
111
+ const actions = buildRecordActions({
112
+ actions: [action],
113
+ record: { id: "abc" },
114
+ translate: (key) => key,
115
+ nav,
116
+ host,
117
+ dispatcher: undefined,
118
+ openDrawer: noOpenDrawer,
119
+ onWriteSuccess: noOnWriteSuccess,
120
+ });
121
+ actions?.[0]?.onPress();
122
+
123
+ expect(navigateCalls).toEqual([{ entity: "vehicle", id: "abc" }]);
124
+ expect(setSearchParamsCalls).toEqual([{ tab: "channels", [RETURN_TO_PARAM]: "vehicle-list" }]);
125
+ });
126
+
88
127
  test("no tab set: search params carry no tab key", () => {
89
128
  const { nav, setSearchParamsCalls } = recordingNav();
90
129
  const action: RowActionNavigate = {
@@ -111,3 +150,74 @@ describe("buildRecordActions — RowActionNavigate.tab", () => {
111
150
  expect(setSearchParamsCalls[0]).not.toHaveProperty("tab");
112
151
  });
113
152
  });
153
+
154
+ describe("buildProjectionToolbarActions — navigate.tab", () => {
155
+ test("tab lands in the setSearchParams call", () => {
156
+ const { nav, navigateCalls, setSearchParamsCalls } = recordingNav();
157
+ const action: RelatedListToolbarAction = {
158
+ kind: "navigate",
159
+ id: "view-channels",
160
+ label: "app.actions.viewChannels",
161
+ screen: "vehicle-detail",
162
+ tab: "channels",
163
+ };
164
+ const actions = buildProjectionToolbarActions({
165
+ toolbarActions: [action],
166
+ translate: (key) => key,
167
+ dispatcher: undefined,
168
+ nav,
169
+ refetch: async () => {},
170
+ host: undefined,
171
+ });
172
+ actions?.[0]?.onTrigger();
173
+
174
+ expect(navigateCalls).toEqual([{ screenId: "vehicle-detail" }]);
175
+ expect(setSearchParamsCalls).toEqual([{ tab: "channels" }]);
176
+ });
177
+
178
+ test("navigatePrefill and tab both survive into the same setSearchParams call, plus returnTo with a host", () => {
179
+ const { nav, setSearchParamsCalls } = recordingNav();
180
+ const action: RelatedListToolbarAction = {
181
+ kind: "navigate",
182
+ id: "create-channel",
183
+ label: "app.actions.createChannel",
184
+ screen: "channel-create",
185
+ tab: "channels",
186
+ };
187
+ const actions = buildProjectionToolbarActions({
188
+ toolbarActions: [action],
189
+ translate: (key) => key,
190
+ dispatcher: undefined,
191
+ nav,
192
+ refetch: async () => {},
193
+ navigatePrefill: { vehicleId: "abc" },
194
+ host,
195
+ });
196
+ actions?.[0]?.onTrigger();
197
+
198
+ expect(setSearchParamsCalls).toHaveLength(1);
199
+ expect(setSearchParamsCalls[0]).toMatchObject({
200
+ tab: "channels",
201
+ vehicleId: "abc",
202
+ [RETURN_TO_PARAM]: "vehicle-list",
203
+ });
204
+ });
205
+ });
206
+
207
+ describe("runProjectionRowNavigate — entity target carries returnTo", () => {
208
+ test("entity action with a host and tab: navigate gets the ObjectTarget, returnTo and tab land together", () => {
209
+ const { nav, navigateCalls, setSearchParamsCalls } = recordingNav(hrefForVehicleDetail);
210
+ const action: RowActionNavigate = {
211
+ kind: "navigate",
212
+ id: "view-channels",
213
+ label: "app.actions.viewChannels",
214
+ entity: "vehicle",
215
+ entityId: "id",
216
+ tab: "channels",
217
+ };
218
+ runProjectionRowNavigate(nav, action, { id: "abc", values: { id: "abc" } }, host);
219
+
220
+ expect(navigateCalls).toEqual([{ entity: "vehicle", id: "abc" }]);
221
+ expect(setSearchParamsCalls).toEqual([{ tab: "channels", [RETURN_TO_PARAM]: "vehicle-list" }]);
222
+ });
223
+ });
@@ -1951,11 +1951,8 @@ function EntityListBody({
1951
1951
  action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
1952
1952
  const id = explicit ?? fallback ?? "";
1953
1953
  if (id === "") return;
1954
- nav.navigate({ entity: action.entity, id });
1955
1954
  const params = navigateActionSearchParams(action, row.values);
1956
- if (params !== undefined) {
1957
- nav.setSearchParams(params);
1958
- }
1955
+ navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
1959
1956
  } else if (action.screen !== undefined) {
1960
1957
  // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1961
1958
  // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
@@ -3,7 +3,7 @@ import { createContext, type ReactNode, useContext, useMemo } from "react";
3
3
  import { useUserRoles } from "../context/user-roles-context";
4
4
  import { useAppFeatures } from "./app-features-context";
5
5
  import type { FeatureSchema } from "./feature-schema";
6
- import { type NavApi, type ScreenTarget, useNav } from "./nav";
6
+ import { type NavApi, type NavTarget, type ObjectTarget, type ScreenTarget, useNav } from "./nav";
7
7
  import { lastSegment } from "./qn";
8
8
  import { screenAccessAllows } from "./screen-access";
9
9
 
@@ -93,14 +93,34 @@ export function returnToParams(
93
93
  return { [RETURN_TO_PARAM]: formatReturnTo(host, hostParams) };
94
94
  }
95
95
 
96
+ // ObjectTarget only resolves to a path inside the NavApi impl (detailFor
97
+ // lookup across all features), so compare hrefs instead of the raw target.
98
+ function objectTargetIsHost(nav: NavApi, target: ObjectTarget, host: ReturnHost): boolean {
99
+ const hostTarget: ScreenTarget = {
100
+ screenId: host.screenId,
101
+ ...(host.entityId !== undefined && { entityId: host.entityId }),
102
+ };
103
+ return nav.hrefFor(target) === nav.hrefFor(hostTarget);
104
+ }
105
+
106
+ function returnParamsFor(
107
+ nav: NavApi,
108
+ target: NavTarget,
109
+ host: ReturnHost | undefined,
110
+ ): Readonly<Record<string, string>> {
111
+ if ("screenId" in target) return returnToParams(host, target, nav.searchParams);
112
+ if (host === undefined || objectTargetIsHost(nav, target, host)) return {};
113
+ return { [RETURN_TO_PARAM]: formatReturnTo(host, nav.searchParams) };
114
+ }
115
+
96
116
  export function navigateWithReturnTo(
97
117
  nav: NavApi,
98
- target: ScreenTarget,
118
+ target: NavTarget,
99
119
  host: ReturnHost | undefined,
100
120
  params?: Readonly<Record<string, string | null>>,
101
121
  ): void {
102
122
  // Snapshot before navigating — nav.searchParams still describes the host.
103
- const returnParams = returnToParams(host, target, nav.searchParams);
123
+ const returnParams = returnParamsFor(nav, target, host);
104
124
  nav.navigate(target);
105
125
  const merged: Record<string, string | null> = {
106
126
  ...(params ?? {}),
@@ -106,11 +106,8 @@ export function runProjectionRowNavigate(
106
106
  const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
107
107
  // skip: no entityId column on this row — nothing to navigate to.
108
108
  if (id === "") return;
109
- nav.navigate({ entity: action.entity, id });
110
109
  const params = navigateActionSearchParams(action, row.values);
111
- if (params !== undefined) {
112
- nav.setSearchParams(params);
113
- }
110
+ navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
114
111
  } else if (action.screen !== undefined) {
115
112
  const entityId =
116
113
  action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
@@ -320,7 +317,10 @@ function buildNavigateToolbarAction(
320
317
  action.params !== undefined && record !== undefined
321
318
  ? evalRowExtractor(action.params, record)
322
319
  : prefill;
323
- const params = resolvedParams !== undefined ? stringifyNavParams(resolvedParams) : undefined;
320
+ const stringifiedParams =
321
+ resolvedParams !== undefined ? stringifyNavParams(resolvedParams) : undefined;
322
+ const params =
323
+ action.tab === undefined ? stringifiedParams : { ...stringifiedParams, tab: action.tab };
324
324
  navigateWithReturnTo(nav, target, host, params);
325
325
  },
326
326
  };
@@ -450,10 +450,6 @@ function buildNavigateRecordAction(
450
450
  defaultScreenTargetEntityId,
451
451
  sameEntityScreenId,
452
452
  } = options;
453
- const runParams = (): void => {
454
- const params = navigateActionSearchParams(action, record);
455
- if (params !== undefined) nav.setSearchParams(params);
456
- };
457
453
  if (action.entity !== undefined) {
458
454
  const targetEntity = action.entity;
459
455
  const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
@@ -466,8 +462,8 @@ function buildNavigateRecordAction(
466
462
  onPress: () => {
467
463
  // No entityId on record (id === "") → nothing to navigate to.
468
464
  if (id !== "") {
469
- nav.navigate({ entity: targetEntity, id });
470
- runParams();
465
+ const params = navigateActionSearchParams(action, record);
466
+ navigateWithReturnTo(nav, { entity: targetEntity, id }, host, params);
471
467
  }
472
468
  },
473
469
  };
@@ -1,28 +1,16 @@
1
1
  import type {
2
- AccessRule,
3
2
  EntityEditScreenDefinition,
4
3
  FeatureSchema,
5
4
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
7
6
  import { lastSegment } from "./qn";
8
7
 
9
- // Minimal role-gate for the screen-render path (#1203 — nav filtering via
10
- // filterByAccess in workspace-shell.tsx hid role-gated screens from the
11
- // menu, but a direct URL/screenQn hit reached KumikoScreen unchecked).
12
- // Reimplemented instead of imported from framework/engine's hasAccess
13
- // (pulls server-side deps) — same bundle-purity reasoning as headless/nav's
14
- // resolve.ts:userCanSee, which this mirrors. Own leaf module (not exported
15
- // from kumiko-screen.tsx directly) so render-field.tsx can import it too
16
- // without the kumiko-screen → RenderEdit → RenderField cycle.
17
- export function screenAccessAllows(
18
- access: AccessRule | undefined,
19
- userRoles: readonly string[] | undefined,
20
- ): boolean {
21
- if (!access) return true;
22
- if ("openToAll" in access) return isOpenToAllGranted(access);
23
- if (userRoles === undefined) return false;
24
- return access.roles.some((role) => userRoles.includes(role));
25
- }
8
+ // Shared UI-visibility predicate for the screen-render path (#1203 — nav
9
+ // filtering via filterByAccess in workspace-shell.tsx hid role-gated
10
+ // screens from the menu, but a direct URL/screenQn hit reached
11
+ // KumikoScreen unchecked). Public name kept as an alias of the shared
12
+ // predicate that headless/nav and workspace-shell use as well.
13
+ export { isUiAccessGranted as screenAccessAllows };
26
14
 
27
15
  // A navigate target (metric click) declares no access rule of its own — the
28
16
  // destination screen already does, so the jump is offered exactly when the
@@ -49,7 +37,7 @@ export function navigateTargetAllows(
49
37
  // First match IS the destination: short ids are globally unique
50
38
  // (validateScreenShortIdCollisions) and both the router and
51
39
  // resolveTarget take the first hit.
52
- if (isTarget) return screenAccessAllows(candidate.access, userRoles);
40
+ if (isTarget) return isUiAccessGranted(candidate.access, userRoles);
53
41
  }
54
42
  }
55
43
  // Unresolvable target — the boot-validator doesn't check metric navigate
@@ -67,7 +55,7 @@ export function findEditScreenFor(
67
55
  for (const feature of appFeatures) {
68
56
  const match = feature.screens.find(
69
57
  (s): s is EntityEditScreenDefinition =>
70
- s.type === "entityEdit" && s.entity === entity && screenAccessAllows(s.access, userRoles),
58
+ s.type === "entityEdit" && s.entity === entity && isUiAccessGranted(s.access, userRoles),
71
59
  );
72
60
  if (match !== undefined) return match;
73
61
  }
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "improvement",
5
+ "title": "navigateWithReturnTo carries returnTo for entity ObjectTargets",
6
+ "detail": "navigateWithReturnTo now accepts a NavTarget (ScreenTarget or ObjectTarget),\nnot just ScreenTarget. For an entity target it compares the resolved href\nagainst the host's to detect a self-navigate (no returnTo) and otherwise\nsets returnTo to the host snapshot, same as the existing screen-target\npath. runProjectionRowNavigate, buildNavigateRecordAction, EntityListBody's\nrow navigate and related-list-section's legacy rowClick now go through\nthis for entity actions."
7
+ },
8
+ {
9
+ "version": "0.320.0",
10
+ "type": "improvement",
11
+ "title": "Toolbar navigate actions merge `tab` into the search params",
12
+ "detail": "buildNavigateToolbarAction (shared by entityList, projectionList and\nrelatedList-section toolbars) sets `tab` in the same params object as any\ndeclared `params`/prefill, so it lands in the single navigateWithReturnTo\ncall next to returnTo."
13
+ },
2
14
  {
3
15
  "version": "0.318.0",
4
16
  "type": "improvement",
@@ -20,7 +20,7 @@ import {
20
20
  } from "../app/list-facets";
21
21
  import { useNav } from "../app/nav";
22
22
  import { ReferenceFacetBridges, type ReferenceFacetOption } from "../app/reference-facet-bridge";
23
- import { useReturnHost } from "../app/return-to";
23
+ import { navigateWithReturnTo, useReturnHost } from "../app/return-to";
24
24
  import {
25
25
  buildDefaultEditRowAction,
26
26
  buildProjectionRowActions,
@@ -252,7 +252,7 @@ export function RelatedListSection({
252
252
  ? (row: ListRowViewModel) => {
253
253
  const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
254
254
  if (id === "") return;
255
- nav.navigate({ entity: rowClick.entity, id });
255
+ navigateWithReturnTo(nav, { entity: rowClick.entity, id }, host);
256
256
  }
257
257
  : rowClickAction !== undefined
258
258
  ? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row, host)
@@ -82,6 +82,12 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
82
82
 
83
83
  "kumiko.dashboard.filter.all": "All",
84
84
 
85
+ "kumiko.app-boot.loading": "Loading…",
86
+ "kumiko.app-boot.unauthorized": "You need to sign in to see this.",
87
+ "kumiko.app-boot.failed": "Couldn't load the app. Check your connection and try again.",
88
+ "kumiko.app-boot.no-open-screen":
89
+ "This app has no screen that's accessible without a role restriction.",
90
+
85
91
  "kumiko.widget.loading": "Loading…",
86
92
  "kumiko.widget.error.title": "Couldn't load.",
87
93