@cosmicdrift/kumiko-renderer 0.270.0 → 0.274.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.270.0",
3
+ "version": "0.274.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.270.0",
19
- "@cosmicdrift/kumiko-headless": "0.270.0",
18
+ "@cosmicdrift/kumiko-framework": "0.274.0",
19
+ "@cosmicdrift/kumiko-headless": "0.274.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.270.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.274.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,92 @@
1
+ // fieldLabels overrides a field's label per screen, via the same
2
+ // label-resolution pipeline as entityEdit (see action-form-shim.ts).
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import { render } from "@testing-library/react";
7
+ import type { ReactNode } from "react";
8
+ import { RenderEdit } from "../../components/render-edit";
9
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
10
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
11
+ import { type CorePrimitives, type FieldProps, PrimitivesProvider } from "../../primitives";
12
+ import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "../action-form-shim";
13
+
14
+ const noop = (): ReactNode => null;
15
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
16
+
17
+ function renderActionFormCapturingFieldLabels(screen: ActionFormScreenDefinition): {
18
+ labels: Record<string, string>;
19
+ } {
20
+ const labels: Record<string, string> = {};
21
+ const capturingField = (props: FieldProps): ReactNode => {
22
+ if (props.testId !== undefined) labels[props.testId] = props.label;
23
+ return props.children;
24
+ };
25
+ const primitives: CorePrimitives = {
26
+ Button: noop,
27
+ Banner: noop,
28
+ Field: capturingField,
29
+ Input: noop,
30
+ DataTable: noop,
31
+ Form: passChildren,
32
+ Section: passChildren,
33
+ Card: passChildren,
34
+ Grid: passChildren,
35
+ GridCell: passChildren,
36
+ Text: passChildren,
37
+ Heading: noop,
38
+ Dialog: noop,
39
+ Modal: noop,
40
+ Lightbox: noop,
41
+ ConfigSourceBadge: noop,
42
+ ConfigCascadeView: noop,
43
+ Link: noop,
44
+ };
45
+ render(
46
+ <LocaleProvider
47
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
48
+ fallbackBundles={[kumikoDefaultTranslations]}
49
+ >
50
+ <PrimitivesProvider value={primitives}>
51
+ <RenderEdit
52
+ screen={synthesizeActionFormScreen(screen)}
53
+ entity={synthesizeActionFormEntity(screen.fields)}
54
+ featureName="shop"
55
+ initial={{ dueAt: "" }}
56
+ />
57
+ </PrimitivesProvider>
58
+ </LocaleProvider>,
59
+ );
60
+ return { labels };
61
+ }
62
+
63
+ describe("RenderEdit — ActionFormScreenDefinition.fieldLabels", () => {
64
+ test("a field named in fieldLabels renders the screen's own label", () => {
65
+ const screen: ActionFormScreenDefinition = {
66
+ id: "reschedule",
67
+ type: "actionForm",
68
+ handler: "shop:write:reschedule",
69
+ fields: { dueAt: { type: "date" } },
70
+ fieldLabels: { dueAt: "shop:reschedule.dueAt" },
71
+ layout: { sections: [{ fields: ["dueAt"] }] },
72
+ };
73
+
74
+ const { labels } = renderActionFormCapturingFieldLabels(screen);
75
+
76
+ expect(labels["field-dueAt"]).toBe("shop:reschedule.dueAt");
77
+ });
78
+
79
+ test("without fieldLabels, the field renders the default __action-form__ convention label", () => {
80
+ const screen: ActionFormScreenDefinition = {
81
+ id: "reschedule",
82
+ type: "actionForm",
83
+ handler: "shop:write:reschedule",
84
+ fields: { dueAt: { type: "date" } },
85
+ layout: { sections: [{ fields: ["dueAt"] }] },
86
+ };
87
+
88
+ const { labels } = renderActionFormCapturingFieldLabels(screen);
89
+
90
+ expect(labels["field-dueAt"]).toBe("shop:entity:__action-form__:field:dueAt");
91
+ });
92
+ });
@@ -69,6 +69,27 @@ describe("synthesizeActionFormScreen", () => {
69
69
  });
70
70
  expect("slots" in withoutSlots).toBe(false);
71
71
  });
72
+
73
+ test("carries fieldLabels through so RenderEdit resolves the screen's own label override", () => {
74
+ const withFieldLabels = synthesizeActionFormScreen({
75
+ id: "reschedule",
76
+ type: "actionForm",
77
+ handler: "shop:write:reschedule",
78
+ layout: { sections: [{ title: "Reschedule", fields: ["dueAt"] }] },
79
+ fields: { dueAt: { type: "date" } },
80
+ fieldLabels: { dueAt: "shop:reschedule.dueAt" },
81
+ });
82
+ expect(withFieldLabels.fieldLabels).toEqual({ dueAt: "shop:reschedule.dueAt" });
83
+
84
+ const withoutFieldLabels = synthesizeActionFormScreen({
85
+ id: "reschedule",
86
+ type: "actionForm",
87
+ handler: "shop:write:reschedule",
88
+ layout: { sections: [{ title: "Reschedule", fields: ["dueAt"] }] },
89
+ fields: { dueAt: { type: "date" } },
90
+ });
91
+ expect("fieldLabels" in withoutFieldLabels).toBe(false);
92
+ });
72
93
  });
73
94
 
74
95
  describe("synthesizeSecretMintConfirmScreen (fw#2838)", () => {
@@ -22,6 +22,7 @@ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
22
22
  import { kumikoDefaultTranslations } from "../../i18n-defaults";
23
23
  import {
24
24
  type ButtonProps,
25
+ type CardProps,
25
26
  type CorePrimitives,
26
27
  type DataTableProps,
27
28
  type DataTableRowAction,
@@ -62,6 +63,16 @@ const captureDataTable: ComponentType<DataTableProps> = (props) => {
62
63
  };
63
64
  const noop = (): ReactNode => null;
64
65
  const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
66
+ // Card's title/subtitle/headerActions arrive as `slots` instead of raw
67
+ // JSX children.
68
+ const cardWithSlots: ComponentType<CardProps> = ({ slots, children }) => (
69
+ <>
70
+ {slots?.title}
71
+ {slots?.subtitle}
72
+ {slots?.headerActions}
73
+ {children}
74
+ </>
75
+ );
65
76
 
66
77
  const testPrimitives: CorePrimitives = {
67
78
  Button: noop,
@@ -71,7 +82,7 @@ const testPrimitives: CorePrimitives = {
71
82
  DataTable: captureDataTable,
72
83
  Form: passChildren,
73
84
  Section: passChildren,
74
- Card: passChildren,
85
+ Card: cardWithSlots,
75
86
  Grid: passChildren,
76
87
  GridCell: passChildren,
77
88
  Text: passChildren,
@@ -102,13 +102,17 @@ function StatefulNav({ children }: { readonly children: ReactNode }): ReactNode
102
102
  return <NavProvider value={value}>{children}</NavProvider>;
103
103
  }
104
104
 
105
- function renderScreen(schema: FeatureSchema, qn: string): void {
105
+ function renderScreen(
106
+ schema: FeatureSchema,
107
+ qn: string,
108
+ dispatcher: Dispatcher = stubDispatcher(),
109
+ ): void {
106
110
  render(
107
111
  <LocaleProvider
108
112
  resolver={createStaticLocaleResolver({ locale: "de-DE" })}
109
113
  fallbackBundles={[kumikoDefaultTranslations]}
110
114
  >
111
- <DispatcherProvider dispatcher={stubDispatcher()}>
115
+ <DispatcherProvider dispatcher={dispatcher}>
112
116
  <StatefulNav>
113
117
  <PrimitivesProvider value={testPrimitives}>
114
118
  <KumikoScreen schema={schema} qn={qn} />
@@ -119,6 +123,27 @@ function renderScreen(schema: FeatureSchema, qn: string): void {
119
123
  );
120
124
  }
121
125
 
126
+ function stubDispatcherByType(
127
+ responses: Readonly<Record<string, { rows: readonly Record<string, unknown>[] }>>,
128
+ ): Dispatcher {
129
+ return {
130
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
131
+ query: (async (type: string, payload: unknown) => {
132
+ queryCalls.push({ type, payload });
133
+ const rows = responses[type]?.rows ?? [];
134
+ return { isSuccess: true, data: { rows, nextCursor: null } };
135
+ }) as unknown as Dispatcher["query"],
136
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
137
+ statusStore: {
138
+ getState: () => "online",
139
+ subscribe: () => () => {},
140
+ } as unknown as Dispatcher["statusStore"],
141
+ async *stream() {},
142
+ pendingWrites: () => [],
143
+ pendingFiles: () => [],
144
+ };
145
+ }
146
+
122
147
  describe("entityList facets — unchanged after the shared buildFilterFacets/buildFilterPayload extraction (fw#2224)", () => {
123
148
  test("a filterable boolean field renders as a facet, and toggling it sends a coerced boolean in payload.filters", async () => {
124
149
  queryCalls = [];
@@ -337,4 +362,67 @@ describe("projectionList filter + facets (fw#2224)", () => {
337
362
  expect(facet?.label).toBe(save);
338
363
  expect(facet?.options?.[0]?.label).toBe(cancel);
339
364
  });
365
+
366
+ test("a reference facet loads its options from the referenced entity's list query, and selecting one sends an id filter", async () => {
367
+ queryCalls = [];
368
+ capturedProps = undefined;
369
+ const dispatcher = stubDispatcherByType({
370
+ "ledger:query:member:list": { rows: [] },
371
+ "ledger:query:tenant:list": {
372
+ rows: [
373
+ { id: "t1", name: "Acme" },
374
+ { id: "t2", name: "Globex" },
375
+ ],
376
+ },
377
+ });
378
+ const screen: ProjectionListScreenDefinition = {
379
+ id: "member-list",
380
+ type: "projectionList",
381
+ query: "ledger:query:member:list",
382
+ columns: ["tenantId"],
383
+ facets: [
384
+ {
385
+ field: "tenantId",
386
+ type: "reference",
387
+ label: "Tenant",
388
+ entity: "tenant",
389
+ labelField: "name",
390
+ },
391
+ ],
392
+ };
393
+ const schema: FeatureSchema = {
394
+ featureName: "ledger",
395
+ entities: {},
396
+ screens: [screen],
397
+ } as FeatureSchema;
398
+
399
+ renderScreen(schema, "ledger:screen:member-list", dispatcher);
400
+
401
+ await waitFor(() =>
402
+ expect(queryCalls.some((c) => c.type === "ledger:query:tenant:list")).toBe(true),
403
+ );
404
+ await waitFor(() => expect(getCapturedProps()?.filterFacets?.[0]?.options).toHaveLength(2));
405
+ const props = getCapturedProps();
406
+ if (props === undefined) throw new Error("DataTable was not rendered");
407
+ expect(props.filterFacets).toEqual([
408
+ {
409
+ field: "tenantId",
410
+ label: "Tenant",
411
+ options: [
412
+ { value: "t1", label: "Acme" },
413
+ { value: "t2", label: "Globex" },
414
+ ],
415
+ },
416
+ ]);
417
+
418
+ const countBeforeSelect = queryCalls.length;
419
+ await act(async () => {
420
+ props.onFilterChange?.("tenantId", ["t1"]);
421
+ });
422
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(countBeforeSelect));
423
+ const lastPayload = queryCalls[queryCalls.length - 1]?.payload;
424
+ expect(lastPayload).toMatchObject({
425
+ filters: [{ field: "tenantId", op: "in", value: ["t1"] }],
426
+ });
427
+ });
340
428
  });
@@ -26,6 +26,7 @@ import {
26
26
  type ActionOverflowMenuProps,
27
27
  type BannerProps,
28
28
  type ButtonProps,
29
+ type CardProps,
29
30
  type CorePrimitives,
30
31
  type DialogProps,
31
32
  type FormProps,
@@ -40,6 +41,17 @@ import { NavProvider } from "../nav";
40
41
  const noop = (): ReactNode => null;
41
42
  const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
42
43
 
44
+ // Card's title/subtitle/headerActions arrive as `slots`; this stub renders
45
+ // them so tests asserting on header-action content still see it.
46
+ const CardWithSlots: ComponentType<CardProps> = ({ slots, children }) => (
47
+ <>
48
+ {slots?.title}
49
+ {slots?.subtitle}
50
+ {slots?.headerActions}
51
+ {children}
52
+ </>
53
+ );
54
+
43
55
  const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) => (
44
56
  <button type="button" data-testid={testId} onClick={() => void onClick?.()}>
45
57
  {children}
@@ -130,7 +142,7 @@ const testPrimitives: CorePrimitives = {
130
142
  DataTable: noop,
131
143
  Form: FormWithActions,
132
144
  Section: passChildren,
133
- Card: passChildren,
145
+ Card: CardWithSlots,
134
146
  Grid: passChildren,
135
147
  GridCell: passChildren,
136
148
  Text: passChildren,
@@ -0,0 +1,255 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { FeatureSchema } from "../feature-schema";
4
+ import { resolveReturnTarget, returnToParams } from "../return-to";
5
+
6
+ function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
7
+ return { featureName, entities: {}, screens };
8
+ }
9
+
10
+ const listScreen: ScreenDefinition = {
11
+ id: "token-list",
12
+ type: "entityList",
13
+ entity: "token",
14
+ columns: ["name"],
15
+ };
16
+
17
+ const editScreen: ScreenDefinition = {
18
+ id: "token-edit",
19
+ type: "entityEdit",
20
+ entity: "token",
21
+ layout: { sections: [{ fields: ["name"] }] },
22
+ };
23
+
24
+ const detailScreen: ScreenDefinition = {
25
+ id: "token-detail",
26
+ type: "projectionDetail",
27
+ query: "tokens:query:token:detail",
28
+ layout: { sections: [{ fields: ["name"] }] },
29
+ };
30
+
31
+ const singletonDetailScreen: ScreenDefinition = {
32
+ id: "my-settings",
33
+ type: "projectionDetail",
34
+ query: "tokens:query:settings:mine",
35
+ singleton: true,
36
+ layout: { sections: [{ fields: ["name"] }] },
37
+ };
38
+
39
+ const adminOnlyScreen: ScreenDefinition = {
40
+ id: "admin-only",
41
+ type: "custom",
42
+ renderer: { react: "stub" },
43
+ access: { roles: ["Admin"] },
44
+ };
45
+
46
+ const features: readonly FeatureSchema[] = [
47
+ schemaWith("tokens", [
48
+ listScreen,
49
+ editScreen,
50
+ detailScreen,
51
+ singletonDetailScreen,
52
+ adminOnlyScreen,
53
+ ]),
54
+ ];
55
+
56
+ describe("resolveReturnTarget", () => {
57
+ test("valid short id resolves to a ScreenTarget", () => {
58
+ expect(resolveReturnTarget("token-list", "token-create", features, undefined)).toEqual({
59
+ screenId: "token-list",
60
+ });
61
+ });
62
+
63
+ test("with entityId on an entityEdit screen", () => {
64
+ expect(resolveReturnTarget("token-edit/abc-123", "token-create", features, undefined)).toEqual({
65
+ screenId: "token-edit",
66
+ entityId: "abc-123",
67
+ });
68
+ });
69
+
70
+ test("with entityId on a projectionDetail screen", () => {
71
+ expect(
72
+ resolveReturnTarget("token-detail/abc-123", "token-create", features, undefined),
73
+ ).toEqual({ screenId: "token-detail", entityId: "abc-123" });
74
+ });
75
+
76
+ test("accepts a '%'-containing entityId that decodes to a safe value", () => {
77
+ expect(resolveReturnTarget("token-edit/abc%41", "token-create", features, undefined)).toEqual({
78
+ screenId: "token-edit",
79
+ entityId: "abc%41",
80
+ });
81
+ });
82
+
83
+ test("undefined raw value", () => {
84
+ expect(resolveReturnTarget(undefined, "token-create", features, undefined)).toBeUndefined();
85
+ });
86
+
87
+ test("empty string", () => {
88
+ expect(resolveReturnTarget("", "token-create", features, undefined)).toBeUndefined();
89
+ });
90
+
91
+ test("rejects an unknown screen id", () => {
92
+ expect(
93
+ resolveReturnTarget("unknown-screen", "token-create", features, undefined),
94
+ ).toBeUndefined();
95
+ });
96
+
97
+ test("rejects a qualified screen id ('feature:screen:x')", () => {
98
+ expect(
99
+ resolveReturnTarget("tokens:screen:token-list", "token-create", features, undefined),
100
+ ).toBeUndefined();
101
+ });
102
+
103
+ test("rejects a protocol-relative value ('//evil.com')", () => {
104
+ expect(resolveReturnTarget("//evil.com", "token-create", features, undefined)).toBeUndefined();
105
+ });
106
+
107
+ test("rejects an absolute URL ('https://evil.com')", () => {
108
+ expect(
109
+ resolveReturnTarget("https://evil.com", "token-create", features, undefined),
110
+ ).toBeUndefined();
111
+ });
112
+
113
+ test("rejects more than two segments ('a/b/c')", () => {
114
+ expect(resolveReturnTarget("a/b/c", "token-create", features, undefined)).toBeUndefined();
115
+ });
116
+
117
+ test("rejects a trailing slash ('x/')", () => {
118
+ expect(resolveReturnTarget("token-list/", "token-create", features, undefined)).toBeUndefined();
119
+ });
120
+
121
+ test("rejects a leading slash ('/x')", () => {
122
+ expect(resolveReturnTarget("/token-list", "token-create", features, undefined)).toBeUndefined();
123
+ });
124
+
125
+ test("rejects a role-gated screen when roles are missing", () => {
126
+ expect(resolveReturnTarget("admin-only", "token-create", features, undefined)).toBeUndefined();
127
+ });
128
+
129
+ test("accepts a role-gated screen when the role matches", () => {
130
+ expect(resolveReturnTarget("admin-only", "token-create", features, ["Admin"])).toEqual({
131
+ screenId: "admin-only",
132
+ });
133
+ });
134
+
135
+ test("rejects the own screen (self)", () => {
136
+ expect(
137
+ resolveReturnTarget("token-create", "token-create", features, undefined),
138
+ ).toBeUndefined();
139
+ });
140
+
141
+ test("rejects an entityId on a list screen", () => {
142
+ expect(
143
+ resolveReturnTarget("token-list/abc-123", "token-create", features, undefined),
144
+ ).toBeUndefined();
145
+ });
146
+
147
+ test("rejects entityId '..'", () => {
148
+ expect(
149
+ resolveReturnTarget("token-edit/..", "token-create", features, undefined),
150
+ ).toBeUndefined();
151
+ });
152
+
153
+ test("rejects entityId '.'", () => {
154
+ expect(
155
+ resolveReturnTarget("token-edit/.", "token-create", features, undefined),
156
+ ).toBeUndefined();
157
+ });
158
+
159
+ test("rejects entityId with '?'", () => {
160
+ expect(
161
+ resolveReturnTarget("token-edit/abc?x=1", "token-create", features, undefined),
162
+ ).toBeUndefined();
163
+ });
164
+
165
+ test("rejects entityId with '#'", () => {
166
+ expect(
167
+ resolveReturnTarget("token-edit/abc#frag", "token-create", features, undefined),
168
+ ).toBeUndefined();
169
+ });
170
+
171
+ test("rejects entityId with '\\\\'", () => {
172
+ expect(
173
+ resolveReturnTarget("token-edit/abc\\def", "token-create", features, undefined),
174
+ ).toBeUndefined();
175
+ });
176
+
177
+ test("rejects entityId with a space", () => {
178
+ expect(
179
+ resolveReturnTarget("token-edit/abc def", "token-create", features, undefined),
180
+ ).toBeUndefined();
181
+ });
182
+
183
+ test("rejects a percent-encoded entityId that decodes to '..'", () => {
184
+ expect(
185
+ resolveReturnTarget("token-edit/%2e%2e", "token-create", features, undefined),
186
+ ).toBeUndefined();
187
+ });
188
+
189
+ test("rejects a percent-encoded entityId that decodes to '.'", () => {
190
+ expect(
191
+ resolveReturnTarget("token-edit/%2E", "token-create", features, undefined),
192
+ ).toBeUndefined();
193
+ });
194
+
195
+ test("rejects a percent-encoded entityId that decodes to a slash", () => {
196
+ expect(
197
+ resolveReturnTarget("token-edit/%2Fx", "token-create", features, undefined),
198
+ ).toBeUndefined();
199
+ });
200
+
201
+ test("rejects a malformed percent-encoded entityId", () => {
202
+ expect(
203
+ resolveReturnTarget("token-edit/%E0%A4%A", "token-create", features, undefined),
204
+ ).toBeUndefined();
205
+ });
206
+
207
+ test("rejects a percent-encoded entityId that decodes to whitespace", () => {
208
+ expect(
209
+ resolveReturnTarget("token-edit/a%20b", "token-create", features, undefined),
210
+ ).toBeUndefined();
211
+ });
212
+
213
+ test("rejects a non-singleton projectionDetail without an entityId", () => {
214
+ expect(
215
+ resolveReturnTarget("token-detail", "token-create", features, undefined),
216
+ ).toBeUndefined();
217
+ });
218
+
219
+ test("accepts a singleton projectionDetail without an entityId", () => {
220
+ expect(resolveReturnTarget("my-settings", "token-create", features, undefined)).toEqual({
221
+ screenId: "my-settings",
222
+ });
223
+ });
224
+ });
225
+
226
+ describe("returnToParams", () => {
227
+ test("no host -> {}", () => {
228
+ expect(returnToParams(undefined, { screenId: "token-list" })).toEqual({});
229
+ });
230
+
231
+ test("target equals the host (self) -> {}", () => {
232
+ expect(returnToParams({ screenId: "token-list" }, { screenId: "token-list" })).toEqual({});
233
+ });
234
+
235
+ test("target equals the host including entityId -> {}", () => {
236
+ expect(
237
+ returnToParams(
238
+ { screenId: "token-edit", entityId: "abc" },
239
+ { screenId: "token-edit", entityId: "abc" },
240
+ ),
241
+ ).toEqual({});
242
+ });
243
+
244
+ test("different target -> returnTo param", () => {
245
+ expect(returnToParams({ screenId: "token-list" }, { screenId: "token-create" })).toEqual({
246
+ returnTo: "token-list",
247
+ });
248
+ });
249
+
250
+ test("host with entityId formats as 'screenId/entityId'", () => {
251
+ expect(
252
+ returnToParams({ screenId: "token-edit", entityId: "abc" }, { screenId: "token-create" }),
253
+ ).toEqual({ returnTo: "token-edit/abc" });
254
+ });
255
+ });
@@ -1,4 +1,4 @@
1
- // kumiko-screen-akte-bedienkonzept: entityList and projectionList rowActions
1
+ // entityList and projectionList rowActions
2
2
  // get a default "Edit" row action for free when the row's entity has a
3
3
  // visible entityEdit screen — same cross-feature resolution as
4
4
  // projectionDetail's header defaultEditAction (fw#2166), reused here via
@@ -50,9 +50,11 @@ export function synthesizeActionFormScreen(
50
50
  layout: screen.layout,
51
51
  ...(screen.description !== undefined && { description: screen.description }),
52
52
  ...(screen.access !== undefined && { access: screen.access }),
53
- // Only ActionFormScreenDefinition carries slots SecretMintScreenDefinition
54
- // has none, so `in` is the narrowing (no cast).
53
+ // Only ActionFormScreenDefinition carries slots/fieldLabels; `in` narrows
54
+ // without a cast since SecretMintScreenDefinition has neither.
55
55
  ...("slots" in screen && screen.slots !== undefined && { slots: screen.slots }),
56
+ ...("fieldLabels" in screen &&
57
+ screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
56
58
  };
57
59
  }
58
60