@cosmicdrift/kumiko-headless 0.187.0 → 0.188.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 +2 -2
- package/src/form/__tests__/submit.test.ts +34 -0
- package/src/form/__tests__/validation.test.ts +103 -0
- package/src/form/form-controller.ts +7 -3
- package/src/form/types.ts +19 -1
- package/src/format/index.ts +1 -0
- package/src/format/money.ts +11 -0
- package/src/index.ts +1 -0
- package/src/view-model/edit.ts +9 -9
- package/src/view-model/types.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.188.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-framework": "0.188.0",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -73,6 +73,40 @@ describe("createFormController — submit()", () => {
|
|
|
73
73
|
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test("validateScope: a required field outside the scope missing/empty does not block submit", async () => {
|
|
77
|
+
// RenderEdit's `fields` filter narrows what's rendered; a schema-required
|
|
78
|
+
// field the user never sees (outside validateScope) must not be able to
|
|
79
|
+
// block a submit they have no way to fix.
|
|
80
|
+
const disp = makeDispatcher();
|
|
81
|
+
const form = createFormController({
|
|
82
|
+
initial: { title: "hello", notes: "" },
|
|
83
|
+
schema: z.object({ title: z.string().min(1), notes: z.string().min(1) }),
|
|
84
|
+
submit: { dispatcher: disp, type: "app:write:task:create", validateScope: ["title"] },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await form.submit();
|
|
88
|
+
|
|
89
|
+
expect(result.validationBlocked).toBe(false);
|
|
90
|
+
expect(disp.writeSpy).toHaveBeenCalledTimes(1);
|
|
91
|
+
const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
|
|
92
|
+
expect(call["title"]).toBe("hello");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("validateScope: a required field inside the scope missing/empty still blocks submit", async () => {
|
|
96
|
+
const disp = makeDispatcher();
|
|
97
|
+
const form = createFormController({
|
|
98
|
+
initial: { title: "", notes: "filled" },
|
|
99
|
+
schema: z.object({ title: z.string().min(1), notes: z.string().min(1) }),
|
|
100
|
+
submit: { dispatcher: disp, type: "app:write:task:create", validateScope: ["title"] },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await form.submit();
|
|
104
|
+
|
|
105
|
+
expect(result.validationBlocked).toBe(true);
|
|
106
|
+
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
107
|
+
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
76
110
|
test("server validation failure: field errors land on the form", async () => {
|
|
77
111
|
const disp = makeDispatcher({
|
|
78
112
|
isSuccess: false,
|
|
@@ -122,3 +122,106 @@ describe("createFormController — validate()", () => {
|
|
|
122
122
|
expect(form.getSnapshot().errors["address.city"]).toBeDefined();
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
describe("createFormController — validate(scope)", () => {
|
|
127
|
+
test("scoped run reports only fields inside scope, drops issues outside it", () => {
|
|
128
|
+
const schema = z.object({
|
|
129
|
+
a: z.string().min(3),
|
|
130
|
+
b: z.string().min(3),
|
|
131
|
+
});
|
|
132
|
+
const form = createFormController({ initial: { a: "x", b: "y" }, schema });
|
|
133
|
+
|
|
134
|
+
const ok = form.validate(["a"]);
|
|
135
|
+
const snap = form.getSnapshot();
|
|
136
|
+
|
|
137
|
+
expect(ok).toBe(false);
|
|
138
|
+
expect(snap.errors["a"]).toBeDefined();
|
|
139
|
+
expect(snap.errors["b"]).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("scoped run returns true when every field in scope is valid, even if fields outside scope are not", () => {
|
|
143
|
+
const schema = z.object({
|
|
144
|
+
a: z.string().min(1),
|
|
145
|
+
b: z.string().min(3),
|
|
146
|
+
});
|
|
147
|
+
const form = createFormController({ initial: { a: "ok", b: "y" }, schema });
|
|
148
|
+
|
|
149
|
+
const ok = form.validate(["a"]);
|
|
150
|
+
|
|
151
|
+
expect(ok).toBe(true);
|
|
152
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Hard rule (kumiko-framework#1885): an object-level .refine() issue has
|
|
156
|
+
// path "(root)", which never matches a field name — every scoped
|
|
157
|
+
// validate() call must ignore it, and only the unscoped final-submit
|
|
158
|
+
// validate() may surface it. Otherwise a step-boundary check would block
|
|
159
|
+
// a wizard step forever on an invariant no single step owns.
|
|
160
|
+
describe("root-path .refine() issues", () => {
|
|
161
|
+
const schema = z
|
|
162
|
+
.object({
|
|
163
|
+
start: z.string(),
|
|
164
|
+
end: z.string(),
|
|
165
|
+
})
|
|
166
|
+
.refine((v) => v.end >= v.start, { message: "end must be >= start" });
|
|
167
|
+
|
|
168
|
+
test("scoped validate() ignores the root issue regardless of which fields are in scope", () => {
|
|
169
|
+
const form = createFormController({ initial: { start: "b", end: "a" }, schema });
|
|
170
|
+
|
|
171
|
+
expect(form.validate(["start"])).toBe(true);
|
|
172
|
+
expect(form.validate(["end"])).toBe(true);
|
|
173
|
+
expect(form.validate(["start", "end"])).toBe(true);
|
|
174
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("unscoped validate() (final submit) reports the root issue under '(root)'", () => {
|
|
178
|
+
const form = createFormController({ initial: { start: "b", end: "a" }, schema });
|
|
179
|
+
|
|
180
|
+
const ok = form.validate();
|
|
181
|
+
|
|
182
|
+
expect(ok).toBe(false);
|
|
183
|
+
expect(form.getSnapshot().errors["(root)"]).toBeDefined();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("hidden fields stay filtered in scoped mode even when the hidden field is itself in scope", () => {
|
|
188
|
+
// Discriminating case: field `b` is both hidden AND inside `scope`, so
|
|
189
|
+
// the assertion actually exercises the hidden-field filter — not just
|
|
190
|
+
// the scope filter dropping an out-of-scope field.
|
|
191
|
+
const schema = z.object({
|
|
192
|
+
a: z.string().min(3),
|
|
193
|
+
b: z.string().min(3),
|
|
194
|
+
});
|
|
195
|
+
const form = createFormController({
|
|
196
|
+
initial: { a: "x", b: "y", showB: false },
|
|
197
|
+
schema,
|
|
198
|
+
fields: { b: { visible: (values) => values.showB } },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const ok = form.validate(["a", "b"]);
|
|
202
|
+
const snap = form.getSnapshot();
|
|
203
|
+
|
|
204
|
+
expect(ok).toBe(false);
|
|
205
|
+
expect(snap.errors["a"]).toBeDefined();
|
|
206
|
+
expect(snap.errors["b"]).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("hidden fields stay filtered in unscoped mode (regression guard alongside the scoped case)", () => {
|
|
210
|
+
const schema = z.object({
|
|
211
|
+
a: z.string().min(3),
|
|
212
|
+
b: z.string().min(3),
|
|
213
|
+
});
|
|
214
|
+
const form = createFormController({
|
|
215
|
+
initial: { a: "x", b: "y", showB: false },
|
|
216
|
+
schema,
|
|
217
|
+
fields: { b: { visible: (values) => values.showB } },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const ok = form.validate();
|
|
221
|
+
const snap = form.getSnapshot();
|
|
222
|
+
|
|
223
|
+
expect(ok).toBe(false);
|
|
224
|
+
expect(snap.errors["a"]).toBeDefined();
|
|
225
|
+
expect(snap.errors["b"]).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -152,7 +152,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
152
152
|
// Local shared implementations so submit() can call validate/rebase
|
|
153
153
|
// without the this-in-object-literal dance. Both also expose themselves
|
|
154
154
|
// as methods on the returned controller.
|
|
155
|
-
function runValidate(): boolean {
|
|
155
|
+
function runValidate(scope?: readonly string[]): boolean {
|
|
156
156
|
if (!options.schema) {
|
|
157
157
|
if (Object.keys(errors).length > 0) {
|
|
158
158
|
errors = Object.freeze({});
|
|
@@ -173,10 +173,14 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
173
173
|
for (const [fieldKey, state] of Object.entries(fieldStates)) {
|
|
174
174
|
if (!state.visible) hiddenFields.add(fieldKey);
|
|
175
175
|
}
|
|
176
|
+
// See the validate() doc comment in types.ts for the scope contract.
|
|
177
|
+
const scopeSet = scope === undefined ? undefined : new Set(scope);
|
|
176
178
|
const allIssues = zodErrorToFieldIssues(parsed.error);
|
|
177
179
|
const relevantIssues = allIssues.filter((issue) => {
|
|
178
180
|
const rootField = issue.path.split(".")[0] ?? "";
|
|
179
|
-
|
|
181
|
+
if (hiddenFields.has(rootField)) return false;
|
|
182
|
+
if (scopeSet && !scopeSet.has(rootField)) return false;
|
|
183
|
+
return true;
|
|
180
184
|
});
|
|
181
185
|
if (relevantIssues.length === 0) {
|
|
182
186
|
if (Object.keys(errors).length > 0) {
|
|
@@ -292,7 +296,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
292
296
|
// pattern the server-side event-dispatcher uses (passInFlight).
|
|
293
297
|
if (submitInFlight) return submitInFlight as Promise<SubmitResult<TData>>;
|
|
294
298
|
|
|
295
|
-
if (!runValidate()) {
|
|
299
|
+
if (!runValidate(submitCfg.validateScope)) {
|
|
296
300
|
return { validationBlocked: true, isSuccess: false };
|
|
297
301
|
}
|
|
298
302
|
|
package/src/form/types.ts
CHANGED
|
@@ -141,7 +141,19 @@ export type FormController<TValues extends FormValues> = {
|
|
|
141
141
|
// values. Populates errors and returns true iff valid. Noop-returns
|
|
142
142
|
// `true` when no schema was wired — a controller without a schema
|
|
143
143
|
// relies entirely on server-side validation via the submit() path.
|
|
144
|
-
|
|
144
|
+
//
|
|
145
|
+
// `scope` restricts which fields' issues get reported — pass the current
|
|
146
|
+
// wizard step's field names to validate only that step (used on
|
|
147
|
+
// "Weiter"). Issues on fields outside `scope` are silently dropped, and
|
|
148
|
+
// so are root-level issues from an object-level `.refine()` (their path
|
|
149
|
+
// is `(root)`, which never matches a real field name) — a scoped run can
|
|
150
|
+
// never surface those. That's deliberate: a step-boundary check exists
|
|
151
|
+
// to gate moving forward, not to catch whole-form invariants. Omit
|
|
152
|
+
// `scope` (as submit() does) to validate everything, including root
|
|
153
|
+
// issues — the final gate before dispatch must not let them through
|
|
154
|
+
// unnoticed. Hidden fields are filtered out in both modes regardless of
|
|
155
|
+
// scope.
|
|
156
|
+
validate(scope?: readonly string[]): boolean;
|
|
145
157
|
|
|
146
158
|
// Reverts values to `initial`, clears errors. Doesn't fire a new
|
|
147
159
|
// "initial" baseline — to adopt the current values as the new baseline
|
|
@@ -251,6 +263,12 @@ export type SubmitConfig<TValues extends FormValues = FormValues> = {
|
|
|
251
263
|
// That's intentional: a caller choosing to write a transformer is
|
|
252
264
|
// making an explicit statement about payload shape.
|
|
253
265
|
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
266
|
+
/** Restricts submit()'s internal validate() call to these field names —
|
|
267
|
+
* same contract as validate()'s `scope` param. Set by RenderEdit when the
|
|
268
|
+
* caller's `fields` prop narrows which fields are rendered, so an
|
|
269
|
+
* unrendered required field can't block a submit the user can't fix.
|
|
270
|
+
* Omitted = unscoped (existing behavior, validates everything). */
|
|
271
|
+
readonly validateScope?: readonly string[];
|
|
254
272
|
};
|
|
255
273
|
|
|
256
274
|
// What submit() returns. Mirrors WriteResult so a failed submit can
|
package/src/format/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ function formatDateCell(
|
|
|
69
69
|
|
|
70
70
|
export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape";
|
|
71
71
|
export { type HtmlValue, html, RawHtml, raw } from "./html-template";
|
|
72
|
+
export { currencyDecimals } from "./money";
|
|
72
73
|
export function applyFormatSpec(
|
|
73
74
|
spec: { format: string } & Record<string, unknown>,
|
|
74
75
|
value: unknown,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Currency decimal places — covers the world's zero- and three-decimal
|
|
2
|
+
// outliers, defaults to 2 for everything else. Shared between renderer-web's
|
|
3
|
+
// MoneyInput (minor-unit display factor) and RenderField's minor/major-unit
|
|
4
|
+
// conversion (kumiko-framework#1923) — a single source keeps both sides
|
|
5
|
+
// scaling the same currency the same way.
|
|
6
|
+
export function currencyDecimals(code: string): number {
|
|
7
|
+
if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
|
|
8
|
+
if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
|
|
9
|
+
return 3;
|
|
10
|
+
return 2;
|
|
11
|
+
}
|
package/src/index.ts
CHANGED
package/src/view-model/edit.ts
CHANGED
|
@@ -163,6 +163,11 @@ export function computeEditViewModel<
|
|
|
163
163
|
// file/image: accept/maxSize ins ViewModel + entityType/fieldName für
|
|
164
164
|
// den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
|
|
165
165
|
const isFileType = fieldDef.type === "file" || fieldDef.type === "image";
|
|
166
|
+
// ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
|
|
167
|
+
// framework/src/engine/field-helpers.ts — headless has no dependency
|
|
168
|
+
// on that module, so the literal is duplicated here instead of
|
|
169
|
+
// importing it just for one fallback string.
|
|
170
|
+
const resolvedCurrency = entity.defaultCurrency ?? "EUR";
|
|
166
171
|
const fileDef = isFileType
|
|
167
172
|
? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
|
|
168
173
|
: undefined;
|
|
@@ -269,15 +274,10 @@ export function computeEditViewModel<
|
|
|
269
274
|
...(embeddedListDef?.totals !== undefined && {
|
|
270
275
|
embeddedListTotals: embeddedListDef.totals,
|
|
271
276
|
}),
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// head aggregate (entity.defaultCurrency), not per row — one value
|
|
277
|
-
// for the whole embedded list.
|
|
278
|
-
...(embeddedListDef !== undefined && {
|
|
279
|
-
embeddedListCurrency: entity.defaultCurrency ?? "EUR",
|
|
280
|
-
}),
|
|
277
|
+
// Currency lives on the head aggregate (entity.defaultCurrency), not
|
|
278
|
+
// per row — one value for the whole embedded list.
|
|
279
|
+
...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
|
|
280
|
+
...(fieldDef.type === "money" && { currency: resolvedCurrency }),
|
|
281
281
|
};
|
|
282
282
|
return view;
|
|
283
283
|
});
|
package/src/view-model/types.ts
CHANGED
|
@@ -204,6 +204,11 @@ export type EditFieldViewModel = {
|
|
|
204
204
|
* currency lives on the head aggregate (EntityDefinition.defaultCurrency),
|
|
205
205
|
* not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
|
|
206
206
|
readonly embeddedListCurrency?: string;
|
|
207
|
+
/** Only set for a plain (non-list, non-embedded) `type: "money"` field —
|
|
208
|
+
* same source as embeddedListCurrency (EntityDefinition.defaultCurrency,
|
|
209
|
+
* falls back to "EUR"). The renderer needs this to build the payload's
|
|
210
|
+
* `{amount, currency}` shape (kumiko-framework#1923). */
|
|
211
|
+
readonly currency?: string;
|
|
207
212
|
};
|
|
208
213
|
|
|
209
214
|
// Discriminated by `kind` — mirrors EditSectionSpec on the engine side.
|