@cosmicdrift/kumiko-renderer 0.193.1 → 0.194.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.194.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.194.0",
19
+ "@cosmicdrift/kumiko-headless": "0.194.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
  };
@@ -96,13 +97,17 @@ describe("useContentEditor", () => {
96
97
  });
97
98
 
98
99
  describe("TextareaContentEditor", () => {
99
- test("passes value + disabled=readOnly through to the primitives Input", () => {
100
+ test("passes value + readOnly through to the primitives Input as real readOnly, not disabled", () => {
101
+ // A disabled textarea isn't focusable/selectable in most browsers — a
102
+ // non-admin viewing read-only content couldn't select/copy it. readOnly
103
+ // keeps the field focusable and selectable while blocking edits.
100
104
  render(
101
105
  <TextareaContentEditor value="draft" onChange={() => {}} variables={[]} readOnly={true} />,
102
106
  { wrapper: Wrapper },
103
107
  );
104
108
  const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
105
109
  expect(el.value).toBe("draft");
106
- expect(el.disabled).toBe(true);
110
+ expect(el.readOnly).toBe(true);
111
+ expect(el.disabled).toBe(false);
107
112
  });
108
113
  });
@@ -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", () => {
@@ -62,7 +62,7 @@ export function TextareaContentEditor({
62
62
  name={CONTENT_EDITOR_ELEMENT_ID}
63
63
  value={value}
64
64
  onChange={onChange}
65
- disabled={readOnly}
65
+ readOnly={readOnly}
66
66
  rows={14}
67
67
  />
68
68
  );
@@ -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
  });
@@ -699,7 +699,8 @@ function EntityEditSingletonBody({
699
699
  </Banner>
700
700
  );
701
701
  }
702
- const existingId = listQuery.data?.rows[0]?.["id"] as string | undefined;
702
+ const rawExistingId = listQuery.data?.rows[0]?.["id"];
703
+ const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
703
704
  if (existingId !== undefined) {
704
705
  return (
705
706
  <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
+ });
@@ -21,6 +21,7 @@ import type {
21
21
  import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
22
22
  import { render } from "@testing-library/react";
23
23
  import type { ComponentType, ReactNode } from "react";
24
+ import { buildInitialValues } from "../../app/kumiko-screen";
24
25
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
25
26
  import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
26
27
  import { RenderField } from "../render-field";
@@ -116,6 +117,19 @@ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
116
117
  expect(result.success).toBe(true);
117
118
  });
118
119
 
120
+ test("create: buildInitialValues default renders as 0.00 in the widget, not empty", () => {
121
+ // Regression: the widget was tested with `values: {}` (no field.value at
122
+ // all), which produces "" — but the real entityEdit create path always
123
+ // feeds buildInitialValues(fields, defaultCurrency), which yields
124
+ // {amount: 0, currency} (kumiko-screen.tsx buildInitialValues).
125
+ const entity = buildEntity("USD");
126
+ const initialValues = buildInitialValues(entity.fields, "USD");
127
+ const field = renderMoneyField(entity, initialValues, () => {});
128
+ expect(field.kind).toBe("money");
129
+ if (field.kind !== "money") return;
130
+ expect(field.value).toBe(0);
131
+ });
132
+
119
133
  test("create: user-entered amount becomes a payload that validates against buildInsertSchema", () => {
120
134
  const entity = buildEntity("USD");
121
135
  let payload: unknown;
@@ -98,11 +98,32 @@ describe.each([
98
98
  expect(capturedInput).toBeUndefined();
99
99
  expect(capturedBanner).toBeDefined();
100
100
  expect(capturedBanner?.variant).toBe("info");
101
- expect(capturedBanner?.children).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
102
101
  // The surrounding <Field> always renders a <label htmlFor={id}> —
103
102
  // without an id on the Banner itself that label points at nothing (#1834 review).
104
103
  expect(capturedBanner?.id).toBe("kumiko-edit-positions");
105
104
  });
105
+
106
+ // #1847#6: the Banner used to show only the generic hint, hiding the
107
+ // actual field value from the user entirely (not even read-only).
108
+ test("shows the underlying value read-only alongside the generic hint", () => {
109
+ renderField(field);
110
+ const children = capturedBanner?.children;
111
+ const childArray = Array.isArray(children) ? children : [children];
112
+ expect(childArray[0]).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
113
+ const valuePreview = childArray[1] as { props: { children: unknown } } | false | undefined;
114
+ if (!valuePreview) throw new Error("expected a value-preview element");
115
+ expect(valuePreview.props.children).toBe(JSON.stringify(field.value));
116
+ });
117
+ });
118
+
119
+ describe("RenderField — unsupported-type Banner ohne Wert", () => {
120
+ test("kein Value-Preview-Element wenn field.value leer ist", () => {
121
+ renderField(baseField({ type: "jsonb", value: null }));
122
+ const children = capturedBanner?.children;
123
+ const childArray = Array.isArray(children) ? children : [children];
124
+ expect(childArray[0]).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
125
+ expect(childArray[1]).toBeFalsy();
126
+ });
106
127
  });
107
128
 
108
129
  describe("RenderField — text bleibt weiterhin editierbar", () => {