@cosmicdrift/kumiko-renderer 0.289.0 → 0.291.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.289.0",
3
+ "version": "0.291.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.289.0",
19
- "@cosmicdrift/kumiko-headless": "0.289.0",
20
- "@cosmicdrift/kumiko-types": "0.289.0",
18
+ "@cosmicdrift/kumiko-framework": "0.291.0",
19
+ "@cosmicdrift/kumiko-headless": "0.291.0",
20
+ "@cosmicdrift/kumiko-types": "0.291.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.289.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.291.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
- import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
2
+ import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import {
4
+ buildInitialValues,
5
+ literalCurrencyOverrides,
6
+ mergeSearchParamsIntoInitial,
7
+ } from "../app/kumiko-screen";
8
+ import { layoutFieldNames } from "../app/layout-fields";
3
9
 
4
10
  type FieldDef = {
5
11
  type?: string;
@@ -188,6 +194,22 @@ describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable
188
194
  expect(result["ownerId"]).toBe("");
189
195
  });
190
196
 
197
+ test("renderableFields from a groups-only layout: searchParam on a group field is kept", () => {
198
+ const screen: EntityEditScreenDefinition = {
199
+ id: "s",
200
+ type: "entityEdit",
201
+ entity: "e",
202
+ layout: { sections: [{ fields: [], groups: [{ title: "g", fields: ["status"] }] }] },
203
+ };
204
+ const fields: Record<string, FieldDef> = { status: { type: "text", default: "draft" } };
205
+ const result = mergeWithAllFieldsUrlPrefillable(
206
+ fields,
207
+ { status: "approved" },
208
+ layoutFieldNames(screen),
209
+ );
210
+ expect(result["status"]).toBe("approved");
211
+ });
212
+
191
213
  test("no renderableFields set given (undefined): behaves as before, all fields eligible", () => {
192
214
  const fields: Record<string, FieldDef> = { ownerId: { type: "text" } };
193
215
  const result = mergeWithAllFieldsUrlPrefillable(fields, { ownerId: "user-123" });
@@ -454,3 +476,50 @@ describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable
454
476
  });
455
477
  });
456
478
  });
479
+
480
+ // fw#2839: an entity-less form screen passes no `defaultCurrency`, so before
481
+ // this the money branch fell through to a bare `0` the handler's zod schema
482
+ // rejected. `literalCurrencyOverrides` resolves a declared literal source with
483
+ // no query; configEdit's plain-number contract depends on getting neither.
484
+ describe("money currency sources on an entity-less form (fw#2839)", () => {
485
+ const amountField = (currency?: unknown): Record<string, FieldDef> => ({
486
+ amount: { type: "money", ...(currency !== undefined && { currency }) } as FieldDef,
487
+ });
488
+
489
+ test("a literal source seeds { amount: 0, currency } without any defaultCurrency", () => {
490
+ const fields = amountField({ kind: "literal", code: "CHF" });
491
+ const result = mergeSearchParamsIntoInitial(fields, {
492
+ searchParams: {},
493
+ urlPrefillFields: [],
494
+ moneyCurrencyOverrides: literalCurrencyOverrides(fields),
495
+ });
496
+ expect(result["amount"]).toEqual({ amount: 0, currency: "CHF" });
497
+ });
498
+
499
+ test("a literal source also carries into a URL-prefilled amount (fw#2763 path)", () => {
500
+ const fields = amountField({ kind: "literal", code: "CHF" });
501
+ const result = mergeSearchParamsIntoInitial(fields, {
502
+ searchParams: { amount: "42.50" },
503
+ urlPrefillFields: ["amount"],
504
+ moneyCurrencyOverrides: literalCurrencyOverrides(fields),
505
+ });
506
+ expect(result["amount"]).toEqual({ amount: 42.5, currency: "CHF" });
507
+ });
508
+
509
+ test("literalCurrencyOverrides ignores tenant-declared and undeclared money fields", () => {
510
+ expect(literalCurrencyOverrides(amountField({ kind: "tenant" }))).toEqual({});
511
+ expect(literalCurrencyOverrides(amountField())).toEqual({});
512
+ });
513
+
514
+ test("configEdit stays on the plain-number contract: no overrides, no currency wrapper", () => {
515
+ const fields = amountField({ kind: "literal", code: "CHF" });
516
+ expect(buildInitialValues(fields)["amount"]).toBe(0);
517
+ });
518
+
519
+ test("entityEdit is unchanged: defaultCurrency still wins where no source is declared", () => {
520
+ expect(buildInitialValues(amountField(), "USD")["amount"]).toEqual({
521
+ amount: 0,
522
+ currency: "USD",
523
+ });
524
+ });
525
+ });
@@ -41,6 +41,21 @@ describe("buildFormSchema", () => {
41
41
  }
42
42
  });
43
43
 
44
+ test("required field inside a groups-only section → presence issue", () => {
45
+ const entity = entityWith({ name: { type: "text", required: true } });
46
+ const screen: EntityEditScreenDefinition = {
47
+ id: "s",
48
+ type: "entityEdit",
49
+ entity: "e",
50
+ layout: { sections: [{ fields: [], groups: [{ title: "g", fields: ["name"] }] }] },
51
+ };
52
+
53
+ const result = buildFormSchema(entity, screen).safeParse({ name: "" });
54
+ expect(result.success).toBe(false);
55
+ if (result.success) return;
56
+ expect(result.error.issues[0]?.path).toEqual(["name"]);
57
+ });
58
+
44
59
  test("required field missing → issue carries the required-field i18nKey override", () => {
45
60
  const entity = entityWith({ name: { type: "text", required: true } });
46
61
  const screen = screenWith(["name"]);
@@ -1,7 +1,7 @@
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 { resolveReturnTarget, returnToParams } from "../return-to";
4
+ import { formatReturnTo, resolveReturnTarget, returnToParams, splitReturnTo } from "../return-to";
5
5
 
6
6
  function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
7
7
  return { featureName, entities: {}, screens };
@@ -156,12 +156,37 @@ describe("resolveReturnTarget", () => {
156
156
  ).toBeUndefined();
157
157
  });
158
158
 
159
- test("rejects entityId with '?'", () => {
159
+ test("rejects an entityId whose decoded form carries '?'", () => {
160
160
  expect(
161
- resolveReturnTarget("token-edit/abc?x=1", "token-create", features, undefined),
161
+ resolveReturnTarget("token-edit/abc%3Fx=1", "token-create", features, undefined),
162
162
  ).toBeUndefined();
163
163
  });
164
164
 
165
+ test("a trailing host-state query resolves to the bare target", () => {
166
+ expect(
167
+ resolveReturnTarget("token-edit/abc?tab=keys", "token-create", features, undefined),
168
+ ).toEqual({ screenId: "token-edit", entityId: "abc" });
169
+ });
170
+
171
+ test("a host-state query does not loosen validation", () => {
172
+ expect(
173
+ resolveReturnTarget("https://evil.com?tab=keys", "token-create", features, undefined),
174
+ ).toBeUndefined();
175
+ expect(
176
+ resolveReturnTarget("//evil.com?tab=keys", "token-create", features, undefined),
177
+ ).toBeUndefined();
178
+ expect(
179
+ resolveReturnTarget("token-edit/..?tab=keys", "token-create", features, undefined),
180
+ ).toBeUndefined();
181
+ expect(
182
+ resolveReturnTarget("unknown-screen?tab=keys", "token-create", features, undefined),
183
+ ).toBeUndefined();
184
+ expect(
185
+ resolveReturnTarget("admin-only?tab=keys", "token-create", features, undefined),
186
+ ).toBeUndefined();
187
+ expect(resolveReturnTarget("?tab=keys", "token-create", features, undefined)).toBeUndefined();
188
+ });
189
+
165
190
  test("rejects entityId with '#'", () => {
166
191
  expect(
167
192
  resolveReturnTarget("token-edit/abc#frag", "token-create", features, undefined),
@@ -253,3 +278,61 @@ describe("returnToParams", () => {
253
278
  ).toEqual({ returnTo: "token-edit/abc" });
254
279
  });
255
280
  });
281
+
282
+ describe("formatReturnTo / splitReturnTo", () => {
283
+ test("no host params formats exactly like before the snapshot existed", () => {
284
+ expect(formatReturnTo({ screenId: "token-list" })).toBe("token-list");
285
+ expect(formatReturnTo({ screenId: "token-list" }, {})).toBe("token-list");
286
+ expect(formatReturnTo({ screenId: "token-edit", entityId: "abc" })).toBe("token-edit/abc");
287
+ });
288
+
289
+ test("host params round-trip through the value", () => {
290
+ const raw = formatReturnTo(
291
+ { screenId: "token-detail", entityId: "abc" },
292
+ { tab: "keys", "orders.sort": "name", "orders.dir": "asc" },
293
+ );
294
+ expect(splitReturnTo(raw)).toEqual({
295
+ path: "token-detail/abc",
296
+ state: { tab: "keys", "orders.sort": "name", "orders.dir": "asc" },
297
+ });
298
+ });
299
+
300
+ test("empty param values are left out of the snapshot", () => {
301
+ expect(formatReturnTo({ screenId: "token-list" }, { q: "", tab: "keys" })).toBe(
302
+ "token-list?tab=keys",
303
+ );
304
+ });
305
+
306
+ test("two nesting levels round-trip one level per split", () => {
307
+ const inner = formatReturnTo({ screenId: "token-list" }, { returnTo: "settings" });
308
+ const outer = formatReturnTo({ screenId: "token-edit", entityId: "abc" }, { returnTo: inner });
309
+
310
+ const first = splitReturnTo(outer);
311
+ expect(first.path).toBe("token-edit/abc");
312
+ expect(first.state["returnTo"]).toBe("token-list?returnTo=settings");
313
+
314
+ const second = splitReturnTo(first.state["returnTo"] as string);
315
+ expect(second.path).toBe("token-list");
316
+ expect(second.state).toEqual({ returnTo: "settings" });
317
+ });
318
+
319
+ test("a fourth level is dropped, the rest of the snapshot survives", () => {
320
+ const level3 = formatReturnTo(
321
+ { screenId: "token-list" },
322
+ { returnTo: formatReturnTo({ screenId: "token-detail" }, { returnTo: "settings" }) },
323
+ );
324
+ const level4 = formatReturnTo({ screenId: "token-edit" }, { returnTo: level3, tab: "keys" });
325
+
326
+ expect(splitReturnTo(level4)).toEqual({ path: "token-edit", state: { tab: "keys" } });
327
+ });
328
+
329
+ test("an over-long snapshot degrades to the bare target", () => {
330
+ expect(formatReturnTo({ screenId: "token-list" }, { q: "x".repeat(600) })).toBe("token-list");
331
+ });
332
+
333
+ test("returnToParams carries the host snapshot", () => {
334
+ expect(
335
+ returnToParams({ screenId: "token-list" }, { screenId: "token-create" }, { tab: "keys" }),
336
+ ).toEqual({ returnTo: "token-list?tab=keys" });
337
+ });
338
+ });
@@ -82,6 +82,8 @@ import { lastSegment, toKebab } from "./qn";
82
82
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
83
83
  import { ReferenceFacetBridges, type ReferenceFacetOption } from "./reference-facet-bridge";
84
84
  import {
85
+ navigateToReturn,
86
+ navigateToReturnOr,
85
87
  navigateWithReturnTo,
86
88
  type ReturnHost,
87
89
  ReturnHostProvider,
@@ -487,6 +489,63 @@ function resolveTenantCurrency(
487
489
  return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
488
490
  }
489
491
 
492
+ // An entity-less form screen has no `entity.defaultCurrency` to fall back on,
493
+ // so a tenant-declared field whose config query errors or returns no value
494
+ // lands here — same last resort as the entityEdit path's `?? "EUR"`.
495
+ const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
496
+
497
+ // A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
498
+ // fw#2839) resolves with no query at all. Read structurally, same idiom as
499
+ // tenantCurrencyMoneyFieldNames above.
500
+ export function literalCurrencyOverrides(
501
+ fields: Readonly<Record<string, unknown>>,
502
+ ): Readonly<Record<string, string>> {
503
+ const out: Record<string, string> = {};
504
+ for (const [name, def] of Object.entries(fields)) {
505
+ const shape = def as {
506
+ readonly type?: string;
507
+ readonly currency?: { readonly kind?: string; readonly code?: string };
508
+ };
509
+ const code = shape.currency?.code;
510
+ if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
511
+ out[name] = code;
512
+ }
513
+ }
514
+ return out;
515
+ }
516
+
517
+ // Per-field currency for the money fields that declare their own source: a
518
+ // literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
519
+ // needs the tenant-settings config query and makes the caller hold the form
520
+ // until it lands (fw#2933) — seeding the fallback meanwhile would render, and
521
+ // on a fast click submit, the wrong currency. Callers pass the tenant field
522
+ // names themselves because an edit form only needs the fetch for fields the
523
+ // record has no stored value for.
524
+ function useMoneyCurrencyOverrides(
525
+ fields: Readonly<Record<string, unknown>>,
526
+ tenantFieldNames: readonly string[],
527
+ fallback: string,
528
+ ): {
529
+ readonly overrides: Readonly<Record<string, string>> | undefined;
530
+ readonly loading: boolean;
531
+ } {
532
+ const query = useQuery<TenantConfigValuesResponse>(
533
+ "config:query:values",
534
+ {},
535
+ { enabled: tenantFieldNames.length > 0 },
536
+ );
537
+ const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
538
+ const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
539
+ const overrides = useMemo(() => {
540
+ const out: Record<string, string> = { ...literalCurrencyOverrides(fields) };
541
+ if (resolvedTenantCurrency !== undefined) {
542
+ for (const name of tenantFieldNames) out[name] = resolvedTenantCurrency;
543
+ }
544
+ return Object.keys(out).length > 0 ? out : undefined;
545
+ }, [fields, tenantFieldNames, resolvedTenantCurrency]);
546
+ return { overrides, loading: resolution.status === "loading" };
547
+ }
548
+
490
549
  function multiSelectOptionValues(shape: {
491
550
  readonly options?: readonly (string | { readonly value: string })[];
492
551
  }): ReadonlySet<string> | undefined {
@@ -857,26 +916,11 @@ function EntityEditCreateBody({
857
916
  () => tenantCurrencyMoneyFieldNames(entity.fields),
858
917
  [entity.fields],
859
918
  );
860
- const needsTenantCurrency = tenantCurrencyFieldNames.length > 0;
861
- const tenantCurrencyQuery = useQuery<TenantConfigValuesResponse>(
862
- "config:query:values",
863
- {},
864
- { enabled: needsTenantCurrency },
865
- );
866
- const tenantCurrencyResolution = resolveTenantCurrency(
919
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
920
+ entity.fields,
867
921
  tenantCurrencyFieldNames,
868
- tenantCurrencyQuery,
869
922
  entityDefaultCurrency,
870
923
  );
871
- const resolvedTenantCurrency =
872
- tenantCurrencyResolution.status === "ready" ? tenantCurrencyResolution.currency : undefined;
873
- const moneyCurrencyOverrides = useMemo(
874
- () =>
875
- resolvedTenantCurrency !== undefined
876
- ? Object.fromEntries(tenantCurrencyFieldNames.map((name) => [name, resolvedTenantCurrency]))
877
- : undefined,
878
- [tenantCurrencyFieldNames, resolvedTenantCurrency],
879
- );
880
924
  const initial = useMemo(
881
925
  () =>
882
926
  mergeSearchParamsIntoInitial(entity.fields, {
@@ -900,7 +944,7 @@ function EntityEditCreateBody({
900
944
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
901
945
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
902
946
  const handleCancel = useCallback(
903
- () => navigateToReturnTargetOr(nav, returnTarget, navigateToList),
947
+ () => navigateToReturnOr(nav, returnTarget, navigateToList),
904
948
  [nav, returnTarget, navigateToList],
905
949
  );
906
950
  // Create's write-handler success payload doesn't flatly expose a parent FK
@@ -916,7 +960,7 @@ function EntityEditCreateBody({
916
960
  const redirect = screen.redirect;
917
961
  if (redirect !== undefined) {
918
962
  if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
919
- nav.navigate(returnTarget);
963
+ navigateToReturn(nav, returnTarget);
920
964
  } else if (typeof redirect === "string") {
921
965
  // String form unchanged: always carries the newly created
922
966
  // record's own id, regardless of the target screen's type. The
@@ -938,7 +982,7 @@ function EntityEditCreateBody({
938
982
  nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
939
983
  }
940
984
  } else {
941
- navigateToReturnTargetOr(nav, returnTarget, navigateToList);
985
+ navigateToReturnOr(nav, returnTarget, navigateToList);
942
986
  onSaved?.();
943
987
  }
944
988
  },
@@ -946,7 +990,7 @@ function EntityEditCreateBody({
946
990
  );
947
991
  // Never seed a tenant-declared money field with entityDefaultCurrency while
948
992
  // its real tenant currency is still in flight (fw#2933) — wait instead.
949
- if (needsTenantCurrency && tenantCurrencyResolution.status === "loading") {
993
+ if (currencyLoading) {
950
994
  return (
951
995
  <Banner padded variant="loading" testId="kumiko-screen-loading">
952
996
  Loading…
@@ -1109,26 +1153,11 @@ function EntityEditUpdateForm({
1109
1153
  ),
1110
1154
  [entity.fields, record],
1111
1155
  );
1112
- const needsTenantCurrency = tenantCurrencyFieldNames.length > 0;
1113
- const tenantCurrencyQuery = useQuery<TenantConfigValuesResponse>(
1114
- "config:query:values",
1115
- {},
1116
- { enabled: needsTenantCurrency },
1117
- );
1118
- const tenantCurrencyResolution = resolveTenantCurrency(
1156
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
1157
+ entity.fields,
1119
1158
  tenantCurrencyFieldNames,
1120
- tenantCurrencyQuery,
1121
1159
  entityDefaultCurrency,
1122
1160
  );
1123
- const resolvedTenantCurrency =
1124
- tenantCurrencyResolution.status === "ready" ? tenantCurrencyResolution.currency : undefined;
1125
- const moneyCurrencyOverrides = useMemo(
1126
- () =>
1127
- resolvedTenantCurrency !== undefined
1128
- ? Object.fromEntries(tenantCurrencyFieldNames.map((name) => [name, resolvedTenantCurrency]))
1129
- : undefined,
1130
- [tenantCurrencyFieldNames, resolvedTenantCurrency],
1131
- );
1132
1161
  const initial = useMemo(() => {
1133
1162
  const out: Record<string, unknown> = {};
1134
1163
  for (const name of Object.keys(entity.fields)) {
@@ -1311,7 +1340,7 @@ function EntityEditUpdateForm({
1311
1340
  const redirect = screen.redirect;
1312
1341
  if (redirect !== undefined) {
1313
1342
  if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
1314
- nav.navigate(returnTarget);
1343
+ navigateToReturn(nav, returnTarget);
1315
1344
  } else if (typeof redirect === "string") {
1316
1345
  // String form unchanged: navigates without an entityId, same as
1317
1346
  // before the object form existed. The object form resolves like
@@ -1334,14 +1363,14 @@ function EntityEditUpdateForm({
1334
1363
  });
1335
1364
  }
1336
1365
  } else {
1337
- navigateToReturnTargetOr(nav, returnTarget, navigateToList);
1366
+ navigateToReturnOr(nav, returnTarget, navigateToList);
1338
1367
  onSaved?.();
1339
1368
  }
1340
1369
  },
1341
1370
  [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget],
1342
1371
  );
1343
1372
  const handleCancel = useCallback(
1344
- () => navigateToReturnTargetOr(nav, returnTarget, navigateToList),
1373
+ () => navigateToReturnOr(nav, returnTarget, navigateToList),
1345
1374
  [nav, returnTarget, navigateToList],
1346
1375
  );
1347
1376
  const handleDelete = useCallback(async () => {
@@ -1349,7 +1378,7 @@ function EntityEditUpdateForm({
1349
1378
  if (res.isSuccess) {
1350
1379
  // Never return onto the just-deleted record.
1351
1380
  const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
1352
- navigateToReturnTargetOr(nav, target, navigateToList);
1381
+ navigateToReturnOr(nav, target, navigateToList);
1353
1382
  onDeleted?.();
1354
1383
  }
1355
1384
  }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
@@ -1358,7 +1387,7 @@ function EntityEditUpdateForm({
1358
1387
  // its real tenant currency is still in flight (fw#2933) — wait instead. A
1359
1388
  // stored (non-empty) value is unaffected — it never reaches this branch's
1360
1389
  // fallback because `initial` above already prefers `record[name]`.
1361
- if (needsTenantCurrency && tenantCurrencyResolution.status === "loading") {
1390
+ if (currencyLoading) {
1362
1391
  return (
1363
1392
  <Banner padded variant="loading" testId="kumiko-screen-loading">
1364
1393
  Loading…
@@ -3281,7 +3310,7 @@ function redirectTargetsRecord(
3281
3310
  }
3282
3311
 
3283
3312
  // Type-guard form narrows `returnTarget` for the caller's subsequent
3284
- // nav.navigate(returnTarget) call.
3313
+ // navigateToReturn(nav, returnTarget) call.
3285
3314
  function returnToWinsOverRedirect(
3286
3315
  returnTarget: ScreenTarget | undefined,
3287
3316
  redirect: string | ActionFormRedirect,
@@ -3291,18 +3320,6 @@ function returnToWinsOverRedirect(
3291
3320
  return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
3292
3321
  }
3293
3322
 
3294
- function navigateToReturnTargetOr(
3295
- nav: NavApi,
3296
- returnTarget: ScreenTarget | undefined,
3297
- fallback: () => void,
3298
- ): void {
3299
- if (returnTarget !== undefined) {
3300
- nav.navigate(returnTarget);
3301
- } else {
3302
- fallback();
3303
- }
3304
- }
3305
-
3306
3323
  // Resolves an object-form redirect to a nav target — shared by
3307
3324
  // actionForm and entityEdit (create + update) so the id
3308
3325
  // carries over identically regardless of which screen type triggered it.
@@ -3367,6 +3384,7 @@ function ActionFormBody({
3367
3384
  }): ReactNode {
3368
3385
  const nav = useNav();
3369
3386
  const appFeatures = useAppFeatures();
3387
+ const { Banner } = usePrimitives();
3370
3388
  // Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
3371
3389
  const returnTarget = useReturnTarget(screen.id);
3372
3390
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
@@ -3374,12 +3392,27 @@ function ActionFormBody({
3374
3392
  const pendingHandoff = useInitialValuesHandoff(screen.id);
3375
3393
  // A drawer-hosted form is not a navigation target, so it never takes a handoff.
3376
3394
  const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
3395
+ // This screen has no entity, so a money field here declares its own currency
3396
+ // source (fw#2839, boot-enforced) — without one the form would seed a bare
3397
+ // `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
3398
+ // applies when a tenant-declared field's config query errors or holds no
3399
+ // value, matching the entityEdit path's behaviour (fw#2937).
3400
+ const tenantCurrencyFieldNames = useMemo(
3401
+ () => tenantCurrencyMoneyFieldNames(screen.fields),
3402
+ [screen.fields],
3403
+ );
3404
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
3405
+ screen.fields,
3406
+ tenantCurrencyFieldNames,
3407
+ ACTION_FORM_CURRENCY_FALLBACK,
3408
+ );
3377
3409
  const initial = useMemo(
3378
3410
  () =>
3379
3411
  mergeSearchParamsIntoInitial(screen.fields, {
3380
3412
  searchParams: nav.searchParams,
3381
3413
  urlPrefillFields: screen.urlPrefillFields,
3382
3414
  renderableFields: layoutFieldNames(synthScreen),
3415
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
3383
3416
  ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
3384
3417
  ...(handoffValues !== undefined && { handoffValues }),
3385
3418
  }) as FormValues,
@@ -3388,6 +3421,7 @@ function ActionFormBody({
3388
3421
  screen.urlPrefillFields,
3389
3422
  nav.searchParams,
3390
3423
  synthScreen,
3424
+ moneyCurrencyOverrides,
3391
3425
  initialOverrides,
3392
3426
  handoffValues,
3393
3427
  ],
@@ -3403,7 +3437,7 @@ function ActionFormBody({
3403
3437
  // navigation, and never one to a record screen (see redirectTargetsRecord).
3404
3438
  if (screen.redirect !== undefined) {
3405
3439
  if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
3406
- nav.navigate(returnTarget);
3440
+ navigateToReturn(nav, returnTarget);
3407
3441
  } else {
3408
3442
  const { screenId, entityId } = resolveRedirectTarget(
3409
3443
  screen.redirect,
@@ -3428,8 +3462,18 @@ function ActionFormBody({
3428
3462
  screen.cancelTarget ??
3429
3463
  (screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
3430
3464
  if (target === undefined || target === false) return undefined;
3431
- return () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
3465
+ return () =>
3466
+ navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
3432
3467
  }, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
3468
+ // Rendering the form before a tenant-declared currency lands would show the
3469
+ // fallback and, on a fast click, submit it (fw#2933) — hold instead.
3470
+ if (currencyLoading) {
3471
+ return (
3472
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
3473
+ Loading…
3474
+ </Banner>
3475
+ );
3476
+ }
3433
3477
  return (
3434
3478
  <RenderEdit
3435
3479
  screen={synthScreen}
@@ -2,7 +2,11 @@ import type {
2
2
  EditFieldSpec,
3
3
  EntityEditScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
- import { isFieldsEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import {
6
+ isFieldsEditSection,
7
+ normalizeEditField,
8
+ sectionFieldSpecs,
9
+ } from "@cosmicdrift/kumiko-framework/ui-types";
6
10
 
7
11
  // Normalized field specs actually rendered by the screen's layout, extension
8
12
  // (and relatedList) sections skipped. Both this and `layoutFieldNames` key
@@ -15,7 +19,7 @@ export function layoutEditFields(
15
19
  const specs: Exclude<EditFieldSpec, string>[] = [];
16
20
  for (const section of screen.layout.sections) {
17
21
  if (!isFieldsEditSection(section)) continue;
18
- for (const spec of section.fields) {
22
+ for (const spec of sectionFieldSpecs(section)) {
19
23
  specs.push(normalizeEditField(spec));
20
24
  }
21
25
  }
@@ -22,7 +22,6 @@
22
22
 
23
23
  import type {
24
24
  EditFieldSpec,
25
- EditFieldsSection,
26
25
  EditLayout,
27
26
  EntityDefinition,
28
27
  EntityEditScreenDefinition,
@@ -32,18 +31,9 @@ import {
32
31
  isFieldsEditSection,
33
32
  normalizeEditField,
34
33
  PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
34
+ sectionFieldSpecs,
35
35
  } from "@cosmicdrift/kumiko-framework/ui-types";
36
36
 
37
- // The boot-validator treats `fields`/`groups` as mutually exclusive per
38
- // section, but this reads both anyway: an extra name the current section
39
- // shape can never carry is harmless, while a name computeEditViewModel does
40
- // reference and this shim omitted throws downstream ("references unknown
41
- // field ... on entity"). Union is strictly safer than picking one branch.
42
- function allFieldSpecs(section: EditFieldsSection): readonly EditFieldSpec[] {
43
- const grouped = section.groups?.flatMap((group) => group.fields) ?? [];
44
- return [...section.fields, ...grouped];
45
- }
46
-
47
37
  /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
48
38
  * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
49
39
  * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
@@ -52,7 +42,7 @@ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefi
52
42
  for (const section of layout.sections) {
53
43
  // relatedList and extension sections carry no `fields` to synthesize.
54
44
  if (!isFieldsEditSection(section)) continue;
55
- for (const spec of allFieldSpecs(section)) {
45
+ for (const spec of sectionFieldSpecs(section)) {
56
46
  fields[normalizeEditField(spec).field] = { type: "text" };
57
47
  }
58
48
  }
@@ -70,6 +60,7 @@ export function synthesizeProjectionDetailScreen(
70
60
  if (!isFieldsEditSection(section)) return section;
71
61
  return {
72
62
  ...section,
63
+ // kumiko-lint-ignore section-fields-raw rewrites both sources in place — the groups branch below does the same for groups[].fields
73
64
  fields: section.fields.map(forceReadOnly),
74
65
  ...(section.groups !== undefined && {
75
66
  groups: section.groups.map((group) => ({
@@ -11,13 +11,72 @@ import { screenAccessAllows } from "./screen-access";
11
11
  // and a copied/shared URL.
12
12
  export const RETURN_TO_PARAM = "returnTo";
13
13
 
14
+ // Screen levels one returnTo value may carry: the host itself plus its own
15
+ // nested chain. Beyond that the host's returnTo is left out of the snapshot.
16
+ const MAX_RETURN_DEPTH = 3;
17
+ // Serialized host snapshot longer than this is dropped whole — the bare
18
+ // target still resolves, only the restored state is lost.
19
+ const MAX_RETURN_STATE_LENGTH = 512;
20
+
21
+ const NO_RETURN_STATE: Readonly<Record<string, string>> = Object.freeze({});
22
+
14
23
  export type ReturnHost = {
15
24
  readonly screenId: string;
16
25
  readonly entityId?: string;
17
26
  };
18
27
 
19
- export function formatReturnTo(host: ReturnHost): string {
20
- return host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
28
+ export type ReturnTo = {
29
+ readonly path: string;
30
+ readonly state: Readonly<Record<string, string>>;
31
+ };
32
+
33
+ /** Splits `<screenId>[/<entityId>][?<host-params>]`. One `URLSearchParams`
34
+ * pass per nesting level — a deeper level stays encoded inside a value and
35
+ * is only decoded when that level is reached. */
36
+ export function splitReturnTo(raw: string): ReturnTo {
37
+ const queryStart = raw.indexOf("?");
38
+ if (queryStart === -1) return { path: raw, state: NO_RETURN_STATE };
39
+ return {
40
+ path: raw.slice(0, queryStart),
41
+ state: Object.fromEntries(new URLSearchParams(raw.slice(queryStart + 1))),
42
+ };
43
+ }
44
+
45
+ // Saturates at MAX_RETURN_DEPTH — callers only ask whether the cap is
46
+ // reached, and an unbounded walk would follow attacker-sized nesting.
47
+ function returnToDepth(raw: string): number {
48
+ let depth = 1;
49
+ let current = raw;
50
+ while (depth < MAX_RETURN_DEPTH) {
51
+ const nested = splitReturnTo(current).state[RETURN_TO_PARAM];
52
+ if (nested === undefined) return depth;
53
+ depth += 1;
54
+ current = nested;
55
+ }
56
+ return depth;
57
+ }
58
+
59
+ // Both caps degrade to the pre-snapshot value instead of failing, so a value
60
+ // that hits one still navigates.
61
+ function returnStateSnapshot(hostParams: Readonly<Record<string, string>> | undefined): string {
62
+ if (hostParams === undefined) return "";
63
+ const snapshot = new URLSearchParams();
64
+ for (const [key, value] of Object.entries(hostParams)) {
65
+ if (value === "") continue;
66
+ if (key === RETURN_TO_PARAM && returnToDepth(value) >= MAX_RETURN_DEPTH) continue;
67
+ snapshot.set(key, value);
68
+ }
69
+ const serialized = snapshot.toString();
70
+ return serialized.length > MAX_RETURN_STATE_LENGTH ? "" : serialized;
71
+ }
72
+
73
+ export function formatReturnTo(
74
+ host: ReturnHost,
75
+ hostParams?: Readonly<Record<string, string>>,
76
+ ): string {
77
+ const path = host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
78
+ const state = returnStateSnapshot(hostParams);
79
+ return state === "" ? path : `${path}?${state}`;
21
80
  }
22
81
 
23
82
  // {} when target IS host — pushPath no-ops on the same path, so the param
@@ -25,12 +84,13 @@ export function formatReturnTo(host: ReturnHost): string {
25
84
  export function returnToParams(
26
85
  host: ReturnHost | undefined,
27
86
  target: ScreenTarget,
87
+ hostParams?: Readonly<Record<string, string>>,
28
88
  ): Readonly<Record<string, string>> {
29
89
  if (host === undefined) return {};
30
90
  if (lastSegment(target.screenId) === host.screenId && target.entityId === host.entityId) {
31
91
  return {};
32
92
  }
33
- return { [RETURN_TO_PARAM]: formatReturnTo(host) };
93
+ return { [RETURN_TO_PARAM]: formatReturnTo(host, hostParams) };
34
94
  }
35
95
 
36
96
  export function navigateWithReturnTo(
@@ -39,16 +99,42 @@ export function navigateWithReturnTo(
39
99
  host: ReturnHost | undefined,
40
100
  params?: Readonly<Record<string, string | null>>,
41
101
  ): void {
102
+ // Snapshot before navigating — nav.searchParams still describes the host.
103
+ const returnParams = returnToParams(host, target, nav.searchParams);
42
104
  nav.navigate(target);
43
105
  const merged: Record<string, string | null> = {
44
106
  ...(params ?? {}),
45
- ...returnToParams(host, target),
107
+ ...returnParams,
46
108
  };
47
109
  if (Object.keys(merged).length > 0) {
48
110
  nav.setSearchParams(merged);
49
111
  }
50
112
  }
51
113
 
114
+ /** Jumps back to an already-validated returnTo target and restores the host
115
+ * search params carried in the same value. The host's own returnTo travels
116
+ * with them, so a chain unwinds one level per jump. */
117
+ export function navigateToReturn(nav: NavApi, target: ScreenTarget): void {
118
+ const raw = nav.searchParams[RETURN_TO_PARAM];
119
+ const state = raw === undefined ? NO_RETURN_STATE : splitReturnTo(raw).state;
120
+ nav.navigate(target);
121
+ // The explicit null drops this screen's own returnTo on nav impls whose
122
+ // navigate keeps the query.
123
+ nav.setSearchParams({ ...state, [RETURN_TO_PARAM]: state[RETURN_TO_PARAM] ?? null });
124
+ }
125
+
126
+ export function navigateToReturnOr(
127
+ nav: NavApi,
128
+ target: ScreenTarget | undefined,
129
+ fallback: () => void,
130
+ ): void {
131
+ if (target === undefined) {
132
+ fallback();
133
+ return;
134
+ }
135
+ navigateToReturn(nav, target);
136
+ }
137
+
52
138
  // "%" is left alone — route entityIds come raw from the path and can carry it.
53
139
  const UNSAFE_ENTITY_ID_PATTERN = /[\\?#\s]/;
54
140
 
@@ -61,7 +147,7 @@ export function resolveReturnTarget(
61
147
  userRoles: readonly string[] | undefined,
62
148
  ): ScreenTarget | undefined {
63
149
  if (raw === undefined || raw === "") return undefined;
64
- const segments = raw.split("/");
150
+ const segments = splitReturnTo(raw).path.split("/");
65
151
  if (segments.length !== 1 && segments.length !== 2) return undefined;
66
152
  if (segments.some((segment) => segment === "")) return undefined;
67
153
  const [screenId, entityId] = segments;
@@ -10,11 +10,15 @@ import {
10
10
  synthesizeSecretMintConfirmScreen,
11
11
  } from "./action-form-shim";
12
12
  import type { FeatureSchema } from "./feature-schema";
13
- import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-screen";
13
+ import {
14
+ buildInitialValues,
15
+ literalCurrencyOverrides,
16
+ mergeSearchParamsIntoInitial,
17
+ } from "./kumiko-screen";
14
18
  import { layoutFieldNames } from "./layout-fields";
15
19
  import { useInitialValuesHandoff, useNav } from "./nav";
16
20
  import { lastSegment } from "./qn";
17
- import { useReturnTarget } from "./return-to";
21
+ import { navigateToReturnOr, useReturnTarget } from "./return-to";
18
22
 
19
23
  export type SecretMintBodyProps = {
20
24
  readonly schema: FeatureSchema;
@@ -71,6 +75,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
71
75
  searchParams: nav.searchParams,
72
76
  urlPrefillFields: screen.urlPrefillFields,
73
77
  renderableFields: layoutFieldNames(synthScreen),
78
+ // A mint form has no entity either, so its money fields name their own
79
+ // currency source (fw#2839). Only the literal form is resolved here:
80
+ // `{ kind: "tenant" }` would need the config query this screen never
81
+ // runs, and a one-time secret mint has no money use case to justify it.
82
+ moneyCurrencyOverrides: literalCurrencyOverrides(screen.fields),
74
83
  ...(handoffValues !== undefined && { handoffValues }),
75
84
  }) as FormValues,
76
85
  [screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues],
@@ -91,7 +100,14 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
91
100
  [screen, confirm],
92
101
  );
93
102
  const confirmInitial = useMemo(
94
- () => (confirm !== undefined ? (buildInitialValues(confirm.fields) as FormValues) : undefined),
103
+ () =>
104
+ confirm !== undefined
105
+ ? (buildInitialValues(
106
+ confirm.fields,
107
+ undefined,
108
+ literalCurrencyOverrides(confirm.fields),
109
+ ) as FormValues)
110
+ : undefined,
95
111
  [confirm],
96
112
  );
97
113
 
@@ -114,7 +130,8 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
114
130
  const handleCancel = useMemo<(() => void) | undefined>(() => {
115
131
  const target = screen.cancelTarget ?? screen.redirect;
116
132
  if (target === undefined || target === false) return undefined;
117
- return () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
133
+ return () =>
134
+ navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
118
135
  }, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
119
136
 
120
137
  // Ends the reveal phase for both paths (the bare acknowledge button, and a
@@ -125,8 +142,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
125
142
  const finishMint = useCallback(() => {
126
143
  setRevealed(null);
127
144
  carriedRef.current = {};
128
- if (screen.redirect !== undefined) {
129
- nav.navigate(returnTarget ?? { screenId: lastSegment(screen.redirect) });
145
+ const redirect = screen.redirect;
146
+ if (redirect !== undefined) {
147
+ navigateToReturnOr(nav, returnTarget, () =>
148
+ nav.navigate({ screenId: lastSegment(redirect) }),
149
+ );
130
150
  } else {
131
151
  setDone(true);
132
152
  }
@@ -118,6 +118,7 @@ export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): Re
118
118
  key={section.title ?? `section-${index}`}
119
119
  {...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
120
120
  >
121
+ {/* kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec */}
121
122
  {section.fields.map((fieldId) => {
122
123
  const qualified = screen.secretKeys[fieldId];
123
124
  if (qualified === undefined) return null;
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.291.0",
4
+ "type": "improvement",
5
+ "title": "returnTo restores the host screen's search params and nests return chains",
6
+ "detail": "A `returnTo` value may now carry the host's search params as a nested query (`token-detail/abc?tab=keys&orders.sort=name`), so jumping back lands on the tab, filter and sort the user left — `use-list-url-state` keys and `layout.mode: \"tabs\"` included, with no per-key allowlist to keep in sync. The host's own `returnTo` travels inside that snapshot, so a chain (list → edit → action form → back → save) unwinds one level per jump instead of losing its tail. Two syntactic caps bound the URL: at most three screen levels per value, and a snapshot over 512 characters is dropped whole. Both degrade to the previous bare value rather than failing, and a value without a snapshot is byte-identical to before, so existing links keep working. Target validation is unchanged: `resolveReturnTarget` sees only the path part and still rejects anything that is not an accessible in-app screen."
7
+ },
8
+ {
9
+ "version": "0.290.0",
10
+ "type": "fix",
11
+ "title": "Layout field readers see groups[].fields",
12
+ "detail": "layoutEditFields flattens section.groups, so required fields inside a groups-only section are presence-validated by buildFormSchema and survive the renderableFields gate for search-param prefills."
13
+ },
2
14
  {
3
15
  "version": "0.283.0",
4
16
  "type": "fix",
@@ -151,7 +151,7 @@ export function EmbeddedListField({
151
151
  const referenceQueries = referenceCells.map((cell) => {
152
152
  const refFeature = cell.refFeature ?? featureName;
153
153
  const refEntity = cell.refEntity ?? "";
154
- const qn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
154
+ const qn = cell.refOptionsQuery ?? `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
155
155
  // biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
156
156
  return useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(qn, {
157
157
  limit: REFERENCE_COMBOBOX_LIMIT,
@@ -165,10 +165,10 @@ export function EmbeddedListField({
165
165
  if (cell.type === "reference") {
166
166
  const idx = referenceCells.indexOf(cell);
167
167
  const query = referenceQueries[idx];
168
- const labelField = cell.refLabelField ?? "id";
168
+ const labelKey = cell.refOptionsQuery !== undefined ? "label" : (cell.refLabelField ?? "id");
169
169
  const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
170
170
  value: String(row["id"] ?? ""),
171
- label: String(row[labelField] ?? row["id"] ?? ""),
171
+ label: String(row[labelKey] ?? row["id"] ?? ""),
172
172
  }));
173
173
  return {
174
174
  field: cell.field,
@@ -167,6 +167,24 @@ export function RenderField({
167
167
  );
168
168
  }
169
169
 
170
+ // Where a reference field's option rows come from, and which of their keys
171
+ // holds the display text — the entity's own list handler plus `refLabelField`
172
+ // by default, or an author-supplied handler whose rows carry `label`
173
+ // (fw#2780), for targets with no readable column of their own.
174
+ function referenceOptionSource(
175
+ field: Pick<EditFieldViewModel, "refLabelField" | "refOptionsQuery">,
176
+ refFeature: string,
177
+ refEntity: string,
178
+ ): { readonly queryQn: string; readonly labelKey: string } {
179
+ if (field.refOptionsQuery !== undefined) {
180
+ return { queryQn: field.refOptionsQuery, labelKey: "label" };
181
+ }
182
+ return {
183
+ queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
184
+ labelKey: field.refLabelField ?? "id",
185
+ };
186
+ }
187
+
170
188
  // Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
171
189
  // gefüllt aus einer Live-Query auf die referenced Entity. Default-
172
190
  // Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
@@ -200,12 +218,13 @@ function ReferenceInput({
200
218
  const { Banner } = usePrimitives();
201
219
  const refEntity = field.refEntity ?? "";
202
220
  const refFeature = field.refFeature ?? featureName;
203
- const labelField = field.refLabelField ?? "id";
204
221
  const isMultiple = field.refMultiple === true;
205
222
  // Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
206
223
  // (z.B. items.assignee → users:query:user:list). Default ist
207
224
  // same-feature, kommt aus dem ViewModel (parseRefTarget).
208
- const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
225
+ // fw#2780: refOptionsQuery replaces that source with an author-supplied
226
+ // handler whose rows already carry a composed `label`.
227
+ const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
209
228
  // Issue #1681: "+ Neu" in der Combobox öffnet den Create-Screen der
210
229
  // referenced entity als Dialog, statt die aktuelle Form zu verlassen.
211
230
  // refFeature kann ein anderes Feature als das aktuell gerenderte sein
@@ -276,10 +295,10 @@ function ReferenceInput({
276
295
  const rows = queryResult.data?.rows ?? [];
277
296
  return rows.map((row) => {
278
297
  const idVal = String(row["id"] ?? "");
279
- const label = String(row[labelField] ?? idVal);
298
+ const label = String(row[labelKey] ?? idVal);
280
299
  return { value: idVal, label };
281
300
  });
282
- }, [queryResult.data, labelField]);
301
+ }, [queryResult.data, labelKey]);
283
302
  // Single: value ist String/null; Multi: Array. Coerce auf das was
284
303
  // der Combobox-Mode erwartet, damit Storage-Drift (Server liefert
285
304
  // alten String wo jetzt Array erwartet wird) keine Crash auslöst.
@@ -431,9 +450,8 @@ function ReadOnlyReferenceValue({
431
450
  const t = useTranslation();
432
451
  const refEntity = field.refEntity ?? "";
433
452
  const refFeature = field.refFeature ?? featureName;
434
- const labelField = field.refLabelField ?? "id";
435
453
  const isMultiple = field.refMultiple === true;
436
- const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
454
+ const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
437
455
  const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
438
456
  limit: REFERENCE_COMBOBOX_LIMIT,
439
457
  });
@@ -454,7 +472,7 @@ function ReadOnlyReferenceValue({
454
472
  const labels = ids.map((id) => {
455
473
  if (systemLabel !== undefined && id === systemLabel.id) return t(systemLabel.labelKey);
456
474
  const row = rows.find((r) => String(r["id"] ?? "") === id);
457
- return row !== undefined ? String(row[labelField] ?? id) : id;
475
+ return row !== undefined ? String(row[labelKey] ?? id) : id;
458
476
  });
459
477
  return <Text testId={`field-value-${field.field}`}>{labels.join(", ")}</Text>;
460
478
  }
package/src/index.ts CHANGED
@@ -82,12 +82,15 @@ export {
82
82
  useNavigateWithInitialValues,
83
83
  } from "./app/nav";
84
84
  export { lastSegment } from "./app/qn";
85
- export type { ReturnHost } from "./app/return-to";
85
+ export type { ReturnHost, ReturnTo } from "./app/return-to";
86
86
  export {
87
+ navigateToReturn,
88
+ navigateToReturnOr,
87
89
  navigateWithReturnTo,
88
90
  RETURN_TO_PARAM,
89
91
  ReturnHostProvider,
90
92
  resolveReturnTarget,
93
+ splitReturnTo,
91
94
  useReturnHost,
92
95
  useReturnTarget,
93
96
  } from "./app/return-to";