@cosmicdrift/kumiko-renderer 0.211.0 → 0.212.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.211.0",
3
+ "version": "0.212.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,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.211.0",
19
- "@cosmicdrift/kumiko-headless": "0.211.0",
18
+ "@cosmicdrift/kumiko-framework": "0.212.0",
19
+ "@cosmicdrift/kumiko-headless": "0.212.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -25,7 +25,7 @@
25
25
  "@testing-library/react": "^16.3.2",
26
26
  "@types/react": "^19.2.14",
27
27
  "jsdom": "^29.1.1",
28
- "@cosmicdrift/kumiko-locale-de": "0.211.0"
28
+ "@cosmicdrift/kumiko-locale-de": "0.212.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -0,0 +1,194 @@
1
+ // fw#2228: RowActionNavigate can now name an ObjectTarget (`entity`) instead
2
+ // of a ScreenTarget (`screen`). This renders the real entityList pipeline
3
+ // (KumikoScreen → EntityListBody) and asserts a click on the entity-target
4
+ // row action calls nav.navigate with `{ entity, id }` — resolution to an
5
+ // actual screen is the NavApi impl's job (renderer-web's resolveTarget), not
6
+ // this platform-neutral package's. Mirrors
7
+ // entity-list-row-action-kumiko-actions-view.test.tsx's harness.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import type {
11
+ EntityDefinition,
12
+ EntityListScreenDefinition,
13
+ } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
15
+ import { render, waitFor } from "@testing-library/react";
16
+ import type { ComponentType, ReactNode } from "react";
17
+ import { DispatcherProvider } from "../../context/dispatcher-context";
18
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
19
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
20
+ import {
21
+ type CorePrimitives,
22
+ type DataTableProps,
23
+ type DataTableRowAction,
24
+ PrimitivesProvider,
25
+ } from "../../primitives";
26
+ import type { FeatureSchema } from "../feature-schema";
27
+ import { KumikoScreen } from "../kumiko-screen";
28
+ import type { NavTarget } from "../nav";
29
+ import { NavProvider } from "../nav";
30
+
31
+ function stubDispatcher(): Dispatcher {
32
+ return {
33
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
34
+ query: (async () => ({
35
+ isSuccess: true,
36
+ data: {
37
+ rows: [{ id: "invoice-42", customerId: "cust-7", status: "open" }],
38
+ nextCursor: null,
39
+ total: 1,
40
+ },
41
+ })) as unknown as Dispatcher["query"],
42
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
43
+ statusStore: {
44
+ getState: () => "online",
45
+ subscribe: () => () => {},
46
+ } as unknown as Dispatcher["statusStore"],
47
+ async *stream() {},
48
+ pendingWrites: () => [],
49
+ pendingFiles: () => [],
50
+ };
51
+ }
52
+
53
+ let capturedRowActions: readonly DataTableRowAction[] | undefined;
54
+ const captureDataTable: ComponentType<DataTableProps> = (props) => {
55
+ capturedRowActions = props.rowActions;
56
+ return null;
57
+ };
58
+ const noop = (): ReactNode => null;
59
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
60
+
61
+ const testPrimitives: CorePrimitives = {
62
+ Button: noop,
63
+ Banner: passChildren,
64
+ Field: passChildren,
65
+ Input: noop,
66
+ DataTable: captureDataTable,
67
+ Form: passChildren,
68
+ Section: passChildren,
69
+ Card: passChildren,
70
+ Grid: passChildren,
71
+ GridCell: passChildren,
72
+ Text: passChildren,
73
+ Heading: noop,
74
+ Dialog: noop,
75
+ Modal: noop,
76
+ Lightbox: noop,
77
+ ConfigSourceBadge: noop,
78
+ ConfigCascadeView: noop,
79
+ Link: noop,
80
+ };
81
+
82
+ function buildSchema(rowAction: EntityListScreenDefinition["rowActions"]): FeatureSchema {
83
+ const entity: EntityDefinition = {
84
+ fields: {
85
+ customerId: {
86
+ type: "text",
87
+ maxLength: 50,
88
+ required: false,
89
+ searchable: false,
90
+ sortable: false,
91
+ },
92
+ status: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
93
+ },
94
+ };
95
+ const listScreen: EntityListScreenDefinition = {
96
+ id: "invoice-list",
97
+ type: "entityList",
98
+ entity: "invoice",
99
+ columns: ["status"],
100
+ rowActions: rowAction,
101
+ };
102
+ return {
103
+ featureName: "billing",
104
+ entities: { invoice: entity },
105
+ screens: [listScreen],
106
+ } as FeatureSchema;
107
+ }
108
+
109
+ function renderListScreen(schema: FeatureSchema, navigateSpy: (target: NavTarget) => void): void {
110
+ render(
111
+ <LocaleProvider
112
+ resolver={createStaticLocaleResolver({ locale: "en" })}
113
+ fallbackBundles={[kumikoDefaultTranslations]}
114
+ >
115
+ <DispatcherProvider dispatcher={stubDispatcher()}>
116
+ <NavProvider
117
+ value={{
118
+ route: { screenId: "billing:invoice-list" },
119
+ navigate: navigateSpy,
120
+ replace: () => {},
121
+ hrefFor: () => "",
122
+ searchParams: {},
123
+ setSearchParams: () => {},
124
+ }}
125
+ >
126
+ <PrimitivesProvider value={testPrimitives}>
127
+ <KumikoScreen schema={schema} qn="billing:screen:invoice-list" />
128
+ </PrimitivesProvider>
129
+ </NavProvider>
130
+ </DispatcherProvider>
131
+ </LocaleProvider>,
132
+ );
133
+ }
134
+
135
+ function requireAction(id: string): DataTableRowAction {
136
+ const action = capturedRowActions?.find((a) => a.id === id);
137
+ if (!action) throw new Error(`expected the '${id}' row action to be captured`);
138
+ return action;
139
+ }
140
+
141
+ describe("entityList navigate row-action with an entity-target (fw#2228)", () => {
142
+ test("clicking the row action calls nav.navigate with { entity, id }, not a screenId", async () => {
143
+ capturedRowActions = undefined;
144
+ const navigateCalls: NavTarget[] = [];
145
+
146
+ renderListScreen(
147
+ buildSchema([
148
+ { kind: "navigate", id: "view", label: "kumiko.actions.view", entity: "invoice" },
149
+ ]),
150
+ (target) => navigateCalls.push(target),
151
+ );
152
+
153
+ await waitFor(() => {
154
+ expect(capturedRowActions).toBeDefined();
155
+ });
156
+
157
+ await requireAction("view").onTrigger({
158
+ id: "invoice-42",
159
+ values: { id: "invoice-42", customerId: "cust-7", status: "open" },
160
+ });
161
+
162
+ expect(navigateCalls).toHaveLength(1);
163
+ expect(navigateCalls[0]).toEqual({ entity: "invoice", id: "invoice-42" });
164
+ });
165
+
166
+ test("an explicit entityId field-name still overrides the row['id'] default", async () => {
167
+ capturedRowActions = undefined;
168
+ const navigateCalls: NavTarget[] = [];
169
+
170
+ renderListScreen(
171
+ buildSchema([
172
+ {
173
+ kind: "navigate",
174
+ id: "view-customer",
175
+ label: "kumiko.actions.view",
176
+ entity: "customer",
177
+ entityId: "customerId",
178
+ },
179
+ ]),
180
+ (target) => navigateCalls.push(target),
181
+ );
182
+
183
+ await waitFor(() => {
184
+ expect(capturedRowActions).toBeDefined();
185
+ });
186
+
187
+ await requireAction("view-customer").onTrigger({
188
+ id: "invoice-42",
189
+ values: { id: "invoice-42", customerId: "cust-7" },
190
+ });
191
+
192
+ expect(navigateCalls).toEqual([{ entity: "customer", id: "cust-7" }]);
193
+ });
194
+ });
@@ -1297,23 +1297,34 @@ function EntityListBody({
1297
1297
  // nicht auseinanderdriften.
1298
1298
  const runNavigate = useCallback(
1299
1299
  (action: RowActionNavigate, row: ListRowViewModel) => {
1300
- // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1301
- // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
1302
- // bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
1303
- const targetIsEntityEdit = schema.screens.some(
1304
- (s) =>
1305
- s.type === "entityEdit" &&
1306
- s.entity === screen.entity &&
1307
- lastSegment(s.id) === action.screen,
1308
- );
1309
- const explicit =
1310
- action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1311
- const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
1312
- const entityId = explicit ?? fallback;
1313
- nav.navigate({
1314
- screenId: action.screen,
1315
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1316
- });
1300
+ if (action.entity !== undefined) {
1301
+ // Entity-Targets (fw#2228) lösen sich erst in der NavApi-Impl gegen
1302
+ // ALLE Features auf (resolveTarget in renderer-web/nav.tsx) — dieses
1303
+ // Package kennt nur `schema` (das eine aufrufende Feature) und kann
1304
+ // detailFor cross-feature nicht selbst nachschlagen.
1305
+ const explicit =
1306
+ action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1307
+ const id = explicit ?? String(row.values["id"] ?? "");
1308
+ nav.navigate({ entity: action.entity, id });
1309
+ } else if (action.screen !== undefined) {
1310
+ // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1311
+ // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
1312
+ // bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
1313
+ const targetIsEntityEdit = schema.screens.some(
1314
+ (s) =>
1315
+ s.type === "entityEdit" &&
1316
+ s.entity === screen.entity &&
1317
+ lastSegment(s.id) === action.screen,
1318
+ );
1319
+ const explicit =
1320
+ action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1321
+ const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
1322
+ const entityId = explicit ?? fallback;
1323
+ nav.navigate({
1324
+ screenId: action.screen,
1325
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
1326
+ });
1327
+ }
1317
1328
  const params =
1318
1329
  action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1319
1330
  if (params !== undefined) {
@@ -1658,12 +1669,24 @@ function ProjectionListBody({
1658
1669
 
1659
1670
  const runNavigate = useCallback(
1660
1671
  (action: RowActionNavigate, row: ListRowViewModel) => {
1661
- const entityId =
1662
- action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1663
- nav.navigate({
1664
- screenId: action.screen,
1665
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1666
- });
1672
+ if (action.entity !== undefined) {
1673
+ // Entity-Targets (fw#2228) siehe EntityListBody.runNavigate für die
1674
+ // Begründung, warum die Auflösung in der NavApi-Impl passiert. Anders
1675
+ // als dort: KEIN row["id"]-Fallback — projectionList-Rows kommen aus
1676
+ // einer beliebigen Query-Projection ohne garantiertes "id"-Feld
1677
+ // (gleiche Begründung wie beim screen-Target unten). Der Boot-
1678
+ // Validator erzwingt deshalb einen expliziten entityId für
1679
+ // projectionList-entity-Targets.
1680
+ const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
1681
+ nav.navigate({ entity: action.entity, id });
1682
+ } else if (action.screen !== undefined) {
1683
+ const entityId =
1684
+ action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1685
+ nav.navigate({
1686
+ screenId: action.screen,
1687
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
1688
+ });
1689
+ }
1667
1690
  const params =
1668
1691
  action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1669
1692
  if (params !== undefined) {
@@ -1963,46 +1986,68 @@ function ProjectionDetailBody({
1963
1986
  continue;
1964
1987
  }
1965
1988
  if (action.kind === "navigate") {
1966
- // Default entityId for an entityEdit target of the SAME entity
1967
- // (screen.detailFor plays entity's role here, like screen.entity
1968
- // does for entityList's runNavigate) without this fallback the
1969
- // target opens an empty create-form instead of the shown record,
1970
- // silently. Searched cross-feature, consistent with editScreen above.
1971
- const explicit =
1972
- action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
1973
- const targetIsEntityEditSameEntity =
1974
- screen.detailFor !== undefined &&
1975
- appFeatures.some((feature) =>
1976
- feature.screens.some(
1977
- (s) =>
1978
- s.type === "entityEdit" &&
1979
- s.entity === screen.detailFor &&
1980
- lastSegment(s.id) === action.screen,
1981
- ),
1982
- );
1983
- const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
1984
- const navEntityId = explicit ?? fallback;
1985
- const targetScreen = action.screen;
1986
- out.push({
1987
- id: action.id,
1988
- label: effectiveTranslate(action.label),
1989
- ...(action.style !== undefined && { style: action.style }),
1990
- onPress: () => {
1991
- nav.navigate({
1992
- screenId: targetScreen,
1993
- ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
1994
- });
1995
- const params =
1996
- action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
1997
- if (params !== undefined) {
1998
- const stringified: Record<string, string | null> = {};
1999
- for (const [k, v] of Object.entries(params)) {
2000
- stringified[k] = v === null || v === undefined ? null : String(v);
2001
- }
2002
- nav.setSearchParams(stringified);
1989
+ const runParams = (): void => {
1990
+ const params =
1991
+ action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
1992
+ if (params !== undefined) {
1993
+ const stringified: Record<string, string | null> = {};
1994
+ for (const [k, v] of Object.entries(params)) {
1995
+ stringified[k] = v === null || v === undefined ? null : String(v);
2003
1996
  }
2004
- },
2005
- });
1997
+ nav.setSearchParams(stringified);
1998
+ }
1999
+ };
2000
+ if (action.entity !== undefined) {
2001
+ // Entity-Targets (fw#2228) — Auflösung passiert in der NavApi-Impl
2002
+ // (siehe EntityListBody.runNavigate), nicht hier. KEIN record["id"]-
2003
+ // Fallback: record kommt aus einer beliebigen Detail-Query ohne
2004
+ // garantiertes "id"-Feld, der Boot-Validator erzwingt deshalb einen
2005
+ // expliziten entityId für projectionDetail-entity-Targets.
2006
+ const targetEntity = action.entity;
2007
+ const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
2008
+ out.push({
2009
+ id: action.id,
2010
+ label: effectiveTranslate(action.label),
2011
+ ...(action.style !== undefined && { style: action.style }),
2012
+ onPress: () => {
2013
+ nav.navigate({ entity: targetEntity, id });
2014
+ runParams();
2015
+ },
2016
+ });
2017
+ } else if (action.screen !== undefined) {
2018
+ // Default entityId for an entityEdit target of the SAME entity
2019
+ // (screen.detailFor plays entity's role here, like screen.entity
2020
+ // does for entityList's runNavigate) — without this fallback the
2021
+ // target opens an empty create-form instead of the shown record,
2022
+ // silently. Searched cross-feature, consistent with editScreen above.
2023
+ const explicit =
2024
+ action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
2025
+ const targetIsEntityEditSameEntity =
2026
+ screen.detailFor !== undefined &&
2027
+ appFeatures.some((feature) =>
2028
+ feature.screens.some(
2029
+ (s) =>
2030
+ s.type === "entityEdit" &&
2031
+ s.entity === screen.detailFor &&
2032
+ lastSegment(s.id) === action.screen,
2033
+ ),
2034
+ );
2035
+ const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
2036
+ const navEntityId = explicit ?? fallback;
2037
+ const targetScreen = action.screen;
2038
+ out.push({
2039
+ id: action.id,
2040
+ label: effectiveTranslate(action.label),
2041
+ ...(action.style !== undefined && { style: action.style }),
2042
+ onPress: () => {
2043
+ nav.navigate({
2044
+ screenId: targetScreen,
2045
+ ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
2046
+ });
2047
+ runParams();
2048
+ },
2049
+ });
2050
+ }
2006
2051
  continue;
2007
2052
  }
2008
2053
  // writeHandler — same dispatch/refetch/failure-surfacing pattern as