@cosmicdrift/kumiko-renderer 0.193.0 → 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 +3 -3
- package/src/__tests__/error-i18n-defaults.test.ts +9 -0
- package/src/app/__tests__/content-editors.test.tsx +8 -3
- package/src/app/__tests__/content-preview.test.tsx +23 -1
- package/src/app/__tests__/form-schema.test.ts +8 -12
- package/src/app/content-editors.tsx +1 -1
- package/src/app/extension-sections.tsx +5 -2
- package/src/app/form-schema.ts +13 -3
- package/src/app/kumiko-screen.tsx +2 -1
- package/src/components/__tests__/embedded-list-field.test.tsx +68 -1
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +212 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +14 -0
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +22 -1
- package/src/components/embedded-list-field.tsx +59 -7
- package/src/components/render-edit-logic.ts +3 -8
- package/src/components/render-edit.tsx +127 -65
- package/src/components/render-field.tsx +15 -3
- package/src/i18n-defaults.ts +23 -5
- package/src/index.ts +1 -0
- package/src/primitives.tsx +28 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
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 +
|
|
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.
|
|
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.
|
|
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: <b>0</b> & 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:
|
|
53
|
+
expect(issue.params).toMatchObject({ i18nKey: REQUIRED_FIELD_I18N_KEY });
|
|
58
54
|
});
|
|
59
55
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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(
|
|
72
|
+
expect(fieldErrors?.[0]?.i18nKey).toBe(REQUIRED_FIELD_I18N_KEY);
|
|
77
73
|
});
|
|
78
74
|
|
|
79
75
|
describe("required field present → no issue", () => {
|
|
@@ -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.
|
|
59
|
-
*
|
|
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
|
package/src/app/form-schema.ts
CHANGED
|
@@ -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([
|
|
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
|
-
//
|
|
86
|
-
params: {
|
|
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
|
|
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", () => {
|