@cosmicdrift/kumiko-renderer 0.275.0 → 0.277.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.275.0",
3
+ "version": "0.277.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,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.275.0",
19
- "@cosmicdrift/kumiko-headless": "0.275.0",
18
+ "@cosmicdrift/kumiko-framework": "0.277.0",
19
+ "@cosmicdrift/kumiko-headless": "0.277.0",
20
+ "@cosmicdrift/kumiko-types": "0.277.0",
20
21
  "react": "^19.2.6",
21
22
  "temporal-polyfill": "^0.3.2",
22
23
  "zod": "^4.4.3"
@@ -27,7 +28,7 @@
27
28
  "@types/react-dom": "^19.2.3",
28
29
  "jsdom": "^29.1.1",
29
30
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.275.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.277.0"
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",
@@ -32,6 +32,7 @@ import type {
32
32
  Translate,
33
33
  } from "@cosmicdrift/kumiko-headless";
34
34
  import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
35
+ import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
35
36
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
36
37
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
37
38
  import {
@@ -48,7 +49,7 @@ import { RenderList } from "../components/render-list";
48
49
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
49
50
  import { useUserRoles } from "../context/user-roles-context";
50
51
  import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
51
- import { useQuery } from "../hooks/use-query";
52
+ import { type UseQueryResult, useQuery } from "../hooks/use-query";
52
53
  import { useTranslation } from "../i18n";
53
54
  import {
54
55
  type DataTableFacet,
@@ -404,9 +405,14 @@ function useNavigateToCreateFor(
404
405
 
405
406
  // `defaultCurrency` is entityEdit-only — it enables the `{amount, currency}`
406
407
  // write shape (fw#1923); config-edit's plain-number contract needs bare `0`.
408
+ // `moneyCurrencyOverrides` (fw#2933) replaces `defaultCurrency` per field name
409
+ // — used for a `money` field that declares `currency: { kind: "tenant" }`, so
410
+ // its empty initial value carries the tenant's own currency instead of the
411
+ // entity-wide default.
407
412
  export function buildInitialValues(
408
413
  fields: Readonly<Record<string, unknown>>,
409
414
  defaultCurrency?: string,
415
+ moneyCurrencyOverrides?: Readonly<Record<string, string>>,
410
416
  ): Readonly<Record<string, unknown>> {
411
417
  const out: Record<string, unknown> = {};
412
418
  for (const [name, def] of Object.entries(fields)) {
@@ -419,9 +425,12 @@ export function buildInitialValues(
419
425
  out[name] = [];
420
426
  continue;
421
427
  }
422
- if (shape.type === "money" && defaultCurrency !== undefined) {
423
- out[name] = { amount: 0, currency: defaultCurrency };
424
- continue;
428
+ if (shape.type === "money") {
429
+ const currency = moneyCurrencyOverrides?.[name] ?? defaultCurrency;
430
+ if (currency !== undefined) {
431
+ out[name] = { amount: 0, currency };
432
+ continue;
433
+ }
425
434
  }
426
435
  out[name] =
427
436
  shape.type === "boolean"
@@ -435,6 +444,49 @@ export function buildInitialValues(
435
444
  return out;
436
445
  }
437
446
 
447
+ // A `money` field opts a currently-empty value into the tenant-settings
448
+ // bundle's per-tenant currency (fw#2933) via `currency: { kind: "tenant" }`
449
+ // on its FieldDefinition. Read structurally — renderer only depends on the
450
+ // client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
451
+ // MoneyCurrencySource types, same idiom as the other field-shape narrowings
452
+ // in this file (PrefillFieldShape etc.).
453
+ function tenantCurrencyMoneyFieldNames(
454
+ fields: Readonly<Record<string, unknown>>,
455
+ ): readonly string[] {
456
+ const names: string[] = [];
457
+ for (const [name, def] of Object.entries(fields)) {
458
+ const shape = def as { readonly type?: string; readonly currency?: { readonly kind?: string } };
459
+ if (shape.type === "money" && shape.currency?.kind === "tenant") names.push(name);
460
+ }
461
+ return names;
462
+ }
463
+
464
+ type TenantConfigValuesResponse = Readonly<
465
+ Record<string, { readonly value: string | number | boolean | undefined }>
466
+ >;
467
+
468
+ type TenantCurrencyResolution =
469
+ | { readonly status: "not-needed" }
470
+ | { readonly status: "loading" }
471
+ | { readonly status: "ready"; readonly currency: string };
472
+
473
+ // Resolves what a tenant-declared money field's currency should be, from the
474
+ // same `config:query:values` query ConfigEditBody already uses. Never falls
475
+ // back to "EUR" while the query is genuinely still in flight (fw#2933) — a
476
+ // screen with no tenant-declared money field, or a query that errored or
477
+ // returned no value for the key, resolves immediately instead of blocking.
478
+ function resolveTenantCurrency(
479
+ fieldNames: readonly string[],
480
+ query: UseQueryResult<TenantConfigValuesResponse>,
481
+ fallback: string,
482
+ ): TenantCurrencyResolution {
483
+ if (fieldNames.length === 0) return { status: "not-needed" };
484
+ if (query.error) return { status: "ready", currency: fallback };
485
+ if (query.data === null) return { status: "loading" };
486
+ const raw = query.data[TENANT_CURRENCY_CONFIG_KEY]?.value;
487
+ return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
488
+ }
489
+
438
490
  function multiSelectOptionValues(shape: {
439
491
  readonly options?: readonly (string | { readonly value: string })[];
440
492
  }): ReadonlySet<string> | undefined {
@@ -586,6 +638,9 @@ export type InitialValueSources = {
586
638
  readonly urlPrefillFields: readonly string[] | undefined;
587
639
  readonly renderableFields?: ReadonlySet<string>;
588
640
  readonly defaultCurrency?: string;
641
+ // Per-field override of `defaultCurrency` for money fields declaring
642
+ // `currency: { kind: "tenant" }` (fw#2933) — see buildInitialValues.
643
+ readonly moneyCurrencyOverrides?: Readonly<Record<string, string>>;
589
644
  // Drawer-kind row actions (fw#2710) prefill from the clicked row's
590
645
  // already-typed values; wins over every other source.
591
646
  readonly drawerOverrides?: Readonly<Record<string, unknown>>;
@@ -600,13 +655,14 @@ function coercePrefillString(
600
655
  shape: PrefillFieldShape,
601
656
  fallback: unknown,
602
657
  defaultCurrency: string | undefined,
658
+ moneyCurrencyOverrides: Readonly<Record<string, string>> | undefined,
603
659
  ): unknown {
604
660
  if (shape.type === "number") {
605
661
  const parsed = Number(raw);
606
662
  return Number.isNaN(parsed) ? fallback : parsed;
607
663
  }
608
664
  if (shape.type === "money") {
609
- const coerced = coerceMoneyValue(raw, name, defaultCurrency);
665
+ const coerced = coerceMoneyValue(raw, name, moneyCurrencyOverrides?.[name] ?? defaultCurrency);
610
666
  return coerced === undefined ? fallback : coerced;
611
667
  }
612
668
  if (shape.type === "boolean") return raw === "true";
@@ -648,10 +704,19 @@ export function mergeSearchParamsIntoInitial(
648
704
  fields: Readonly<Record<string, unknown>>,
649
705
  sources: InitialValueSources,
650
706
  ): Record<string, unknown> {
651
- const { searchParams, renderableFields, defaultCurrency, drawerOverrides, handoffValues } =
652
- sources;
707
+ const {
708
+ searchParams,
709
+ renderableFields,
710
+ defaultCurrency,
711
+ moneyCurrencyOverrides,
712
+ drawerOverrides,
713
+ handoffValues,
714
+ } = sources;
653
715
  const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
654
- const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
716
+ const defaults = buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides) as Record<
717
+ string,
718
+ unknown
719
+ >;
655
720
  const merged: Record<string, unknown> = { ...defaults };
656
721
  for (const [name, fieldDef] of Object.entries(fields)) {
657
722
  if (renderableFields !== undefined && !renderableFields.has(name)) continue;
@@ -670,14 +735,28 @@ export function mergeSearchParamsIntoInitial(
670
735
  if (handedOff !== undefined) {
671
736
  merged[name] =
672
737
  typeof handedOff === "string"
673
- ? coercePrefillString(handedOff, name, shape, defaults[name], defaultCurrency)
738
+ ? coercePrefillString(
739
+ handedOff,
740
+ name,
741
+ shape,
742
+ defaults[name],
743
+ defaultCurrency,
744
+ moneyCurrencyOverrides,
745
+ )
674
746
  : handedOff;
675
747
  continue;
676
748
  }
677
749
  if (!urlPrefillFields.has(name)) continue;
678
750
  const raw = searchParams[name];
679
751
  if (raw === undefined) continue;
680
- merged[name] = coercePrefillString(raw, name, shape, defaults[name], defaultCurrency);
752
+ merged[name] = coercePrefillString(
753
+ raw,
754
+ name,
755
+ shape,
756
+ defaults[name],
757
+ defaultCurrency,
758
+ moneyCurrencyOverrides,
759
+ );
681
760
  }
682
761
  return merged;
683
762
  }
@@ -761,6 +840,7 @@ function EntityEditCreateBody({
761
840
  // new row.
762
841
  readonly onSaved?: () => void;
763
842
  }): ReactNode {
843
+ const { Banner } = usePrimitives();
764
844
  const nav = useNav();
765
845
  const handoffValues = useInitialValuesHandoff(screen.id);
766
846
  const appFeatures = useAppFeatures();
@@ -768,16 +848,53 @@ function EntityEditCreateBody({
768
848
  // A singleton stays on its own screen after saving (onSaved/refetch) —
769
849
  // returnTo would fight that.
770
850
  const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
851
+ const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
852
+ // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
853
+ // its empty-value currency from the tenant-settings config key instead of
854
+ // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
855
+ // without any such field.
856
+ const tenantCurrencyFieldNames = useMemo(
857
+ () => tenantCurrencyMoneyFieldNames(entity.fields),
858
+ [entity.fields],
859
+ );
860
+ const needsTenantCurrency = tenantCurrencyFieldNames.length > 0;
861
+ const tenantCurrencyQuery = useQuery<TenantConfigValuesResponse>(
862
+ "config:query:values",
863
+ {},
864
+ { enabled: needsTenantCurrency },
865
+ );
866
+ const tenantCurrencyResolution = resolveTenantCurrency(
867
+ tenantCurrencyFieldNames,
868
+ tenantCurrencyQuery,
869
+ entityDefaultCurrency,
870
+ );
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
+ );
771
880
  const initial = useMemo(
772
881
  () =>
773
882
  mergeSearchParamsIntoInitial(entity.fields, {
774
883
  searchParams: nav.searchParams,
775
884
  urlPrefillFields: screen.urlPrefillFields,
776
885
  renderableFields: layoutFieldNames(screen),
777
- defaultCurrency: entity.defaultCurrency ?? "EUR",
886
+ defaultCurrency: entityDefaultCurrency,
887
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
778
888
  ...(handoffValues !== undefined && { handoffValues }),
779
889
  }) as FormValues,
780
- [entity.fields, nav.searchParams, screen, entity.defaultCurrency, handoffValues],
890
+ [
891
+ entity.fields,
892
+ nav.searchParams,
893
+ screen,
894
+ entityDefaultCurrency,
895
+ moneyCurrencyOverrides,
896
+ handoffValues,
897
+ ],
781
898
  );
782
899
  const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
783
900
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
@@ -827,6 +944,15 @@ function EntityEditCreateBody({
827
944
  },
828
945
  [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved, returnTarget],
829
946
  );
947
+ // Never seed a tenant-declared money field with entityDefaultCurrency while
948
+ // its real tenant currency is still in flight (fw#2933) — wait instead.
949
+ if (needsTenantCurrency && tenantCurrencyResolution.status === "loading") {
950
+ return (
951
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
952
+ Loading…
953
+ </Banner>
954
+ );
955
+ }
830
956
  // Deliberately no `actions` prop here: `screen.actions` targets an
831
957
  // EXISTING record (publish/archive/duplicate and friends), which the
832
958
  // create branch has none of yet — see EntityEditUpdateForm for the
@@ -958,6 +1084,7 @@ function EntityEditUpdateForm({
958
1084
  readonly onSaved?: () => void;
959
1085
  readonly onDeleted?: () => void;
960
1086
  }): ReactNode {
1087
+ const { Banner } = usePrimitives();
961
1088
  // Seed the form with the server values for the entity's declared
962
1089
  // fields; anything else (id, tenant_id, created_at…) stays out of
963
1090
  // the form and lives in the closure. The record's `version` is
@@ -965,15 +1092,56 @@ function EntityEditUpdateForm({
965
1092
  // concurrent writer bumps it, the server returns a version-conflict
966
1093
  // error and the user reloads.
967
1094
  const recordVersion = (record as { version?: number }).version ?? 1;
1095
+ const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
1096
+ // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
1097
+ // its empty-value currency from the tenant-settings config key instead of
1098
+ // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
1099
+ // without any such field. A record's own stored value (below) always wins
1100
+ // over this, regardless of the declaration.
1101
+ //
1102
+ // Only fields the record itself has no value for need the fetch at all —
1103
+ // an already-set tenant-declared field keeps its own stored currency and
1104
+ // must never block the form on this query.
1105
+ const tenantCurrencyFieldNames = useMemo(
1106
+ () =>
1107
+ tenantCurrencyMoneyFieldNames(entity.fields).filter(
1108
+ (name) => record[name] === null || record[name] === undefined,
1109
+ ),
1110
+ [entity.fields, record],
1111
+ );
1112
+ const needsTenantCurrency = tenantCurrencyFieldNames.length > 0;
1113
+ const tenantCurrencyQuery = useQuery<TenantConfigValuesResponse>(
1114
+ "config:query:values",
1115
+ {},
1116
+ { enabled: needsTenantCurrency },
1117
+ );
1118
+ const tenantCurrencyResolution = resolveTenantCurrency(
1119
+ tenantCurrencyFieldNames,
1120
+ tenantCurrencyQuery,
1121
+ entityDefaultCurrency,
1122
+ );
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
+ );
968
1132
  const initial = useMemo(() => {
969
1133
  const out: Record<string, unknown> = {};
970
- const defaultCurrency = entity.defaultCurrency ?? "EUR";
971
1134
  for (const name of Object.keys(entity.fields)) {
972
1135
  out[name] =
973
- record[name] ?? buildInitialValues({ [name]: entity.fields[name] }, defaultCurrency)[name];
1136
+ record[name] ??
1137
+ buildInitialValues(
1138
+ { [name]: entity.fields[name] },
1139
+ entityDefaultCurrency,
1140
+ moneyCurrencyOverrides,
1141
+ )[name];
974
1142
  }
975
1143
  return out as FormValues;
976
- }, [entity.fields, entity.defaultCurrency, record]);
1144
+ }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
977
1145
 
978
1146
  const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
979
1147
 
@@ -1186,6 +1354,18 @@ function EntityEditUpdateForm({
1186
1354
  }
1187
1355
  }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
1188
1356
 
1357
+ // Never seed a tenant-declared money field with entityDefaultCurrency while
1358
+ // its real tenant currency is still in flight (fw#2933) — wait instead. A
1359
+ // stored (non-empty) value is unaffected — it never reaches this branch's
1360
+ // fallback because `initial` above already prefers `record[name]`.
1361
+ if (needsTenantCurrency && tenantCurrencyResolution.status === "loading") {
1362
+ return (
1363
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
1364
+ Loading…
1365
+ </Banner>
1366
+ );
1367
+ }
1368
+
1189
1369
  return (
1190
1370
  <>
1191
1371
  <RenderEdit
@@ -35,6 +35,7 @@ function buildEntity(): EntityDefinition {
35
35
  function renderEditCapturingForm(
36
36
  screen: EntityEditScreenDefinition,
37
37
  extraTranslations?: TranslationsByLocale,
38
+ hideSectionTitles = false,
38
39
  ): {
39
40
  captured: FormProps | undefined;
40
41
  } {
@@ -78,6 +79,7 @@ function renderEditCapturingForm(
78
79
  entity={buildEntity()}
79
80
  featureName="widgets"
80
81
  initial={{ name: "" }}
82
+ {...(hideSectionTitles && { hideSectionTitles: true })}
81
83
  />
82
84
  </PrimitivesProvider>
83
85
  </LocaleProvider>,
@@ -129,4 +131,22 @@ describe("RenderEdit — screen.description as form subtitle (fw#2723)", () => {
129
131
  expect(captured).toBeDefined();
130
132
  expect(captured && "subtitle" in captured).toBe(false);
131
133
  });
134
+
135
+ test("tabs mode suppresses a description whose key matches the subtitle probe", () => {
136
+ const screen: EntityEditScreenDefinition = {
137
+ id: "widget-edit",
138
+ type: "entityEdit",
139
+ entity: "widget",
140
+ description: "screen:widget-edit.subtitle",
141
+ layout: { mode: "tabs", sections: [{ title: "Basics", fields: ["name"] }] },
142
+ };
143
+
144
+ const { captured } = renderEditCapturingForm(
145
+ screen,
146
+ { "en-US": { "screen:widget-edit.subtitle": "Widget details." } },
147
+ true,
148
+ );
149
+
150
+ expect(captured && "subtitle" in captured).toBe(false);
151
+ });
132
152
  });
@@ -208,7 +208,7 @@ function EditSlotMount({
208
208
  wizardStep,
209
209
  }: {
210
210
  readonly slot: PlatformComponent;
211
- readonly slotName: "header" | "footer";
211
+ readonly slotName: "header" | "titleAction" | "footer";
212
212
  readonly screenId: string;
213
213
  readonly entityName: string;
214
214
  readonly entityId: string | null;
@@ -1088,6 +1088,18 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1088
1088
  ) : (
1089
1089
  headerRegion
1090
1090
  );
1091
+ const titleActionSlot = screen.slots?.titleAction;
1092
+ const titleActionMount =
1093
+ titleActionSlot !== undefined ? (
1094
+ <EditSlotMount
1095
+ slot={titleActionSlot}
1096
+ slotName="titleAction"
1097
+ screenId={screen.id}
1098
+ entityName={vm.entityName}
1099
+ entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
1100
+ values={snapshot.values}
1101
+ />
1102
+ ) : undefined;
1091
1103
  const footerSlot = screen.slots?.footer;
1092
1104
  // Mirrors every branch inside formActions below — without this guard
1093
1105
  // DefaultForm renders an empty footer strip (border + padding, no content)
@@ -1181,10 +1193,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1181
1193
  // screen.description is head-card copy, not tab content — in tabs mode
1182
1194
  // (hideSectionTitles) the head card already carries title/subtitle/status.
1183
1195
  const formSubtitle =
1184
- resolveScreenText("subtitle") ??
1185
- (hideSectionTitles !== true && screen.description !== undefined
1186
- ? translate(screen.description)
1187
- : undefined);
1196
+ hideSectionTitles !== true
1197
+ ? (resolveScreenText("subtitle") ??
1198
+ (screen.description !== undefined ? translate(screen.description) : undefined))
1199
+ : undefined;
1188
1200
 
1189
1201
  return (
1190
1202
  <ExtensionFormRegistryProvider value={extensionFormRegistry}>
@@ -1199,6 +1211,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1199
1211
  stickyActions={isWizard}
1200
1212
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
1201
1213
  {...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion })}
1214
+ {...(titleActionMount !== undefined && { titleAction: titleActionMount })}
1202
1215
  {...(fillHeight && { fillHeight })}
1203
1216
  {...(hideSectionTitles === true && { chromeless: true })}
1204
1217
  >
@@ -811,6 +811,10 @@ export type FormProps = {
811
811
  * instead of rendering as unpadded siblings before it. Native impls may
812
812
  * ignore this prop. */
813
813
  readonly headerRegion?: ReactNode;
814
+ /** Compact content on the right of the form title, same row — status
815
+ * chips or allowance badges that belong to the screen, not to a field.
816
+ * Native impls may ignore this prop. */
817
+ readonly titleAction?: ReactNode;
814
818
  /** Sizes the form to fill its container's height (instead of the page's
815
819
  * natural content height) so a single scrolling child — a lone
816
820
  * relatedList tab's table — can scroll internally instead of stretching