@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 +3 -3
- package/src/__tests__/error-i18n-defaults.test.ts +9 -0
- package/src/app/__tests__/content-editors.test.tsx +24 -5
- 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 +13 -6
- package/src/app/content-preview.tsx +3 -1
- package/src/app/extension-sections.tsx +5 -2
- package/src/app/form-schema.ts +13 -3
- package/src/app/kumiko-screen.tsx +8 -2
- 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/reference-create-dialog.tsx +1 -1
- package/src/components/render-edit-logic.ts +3 -8
- package/src/components/render-edit.tsx +152 -75
- 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.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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
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
|
|
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 +
|
|
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
|
|
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.
|
|
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.
|
|
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", () => {
|
|
@@ -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
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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={
|
|
68
|
+
id={id}
|
|
62
69
|
name={CONTENT_EDITOR_ELEMENT_ID}
|
|
63
70
|
value={value}
|
|
64
71
|
onChange={onChange}
|
|
65
|
-
|
|
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
|
|
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.
|
|
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
|
});
|
|
@@ -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
|
-
|
|
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
|
|
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
|
+
});
|