@cosmicdrift/kumiko-renderer 0.187.0 → 0.189.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.
@@ -15,11 +15,7 @@ import type {
15
15
  ScreenDefinition,
16
16
  ToolbarAction,
17
17
  } from "@cosmicdrift/kumiko-framework/ui-types";
18
- import {
19
- evalFieldCondition,
20
- isExtensionEditSection,
21
- normalizeEditField,
22
- } from "@cosmicdrift/kumiko-framework/ui-types";
18
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
23
19
  import type {
24
20
  Command,
25
21
  FormSnapshot,
@@ -43,6 +39,8 @@ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config
43
39
  import { useCustomScreenComponent } from "./custom-screens";
44
40
  import { useDashboardBody } from "./dashboard-body";
45
41
  import type { FeatureSchema } from "./feature-schema";
42
+ import { buildFormSchema } from "./form-schema";
43
+ import { layoutFieldNames } from "./layout-fields";
46
44
  import { useNav } from "./nav";
47
45
  import {
48
46
  synthesizeProjectionDetailEntity,
@@ -310,8 +308,15 @@ function useNavigateToCreateFor(
310
308
  // with a `default: true`/`default: 5` would show the form in a state
311
309
  // the entity didn't ask for — subtle and easy to miss until a user
312
310
  // submits and is surprised.
311
+ // `defaultCurrency` is only passed by entityEdit call sites — the money
312
+ // payload shape it enables (`{amount, currency}`) matches the entity's
313
+ // write schema (schema-builder.ts, kumiko-framework#1923). Callers outside
314
+ // that path (config-edit, action-form) synthesize their own entity and use
315
+ // money as a plain number against a different write contract, so they
316
+ // deliberately keep the old bare-`0` default by omitting the argument.
313
317
  export function buildInitialValues(
314
318
  fields: Readonly<Record<string, unknown>>,
319
+ defaultCurrency?: string,
315
320
  ): Readonly<Record<string, unknown>> {
316
321
  const out: Record<string, unknown> = {};
317
322
  for (const [name, def] of Object.entries(fields)) {
@@ -320,33 +325,23 @@ export function buildInitialValues(
320
325
  out[name] = shape.default;
321
326
  continue;
322
327
  }
328
+ if (shape.type === "money" && defaultCurrency !== undefined) {
329
+ out[name] = { amount: 0, currency: defaultCurrency };
330
+ continue;
331
+ }
323
332
  out[name] =
324
333
  shape.type === "boolean" ? false : shape.type === "number" || shape.type === "money" ? 0 : "";
325
334
  }
326
335
  return out;
327
336
  }
328
337
 
329
- // Field names actually rendered by the screen's layout — a search-param
330
- // merge must not set fields the form never shows the user (#1708:
331
- // unrendered fields get no client-side validation and no chance to
332
- // review/correct the injected value).
333
- function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
334
- const names = new Set<string>();
335
- for (const section of screen.layout.sections) {
336
- if (isExtensionEditSection(section)) continue;
337
- for (const spec of section.fields) {
338
- names.add(normalizeEditField(spec).field);
339
- }
340
- }
341
- return names;
342
- }
343
-
344
338
  export function mergeSearchParamsIntoInitial(
345
339
  fields: Readonly<Record<string, unknown>>,
346
340
  searchParams: Readonly<Record<string, string>>,
347
341
  renderableFields?: ReadonlySet<string>,
342
+ defaultCurrency?: string,
348
343
  ): Record<string, unknown> {
349
- const defaults = buildInitialValues(fields) as Record<string, unknown>;
344
+ const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
350
345
  const merged: Record<string, unknown> = { ...defaults };
351
346
  for (const [name, fieldDef] of Object.entries(fields)) {
352
347
  if (renderableFields !== undefined && !renderableFields.has(name)) continue;
@@ -354,9 +349,16 @@ export function mergeSearchParamsIntoInitial(
354
349
  if (shape.sensitive === true) continue;
355
350
  const raw = searchParams[name];
356
351
  if (raw === undefined) continue;
357
- if (shape.type === "number" || shape.type === "money") {
352
+ if (shape.type === "number") {
358
353
  const parsed = Number(raw);
359
354
  merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
355
+ } else if (shape.type === "money") {
356
+ const parsed = Number(raw);
357
+ merged[name] = Number.isNaN(parsed)
358
+ ? defaults[name]
359
+ : defaultCurrency !== undefined
360
+ ? { amount: parsed, currency: defaultCurrency }
361
+ : parsed;
360
362
  } else if (shape.type === "boolean") {
361
363
  merged[name] = raw === "true";
362
364
  } else {
@@ -404,19 +406,19 @@ function EntityEditScreen({
404
406
  />
405
407
  );
406
408
  }
407
- if (screen.allowCreate === false) {
408
- // Update-only Screen ohne entityId (Direkt-URL / verirrte Navigation):
409
- // ein Create-Form würde gegen den nicht registrierten
410
- // `<entity>:create`-Handler submitten — Fehler statt Falle.
409
+ if (screen.singleton === true) {
411
410
  return (
412
- <Banner padded variant="error" testId="kumiko-screen-create-disabled">
413
- Screen <Text variant="code">{screen.id}</Text> is update-only (allowCreate: false) — open it
414
- from a row action with an entity id.
415
- </Banner>
411
+ <EntityEditSingletonBody
412
+ schema={schema}
413
+ screen={screen}
414
+ entity={entity}
415
+ {...(translate !== undefined && { translate })}
416
+ {...(onCopyLink !== undefined && { onCopyLink })}
417
+ />
416
418
  );
417
419
  }
418
420
  return (
419
- <EntityEditCreateBody
421
+ <EntityEditCreateOrDisabled
420
422
  schema={schema}
421
423
  screen={screen}
422
424
  entity={entity}
@@ -443,16 +445,23 @@ function EntityEditCreateBody({
443
445
  entity.fields,
444
446
  nav.searchParams,
445
447
  layoutFieldNames(screen),
448
+ entity.defaultCurrency ?? "EUR",
446
449
  ) as FormValues,
447
- [entity.fields, nav.searchParams, screen],
450
+ [entity.fields, nav.searchParams, screen, entity.defaultCurrency],
448
451
  );
452
+ const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
449
453
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
450
454
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
451
455
  const handleSubmitted = useCallback(
452
456
  (result: SubmitResult<unknown>) => {
453
- if (result.isSuccess) navigateToList();
457
+ if (!result.isSuccess) return;
458
+ if (screen.redirect !== undefined) {
459
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
460
+ return;
461
+ }
462
+ navigateToList();
454
463
  },
455
- [navigateToList],
464
+ [nav, screen.redirect, navigateToList],
456
465
  );
457
466
  return (
458
467
  <RenderEdit
@@ -460,6 +469,7 @@ function EntityEditCreateBody({
460
469
  entity={entity}
461
470
  featureName={schema.featureName}
462
471
  initial={initial}
472
+ schema={formSchema}
463
473
  writeCommand={writeCommand}
464
474
  onSubmit={handleSubmitted}
465
475
  onCancel={navigateToList}
@@ -566,11 +576,15 @@ function EntityEditUpdateForm({
566
576
  const recordVersion = (record as { version?: number }).version ?? 1;
567
577
  const initial = useMemo(() => {
568
578
  const out: Record<string, unknown> = {};
579
+ const defaultCurrency = entity.defaultCurrency ?? "EUR";
569
580
  for (const name of Object.keys(entity.fields)) {
570
- out[name] = record[name] ?? buildInitialValues({ [name]: entity.fields[name] })[name];
581
+ out[name] =
582
+ record[name] ?? buildInitialValues({ [name]: entity.fields[name] }, defaultCurrency)[name];
571
583
  }
572
584
  return out as FormValues;
573
- }, [entity.fields, record]);
585
+ }, [entity.fields, entity.defaultCurrency, record]);
586
+
587
+ const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
574
588
 
575
589
  // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
576
590
  // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
@@ -593,13 +607,19 @@ function EntityEditUpdateForm({
593
607
  [entityId, recordVersion],
594
608
  );
595
609
 
610
+ const nav = useNav();
596
611
  const dispatcher = useDispatcher();
597
612
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
598
613
  const handleSubmitted = useCallback(
599
614
  (result: SubmitResult<unknown>) => {
600
- if (result.isSuccess) navigateToList();
615
+ if (!result.isSuccess) return;
616
+ if (screen.redirect !== undefined) {
617
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
618
+ return;
619
+ }
620
+ navigateToList();
601
621
  },
602
- [navigateToList],
622
+ [nav, screen.redirect, navigateToList],
603
623
  );
604
624
  const handleDelete = useCallback(async () => {
605
625
  const res = await dispatcher.write(deleteCommand, { id: entityId });
@@ -619,6 +639,7 @@ function EntityEditUpdateForm({
619
639
  // customFields-Bestand an die extension-section, damit sie beim Edit
620
640
  // die gespeicherten Werte zeigt (nicht write-only).
621
641
  extensionInitialValues={extensionInitialValues}
642
+ schema={formSchema}
622
643
  writeCommand={writeCommand}
623
644
  payloadMode="changes"
624
645
  buildPayload={buildPayload}
@@ -636,6 +657,100 @@ function EntityEditUpdateForm({
636
657
  );
637
658
  }
638
659
 
660
+ // Singleton entities (`singleton: true`, exactly one record per tenant):
661
+ // EntityEditScreen reaches here only without an entityId. Resolve the
662
+ // existing record via list(limit:1) before deciding create vs update —
663
+ // otherwise every visit without an id would create another record.
664
+ function EntityEditSingletonBody({
665
+ schema,
666
+ screen,
667
+ entity,
668
+ translate,
669
+ onCopyLink,
670
+ }: {
671
+ readonly schema: FeatureSchema;
672
+ readonly screen: EntityEditScreenDefinition;
673
+ readonly entity: EntityDefinition;
674
+ readonly translate?: Translate;
675
+ readonly onCopyLink?: () => Promise<void> | void;
676
+ }): ReactNode {
677
+ const { Banner } = usePrimitives();
678
+ const t = useTranslation();
679
+ const effectiveTranslate = translate ?? t;
680
+ const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
681
+ const listQuery = useQuery<PagedRows>(listQn, { limit: 1 });
682
+
683
+ if (listQuery.loading && listQuery.data === null) {
684
+ return (
685
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
686
+ Loading…
687
+ </Banner>
688
+ );
689
+ }
690
+ if (listQuery.error) {
691
+ return (
692
+ <Banner padded variant="error" testId="kumiko-screen-error">
693
+ {dispatcherErrorText(listQuery.error, effectiveTranslate)}
694
+ </Banner>
695
+ );
696
+ }
697
+ const existingId = listQuery.data?.rows[0]?.["id"] as string | undefined;
698
+ if (existingId !== undefined) {
699
+ return (
700
+ <EntityEditUpdateBody
701
+ schema={schema}
702
+ screen={screen}
703
+ entity={entity}
704
+ entityId={existingId}
705
+ {...(translate !== undefined && { translate })}
706
+ {...(onCopyLink !== undefined && { onCopyLink })}
707
+ />
708
+ );
709
+ }
710
+ return (
711
+ <EntityEditCreateOrDisabled
712
+ schema={schema}
713
+ screen={screen}
714
+ entity={entity}
715
+ {...(translate !== undefined && { translate })}
716
+ />
717
+ );
718
+ }
719
+
720
+ // Shared no-entityId tail for both the plain create path and the
721
+ // singleton path's empty-table fallback: `allowCreate: false` blocks
722
+ // both the same way, since a create submit there would hit an
723
+ // unregistered `<entity>:create` handler.
724
+ function EntityEditCreateOrDisabled({
725
+ schema,
726
+ screen,
727
+ entity,
728
+ translate,
729
+ }: {
730
+ readonly schema: FeatureSchema;
731
+ readonly screen: EntityEditScreenDefinition;
732
+ readonly entity: EntityDefinition;
733
+ readonly translate?: Translate;
734
+ }): ReactNode {
735
+ const { Banner, Text } = usePrimitives();
736
+ if (screen.allowCreate === false) {
737
+ return (
738
+ <Banner padded variant="error" testId="kumiko-screen-create-disabled">
739
+ Screen <Text variant="code">{screen.id}</Text> is update-only (allowCreate: false) — open it
740
+ from a row action with an entity id.
741
+ </Banner>
742
+ );
743
+ }
744
+ return (
745
+ <EntityEditCreateBody
746
+ schema={schema}
747
+ screen={screen}
748
+ entity={entity}
749
+ {...(translate !== undefined && { translate })}
750
+ />
751
+ );
752
+ }
753
+
639
754
  // ---- entity-list ----
640
755
 
641
756
  function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
@@ -1456,7 +1571,7 @@ function ActionFormBody({
1456
1571
  // Author entscheidet bewusst ob "stay on form" (default) oder
1457
1572
  // "back to list" (typisch bei Create-style Aktionen).
1458
1573
  if (result.isSuccess && screen.redirect !== undefined) {
1459
- nav.navigate({ screenId: screen.redirect });
1574
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
1460
1575
  }
1461
1576
  },
1462
1577
  [nav, screen.redirect],
@@ -1468,7 +1583,7 @@ function ActionFormBody({
1468
1583
  const handleCancel = useMemo<(() => void) | undefined>(() => {
1469
1584
  const target = screen.cancelTarget ?? screen.redirect;
1470
1585
  if (target === undefined || target === false) return undefined;
1471
- return () => nav.navigate({ screenId: target });
1586
+ return () => nav.navigate({ screenId: lastSegment(target) });
1472
1587
  }, [nav, screen.redirect, screen.cancelTarget]);
1473
1588
  return (
1474
1589
  <RenderEdit
@@ -1513,6 +1628,19 @@ type ConfigValueResponse = Readonly<
1513
1628
  Record<string, { value: string | number | boolean | undefined; scope: string; source: string }>
1514
1629
  >;
1515
1630
 
1631
+ // A money-typed config-edit field renders through RenderField's entityEdit
1632
+ // `{amount, currency}` payload shape (render-field.tsx, #1923), but
1633
+ // `ConfigKeyType` (write-helpers.ts validateType) only ever knows
1634
+ // number/boolean/text/select — a config value is always a bare scalar.
1635
+ // Unwrap back to the amount before it hits config:write:set.
1636
+ function unwrapMoneyValue(value: unknown): unknown {
1637
+ if (typeof value === "object" && value !== null && "amount" in value) {
1638
+ const amount = (value as { amount?: unknown }).amount;
1639
+ if (typeof amount === "number") return amount;
1640
+ }
1641
+ return value;
1642
+ }
1643
+
1516
1644
  function ConfigEditBody({
1517
1645
  schema,
1518
1646
  screen,
@@ -1604,9 +1732,14 @@ function ConfigEditBody({
1604
1732
  for (const [shortName, value] of Object.entries(snapshot.changes)) {
1605
1733
  const qualified = screen.configKeys[shortName];
1606
1734
  if (qualified === undefined) continue;
1735
+ const ftype = (screen.fields[shortName] as { type?: string } | undefined)?.type;
1607
1736
  commands.push({
1608
1737
  type: "config:write:set",
1609
- payload: { key: qualified, value, scope: screen.scope },
1738
+ payload: {
1739
+ key: qualified,
1740
+ value: ftype === "money" ? unwrapMoneyValue(value) : value,
1741
+ scope: screen.scope,
1742
+ },
1610
1743
  });
1611
1744
  }
1612
1745
  if (commands.length === 0) {
@@ -1623,7 +1756,14 @@ function ConfigEditBody({
1623
1756
  await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
1624
1757
  return { validationBlocked: false, isSuccess: true, data: undefined };
1625
1758
  },
1626
- [dispatcher, screen.configKeys, screen.scope, valuesQuery.refetch, cascadeQuery.refetch],
1759
+ [
1760
+ dispatcher,
1761
+ screen.configKeys,
1762
+ screen.fields,
1763
+ screen.scope,
1764
+ valuesQuery.refetch,
1765
+ cascadeQuery.refetch,
1766
+ ],
1627
1767
  );
1628
1768
 
1629
1769
  // Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
@@ -0,0 +1,27 @@
1
+ import type {
2
+ EditFieldSpec,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
+
7
+ // Normalized field specs actually rendered by the screen's layout, extension
8
+ // sections skipped. Both this and `layoutFieldNames` key off "rendered by
9
+ // the layout" for the same reason: a field the user never sees gets no
10
+ // chance to review/correct a value nor to fix a presence error
11
+ // (search-param merge, #1708; presence schema in form-schema.ts).
12
+ export function layoutEditFields(
13
+ screen: EntityEditScreenDefinition,
14
+ ): readonly Exclude<EditFieldSpec, string>[] {
15
+ const specs: Exclude<EditFieldSpec, string>[] = [];
16
+ for (const section of screen.layout.sections) {
17
+ if (isExtensionEditSection(section)) continue;
18
+ for (const spec of section.fields) {
19
+ specs.push(normalizeEditField(spec));
20
+ }
21
+ }
22
+ return specs;
23
+ }
24
+
25
+ export function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
26
+ return new Set(layoutEditFields(screen).map((spec) => spec.field));
27
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  SubmitResult,
7
7
  } from "@cosmicdrift/kumiko-headless";
8
8
  import {
9
+ filterEditSections,
9
10
  hasEditableSection,
10
11
  resolveExtensionEntityId,
11
12
  shouldNotifyCaller,
@@ -177,3 +178,50 @@ describe("hasEditableSection", () => {
177
178
  expect(hasEditableSection([section])).toBe(false);
178
179
  });
179
180
  });
181
+
182
+ const namedField = (name: string): EditFieldViewModel => ({
183
+ field: name,
184
+ label: name,
185
+ type: "text",
186
+ value: "",
187
+ visible: true,
188
+ readOnly: false,
189
+ required: false,
190
+ });
191
+ const namedFieldsSection = (...names: string[]): EditSectionViewModel => ({
192
+ kind: "fields",
193
+ columns: 1,
194
+ visible: true,
195
+ fields: names.map(namedField),
196
+ });
197
+
198
+ describe("filterEditSections", () => {
199
+ test("fieldsFilter undefined → returns the same array reference (unchanged behavior)", () => {
200
+ const sections = [namedFieldsSection("a", "b")];
201
+ expect(filterEditSections(sections, undefined)).toBe(sections);
202
+ });
203
+
204
+ test("mixed section → only the filtered-in fields remain in section.fields", () => {
205
+ const sections = [namedFieldsSection("a", "b", "c")];
206
+ const result = filterEditSections(sections, ["a", "c"]);
207
+ expect(result).toHaveLength(1);
208
+ expect(result[0]?.kind).toBe("fields");
209
+ expect(result[0]?.kind === "fields" ? result[0].fields.map((f) => f.field) : []).toEqual([
210
+ "a",
211
+ "c",
212
+ ]);
213
+ });
214
+
215
+ test("section whose filtered field list becomes empty is dropped entirely, not rendered empty", () => {
216
+ const sections = [namedFieldsSection("a", "b"), namedFieldsSection("c")];
217
+ const result = filterEditSections(sections, ["c"]);
218
+ expect(result).toHaveLength(1);
219
+ expect(result[0]?.kind === "fields" ? result[0].fields.map((f) => f.field) : []).toEqual(["c"]);
220
+ });
221
+
222
+ test("extension section always survives the filter regardless of its content", () => {
223
+ const sections = [extensionSection, namedFieldsSection("a")];
224
+ const result = filterEditSections(sections, ["zzz"]);
225
+ expect(result).toEqual([extensionSection]);
226
+ });
227
+ });
@@ -0,0 +1,189 @@
1
+ // kumiko-framework#1923: money didn't round-trip on the auto-wired
2
+ // entityEdit path — create sent a bare number against the server's
3
+ // `{amount, currency}` schema, update rendered the rehydrated read value as
4
+ // NaN, and a naive fix would have been 100x off (minor vs. major units).
5
+ //
6
+ // This test walks the whole chain with real functions, no mocks:
7
+ // computeEditViewModel (headless) → RenderField → simulated widget
8
+ // onChange → buildInsertSchema/buildUpdateSchema (framework/engine). The
9
+ // widget's own minor-units math (MoneyInput) is pinned separately in
10
+ // packages/renderer-web/src/primitives/__tests__/money-input.test.tsx —
11
+ // this test starts one step in, at the value RenderField hands to/from the
12
+ // widget contract (`number | ""` minor units).
13
+
14
+ import { describe, expect, test } from "bun:test";
15
+ import { rehydrateMoney } from "@cosmicdrift/kumiko-framework/db";
16
+ import { buildInsertSchema, buildUpdateSchema } from "@cosmicdrift/kumiko-framework/engine";
17
+ import type {
18
+ EntityDefinition,
19
+ EntityEditScreenDefinition,
20
+ } from "@cosmicdrift/kumiko-framework/ui-types";
21
+ import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
22
+ import { render } from "@testing-library/react";
23
+ import type { ComponentType, ReactNode } from "react";
24
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
25
+ import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
26
+ import { RenderField } from "../render-field";
27
+
28
+ let captured: InputProps | undefined;
29
+ const captureInput: ComponentType<InputProps> = (props) => {
30
+ captured = props;
31
+ return null;
32
+ };
33
+ const noop = (): ReactNode => null;
34
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
35
+
36
+ const testPrimitives: CorePrimitives = {
37
+ Button: noop,
38
+ Banner: noop,
39
+ Field: passChildren,
40
+ Input: captureInput,
41
+ DataTable: noop,
42
+ Form: noop,
43
+ Section: noop,
44
+ Card: noop,
45
+ Grid: noop,
46
+ GridCell: noop,
47
+ Text: noop,
48
+ Heading: noop,
49
+ Dialog: noop,
50
+ Modal: noop,
51
+ Lightbox: noop,
52
+ ConfigSourceBadge: noop,
53
+ ConfigCascadeView: noop,
54
+ Link: noop,
55
+ };
56
+
57
+ function buildEntity(defaultCurrency: string): EntityDefinition {
58
+ return {
59
+ fields: {
60
+ price: { type: "money", required: true },
61
+ },
62
+ defaultCurrency,
63
+ } as EntityDefinition;
64
+ }
65
+
66
+ function buildScreen(): EntityEditScreenDefinition {
67
+ return {
68
+ id: "product-edit",
69
+ type: "entityEdit",
70
+ entity: "product",
71
+ layout: { sections: [{ columns: 1, fields: ["price"] }] },
72
+ } as EntityEditScreenDefinition;
73
+ }
74
+
75
+ function priceField(entity: EntityDefinition, values: Record<string, unknown>): EditFieldViewModel {
76
+ const vm = computeEditViewModel({
77
+ screen: buildScreen(),
78
+ entity,
79
+ values,
80
+ translate: (key) => key,
81
+ featureName: "shop",
82
+ });
83
+ const section = vm.sections[0];
84
+ if (section === undefined || section.kind !== "fields") {
85
+ throw new Error("expected a fields section");
86
+ }
87
+ const field = section.fields[0];
88
+ if (field === undefined) throw new Error("expected a price field");
89
+ return field;
90
+ }
91
+
92
+ function renderMoneyField(
93
+ entity: EntityDefinition,
94
+ values: Record<string, unknown>,
95
+ onChange: (v: unknown) => void,
96
+ ): InputProps {
97
+ captured = undefined;
98
+ render(
99
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
100
+ <PrimitivesProvider value={testPrimitives}>
101
+ <RenderField field={priceField(entity, values)} onChange={onChange} />
102
+ </PrimitivesProvider>
103
+ </LocaleProvider>,
104
+ );
105
+ if (captured === undefined) throw new Error("money field did not render an Input");
106
+ return captured;
107
+ }
108
+
109
+ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
110
+ test("create: untouched required field, buildInitialValues default validates against buildInsertSchema", () => {
111
+ const entity = buildEntity("USD");
112
+ // Same default shape kumiko-screen.tsx's buildInitialValues produces
113
+ // for a money field once a defaultCurrency is threaded through.
114
+ const initialPrice = { amount: 0, currency: "USD" };
115
+ const result = buildInsertSchema(entity).safeParse({ price: initialPrice });
116
+ expect(result.success).toBe(true);
117
+ });
118
+
119
+ test("create: user-entered amount becomes a payload that validates against buildInsertSchema", () => {
120
+ const entity = buildEntity("USD");
121
+ let payload: unknown;
122
+ const field = renderMoneyField(entity, {}, (v) => {
123
+ payload = v;
124
+ });
125
+ expect(field.kind).toBe("money");
126
+ if (field.kind !== "money") return;
127
+ expect(field.value).toBe(""); // untouched — empty widget state, not NaN/0
128
+ field.onChange(1299); // widget emits minor units: 12.99 USD
129
+ expect(payload).toEqual({ amount: 12.99, currency: "USD" });
130
+ const result = buildInsertSchema(entity).safeParse({ price: payload });
131
+ expect(result.success).toBe(true);
132
+ });
133
+
134
+ test("update: server-rehydrated value renders as a real number, not NaN (regression)", () => {
135
+ const entity = buildEntity("USD");
136
+ const row = { price: 1299, priceCurrency: "USD" };
137
+ const record = rehydrateMoney(row, entity);
138
+ const field = renderMoneyField(entity, record, () => {});
139
+ expect(field.kind).toBe("money");
140
+ if (field.kind !== "money") return;
141
+ expect(field.value).toBe(1299);
142
+ expect(Number.isNaN(field.value)).toBe(false);
143
+ });
144
+
145
+ test("update: edited amount becomes a payload that validates against buildUpdateSchema", () => {
146
+ const entity = buildEntity("USD");
147
+ const row = { price: 1299, priceCurrency: "USD" };
148
+ const record = rehydrateMoney(row, entity);
149
+ let payload: unknown;
150
+ const field = renderMoneyField(entity, record, (v) => {
151
+ payload = v;
152
+ });
153
+ if (field.kind !== "money") throw new Error("expected money kind");
154
+ field.onChange(1500); // user edits to 15.00
155
+ expect(payload).toEqual({ amount: 15, currency: "USD" });
156
+ const result = buildUpdateSchema(entity).safeParse({ price: payload });
157
+ expect(result.success).toBe(true);
158
+ });
159
+
160
+ test("JPY (zero-decimal currency): amountMinor is not read, so the widget value is not 100x off", () => {
161
+ const entity = buildEntity("JPY");
162
+ // DB stores minor units at the framework's flat MINOR_UNIT_SCALE=100,
163
+ // so ¥500 is row-stored as 50000 — rehydrateMoney's amountMinor mirrors
164
+ // that scale, which disagrees with JPY's real 0 decimal places. Reading
165
+ // amountMinor here would render 50000 instead of 500.
166
+ const row = { price: 50000, priceCurrency: "JPY" };
167
+ const record = rehydrateMoney(row, entity);
168
+ let payload: unknown;
169
+ const field = renderMoneyField(entity, record, (v) => {
170
+ payload = v;
171
+ });
172
+ if (field.kind !== "money") throw new Error("expected money kind");
173
+ expect(field.value).toBe(500);
174
+ field.onChange(500); // user submits the same amount unchanged
175
+ expect(payload).toEqual({ amount: 500, currency: "JPY" });
176
+ const result = buildInsertSchema(entity).safeParse({ price: payload });
177
+ expect(result.success).toBe(true);
178
+ expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
179
+ });
180
+
181
+ test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
182
+ const entity = buildEntity("USD");
183
+ const result = buildUpdateSchema(entity).safeParse({
184
+ price: { amount: 12.99, currency: "USD", amountMinor: 1299 },
185
+ });
186
+ expect(result.success).toBe(true);
187
+ expect(result.data?.["price"]).toEqual({ amount: 12.99, currency: "USD" });
188
+ });
189
+ });