@cosmicdrift/kumiko-renderer 0.309.0 → 0.310.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.309.0",
3
+ "version": "0.310.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.309.0",
19
- "@cosmicdrift/kumiko-headless": "0.309.0",
20
- "@cosmicdrift/kumiko-types": "0.309.0",
18
+ "@cosmicdrift/kumiko-framework": "0.310.0",
19
+ "@cosmicdrift/kumiko-headless": "0.310.0",
20
+ "@cosmicdrift/kumiko-types": "0.310.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.309.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.310.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { RowActionNavigate } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { NavApi, NavTarget } from "../nav";
4
+ import { RETURN_TO_PARAM, type ReturnHost } from "../return-to";
5
+ import { buildRecordActions } from "../row-actions";
6
+
7
+ function recordingNav(): {
8
+ readonly nav: NavApi;
9
+ readonly navigateCalls: NavTarget[];
10
+ readonly setSearchParamsCalls: ReadonlyArray<Readonly<Record<string, string | null>>>;
11
+ } {
12
+ const navigateCalls: NavTarget[] = [];
13
+ const setSearchParamsCalls: Array<Readonly<Record<string, string | null>>> = [];
14
+ const nav: NavApi = {
15
+ route: undefined,
16
+ navigate: (target) => navigateCalls.push(target),
17
+ replace: () => {},
18
+ hrefFor: () => "",
19
+ searchParams: {},
20
+ setSearchParams: (updates) => setSearchParamsCalls.push(updates),
21
+ };
22
+ return { nav, navigateCalls, setSearchParamsCalls };
23
+ }
24
+
25
+ const host: ReturnHost = { screenId: "vehicle-list" };
26
+ const noOpenDrawer = (): void => {};
27
+ const noOnWriteSuccess = async (): Promise<void> => {};
28
+
29
+ describe("buildRecordActions — RowActionNavigate.tab", () => {
30
+ test("screen-target: tab lands in the same setSearchParams call as returnTo", () => {
31
+ const { nav, navigateCalls, setSearchParamsCalls } = recordingNav();
32
+ const action: RowActionNavigate = {
33
+ kind: "navigate",
34
+ id: "view-channels",
35
+ label: "app.actions.viewChannels",
36
+ screen: "vehicle-detail",
37
+ entityId: "id",
38
+ tab: "channels",
39
+ params: { pick: ["name"] },
40
+ };
41
+ const actions = buildRecordActions({
42
+ actions: [action],
43
+ record: { id: "abc", name: "Vehicle A" },
44
+ translate: (key) => key,
45
+ nav,
46
+ host,
47
+ dispatcher: undefined,
48
+ openDrawer: noOpenDrawer,
49
+ onWriteSuccess: noOnWriteSuccess,
50
+ });
51
+ actions?.[0]?.onPress();
52
+
53
+ expect(navigateCalls).toEqual([{ screenId: "vehicle-detail", entityId: "abc" }]);
54
+ expect(setSearchParamsCalls).toHaveLength(1);
55
+ expect(setSearchParamsCalls[0]).toMatchObject({
56
+ tab: "channels",
57
+ name: "Vehicle A",
58
+ [RETURN_TO_PARAM]: "vehicle-list",
59
+ });
60
+ });
61
+
62
+ test("entity-target: tab is included in the navigate search params", () => {
63
+ const { nav, setSearchParamsCalls } = recordingNav();
64
+ const action: RowActionNavigate = {
65
+ kind: "navigate",
66
+ id: "view-channels",
67
+ label: "app.actions.viewChannels",
68
+ entity: "vehicle",
69
+ entityId: "id",
70
+ tab: "channels",
71
+ };
72
+ const actions = buildRecordActions({
73
+ actions: [action],
74
+ record: { id: "abc" },
75
+ translate: (key) => key,
76
+ nav,
77
+ host,
78
+ dispatcher: undefined,
79
+ openDrawer: noOpenDrawer,
80
+ onWriteSuccess: noOnWriteSuccess,
81
+ });
82
+ actions?.[0]?.onPress();
83
+
84
+ expect(setSearchParamsCalls).toHaveLength(1);
85
+ expect(setSearchParamsCalls[0]).toEqual({ tab: "channels" });
86
+ });
87
+
88
+ test("no tab set: search params carry no tab key", () => {
89
+ const { nav, setSearchParamsCalls } = recordingNav();
90
+ const action: RowActionNavigate = {
91
+ kind: "navigate",
92
+ id: "view-channels",
93
+ label: "app.actions.viewChannels",
94
+ screen: "vehicle-detail",
95
+ entityId: "id",
96
+ params: { pick: ["name"] },
97
+ };
98
+ const actions = buildRecordActions({
99
+ actions: [action],
100
+ record: { id: "abc", name: "Vehicle A" },
101
+ translate: (key) => key,
102
+ nav,
103
+ host,
104
+ dispatcher: undefined,
105
+ openDrawer: noOpenDrawer,
106
+ onWriteSuccess: noOnWriteSuccess,
107
+ });
108
+ actions?.[0]?.onPress();
109
+
110
+ expect(setSearchParamsCalls).toHaveLength(1);
111
+ expect(setSearchParamsCalls[0]).not.toHaveProperty("tab");
112
+ });
113
+ });
@@ -103,9 +103,9 @@ import {
103
103
  buildRecordActions,
104
104
  evalRowExtractor,
105
105
  isWriteHandlerRowAction,
106
+ navigateActionSearchParams,
106
107
  refetchAfterWrite,
107
108
  runProjectionRowNavigate,
108
- stringifyNavParams,
109
109
  } from "./row-actions";
110
110
  import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access";
111
111
  import { SecretMintBody } from "./secret-mint-body";
@@ -1952,10 +1952,9 @@ function EntityListBody({
1952
1952
  const id = explicit ?? fallback ?? "";
1953
1953
  if (id === "") return;
1954
1954
  nav.navigate({ entity: action.entity, id });
1955
- const params =
1956
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1955
+ const params = navigateActionSearchParams(action, row.values);
1957
1956
  if (params !== undefined) {
1958
- nav.setSearchParams(stringifyNavParams(params));
1957
+ nav.setSearchParams(params);
1959
1958
  }
1960
1959
  } else if (action.screen !== undefined) {
1961
1960
  // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
@@ -1979,10 +1978,7 @@ function EntityListBody({
1979
1978
  // kennt via URL nur Strings). Known-edge: zielt die Action auf den
1980
1979
  // AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
1981
1980
  // Row-Actions praktisch nicht erreichbar, Pfad differiert).
1982
- const params =
1983
- action.params !== undefined
1984
- ? stringifyNavParams(evalRowExtractor(action.params, row.values))
1985
- : undefined;
1981
+ const params = navigateActionSearchParams(action, row.values);
1986
1982
  navigateWithReturnTo(nav, target, host, params);
1987
1983
  }
1988
1984
  },
@@ -2586,6 +2582,7 @@ function runMetricNavigate(
2586
2582
  entity: navigate.entity,
2587
2583
  ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
2588
2584
  ...(navigate.params !== undefined && { params: navigate.params }),
2585
+ ...(navigate.tab !== undefined && { tab: navigate.tab }),
2589
2586
  }
2590
2587
  : navigate.screen !== undefined
2591
2588
  ? {
@@ -2593,11 +2590,11 @@ function runMetricNavigate(
2593
2590
  screen: navigate.screen,
2594
2591
  ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
2595
2592
  ...(navigate.params !== undefined && { params: navigate.params }),
2593
+ ...(navigate.tab !== undefined && { tab: navigate.tab }),
2596
2594
  }
2597
2595
  : undefined;
2598
2596
  if (action === undefined) return;
2599
2597
  runProjectionRowNavigate(nav, action, { id: "", values: record }, host);
2600
- if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
2601
2598
  }
2602
2599
 
2603
2600
  // Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
@@ -71,6 +71,18 @@ export function stringifyNavParams(params: Record<string, unknown>): Record<stri
71
71
  return out;
72
72
  }
73
73
 
74
+ export function navigateActionSearchParams(
75
+ action: RowActionNavigate,
76
+ values: Readonly<Record<string, unknown>>,
77
+ ): Record<string, string | null> | undefined {
78
+ const params =
79
+ action.params !== undefined
80
+ ? stringifyNavParams(evalRowExtractor(action.params, values))
81
+ : undefined;
82
+ if (action.tab === undefined) return params;
83
+ return { ...params, tab: action.tab };
84
+ }
85
+
74
86
  export async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
75
87
  await refetch().catch((err: unknown) => {
76
88
  // biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
@@ -95,10 +107,9 @@ export function runProjectionRowNavigate(
95
107
  // skip: no entityId column on this row — nothing to navigate to.
96
108
  if (id === "") return;
97
109
  nav.navigate({ entity: action.entity, id });
98
- const params =
99
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
110
+ const params = navigateActionSearchParams(action, row.values);
100
111
  if (params !== undefined) {
101
- nav.setSearchParams(stringifyNavParams(params));
112
+ nav.setSearchParams(params);
102
113
  }
103
114
  } else if (action.screen !== undefined) {
104
115
  const entityId =
@@ -107,10 +118,7 @@ export function runProjectionRowNavigate(
107
118
  screenId: action.screen,
108
119
  ...(entityId !== undefined && entityId !== "" && { entityId }),
109
120
  };
110
- const params =
111
- action.params !== undefined
112
- ? stringifyNavParams(evalRowExtractor(action.params, row.values))
113
- : undefined;
121
+ const params = navigateActionSearchParams(action, row.values);
114
122
  navigateWithReturnTo(nav, target, host, params);
115
123
  }
116
124
  // skip: neither entity nor screen set — the boot-validator rejects this
@@ -443,9 +451,8 @@ function buildNavigateRecordAction(
443
451
  sameEntityScreenId,
444
452
  } = options;
445
453
  const runParams = (): void => {
446
- const params =
447
- action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
448
- if (params !== undefined) nav.setSearchParams(stringifyNavParams(params));
454
+ const params = navigateActionSearchParams(action, record);
455
+ if (params !== undefined) nav.setSearchParams(params);
449
456
  };
450
457
  if (action.entity !== undefined) {
451
458
  const targetEntity = action.entity;
@@ -482,10 +489,7 @@ function buildNavigateRecordAction(
482
489
  screenId: targetScreen,
483
490
  ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
484
491
  };
485
- const params =
486
- action.params !== undefined
487
- ? stringifyNavParams(evalRowExtractor(action.params, record))
488
- : undefined;
492
+ const params = navigateActionSearchParams(action, record);
489
493
  navigateWithReturnTo(nav, target, host, params);
490
494
  },
491
495
  };
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.310.0",
4
+ "type": "improvement",
5
+ "title": "Navigate actions merge `tab` into the search params next to returnTo",
6
+ "detail": "Row, record and section navigate actions set `tab` in the same\nsetSearchParams call as their `params` and `returnTo`, so the back\nnavigation survives. Metric navigate with screen/entity plus tab now also\nlands in that single call instead of a second setSearchParams after it."
7
+ },
2
8
  {
3
9
  "version": "0.308.0",
4
10
  "type": "improvement",