@cosmicdrift/kumiko-renderer 0.187.0 → 0.189.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +13 -1
- package/src/app/__tests__/form-schema.test.ts +227 -0
- package/src/app/extension-sections.tsx +17 -0
- package/src/app/form-schema.ts +90 -0
- package/src/app/kumiko-screen.tsx +182 -42
- package/src/app/layout-fields.ts +27 -0
- package/src/components/__tests__/render-edit-logic.test.ts +48 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +189 -0
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +66 -2
- package/src/components/reference-create-dialog.tsx +4 -1
- package/src/components/render-edit-logic.ts +27 -0
- package/src/components/render-edit.tsx +579 -58
- package/src/components/render-field.tsx +86 -14
- package/src/context/draft-storage-context.tsx +39 -0
- package/src/i18n-defaults.ts +15 -0
- package/src/index.ts +8 -1
- package/src/primitives.tsx +35 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.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,10 +15,11 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.189.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.189.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
|
-
"temporal-polyfill": "^0.3.2"
|
|
21
|
+
"temporal-polyfill": "^0.3.2",
|
|
22
|
+
"zod": "^4.4.3"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@testing-library/react": "^16.3.2",
|
|
@@ -40,12 +40,24 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
40
40
|
expect(result["total"]).toBe(100);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test("money-type field coerces a numeric string", () => {
|
|
43
|
+
test("money-type field without a defaultCurrency coerces a bare numeric string (legacy callers, e.g. config-edit/action-form)", () => {
|
|
44
44
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
45
45
|
const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
|
|
46
46
|
expect(result["price"]).toBe(19.99);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
test("money-type field WITH a defaultCurrency merges the entityEdit payload shape (#1923)", () => {
|
|
50
|
+
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
51
|
+
const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" }, undefined, "USD");
|
|
52
|
+
expect(result["price"]).toEqual({ amount: 19.99, currency: "USD" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("money-type field WITH a defaultCurrency but no matching searchParam still defaults to the object shape", () => {
|
|
56
|
+
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
57
|
+
const result = mergeSearchParamsIntoInitial(fields, {}, undefined, "USD");
|
|
58
|
+
expect(result["price"]).toEqual({ amount: 0, currency: "USD" });
|
|
59
|
+
});
|
|
60
|
+
|
|
49
61
|
test("renderableFields set given: searchParam for a non-rendered field is ignored (#1708)", () => {
|
|
50
62
|
const fields: Record<string, FieldDef> = {
|
|
51
63
|
status: { type: "text", default: "draft" },
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
EditFieldSpec,
|
|
4
|
+
EntityDefinition,
|
|
5
|
+
EntityEditScreenDefinition,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import { createFormController } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { buildFormSchema } from "../form-schema";
|
|
9
|
+
|
|
10
|
+
function screenWith(fields: readonly EditFieldSpec[]): EntityEditScreenDefinition {
|
|
11
|
+
return {
|
|
12
|
+
id: "s",
|
|
13
|
+
type: "entityEdit",
|
|
14
|
+
entity: "e",
|
|
15
|
+
layout: { sections: [{ fields }] },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function entityWith(fields: EntityDefinition["fields"]): EntityDefinition {
|
|
20
|
+
return { fields } as EntityDefinition;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("buildFormSchema", () => {
|
|
24
|
+
describe("required field missing → issue on that field", () => {
|
|
25
|
+
const entity = entityWith({ name: { type: "text", required: true } });
|
|
26
|
+
const screen = screenWith(["name"]);
|
|
27
|
+
|
|
28
|
+
for (const [label, value] of [
|
|
29
|
+
['""', ""],
|
|
30
|
+
["null", null],
|
|
31
|
+
["undefined", undefined],
|
|
32
|
+
["[]", [] as unknown[]],
|
|
33
|
+
] as const) {
|
|
34
|
+
test(label, () => {
|
|
35
|
+
const result = buildFormSchema(entity, screen).safeParse({ name: value });
|
|
36
|
+
expect(result.success).toBe(false);
|
|
37
|
+
if (result.success) return;
|
|
38
|
+
expect(result.error.issues).toHaveLength(1);
|
|
39
|
+
expect(result.error.issues[0]?.path).toEqual(["name"]);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
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
|
+
test("required field missing → issue carries the required-field i18nKey override", () => {
|
|
49
|
+
const entity = entityWith({ name: { type: "text", required: true } });
|
|
50
|
+
const screen = screenWith(["name"]);
|
|
51
|
+
|
|
52
|
+
const result = buildFormSchema(entity, screen).safeParse({ name: "" });
|
|
53
|
+
expect(result.success).toBe(false);
|
|
54
|
+
if (result.success) return;
|
|
55
|
+
const issue = result.error.issues[0];
|
|
56
|
+
if (issue?.code !== "custom") throw new Error("expected a custom issue");
|
|
57
|
+
expect(issue.params).toMatchObject({ i18nKey: "kumiko.validation.required" });
|
|
58
|
+
});
|
|
59
|
+
|
|
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).
|
|
65
|
+
test("end-to-end via createFormController: required field left empty → snapshot error carries kumiko.validation.required", () => {
|
|
66
|
+
const entity = entityWith({ name: { type: "text", required: true } });
|
|
67
|
+
const screen = screenWith(["name"]);
|
|
68
|
+
|
|
69
|
+
const form = createFormController({
|
|
70
|
+
initial: { name: "" },
|
|
71
|
+
schema: buildFormSchema(entity, screen),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(form.validate()).toBe(false);
|
|
75
|
+
const fieldErrors = form.getSnapshot().errors["name"];
|
|
76
|
+
expect(fieldErrors?.[0]?.i18nKey).toBe("kumiko.validation.required");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("required field present → no issue", () => {
|
|
80
|
+
const entity = entityWith({ name: { type: "number", required: true } });
|
|
81
|
+
const screen = screenWith(["name"]);
|
|
82
|
+
|
|
83
|
+
for (const [label, value] of [
|
|
84
|
+
["0", 0],
|
|
85
|
+
["false", false],
|
|
86
|
+
['"Ada"', "Ada"],
|
|
87
|
+
] as const) {
|
|
88
|
+
test(label, () => {
|
|
89
|
+
const result = buildFormSchema(entity, screen).safeParse({ name: value });
|
|
90
|
+
expect(result.success).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("optional field left empty → no issue", () => {
|
|
96
|
+
const entity = entityWith({ name: { type: "text", required: false } });
|
|
97
|
+
const screen = screenWith(["name"]);
|
|
98
|
+
expect(buildFormSchema(entity, screen).safeParse({ name: "" }).success).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("required field not rendered by the layout → no issue", () => {
|
|
102
|
+
const entity = entityWith({
|
|
103
|
+
name: { type: "text", required: true },
|
|
104
|
+
hidden: { type: "text", required: true },
|
|
105
|
+
});
|
|
106
|
+
const screen = screenWith(["name"]);
|
|
107
|
+
const result = buildFormSchema(entity, screen).safeParse({ name: "Ada", hidden: "" });
|
|
108
|
+
expect(result.success).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("required multiSelect, empty array → issue on that field (#1925: has a combobox widget now)", () => {
|
|
112
|
+
const entity = entityWith({
|
|
113
|
+
tags: { type: "multiSelect", required: true, options: ["a", "b"] },
|
|
114
|
+
});
|
|
115
|
+
const screen = screenWith(["tags"]);
|
|
116
|
+
const result = buildFormSchema(entity, screen).safeParse({ tags: [] });
|
|
117
|
+
expect(result.success).toBe(false);
|
|
118
|
+
if (result.success) return;
|
|
119
|
+
expect(result.error.issues).toHaveLength(1);
|
|
120
|
+
expect(result.error.issues[0]?.path).toEqual(["tags"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("required multiSelect, non-empty array → no issue", () => {
|
|
124
|
+
const entity = entityWith({
|
|
125
|
+
tags: { type: "multiSelect", required: true, options: ["a", "b"] },
|
|
126
|
+
});
|
|
127
|
+
const screen = screenWith(["tags"]);
|
|
128
|
+
expect(buildFormSchema(entity, screen).safeParse({ tags: ["a"] }).success).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("jsonb field → no issue (no editable widget on the auto-wired path)", () => {
|
|
132
|
+
const entity = entityWith({ data: { type: "jsonb" } });
|
|
133
|
+
const screen = screenWith(["data"]);
|
|
134
|
+
expect(buildFormSchema(entity, screen).safeParse({ data: undefined }).success).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("required embedded field → no issue (no editable widget on the auto-wired path)", () => {
|
|
138
|
+
const entity = entityWith({
|
|
139
|
+
lines: { type: "embedded", required: true, schema: {} },
|
|
140
|
+
});
|
|
141
|
+
const screen = screenWith(["lines"]);
|
|
142
|
+
expect(buildFormSchema(entity, screen).safeParse({ lines: undefined }).success).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("required files field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
|
|
146
|
+
const entity = entityWith({ attachments: { type: "files" } });
|
|
147
|
+
const screen = screenWith([{ field: "attachments", required: true }]);
|
|
148
|
+
expect(buildFormSchema(entity, screen).safeParse({ attachments: undefined }).success).toBe(
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("required images field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
|
|
154
|
+
const entity = entityWith({ gallery: { type: "images" } });
|
|
155
|
+
const screen = screenWith([{ field: "gallery", required: true }]);
|
|
156
|
+
expect(buildFormSchema(entity, screen).safeParse({ gallery: undefined }).success).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("required money — bare number (create-form representation) → no issue", () => {
|
|
160
|
+
const entity = entityWith({ price: { type: "money", required: true } });
|
|
161
|
+
const screen = screenWith(["price"]);
|
|
162
|
+
expect(buildFormSchema(entity, screen).safeParse({ price: 1000 }).success).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("required money — {amount,currency} (update-form representation) → no issue", () => {
|
|
166
|
+
const entity = entityWith({ price: { type: "money", required: true } });
|
|
167
|
+
const screen = screenWith(["price"]);
|
|
168
|
+
const result = buildFormSchema(entity, screen).safeParse({
|
|
169
|
+
price: { amount: 10, currency: "EUR" },
|
|
170
|
+
});
|
|
171
|
+
expect(result.success).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("required money — undefined → issue on that field (has a widget)", () => {
|
|
175
|
+
const entity = entityWith({ price: { type: "money", required: true } });
|
|
176
|
+
const screen = screenWith(["price"]);
|
|
177
|
+
const result = buildFormSchema(entity, screen).safeParse({ price: undefined });
|
|
178
|
+
expect(result.success).toBe(false);
|
|
179
|
+
if (result.success) return;
|
|
180
|
+
expect(result.error.issues).toHaveLength(1);
|
|
181
|
+
expect(result.error.issues[0]?.path).toEqual(["price"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("screen-spec required/readOnly override the entity default", () => {
|
|
185
|
+
test("spec required:false on an entity-required field, left empty → no issue", () => {
|
|
186
|
+
const entity = entityWith({ x: { type: "text", required: true } });
|
|
187
|
+
const screen = screenWith([{ field: "x", required: false }]);
|
|
188
|
+
expect(buildFormSchema(entity, screen).safeParse({ x: "" }).success).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("spec required:true on an entity-optional field, left empty → issue on that field", () => {
|
|
192
|
+
const entity = entityWith({ x: { type: "text", required: false } });
|
|
193
|
+
const screen = screenWith([{ field: "x", required: true }]);
|
|
194
|
+
const result = buildFormSchema(entity, screen).safeParse({ x: "" });
|
|
195
|
+
expect(result.success).toBe(false);
|
|
196
|
+
if (result.success) return;
|
|
197
|
+
expect(result.error.issues).toHaveLength(1);
|
|
198
|
+
expect(result.error.issues[0]?.path).toEqual(["x"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("readOnly field, entity-required and left empty → no issue (unresolvable by the user)", () => {
|
|
202
|
+
const entity = entityWith({ x: { type: "text", required: true } });
|
|
203
|
+
const screen = screenWith([{ field: "x", readOnly: true }]);
|
|
204
|
+
expect(buildFormSchema(entity, screen).safeParse({ x: "" }).success).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("conditional required, entity-optional field", () => {
|
|
208
|
+
const entity = entityWith({
|
|
209
|
+
kind: { type: "text", required: false },
|
|
210
|
+
x: { type: "text", required: false },
|
|
211
|
+
});
|
|
212
|
+
const screen = screenWith([
|
|
213
|
+
"kind",
|
|
214
|
+
{ field: "x", required: { field: "kind", eq: "business" } },
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const notTriggered = buildFormSchema(entity, screen).safeParse({ kind: "private", x: "" });
|
|
218
|
+
expect(notTriggered.success).toBe(true);
|
|
219
|
+
|
|
220
|
+
const triggered = buildFormSchema(entity, screen).safeParse({ kind: "business", x: "" });
|
|
221
|
+
expect(triggered.success).toBe(false);
|
|
222
|
+
if (triggered.success) return;
|
|
223
|
+
expect(triggered.error.issues).toHaveLength(1);
|
|
224
|
+
expect(triggered.error.issues[0]?.path).toEqual(["x"]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -46,6 +46,23 @@ export type ExtensionSectionProps = {
|
|
|
46
46
|
* Panels haben keine Entity — entityName/entityId tragen dort die
|
|
47
47
|
* screen.id bzw. null, siehe CustomPanelBody in dashboard-body.tsx. */
|
|
48
48
|
readonly filterParams?: Readonly<Record<string, unknown>>;
|
|
49
|
+
/** Current form values of the host RenderEdit (controlled mode,
|
|
50
|
+
* issue #1887/#1888) — same snapshot reference as
|
|
51
|
+
* `RenderEditChangeState.values`. Lets the section read the host
|
|
52
|
+
* form's live state (e.g. for a review step). Only set in
|
|
53
|
+
* entityEdit sections; undefined in list-header and dashboard
|
|
54
|
+
* mounts, where no host form exists. */
|
|
55
|
+
readonly values?: Readonly<Record<string, unknown>>;
|
|
56
|
+
/** Sets host RenderEdit form values from outside (e.g. a VIN-decode
|
|
57
|
+
* roundtrip that fills other fields) — same function as
|
|
58
|
+
* `RenderEditControls.patch`, merges only the given keys. Undefined
|
|
59
|
+
* outside entityEdit sections. */
|
|
60
|
+
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
61
|
+
/** Validates the host form without writing — same function as
|
|
62
|
+
* `RenderEditControls.validate`; field errors land in
|
|
63
|
+
* `snapshot.errors` on the field instead of a collective message.
|
|
64
|
+
* Undefined outside entityEdit sections. */
|
|
65
|
+
readonly validate?: () => boolean;
|
|
49
66
|
};
|
|
50
67
|
|
|
51
68
|
export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { layoutEditFields } from "./layout-fields";
|
|
8
|
+
|
|
9
|
+
// `required` means "has a value", not "is truthy" — `false` and `0` count
|
|
10
|
+
// as present, only the actually-empty representations don't.
|
|
11
|
+
function isPresent(value: unknown): boolean {
|
|
12
|
+
if (value === undefined || value === null || value === "") return false;
|
|
13
|
+
if (Array.isArray(value) && value.length === 0) return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Field types without a bound, editable widget on the auto-wired
|
|
18
|
+
// entityEdit path (render-field.tsx renders a read-only banner instead) —
|
|
19
|
+
// a presence error on one of them would be unresolvable by the user.
|
|
20
|
+
// #1925 gave multiSelect/decimal/bigInt/tz/longText real widgets, so they
|
|
21
|
+
// dropped out of this set; files/images stay out of scope (deferred, no
|
|
22
|
+
// multi-upload widget yet) alongside jsonb/embedded (structural types with
|
|
23
|
+
// no editor at all). A statically-`required: true` field of one of these
|
|
24
|
+
// types is caught loudly at boot — validateNoWidgetRequiredField in
|
|
25
|
+
// packages/framework/src/engine/boot-validator/screens.ts, which mirrors
|
|
26
|
+
// this set (framework can't import renderer, so it can't import this
|
|
27
|
+
// constant directly — keep both in sync).
|
|
28
|
+
const FIELD_TYPES_WITHOUT_WIDGET = new Set(["jsonb", "embedded", "files", "images"]);
|
|
29
|
+
|
|
30
|
+
// Client-side presence validation for the auto-wired entityEdit path —
|
|
31
|
+
// checks that every rendered required field HAS a value, not that the
|
|
32
|
+
// value has the right shape. Format/range/type validation stays server-
|
|
33
|
+
// authoritative (buildInsertSchema/buildUpdateSchema): the form-state
|
|
34
|
+
// representation of a value can diverge from the server-payload shape (e.g.
|
|
35
|
+
// money is `{amount,currency}`, not a bare number), so a format check here
|
|
36
|
+
// would either reject valid values or need per-representation branches that
|
|
37
|
+
// rot the moment either side changes.
|
|
38
|
+
//
|
|
39
|
+
// One `superRefine` instead of a per-field shape: a field-level `.refine()`
|
|
40
|
+
// wouldn't run at all for a key that's simply absent from `values` —
|
|
41
|
+
// `superRefine` sees the whole object and catches that case too.
|
|
42
|
+
// `.passthrough()` is load-bearing: the default `z.object({})` STRIPS every
|
|
43
|
+
// key (there's no declared shape), so `superRefine` would see an empty
|
|
44
|
+
// object regardless of what was actually submitted — every required field
|
|
45
|
+
// would misreport as missing.
|
|
46
|
+
//
|
|
47
|
+
// Iterates the screen's layout field specs, not `entity.fields` — `required`
|
|
48
|
+
// and `readOnly` are per-spec, evaluated against the current values the
|
|
49
|
+
// same way `view-model/edit.ts` resolves them for the rendered form. Not
|
|
50
|
+
// checked here: `visible`. `runValidate` already filters issues on hidden
|
|
51
|
+
// fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
|
|
52
|
+
// fed by `deriveFormFields(screen)` in render-edit.tsx.
|
|
53
|
+
export function buildFormSchema(
|
|
54
|
+
entity: EntityDefinition,
|
|
55
|
+
screen: EntityEditScreenDefinition,
|
|
56
|
+
): z.ZodType {
|
|
57
|
+
const fields = layoutEditFields(screen);
|
|
58
|
+
return z
|
|
59
|
+
.object({})
|
|
60
|
+
.passthrough()
|
|
61
|
+
.superRefine((values, ctx) => {
|
|
62
|
+
// `.passthrough()` types `values` as a plain object but doesn't declare
|
|
63
|
+
// its keys — the runtime object always carries every form field.
|
|
64
|
+
// @cast-boundary form-values
|
|
65
|
+
const record = values as Record<string, unknown>;
|
|
66
|
+
for (const spec of fields) {
|
|
67
|
+
const field = entity.fields[spec.field];
|
|
68
|
+
if (!field) continue;
|
|
69
|
+
// Not operable by the user — a presence error would be unresolvable,
|
|
70
|
+
// same reason as the FIELD_TYPES_WITHOUT_WIDGET check below.
|
|
71
|
+
if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) continue;
|
|
72
|
+
// Screen-spec `required` overrides the entity default, mirroring
|
|
73
|
+
// `view-model/edit.ts` — the rendered form is the reference, and a
|
|
74
|
+
// presence check stricter than the form blocks the user for nothing.
|
|
75
|
+
const entityRequired = "required" in field && field.required === true;
|
|
76
|
+
const isRequired =
|
|
77
|
+
spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
|
|
78
|
+
if (!isRequired) continue;
|
|
79
|
+
if (FIELD_TYPES_WITHOUT_WIDGET.has(field.type)) continue;
|
|
80
|
+
if (isPresent(record[spec.field])) continue;
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: "custom",
|
|
83
|
+
path: [spec.field],
|
|
84
|
+
message: `"${spec.field}" is required.`,
|
|
85
|
+
// `params.i18nKey` override, see packages/headless/src/form/zod-bridge.ts.
|
|
86
|
+
params: { i18nKey: "kumiko.validation.required" },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|