@cosmicdrift/kumiko-renderer 0.290.0 → 0.292.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.
@@ -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
+ });
@@ -50,8 +50,9 @@ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-cont
50
50
  import { useUserRoles } from "../context/user-roles-context";
51
51
  import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
52
52
  import { type UseQueryResult, useQuery } from "../hooks/use-query";
53
- import { useTranslation } from "../i18n";
53
+ import { useOptionalTimeZone, useTranslation } from "../i18n";
54
54
  import {
55
+ type DataTableDateRangeFacet,
55
56
  type DataTableFacet,
56
57
  type DataTableRowAction,
57
58
  statusToneForValue,
@@ -66,10 +67,14 @@ import type { FeatureSchema } from "./feature-schema";
66
67
  import { buildFormSchema } from "./form-schema";
67
68
  import { layoutFieldNames } from "./layout-fields";
68
69
  import {
70
+ buildDateRangePayload,
69
71
  buildFilterFacets,
70
72
  buildFilterPayload,
73
+ clampDateRange,
71
74
  mergeReferenceFacetOptions,
72
75
  type ResolvedFacetSpec,
76
+ readDateRange,
77
+ resolveDateRangeFacets,
73
78
  resolveProjectionFacetSpecs,
74
79
  } from "./list-facets";
75
80
  import { type NavApi, type ScreenTarget, useInitialValuesHandoff, useNav } from "./nav";
@@ -82,6 +87,8 @@ import { lastSegment, toKebab } from "./qn";
82
87
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
83
88
  import { ReferenceFacetBridges, type ReferenceFacetOption } from "./reference-facet-bridge";
84
89
  import {
90
+ navigateToReturn,
91
+ navigateToReturnOr,
85
92
  navigateWithReturnTo,
86
93
  type ReturnHost,
87
94
  ReturnHostProvider,
@@ -99,7 +106,7 @@ import {
99
106
  runProjectionRowNavigate,
100
107
  stringifyNavParams,
101
108
  } from "./row-actions";
102
- import { findEditScreenFor, screenAccessAllows } from "./screen-access";
109
+ import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access";
103
110
  import { SecretMintBody } from "./secret-mint-body";
104
111
  import { SecretsEditBody } from "./secrets-edit-body";
105
112
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
@@ -487,6 +494,63 @@ function resolveTenantCurrency(
487
494
  return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
488
495
  }
489
496
 
497
+ // An entity-less form screen has no `entity.defaultCurrency` to fall back on,
498
+ // so a tenant-declared field whose config query errors or returns no value
499
+ // lands here — same last resort as the entityEdit path's `?? "EUR"`.
500
+ const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
501
+
502
+ // A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
503
+ // fw#2839) resolves with no query at all. Read structurally, same idiom as
504
+ // tenantCurrencyMoneyFieldNames above.
505
+ export function literalCurrencyOverrides(
506
+ fields: Readonly<Record<string, unknown>>,
507
+ ): Readonly<Record<string, string>> {
508
+ const out: Record<string, string> = {};
509
+ for (const [name, def] of Object.entries(fields)) {
510
+ const shape = def as {
511
+ readonly type?: string;
512
+ readonly currency?: { readonly kind?: string; readonly code?: string };
513
+ };
514
+ const code = shape.currency?.code;
515
+ if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
516
+ out[name] = code;
517
+ }
518
+ }
519
+ return out;
520
+ }
521
+
522
+ // Per-field currency for the money fields that declare their own source: a
523
+ // literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
524
+ // needs the tenant-settings config query and makes the caller hold the form
525
+ // until it lands (fw#2933) — seeding the fallback meanwhile would render, and
526
+ // on a fast click submit, the wrong currency. Callers pass the tenant field
527
+ // names themselves because an edit form only needs the fetch for fields the
528
+ // record has no stored value for.
529
+ function useMoneyCurrencyOverrides(
530
+ fields: Readonly<Record<string, unknown>>,
531
+ tenantFieldNames: readonly string[],
532
+ fallback: string,
533
+ ): {
534
+ readonly overrides: Readonly<Record<string, string>> | undefined;
535
+ readonly loading: boolean;
536
+ } {
537
+ const query = useQuery<TenantConfigValuesResponse>(
538
+ "config:query:values",
539
+ {},
540
+ { enabled: tenantFieldNames.length > 0 },
541
+ );
542
+ const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
543
+ const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
544
+ const overrides = useMemo(() => {
545
+ const out: Record<string, string> = { ...literalCurrencyOverrides(fields) };
546
+ if (resolvedTenantCurrency !== undefined) {
547
+ for (const name of tenantFieldNames) out[name] = resolvedTenantCurrency;
548
+ }
549
+ return Object.keys(out).length > 0 ? out : undefined;
550
+ }, [fields, tenantFieldNames, resolvedTenantCurrency]);
551
+ return { overrides, loading: resolution.status === "loading" };
552
+ }
553
+
490
554
  function multiSelectOptionValues(shape: {
491
555
  readonly options?: readonly (string | { readonly value: string })[];
492
556
  }): ReadonlySet<string> | undefined {
@@ -857,26 +921,11 @@ function EntityEditCreateBody({
857
921
  () => tenantCurrencyMoneyFieldNames(entity.fields),
858
922
  [entity.fields],
859
923
  );
860
- const needsTenantCurrency = tenantCurrencyFieldNames.length > 0;
861
- const tenantCurrencyQuery = useQuery<TenantConfigValuesResponse>(
862
- "config:query:values",
863
- {},
864
- { enabled: needsTenantCurrency },
865
- );
866
- const tenantCurrencyResolution = resolveTenantCurrency(
924
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
925
+ entity.fields,
867
926
  tenantCurrencyFieldNames,
868
- tenantCurrencyQuery,
869
927
  entityDefaultCurrency,
870
928
  );
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
929
  const initial = useMemo(
881
930
  () =>
882
931
  mergeSearchParamsIntoInitial(entity.fields, {
@@ -900,7 +949,7 @@ function EntityEditCreateBody({
900
949
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
901
950
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
902
951
  const handleCancel = useCallback(
903
- () => navigateToReturnTargetOr(nav, returnTarget, navigateToList),
952
+ () => navigateToReturnOr(nav, returnTarget, navigateToList),
904
953
  [nav, returnTarget, navigateToList],
905
954
  );
906
955
  // Create's write-handler success payload doesn't flatly expose a parent FK
@@ -916,7 +965,7 @@ function EntityEditCreateBody({
916
965
  const redirect = screen.redirect;
917
966
  if (redirect !== undefined) {
918
967
  if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
919
- nav.navigate(returnTarget);
968
+ navigateToReturn(nav, returnTarget);
920
969
  } else if (typeof redirect === "string") {
921
970
  // String form unchanged: always carries the newly created
922
971
  // record's own id, regardless of the target screen's type. The
@@ -938,7 +987,7 @@ function EntityEditCreateBody({
938
987
  nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
939
988
  }
940
989
  } else {
941
- navigateToReturnTargetOr(nav, returnTarget, navigateToList);
990
+ navigateToReturnOr(nav, returnTarget, navigateToList);
942
991
  onSaved?.();
943
992
  }
944
993
  },
@@ -946,7 +995,7 @@ function EntityEditCreateBody({
946
995
  );
947
996
  // Never seed a tenant-declared money field with entityDefaultCurrency while
948
997
  // its real tenant currency is still in flight (fw#2933) — wait instead.
949
- if (needsTenantCurrency && tenantCurrencyResolution.status === "loading") {
998
+ if (currencyLoading) {
950
999
  return (
951
1000
  <Banner padded variant="loading" testId="kumiko-screen-loading">
952
1001
  Loading…
@@ -1109,26 +1158,11 @@ function EntityEditUpdateForm({
1109
1158
  ),
1110
1159
  [entity.fields, record],
1111
1160
  );
1112
- const needsTenantCurrency = tenantCurrencyFieldNames.length > 0;
1113
- const tenantCurrencyQuery = useQuery<TenantConfigValuesResponse>(
1114
- "config:query:values",
1115
- {},
1116
- { enabled: needsTenantCurrency },
1117
- );
1118
- const tenantCurrencyResolution = resolveTenantCurrency(
1161
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
1162
+ entity.fields,
1119
1163
  tenantCurrencyFieldNames,
1120
- tenantCurrencyQuery,
1121
1164
  entityDefaultCurrency,
1122
1165
  );
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
1166
  const initial = useMemo(() => {
1133
1167
  const out: Record<string, unknown> = {};
1134
1168
  for (const name of Object.keys(entity.fields)) {
@@ -1311,7 +1345,7 @@ function EntityEditUpdateForm({
1311
1345
  const redirect = screen.redirect;
1312
1346
  if (redirect !== undefined) {
1313
1347
  if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
1314
- nav.navigate(returnTarget);
1348
+ navigateToReturn(nav, returnTarget);
1315
1349
  } else if (typeof redirect === "string") {
1316
1350
  // String form unchanged: navigates without an entityId, same as
1317
1351
  // before the object form existed. The object form resolves like
@@ -1334,14 +1368,14 @@ function EntityEditUpdateForm({
1334
1368
  });
1335
1369
  }
1336
1370
  } else {
1337
- navigateToReturnTargetOr(nav, returnTarget, navigateToList);
1371
+ navigateToReturnOr(nav, returnTarget, navigateToList);
1338
1372
  onSaved?.();
1339
1373
  }
1340
1374
  },
1341
1375
  [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget],
1342
1376
  );
1343
1377
  const handleCancel = useCallback(
1344
- () => navigateToReturnTargetOr(nav, returnTarget, navigateToList),
1378
+ () => navigateToReturnOr(nav, returnTarget, navigateToList),
1345
1379
  [nav, returnTarget, navigateToList],
1346
1380
  );
1347
1381
  const handleDelete = useCallback(async () => {
@@ -1349,7 +1383,7 @@ function EntityEditUpdateForm({
1349
1383
  if (res.isSuccess) {
1350
1384
  // Never return onto the just-deleted record.
1351
1385
  const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
1352
- navigateToReturnTargetOr(nav, target, navigateToList);
1386
+ navigateToReturnOr(nav, target, navigateToList);
1353
1387
  onDeleted?.();
1354
1388
  }
1355
1389
  }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
@@ -1358,7 +1392,7 @@ function EntityEditUpdateForm({
1358
1392
  // its real tenant currency is still in flight (fw#2933) — wait instead. A
1359
1393
  // stored (non-empty) value is unaffected — it never reaches this branch's
1360
1394
  // fallback because `initial` above already prefers `record[name]`.
1361
- if (needsTenantCurrency && tenantCurrencyResolution.status === "loading") {
1395
+ if (currencyLoading) {
1362
1396
  return (
1363
1397
  <Banner padded variant="loading" testId="kumiko-screen-loading">
1364
1398
  Loading…
@@ -2381,6 +2415,19 @@ function ProjectionListBody({
2381
2415
  [urlState.filters, facetSpecs],
2382
2416
  );
2383
2417
 
2418
+ // dateRange facets (fw#3104) bypass `filters` entirely — their two bounds
2419
+ // become the top-level query params the facet declares, resolved from a
2420
+ // calendar date to an instant in the viewer's zone.
2421
+ const timeZone = useOptionalTimeZone();
2422
+ const dateRangeSpecs = useMemo(
2423
+ () => resolveDateRangeFacets(screen.facets, effectiveTranslate),
2424
+ [screen.facets, effectiveTranslate],
2425
+ );
2426
+ const dateRangePayload = useMemo(
2427
+ () => buildDateRangePayload(dateRangeSpecs, urlState.filters, timeZone),
2428
+ [dateRangeSpecs, urlState.filters, timeZone],
2429
+ );
2430
+
2384
2431
  const queryPayload = useMemo(() => {
2385
2432
  const payload = buildListQueryPayload({
2386
2433
  limit,
@@ -2401,6 +2448,7 @@ function ProjectionListBody({
2401
2448
  if (screen.facets !== undefined && filterPayload.length > 0) {
2402
2449
  payload["filters"] = filterPayload;
2403
2450
  }
2451
+ Object.assign(payload, dateRangePayload);
2404
2452
  return payload;
2405
2453
  }, [
2406
2454
  limit,
@@ -2411,6 +2459,7 @@ function ProjectionListBody({
2411
2459
  screen.filter,
2412
2460
  screen.facets,
2413
2461
  filterPayload,
2462
+ dateRangePayload,
2414
2463
  ]);
2415
2464
 
2416
2465
  const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
@@ -2419,6 +2468,24 @@ function ProjectionListBody({
2419
2468
  () => buildFilterFacets(resolvedFacetSpecs),
2420
2469
  [resolvedFacetSpecs],
2421
2470
  );
2471
+ const dateRangeFacets = useMemo<DataTableDateRangeFacet[]>(
2472
+ () =>
2473
+ dateRangeSpecs.map((spec) => ({
2474
+ field: spec.field,
2475
+ label: spec.label,
2476
+ ...readDateRange(urlState.filters, spec.field),
2477
+ })),
2478
+ [dateRangeSpecs, urlState.filters],
2479
+ );
2480
+ const handleDateRangeChange = useCallback(
2481
+ (field: string, bound: "from" | "to", value: string) => {
2482
+ urlState.setDateRange(
2483
+ field,
2484
+ clampDateRange(readDateRange(urlState.filters, field), bound, value),
2485
+ );
2486
+ },
2487
+ [urlState.setDateRange, urlState.filters],
2488
+ );
2422
2489
 
2423
2490
  // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
2424
2491
  // resolution happens in the NavApi impl. Unlike there: NO row["id"]
@@ -2554,8 +2621,14 @@ function ProjectionListBody({
2554
2621
  {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
2555
2622
  {...(filterFacets.length > 0 && {
2556
2623
  filterFacets,
2557
- filterValues: urlState.filters,
2558
2624
  onFilterChange: urlState.setFilter,
2625
+ })}
2626
+ {...(dateRangeFacets.length > 0 && {
2627
+ dateRangeFacets,
2628
+ onDateRangeChange: handleDateRangeChange,
2629
+ })}
2630
+ {...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
2631
+ filterValues: urlState.filters,
2559
2632
  onFilterReset: urlState.clearFilters,
2560
2633
  })}
2561
2634
  />
@@ -3155,7 +3228,7 @@ function ProjectionDetailBody({
3155
3228
  const testId = `kumiko-screen-projection-detail-metric-${field}`;
3156
3229
  const navigate = metricNavigateSpec(metric);
3157
3230
  const onPress =
3158
- navigate !== undefined
3231
+ navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
3159
3232
  ? () => runMetricNavigate(nav, navigate, record, host)
3160
3233
  : undefined;
3161
3234
  return Metric !== undefined ? (
@@ -3281,7 +3354,7 @@ function redirectTargetsRecord(
3281
3354
  }
3282
3355
 
3283
3356
  // Type-guard form narrows `returnTarget` for the caller's subsequent
3284
- // nav.navigate(returnTarget) call.
3357
+ // navigateToReturn(nav, returnTarget) call.
3285
3358
  function returnToWinsOverRedirect(
3286
3359
  returnTarget: ScreenTarget | undefined,
3287
3360
  redirect: string | ActionFormRedirect,
@@ -3291,18 +3364,6 @@ function returnToWinsOverRedirect(
3291
3364
  return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
3292
3365
  }
3293
3366
 
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
3367
  // Resolves an object-form redirect to a nav target — shared by
3307
3368
  // actionForm and entityEdit (create + update) so the id
3308
3369
  // carries over identically regardless of which screen type triggered it.
@@ -3367,6 +3428,7 @@ function ActionFormBody({
3367
3428
  }): ReactNode {
3368
3429
  const nav = useNav();
3369
3430
  const appFeatures = useAppFeatures();
3431
+ const { Banner } = usePrimitives();
3370
3432
  // Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
3371
3433
  const returnTarget = useReturnTarget(screen.id);
3372
3434
  const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
@@ -3374,12 +3436,27 @@ function ActionFormBody({
3374
3436
  const pendingHandoff = useInitialValuesHandoff(screen.id);
3375
3437
  // A drawer-hosted form is not a navigation target, so it never takes a handoff.
3376
3438
  const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
3439
+ // This screen has no entity, so a money field here declares its own currency
3440
+ // source (fw#2839, boot-enforced) — without one the form would seed a bare
3441
+ // `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
3442
+ // applies when a tenant-declared field's config query errors or holds no
3443
+ // value, matching the entityEdit path's behaviour (fw#2937).
3444
+ const tenantCurrencyFieldNames = useMemo(
3445
+ () => tenantCurrencyMoneyFieldNames(screen.fields),
3446
+ [screen.fields],
3447
+ );
3448
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
3449
+ screen.fields,
3450
+ tenantCurrencyFieldNames,
3451
+ ACTION_FORM_CURRENCY_FALLBACK,
3452
+ );
3377
3453
  const initial = useMemo(
3378
3454
  () =>
3379
3455
  mergeSearchParamsIntoInitial(screen.fields, {
3380
3456
  searchParams: nav.searchParams,
3381
3457
  urlPrefillFields: screen.urlPrefillFields,
3382
3458
  renderableFields: layoutFieldNames(synthScreen),
3459
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
3383
3460
  ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
3384
3461
  ...(handoffValues !== undefined && { handoffValues }),
3385
3462
  }) as FormValues,
@@ -3388,6 +3465,7 @@ function ActionFormBody({
3388
3465
  screen.urlPrefillFields,
3389
3466
  nav.searchParams,
3390
3467
  synthScreen,
3468
+ moneyCurrencyOverrides,
3391
3469
  initialOverrides,
3392
3470
  handoffValues,
3393
3471
  ],
@@ -3403,7 +3481,7 @@ function ActionFormBody({
3403
3481
  // navigation, and never one to a record screen (see redirectTargetsRecord).
3404
3482
  if (screen.redirect !== undefined) {
3405
3483
  if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
3406
- nav.navigate(returnTarget);
3484
+ navigateToReturn(nav, returnTarget);
3407
3485
  } else {
3408
3486
  const { screenId, entityId } = resolveRedirectTarget(
3409
3487
  screen.redirect,
@@ -3428,8 +3506,18 @@ function ActionFormBody({
3428
3506
  screen.cancelTarget ??
3429
3507
  (screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
3430
3508
  if (target === undefined || target === false) return undefined;
3431
- return () => nav.navigate(returnTarget ?? { screenId: lastSegment(target) });
3509
+ return () =>
3510
+ navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
3432
3511
  }, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
3512
+ // Rendering the form before a tenant-declared currency lands would show the
3513
+ // fallback and, on a fast click, submit it (fw#2933) — hold instead.
3514
+ if (currencyLoading) {
3515
+ return (
3516
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
3517
+ Loading…
3518
+ </Banner>
3519
+ );
3520
+ }
3433
3521
  return (
3434
3522
  <RenderEdit
3435
3523
  screen={synthScreen}