@cosmicdrift/kumiko-renderer 0.193.1 → 0.195.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.193.1",
3
+ "version": "0.195.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.193.1",
19
- "@cosmicdrift/kumiko-headless": "0.193.1",
18
+ "@cosmicdrift/kumiko-framework": "0.195.0",
19
+ "@cosmicdrift/kumiko-headless": "0.195.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -12,6 +12,7 @@ import {
12
12
  ValidationError,
13
13
  VersionConflictError,
14
14
  } from "@cosmicdrift/kumiko-framework/errors";
15
+ import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema";
15
16
  import { kumikoDefaultTranslations } from "../i18n-defaults";
16
17
 
17
18
  // Keys are read off LIVE error instances, not a hardcoded list — a copied list
@@ -69,4 +70,12 @@ describe("kumikoDefaultTranslations covers every error i18nKey", () => {
69
70
  expect(de?.["dispatcher.errors.aborted"]).toBeTruthy();
70
71
  expect(en?.["dispatcher.errors.aborted"]).toBeTruthy();
71
72
  });
73
+
74
+ // Client-emitted (form-schema.ts required-field presence check) — not
75
+ // thrown by an error class, applied as a params.i18nKey override on a zod
76
+ // issue instead; still rendered through this last-resort bundle.
77
+ test("form-schema's required-field i18nKey has de+en default", () => {
78
+ expect(de?.[REQUIRED_FIELD_I18N_KEY]).toBeTruthy();
79
+ expect(en?.[REQUIRED_FIELD_I18N_KEY]).toBeTruthy();
80
+ });
72
81
  });
@@ -18,7 +18,8 @@ const captureInput: ComponentType<InputProps> = (props) => {
18
18
  id={props.id}
19
19
  value={props.value}
20
20
  disabled={props.disabled}
21
- readOnly
21
+ readOnly={props.readOnly}
22
+ onChange={() => {}}
22
23
  />
23
24
  );
24
25
  };
@@ -57,7 +58,15 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
57
58
 
58
59
  function Probe({ contentFormat }: { readonly contentFormat?: string }): ReactNode {
59
60
  const Editor = useContentEditor(contentFormat);
60
- return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
61
+ return (
62
+ <Editor
63
+ id={CONTENT_EDITOR_ELEMENT_ID}
64
+ value="hello"
65
+ onChange={() => {}}
66
+ variables={[]}
67
+ readOnly={false}
68
+ />
69
+ );
61
70
  }
62
71
 
63
72
  describe("useContentEditor", () => {
@@ -96,13 +105,23 @@ describe("useContentEditor", () => {
96
105
  });
97
106
 
98
107
  describe("TextareaContentEditor", () => {
99
- test("passes value + disabled=readOnly through to the primitives Input", () => {
108
+ test("passes value + readOnly through to the primitives Input as real readOnly, not disabled", () => {
109
+ // A disabled textarea isn't focusable/selectable in most browsers — a
110
+ // non-admin viewing read-only content couldn't select/copy it. readOnly
111
+ // keeps the field focusable and selectable while blocking edits.
100
112
  render(
101
- <TextareaContentEditor value="draft" onChange={() => {}} variables={[]} readOnly={true} />,
113
+ <TextareaContentEditor
114
+ id={CONTENT_EDITOR_ELEMENT_ID}
115
+ value="draft"
116
+ onChange={() => {}}
117
+ variables={[]}
118
+ readOnly={true}
119
+ />,
102
120
  { wrapper: Wrapper },
103
121
  );
104
122
  const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
105
123
  expect(el.value).toBe("draft");
106
- expect(el.disabled).toBe(true);
124
+ expect(el.readOnly).toBe(true);
125
+ expect(el.disabled).toBe(false);
107
126
  });
108
127
  });
@@ -99,7 +99,8 @@ describe("ContentPreview", () => {
99
99
 
100
100
  const el = screen.getByTestId("cp-textarea") as HTMLTextAreaElement;
101
101
  expect(el.value).toBe("Hi A-1042");
102
- expect(el.disabled).toBe(true);
102
+ expect(el.readOnly).toBe(true);
103
+ expect(el.disabled).toBe(false);
103
104
  });
104
105
 
105
106
  test("rich format: an example value with markup characters is escaped, not injected as HTML", () => {
@@ -124,6 +125,27 @@ describe("ContentPreview", () => {
124
125
  expect(el.textContent).toBe("Preis: <b>0</b> & up");
125
126
  });
126
127
 
128
+ // Known limitation: escaping is keyed off the *declared* format, not the
129
+ // actually-resolved editor. If "rich" is declared but no rich editor is
130
+ // registered, useContentEditor falls back to the plain textarea and the
131
+ // markup-safe escaping (only applied for contentFormat === "rich") never
132
+ // ran for the plain path — entities show up literal instead of rendered.
133
+ // Pinned here rather than fixed: fixing it needs a per-editor
134
+ // `rendersHtml` flag threaded through content-editors.tsx.
135
+ test("rich format with no registered editor → falls back to the textarea, entities shown literally", () => {
136
+ render(
137
+ <ContentPreview
138
+ content="Preis: {{price}}"
139
+ variables={{ price: "<b>0</b> & up" }}
140
+ contentFormat="rich"
141
+ />,
142
+ { wrapper: Wrapper },
143
+ );
144
+
145
+ const el = screen.getByTestId("cp-textarea") as HTMLTextAreaElement;
146
+ expect(el.value).toBe("Preis: &lt;b&gt;0&lt;/b&gt; &amp; up");
147
+ });
148
+
127
149
  test("plain format: an example value with markup characters passes through unescaped", () => {
128
150
  render(
129
151
  <ContentPreview
@@ -5,7 +5,7 @@ import type {
5
5
  EntityEditScreenDefinition,
6
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
7
7
  import { createFormController } from "@cosmicdrift/kumiko-headless";
8
- import { buildFormSchema } from "../form-schema";
8
+ import { buildFormSchema, REQUIRED_FIELD_I18N_KEY } from "../form-schema";
9
9
 
10
10
  function screenWith(fields: readonly EditFieldSpec[]): EntityEditScreenDefinition {
11
11
  return {
@@ -41,10 +41,6 @@ describe("buildFormSchema", () => {
41
41
  }
42
42
  });
43
43
 
44
- // kumiko-framework#1927: a bare presence issue used to render as
45
- // "Invalid value." — pin the params.i18nKey override so the resolved
46
- // FieldIssue points at "kumiko.validation.required" ("Pflichtfeld.")
47
- // instead of the generic errors.validation.custom fallback.
48
44
  test("required field missing → issue carries the required-field i18nKey override", () => {
49
45
  const entity = entityWith({ name: { type: "text", required: true } });
50
46
  const screen = screenWith(["name"]);
@@ -54,14 +50,14 @@ describe("buildFormSchema", () => {
54
50
  if (result.success) return;
55
51
  const issue = result.error.issues[0];
56
52
  if (issue?.code !== "custom") throw new Error("expected a custom issue");
57
- expect(issue.params).toMatchObject({ i18nKey: "kumiko.validation.required" });
53
+ expect(issue.params).toMatchObject({ i18nKey: REQUIRED_FIELD_I18N_KEY });
58
54
  });
59
55
 
60
- // kumiko-framework#1927: the seam the bug actually lived in — a schema
61
- // built here only round-trips through createFormController's validate(),
62
- // which is what feeds FormSnapshot.errors that render-edit.tsx passes to
63
- // RenderField. A unit test on buildFormSchema() alone can't catch a break
64
- // in that hand-off (e.g. zodErrorToFieldIssues not honoring the override).
56
+ // The seam the bug actually lived in — a schema built here only round-trips
57
+ // through createFormController's validate(), which is what feeds
58
+ // FormSnapshot.errors that render-edit.tsx passes to RenderField. A unit
59
+ // test on buildFormSchema() alone can't catch a break in that hand-off
60
+ // (e.g. zodErrorToFieldIssues not honoring the override).
65
61
  test("end-to-end via createFormController: required field left empty → snapshot error carries kumiko.validation.required", () => {
66
62
  const entity = entityWith({ name: { type: "text", required: true } });
67
63
  const screen = screenWith(["name"]);
@@ -73,7 +69,7 @@ describe("buildFormSchema", () => {
73
69
 
74
70
  expect(form.validate()).toBe(false);
75
71
  const fieldErrors = form.getSnapshot().errors["name"];
76
- expect(fieldErrors?.[0]?.i18nKey).toBe("kumiko.validation.required");
72
+ expect(fieldErrors?.[0]?.i18nKey).toBe(REQUIRED_FIELD_I18N_KEY);
77
73
  });
78
74
 
79
75
  describe("required field present → no issue", () => {
@@ -12,6 +12,12 @@ import { type ComponentType, createContext, type ReactNode, useContext } from "r
12
12
  import { usePrimitives } from "../primitives";
13
13
 
14
14
  export type ContentEditorProps = {
15
+ /** DOM id every registered editor must render onto its own focusable
16
+ * root element. Callers that wrap an editor in a `Field` (label +
17
+ * htmlFor) pass a stable id here so the label stays associated with
18
+ * whatever element actually renders — a fixed constant would break as
19
+ * soon as a registered editor swaps in for the textarea fallback. */
20
+ readonly id: string;
15
21
  readonly value: string;
16
22
  readonly onChange: (value: string) => void;
17
23
  /** Variable names insertable as chips (from the collection's
@@ -24,10 +30,10 @@ export type ContentEditorProps = {
24
30
 
25
31
  export type ContentEditorComponent = ComponentType<ContentEditorProps>;
26
32
 
27
- /** Fixed DOM id the fallback textarea renders under. Callers that wrap an
28
- * editor in a `Field` (label + htmlFor) use this as the Field's `id` so the
29
- * label stays associated the editor contract has no `id` prop of its own,
30
- * every registered editor owns its own focusable element's id. */
33
+ /** Default id value for callers that don't generate their own (e.g. a
34
+ * standalone TextareaContentEditor render outside a multi-editor page).
35
+ * Registered editors don't apply this automatically every caller passes
36
+ * its own id via ContentEditorProps.id. */
31
37
  export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
32
38
 
33
39
  export type ContentEditorsMap = Readonly<Record<string, ContentEditorComponent>>;
@@ -50,6 +56,7 @@ export function ContentEditorsProvider({
50
56
  * Uses the primitives Input like every other form field, so it renders on
51
57
  * every platform without requiring the platform's DOM/native equivalent. */
52
58
  export function TextareaContentEditor({
59
+ id,
53
60
  value,
54
61
  onChange,
55
62
  readOnly,
@@ -58,11 +65,11 @@ export function TextareaContentEditor({
58
65
  return (
59
66
  <Input
60
67
  kind="textarea"
61
- id={CONTENT_EDITOR_ELEMENT_ID}
68
+ id={id}
62
69
  name={CONTENT_EDITOR_ELEMENT_ID}
63
70
  value={value}
64
71
  onChange={onChange}
65
- disabled={readOnly}
72
+ readOnly={readOnly}
66
73
  rows={14}
67
74
  />
68
75
  );
@@ -4,7 +4,7 @@
4
4
  // renders formatted HTML and "plain"/"markdown" render as text through the
5
5
  // exact same component, no separate render path per format.
6
6
 
7
- import type { ReactNode } from "react";
7
+ import { type ReactNode, useId } from "react";
8
8
  import { useContentEditor } from "./content-editors";
9
9
 
10
10
  const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
@@ -36,6 +36,7 @@ export function ContentPreview({
36
36
  contentFormat,
37
37
  }: ContentPreviewProps): ReactNode {
38
38
  const Editor = useContentEditor(contentFormat);
39
+ const id = useId();
39
40
  // "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
40
41
  // an example value substituted in raw could break the markup (`<`) or
41
42
  // render as an unescaped entity (`&`). "plain"/"markdown" content is text,
@@ -48,6 +49,7 @@ export function ContentPreview({
48
49
  : variables;
49
50
  return (
50
51
  <Editor
52
+ id={id}
51
53
  value={substituteVariables(content, safeVariables)}
52
54
  onChange={noop}
53
55
  variables={[]}
@@ -55,8 +55,11 @@ export type ExtensionSectionProps = {
55
55
  readonly values?: Readonly<Record<string, unknown>>;
56
56
  /** Sets host RenderEdit form values from outside (e.g. a VIN-decode
57
57
  * roundtrip that fills other fields) — same function as
58
- * `RenderEditControls.patch`, merges only the given keys. Undefined
59
- * outside entityEdit sections. */
58
+ * `RenderEditControls.patch`, merges only the given keys. `patch` must
59
+ * converge: `setValues` is a no-op only when the merged result is
60
+ * reference-equal to the current values, so an unconditional
61
+ * `patch(...)` from a `values`-dependent effect loops. Undefined outside
62
+ * entityEdit sections. */
60
63
  readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
61
64
  /** Validates the host form without writing — same function as
62
65
  * `RenderEditControls.validate`; field errors land in
@@ -1,8 +1,10 @@
1
1
  import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
+ FieldDefinition,
4
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
6
  import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
6
8
  import { z } from "zod";
7
9
  import { layoutEditFields } from "./layout-fields";
8
10
 
@@ -25,7 +27,15 @@ function isPresent(value: unknown): boolean {
25
27
  // packages/framework/src/engine/boot-validator/screens.ts, which mirrors
26
28
  // this set (framework can't import renderer, so it can't import this
27
29
  // constant directly — keep both in sync).
28
- const FIELD_TYPES_WITHOUT_WIDGET = new Set(["jsonb", "embedded", "files", "images"]);
30
+ const FIELD_TYPES_WITHOUT_WIDGET: ReadonlySet<FieldDefinition["type"]> = new Set([
31
+ "jsonb",
32
+ "embedded",
33
+ "files",
34
+ "images",
35
+ ]);
36
+
37
+ // Renders raw to the user without a de+en default in i18n-defaults.ts.
38
+ export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
29
39
 
30
40
  // Client-side presence validation for the auto-wired entityEdit path —
31
41
  // checks that every rendered required field HAS a value, not that the
@@ -82,8 +92,8 @@ export function buildFormSchema(
82
92
  code: "custom",
83
93
  path: [spec.field],
84
94
  message: `"${spec.field}" is required.`,
85
- // `params.i18nKey` override, see packages/headless/src/form/zod-bridge.ts.
86
- params: { i18nKey: "kumiko.validation.required" },
95
+ // I18N_KEY_PARAM override, see packages/headless/src/form/zod-bridge.ts.
96
+ params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
87
97
  });
88
98
  }
89
99
  });
@@ -26,6 +26,7 @@ import type {
26
26
  } from "@cosmicdrift/kumiko-headless";
27
27
  import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
28
28
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
29
+ import { extractCreatedId } from "../components/reference-create-dialog";
29
30
  import { RenderEdit } from "../components/render-edit";
30
31
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
31
32
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
@@ -461,7 +462,11 @@ function EntityEditCreateBody({
461
462
  (result: SubmitResult<unknown>) => {
462
463
  if (!result.isSuccess) return;
463
464
  if (screen.redirect !== undefined) {
464
- nav.navigate({ screenId: lastSegment(screen.redirect) });
465
+ const entityId = extractCreatedId(result.data);
466
+ nav.navigate({
467
+ screenId: lastSegment(screen.redirect),
468
+ ...(entityId !== undefined && { entityId }),
469
+ });
465
470
  return;
466
471
  }
467
472
  navigateToList();
@@ -699,7 +704,8 @@ function EntityEditSingletonBody({
699
704
  </Banner>
700
705
  );
701
706
  }
702
- const existingId = listQuery.data?.rows[0]?.["id"] as string | undefined;
707
+ const rawExistingId = listQuery.data?.rows[0]?.["id"];
708
+ const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
703
709
  if (existingId !== undefined) {
704
710
  return (
705
711
  <EntityEditUpdateBody
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
9
  import type { Dispatcher, EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
10
- import { render, waitFor } from "@testing-library/react";
10
+ import { act, render, waitFor } from "@testing-library/react";
11
11
  import type { ComponentType, ReactNode } from "react";
12
12
  import { DispatcherProvider } from "../../context/dispatcher-context";
13
13
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
@@ -311,6 +311,26 @@ describe("EmbeddedListField — paste coercion", () => {
311
311
  ]);
312
312
  });
313
313
 
314
+ // kumiko-framework#1972: a hardcoded ×100 in the paste path diverged from
315
+ // the typed-in path's currencyDecimals-based scaling for any non-2-decimal
316
+ // currency — JPY (0 decimals) pasted "1234" would have landed on 123400
317
+ // (100x too large) instead of 1234.
318
+ test("pastes a money cell for a zero-decimal currency (JPY) without the paste-path ×100 bug", () => {
319
+ const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 0, amount: 0 }];
320
+ let lastValue: unknown;
321
+ renderEmbeddedListField(
322
+ invoiceLinesField({ value: rows, embeddedListCurrency: "JPY" }),
323
+ (v) => {
324
+ lastValue = v;
325
+ },
326
+ );
327
+ // Columns in order: product, unit, quantity, unitPrice, amount. Paste
328
+ // starting at "unitPrice" (index 3).
329
+ captured?.onPasteCells?.(0, 3, [["1234"]]);
330
+ const result = lastValue as readonly Record<string, unknown>[];
331
+ expect(result[0]?.["unitPrice"]).toBe(1234);
332
+ });
333
+
314
334
  test("paste beyond the current rows appends new rows but never past maxItems", () => {
315
335
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
316
336
  let lastValue: unknown;
@@ -325,6 +345,53 @@ describe("EmbeddedListField — paste coercion", () => {
325
345
  const result = lastValue as readonly Record<string, unknown>[];
326
346
  expect(result.length).toBe(2);
327
347
  });
348
+
349
+ test("paste beyond maxItems surfaces a listIssue with the dropped-row count", async () => {
350
+ const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
351
+ renderEmbeddedListField(invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }), () => {});
352
+ act(() => {
353
+ captured?.onPasteCells?.(0, 2, [
354
+ ["1", "100"],
355
+ ["2", "200"],
356
+ ["3", "300"],
357
+ ]);
358
+ });
359
+ await waitFor(() => {
360
+ expect(captured?.listIssues).toEqual([
361
+ {
362
+ path: "lines",
363
+ code: "paste-rows-truncated",
364
+ i18nKey: "kumiko.field.embedded-list.paste-rows-truncated",
365
+ params: { count: 1 },
366
+ },
367
+ ]);
368
+ });
369
+ });
370
+
371
+ test("a select paste value with no matching option leaves the cell unchanged and surfaces a listIssue", async () => {
372
+ const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
373
+ let lastValue: unknown;
374
+ renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
375
+ lastValue = v;
376
+ });
377
+ // Columns in order: product, unit, quantity, unitPrice, amount. Column
378
+ // index 1 = "unit" (select, options ["pcs","hours","kg"]) — "not-a-unit"
379
+ // matches none of them.
380
+ act(() => {
381
+ captured?.onPasteCells?.(0, 1, [["not-a-unit"]]);
382
+ });
383
+ expect(lastValue).toEqual([rows[0]]);
384
+ await waitFor(() => {
385
+ expect(captured?.listIssues).toEqual([
386
+ {
387
+ path: "lines",
388
+ code: "paste-cells-unmatched",
389
+ i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
390
+ params: { count: 1 },
391
+ },
392
+ ]);
393
+ });
394
+ });
328
395
  });
329
396
 
330
397
  describe("EmbeddedListField — reference column populated via useQuery", () => {
@@ -0,0 +1,212 @@
1
+ // kumiko-framework#1972: one form, two money contracts. A top-level money
2
+ // field's write payload is `{amount, currency}` in MAJOR units
3
+ // (render-field.tsx's moneyPayload) while an embedded-list row's money
4
+ // sub-field is a bare number in MINOR units (embedded-list-field.tsx passes
5
+ // the widget's minor-unit value straight through — currency lives on the
6
+ // head aggregate, not the row). Both are correct in isolation; the risk is
7
+ // a consumer that reads the combined payload and compares the two amounts
8
+ // without converting units first (exactly the reported solon incident:
9
+ // `linesSum:10000` vs `netTotal:100`).
10
+ //
11
+ // This test drives BOTH real conversion paths — RenderField's moneyPayload
12
+ // for the top-level field, EmbeddedListField's handleCellChange for the
13
+ // list rows — with a crooked amount (not a round number, which can hide a
14
+ // factor-of-100 bug by coincidence), assembles the resulting wire payload
15
+ // exactly like a real form submission would, and validates it against the
16
+ // real server schema (buildInsertSchema + EmbeddedFieldDef.totalsMatch). No
17
+ // hand-built wire-form fixture stands in for either conversion function.
18
+
19
+ import { describe, expect, test } from "bun:test";
20
+ import {
21
+ buildInsertSchema,
22
+ createEmbeddedListField,
23
+ createEntity,
24
+ createMoneyField,
25
+ } from "@cosmicdrift/kumiko-framework/engine";
26
+ import type {
27
+ EntityDefinition,
28
+ EntityEditScreenDefinition,
29
+ } from "@cosmicdrift/kumiko-framework/ui-types";
30
+ import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
31
+ import { render } from "@testing-library/react";
32
+ import type { ComponentType, ReactNode } from "react";
33
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
34
+ import {
35
+ type CorePrimitives,
36
+ type EmbeddedListInputProps,
37
+ type InputProps,
38
+ PrimitivesProvider,
39
+ } from "../../primitives";
40
+ import { RenderField } from "../render-field";
41
+
42
+ let capturedInput: InputProps | undefined;
43
+ let capturedList: EmbeddedListInputProps | undefined;
44
+ let lastTotalPayload: unknown;
45
+ let lastLinesPayload: unknown;
46
+
47
+ const captureInput: ComponentType<InputProps> = (props) => {
48
+ capturedInput = props;
49
+ return null;
50
+ };
51
+ const captureEmbeddedListInput: ComponentType<EmbeddedListInputProps> = (props) => {
52
+ capturedList = props;
53
+ return null;
54
+ };
55
+ const onTotalChange = (v: unknown): void => {
56
+ lastTotalPayload = v;
57
+ };
58
+ const onLinesChange = (v: unknown): void => {
59
+ lastLinesPayload = v;
60
+ };
61
+ const noop = (): ReactNode => null;
62
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
63
+
64
+ const testPrimitives: CorePrimitives = {
65
+ Button: noop,
66
+ Banner: noop,
67
+ Field: passChildren,
68
+ Input: captureInput,
69
+ EmbeddedListInput: captureEmbeddedListInput,
70
+ DataTable: noop,
71
+ Form: passChildren,
72
+ Section: passChildren,
73
+ Card: passChildren,
74
+ Grid: passChildren,
75
+ GridCell: passChildren,
76
+ Text: passChildren,
77
+ Heading: noop,
78
+ Dialog: noop,
79
+ Modal: noop,
80
+ Lightbox: noop,
81
+ ConfigSourceBadge: noop,
82
+ ConfigCascadeView: noop,
83
+ Link: noop,
84
+ };
85
+
86
+ function invoiceEntity(): EntityDefinition {
87
+ return createEntity({
88
+ table: "Invoices",
89
+ fields: {
90
+ total: createMoneyField({ required: true }),
91
+ lines: createEmbeddedListField(
92
+ { amount: { type: "money", required: true } },
93
+ { totalsMatch: { amount: "total" } },
94
+ ),
95
+ },
96
+ defaultCurrency: "EUR",
97
+ });
98
+ }
99
+
100
+ function invoiceScreen(): EntityEditScreenDefinition {
101
+ return {
102
+ id: "invoice-edit",
103
+ type: "entityEdit",
104
+ entity: "invoice",
105
+ layout: { sections: [{ columns: 1, fields: ["total", "lines"] }] },
106
+ } as EntityEditScreenDefinition;
107
+ }
108
+
109
+ function computeInvoiceFields(
110
+ entity: EntityDefinition,
111
+ values: Record<string, unknown>,
112
+ ): { readonly total: EditFieldViewModel; readonly lines: EditFieldViewModel } {
113
+ const vm = computeEditViewModel({
114
+ screen: invoiceScreen(),
115
+ entity,
116
+ values,
117
+ translate: (key) => key,
118
+ featureName: "invoices",
119
+ });
120
+ const section = vm.sections[0];
121
+ if (section === undefined || section.kind !== "fields") {
122
+ throw new Error("expected a fields section");
123
+ }
124
+ const total = section.fields.find((f) => f.field === "total");
125
+ const lines = section.fields.find((f) => f.field === "lines");
126
+ if (total === undefined || lines === undefined) {
127
+ throw new Error("expected total + lines fields");
128
+ }
129
+ return { total, lines };
130
+ }
131
+
132
+ // Mounts both fields fresh against the given values and re-captures their
133
+ // widget props. Called again after every simulated edit — a real controlled
134
+ // form re-renders with the previous onChange result fed back in the same
135
+ // way, so this mirrors that instead of mutating one long-lived tree.
136
+ function renderInvoiceForm(entity: EntityDefinition, values: Record<string, unknown>): void {
137
+ capturedInput = undefined;
138
+ capturedList = undefined;
139
+ const { total, lines } = computeInvoiceFields(entity, values);
140
+ render(
141
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
142
+ <PrimitivesProvider value={testPrimitives}>
143
+ <RenderField field={total} onChange={onTotalChange} />
144
+ <RenderField field={lines} onChange={onLinesChange} featureName="invoices" />
145
+ </PrimitivesProvider>
146
+ </LocaleProvider>,
147
+ );
148
+ if (capturedInput === undefined) {
149
+ throw new Error("total field did not render an Input");
150
+ }
151
+ if (capturedList === undefined) {
152
+ throw new Error("lines field did not render an EmbeddedListInput");
153
+ }
154
+ }
155
+
156
+ // Mirrors what MoneyInput actually emits on blur: the widget's minor-units
157
+ // value. `InputProps` is a discriminated union keyed by `kind`, so the
158
+ // money-typed onChange only narrows to a callable signature once `kind` is
159
+ // checked.
160
+ function emitMoneyChange(minorUnits: number): void {
161
+ if (capturedInput === undefined) throw new Error("no Input captured");
162
+ if (capturedInput.kind !== "money") throw new Error("expected a money Input");
163
+ capturedInput.onChange(minorUnits);
164
+ }
165
+
166
+ describe("RenderField money contract consistency across top-level + embedded-list (kumiko-framework#1972)", () => {
167
+ test("a crooked invoice total (1234.56 EUR) whose line amounts sum to the same value validates end to end", () => {
168
+ const entity = invoiceEntity();
169
+
170
+ // Two rows, edited one at a time via the widget's real onCellChange
171
+ // (minor units) — each edit re-renders with the previous result fed
172
+ // back in as `lines`, exactly like a real controlled form.
173
+ renderInvoiceForm(entity, { lines: [{}, {}] });
174
+ capturedList?.onCellChange(0, "amount", 100_000); // 1000.00 EUR
175
+ expect(lastLinesPayload).toEqual([{ amount: 100_000 }, {}]);
176
+
177
+ renderInvoiceForm(entity, { lines: lastLinesPayload as Record<string, unknown>[] });
178
+ capturedList?.onCellChange(1, "amount", 23_456); // 234.56 EUR
179
+ expect(lastLinesPayload).toEqual([{ amount: 100_000 }, { amount: 23_456 }]);
180
+
181
+ // Top-level total: the widget (MoneyInput) emits minor units on blur;
182
+ // RenderField's moneyPayload converts that to the wire's major-unit
183
+ // object.
184
+ emitMoneyChange(123_456); // 1234.56 EUR, minor units
185
+ expect(lastTotalPayload).toEqual({ amount: 1234.56, currency: "EUR" });
186
+
187
+ const result = buildInsertSchema(entity).safeParse({
188
+ total: lastTotalPayload,
189
+ lines: lastLinesPayload,
190
+ });
191
+ expect(result.success).toBe(true);
192
+ });
193
+
194
+ test("a genuinely wrong total (mismatched, not just a unit mixup) is still rejected", () => {
195
+ const entity = invoiceEntity();
196
+ renderInvoiceForm(entity, { lines: [{}] });
197
+ capturedList?.onCellChange(0, "amount", 100_000); // 1000.00 EUR
198
+ expect(lastLinesPayload).toEqual([{ amount: 100_000 }]);
199
+
200
+ emitMoneyChange(50_000); // user enters 500.00 EUR — doesn't match the line
201
+ expect(lastTotalPayload).toEqual({ amount: 500, currency: "EUR" });
202
+
203
+ const result = buildInsertSchema(entity).safeParse({
204
+ total: lastTotalPayload,
205
+ lines: lastLinesPayload,
206
+ });
207
+ expect(result.success).toBe(false);
208
+ if (!result.success) {
209
+ expect(result.error.issues.some((issue) => issue.path.join(".") === "lines")).toBe(true);
210
+ }
211
+ });
212
+ });