@cosmicdrift/kumiko-renderer 0.221.0 → 0.222.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.221.0",
3
+ "version": "0.222.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.221.0",
19
- "@cosmicdrift/kumiko-headless": "0.221.0",
18
+ "@cosmicdrift/kumiko-framework": "0.222.0",
19
+ "@cosmicdrift/kumiko-headless": "0.222.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -25,7 +25,7 @@
25
25
  "@testing-library/react": "^16.3.2",
26
26
  "@types/react": "^19.2.14",
27
27
  "jsdom": "^29.1.1",
28
- "@cosmicdrift/kumiko-locale-de": "0.221.0"
28
+ "@cosmicdrift/kumiko-locale-de": "0.222.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -42,13 +42,18 @@ export const ExtensionFormRegistryProvider = ExtensionFormRegistryContext.Provid
42
42
  // Host-Seite (render-edit): hält die Registrierungen in einem ref, meldet den
43
43
  // aggregierten dirty-State via onDirtyChange hoch (damit der Save-Button
44
44
  // re-rendert) und liefert runAll() zum Ausführen aller Handler beim Submit.
45
- export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void): {
45
+ export type ExtensionFormHostStatus = {
46
+ readonly anyDirty: boolean;
47
+ readonly hasRegistrations: boolean;
48
+ };
49
+
50
+ export function useExtensionFormHost(onStatusChange: (status: ExtensionFormHostStatus) => void): {
46
51
  readonly registry: ExtensionFormRegistry;
47
52
  readonly runAll: (ctx: ExtensionSubmitContext) => Promise<readonly ExtensionSubmitResult[]>;
48
53
  } {
49
54
  const regsRef = useRef<Map<string, Registration>>(new Map());
50
- const onDirtyRef = useRef(onDirtyChange);
51
- onDirtyRef.current = onDirtyChange;
55
+ const onStatusRef = useRef(onStatusChange);
56
+ onStatusRef.current = onStatusChange;
52
57
 
53
58
  const registry = useMemo<ExtensionFormRegistry>(() => {
54
59
  const emitDirty = (): void => {
@@ -59,7 +64,7 @@ export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void)
59
64
  break;
60
65
  }
61
66
  }
62
- onDirtyRef.current(any);
67
+ onStatusRef.current({ anyDirty: any, hasRegistrations: regsRef.current.size > 0 });
63
68
  };
64
69
  return {
65
70
  upsert: (reg) => {
@@ -34,7 +34,16 @@ const TestDialog: ComponentType<DialogProps> = ({
34
34
  {description !== undefined && (
35
35
  <span data-testid={`${testId}-description`}>{description}</span>
36
36
  )}
37
- <button type="button" data-testid={`${testId}-confirm`} onClick={() => void onConfirm()}>
37
+ <button
38
+ type="button"
39
+ data-testid={`${testId}-confirm`}
40
+ onClick={() => {
41
+ void (async () => {
42
+ await onConfirm();
43
+ onOpenChange(false);
44
+ })();
45
+ }}
46
+ >
38
47
  {confirmLabel ?? "Confirm"}
39
48
  </button>
40
49
  <button type="button" data-testid={`${testId}-cancel`} onClick={() => onOpenChange(false)}>
@@ -98,6 +107,9 @@ describe("RenderEditActionButton", () => {
98
107
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive"));
99
108
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive-dialog-confirm"));
100
109
  await waitFor(() => expect(pressed).toBe(1));
110
+ await waitFor(() =>
111
+ expect(rtlScreen.queryByTestId("render-edit-action-archive-dialog")).toBeNull(),
112
+ );
101
113
  });
102
114
 
103
115
  test("danger style forces confirm dialog even without confirm text", async () => {
@@ -192,6 +192,20 @@ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
192
192
  expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
193
193
  });
194
194
 
195
+ test("record currency differs from entity.defaultCurrency (#1930)", () => {
196
+ const entity = buildEntity("EUR");
197
+ let payload: unknown;
198
+ const field = renderMoneyField(entity, { price: { amount: 500, currency: "JPY" } }, (v) => {
199
+ payload = v;
200
+ });
201
+ if (field.kind !== "money") throw new Error("expected money kind");
202
+ // Prefer the value's own currency over entity.defaultCurrency (EUR=2dp).
203
+ expect(field.currency).toBe("JPY");
204
+ expect(field.value).toBe(500);
205
+ field.onChange(500);
206
+ expect(payload).toEqual({ amount: 500, currency: "JPY" });
207
+ });
208
+
195
209
  test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
196
210
  const entity = buildEntity("USD");
197
211
  const result = buildUpdateSchema(entity).safeParse({
@@ -99,4 +99,9 @@ describe("RenderField — App-Locale an FieldRendererOutput durchreichen (fw#218
99
99
  renderUnderLocale("de-DE", livingSpaceField({ format: "number" }, 1234.5));
100
100
  expect(captured?.children).toBe("1.234,5");
101
101
  });
102
+
103
+ test("explicit locale: undefined still falls back to App-Locale (#2332)", () => {
104
+ renderUnderLocale("de-DE", livingSpaceField({ format: "number", locale: undefined }, 1234.5));
105
+ expect(captured?.children).toBe("1.234,5");
106
+ });
102
107
  });
@@ -277,9 +277,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
277
277
  // never enters `fields`/`controller.getSnapshot().values` — there is no
278
278
  // draft-blob-covered state left for persistExtensions() to compete over.
279
279
  const [extensionDirty, setExtensionDirty] = useState(false);
280
+ const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
280
281
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
281
- const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
282
- useExtensionFormHost(setExtensionDirty);
282
+ const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(
283
+ ({ anyDirty, hasRegistrations }) => {
284
+ setExtensionDirty(anyDirty);
285
+ setHasExtensionRegistrations(hasRegistrations);
286
+ },
287
+ );
283
288
  const {
284
289
  Button,
285
290
  Banner,
@@ -971,7 +976,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
971
976
  {translate("kumiko.actions.next")}
972
977
  </Button>
973
978
  )}
974
- {isFormEditable && (!isWizard || isLastWizardStep) && (
979
+ {(isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep) && (
975
980
  <Button
976
981
  type="submit"
977
982
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
@@ -1103,6 +1108,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1103
1108
  // navigating past its step — otherwise Finish only ran the last
1104
1109
  // mounted step's handler and silently dropped earlier steps' writes.
1105
1110
  const stepHidden = isWizard && sectionIndex !== currentStep;
1111
+ const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
1112
+ if (!isWizard) return el;
1113
+ if (WizardStepGroup === undefined) {
1114
+ throw new Error(
1115
+ "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1116
+ );
1117
+ }
1118
+ return (
1119
+ <WizardStepGroup key={key} hidden={stepHidden}>
1120
+ {el}
1121
+ </WizardStepGroup>
1122
+ );
1123
+ };
1106
1124
  // Off-screen wizard steps stay mounted (see comment above) but must
1107
1125
  // not participate in native constraint validation, or the Next
1108
1126
  // button's `type="submit"` triggers the browser's full-form check
@@ -1129,18 +1147,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1129
1147
  validate={scopedValidate}
1130
1148
  />
1131
1149
  );
1132
- if (!isWizard) return mount;
1133
- if (WizardStepGroup === undefined) {
1134
- // Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
1135
- throw new Error(
1136
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1137
- );
1138
- }
1139
- return (
1140
- <WizardStepGroup key={section.title} hidden={stepHidden}>
1141
- {mount}
1142
- </WizardStepGroup>
1143
- );
1150
+ return wrapWizardStep(section.title, mount);
1144
1151
  }
1145
1152
  if (section.kind === "relatedList") {
1146
1153
  // parentId is the displayed record's id — without it there's no
@@ -1198,17 +1205,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1198
1205
  </Grid>
1199
1206
  </Section>
1200
1207
  );
1201
- if (!isWizard) return sectionEl;
1202
- if (WizardStepGroup === undefined) {
1203
- throw new Error(
1204
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1205
- );
1206
- }
1207
- return (
1208
- <WizardStepGroup key={sectionKey} hidden={stepHidden}>
1209
- {sectionEl}
1210
- </WizardStepGroup>
1211
- );
1208
+ return wrapWizardStep(sectionKey, sectionEl);
1212
1209
  })}
1213
1210
  {formError !== null && (
1214
1211
  <Banner
@@ -359,12 +359,18 @@ function FieldRendererOutput({
359
359
  // App locale as default when the FormatSpec declares none of its own —
360
360
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
361
361
  // bigInt/unit) fell back to Intl's runtime default instead of the app
362
- // language chosen via LocaleProvider (fw#2187). An explicit
363
- // `renderer.locale` still wins, same pattern as dateLocale vs. appLocale
364
- // further below in readOnlyDisplayText.
362
+ // language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
363
+ // when set; coalesce undefined (spread override) back to appLocale (#2332).
365
364
  return (
366
365
  <Text testId={`field-value-${field.field}`}>
367
- {applyFormatSpec({ locale: appLocale, ...renderer }, field.value, t)}
366
+ {applyFormatSpec(
367
+ {
368
+ ...renderer,
369
+ locale: (renderer as { locale?: string }).locale ?? appLocale,
370
+ },
371
+ field.value,
372
+ t,
373
+ )}
368
374
  </Text>
369
375
  );
370
376
  }
@@ -520,7 +526,7 @@ function renderInput({
520
526
  );
521
527
  }
522
528
  case "money": {
523
- const currency = field.currency ?? "EUR";
529
+ const currency = resolveMoneyCurrency(field.value, field.currency);
524
530
  return (
525
531
  <Input
526
532
  kind="money"
@@ -692,6 +698,17 @@ function numberValue(v: unknown): number | "" {
692
698
  // stored-config coercion) is MAJOR units too — every producer in this repo
693
699
  // hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
694
700
  // pre-scaled minor units.
701
+ function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
702
+ if (
703
+ typeof value === "object" &&
704
+ value !== null &&
705
+ typeof (value as { currency?: unknown }).currency === "string"
706
+ ) {
707
+ return (value as { currency: string }).currency;
708
+ }
709
+ return fieldCurrency ?? "EUR";
710
+ }
711
+
695
712
  function moneyMinorValue(v: unknown, currency: string): number | "" {
696
713
  if (v === undefined || v === null || v === "") return "";
697
714
  if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
@@ -756,7 +773,7 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
756
773
  return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
757
774
  }
758
775
  case "money": {
759
- const currency = field.currency ?? "EUR";
776
+ const currency = resolveMoneyCurrency(value, field.currency);
760
777
  const minor = moneyMinorValue(value, currency);
761
778
  if (minor === "") return "—";
762
779
  const major = minor / 10 ** currencyDecimals(currency);