@cosmicdrift/kumiko-headless 0.193.1 → 0.194.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/form/__tests__/form-controller.test.ts +36 -0
- package/src/form/__tests__/submit.test.ts +38 -0
- package/src/form/__tests__/validation.test.ts +0 -4
- package/src/form/form-controller.ts +33 -7
- package/src/form/index.ts +1 -1
- package/src/form/types.ts +3 -11
- package/src/form/zod-bridge.ts +9 -11
- package/src/index.ts +1 -1
- package/src/view-model/__tests__/edit.test.ts +38 -0
- package/src/view-model/edit.ts +3 -0
- package/src/view-model/embedded-list.ts +2 -1
- package/src/view-model/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.194.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.194.0",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { createFormController } from "../form-controller";
|
|
3
4
|
|
|
4
5
|
describe("createFormController — core state machine", () => {
|
|
@@ -193,3 +194,38 @@ describe("createFormController — core state machine", () => {
|
|
|
193
194
|
expect(snap.changes["title"]).toBeUndefined();
|
|
194
195
|
});
|
|
195
196
|
});
|
|
197
|
+
|
|
198
|
+
describe("validate() scoping", () => {
|
|
199
|
+
const schema = z.object({ step1: z.string().min(1), step2: z.string().min(1) });
|
|
200
|
+
|
|
201
|
+
test("a clean scoped validate() doesn't wipe an out-of-scope field's existing errors", () => {
|
|
202
|
+
const form = createFormController({ initial: { step1: "", step2: "filled" }, schema });
|
|
203
|
+
|
|
204
|
+
// Unscoped run (e.g. submit) surfaces errors on both fields.
|
|
205
|
+
expect(form.validate()).toBe(false);
|
|
206
|
+
expect(form.getSnapshot().errors["step1"]).toBeDefined();
|
|
207
|
+
|
|
208
|
+
// step2 is now fixed; a scoped validate() for step2 alone must not
|
|
209
|
+
// clear step1's still-outstanding error.
|
|
210
|
+
form.setField("step2", "filled");
|
|
211
|
+
expect(form.validate(["step2"])).toBe(true);
|
|
212
|
+
const snap = form.getSnapshot();
|
|
213
|
+
expect(snap.errors["step2"]).toBeUndefined();
|
|
214
|
+
expect(snap.errors["step1"]).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a failing scoped validate() doesn't overwrite an out-of-scope field's existing errors", () => {
|
|
218
|
+
const form = createFormController({ initial: { step1: "", step2: "" }, schema });
|
|
219
|
+
|
|
220
|
+
expect(form.validate()).toBe(false);
|
|
221
|
+
expect(form.getSnapshot().errors["step1"]).toBeDefined();
|
|
222
|
+
expect(form.getSnapshot().errors["step2"]).toBeDefined();
|
|
223
|
+
|
|
224
|
+
// Re-running validate scoped to step1 only (still empty) must refresh
|
|
225
|
+
// step1's error without touching step2's independently-set error.
|
|
226
|
+
expect(form.validate(["step1"])).toBe(false);
|
|
227
|
+
const snap = form.getSnapshot();
|
|
228
|
+
expect(snap.errors["step1"]).toBeDefined();
|
|
229
|
+
expect(snap.errors["step2"]).toBeDefined();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -231,6 +231,44 @@ describe("createFormController — submit()", () => {
|
|
|
231
231
|
});
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
test("payloadMode: 'values' — strips a field that was touched and returned to \"\" (value-based, not touch-based)", async () => {
|
|
235
|
+
// stripUntouchedEmptyStrings only compares current vs. initial value —
|
|
236
|
+
// it has no concept of "touched". A field the user typed into and then
|
|
237
|
+
// cleared back to "" is dropped exactly like one nobody ever touched.
|
|
238
|
+
const disp = makeDispatcher();
|
|
239
|
+
const form = createFormController({
|
|
240
|
+
initial: { title: "hello", dueDate: "" },
|
|
241
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
242
|
+
});
|
|
243
|
+
form.setField("dueDate", "x");
|
|
244
|
+
form.setField("dueDate", "");
|
|
245
|
+
|
|
246
|
+
await form.submit();
|
|
247
|
+
|
|
248
|
+
const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
|
|
249
|
+
expect("dueDate" in call).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("second submit after a rebase diffs against the new baseline, not the stale original initial", async () => {
|
|
253
|
+
const disp = makeDispatcher();
|
|
254
|
+
const form = createFormController({
|
|
255
|
+
initial: { title: "hello" },
|
|
256
|
+
submit: { dispatcher: disp, type: "app:write:task:update", payloadMode: "changes" },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
form.setField("title", "world");
|
|
260
|
+
await form.submit();
|
|
261
|
+
expect(form.getSnapshot().initial.title).toBe("world"); // baseline rebased
|
|
262
|
+
|
|
263
|
+
// Revert to the ORIGINAL initial value — clean against the stale
|
|
264
|
+
// baseline, but a real change against the rebased one.
|
|
265
|
+
form.setField("title", "hello");
|
|
266
|
+
await form.submit();
|
|
267
|
+
|
|
268
|
+
expect(disp.writeSpy).toHaveBeenCalledTimes(2);
|
|
269
|
+
expect(disp.writeSpy.mock.calls[1]).toEqual(["app:write:task:update", { title: "hello" }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
234
272
|
test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
|
|
235
273
|
// User submits "hello", the network takes 50ms. During those 50ms the
|
|
236
274
|
// user types "world" into the same field. The server sees "hello"
|
|
@@ -46,10 +46,6 @@ describe("zodErrorToFieldIssues", () => {
|
|
|
46
46
|
expect(issues[0]?.params?.["minimum"]).toBe(10);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
// kumiko-framework#1927: a bare `code:"custom"` issue (superRefine's only
|
|
50
|
-
// code) would otherwise always resolve to the generic "Invalid value."
|
|
51
|
-
// key — `params.i18nKey` lets a check like form-schema.ts's presence
|
|
52
|
-
// check point at its own key instead.
|
|
53
49
|
test("custom issue with params.i18nKey overrides the mechanical errors.validation.custom key", () => {
|
|
54
50
|
const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
|
|
55
51
|
if (values.name === "") {
|
|
@@ -76,6 +76,11 @@ function valuesDiff<TValues extends FormValues>(
|
|
|
76
76
|
|
|
77
77
|
// buildInitialValues seeds untouched optional fields with "" for controlled
|
|
78
78
|
// inputs; a `.optional()` server schema accepts undefined but not "".
|
|
79
|
+
// String-only: number/money seed to 0 and boolean seeds to false, and an
|
|
80
|
+
// untouched optional field of those types still submits that seed instead
|
|
81
|
+
// of being omitted — no validation error surfaces it, unlike the "" case.
|
|
82
|
+
// Fixing that needs real per-field seed-tracking (which value was actually
|
|
83
|
+
// user-set vs. defaulted), not just a wider strip predicate here.
|
|
79
84
|
function stripUntouchedEmptyStrings<TValues extends FormValues>(
|
|
80
85
|
values: TValues,
|
|
81
86
|
initial: TValues,
|
|
@@ -84,7 +89,7 @@ function stripUntouchedEmptyStrings<TValues extends FormValues>(
|
|
|
84
89
|
const ini = initial as Record<string, unknown>; // @cast-boundary form-values
|
|
85
90
|
let out: Record<string, unknown> | undefined;
|
|
86
91
|
for (const key of Object.keys(v)) {
|
|
87
|
-
if (v[key] === "" &&
|
|
92
|
+
if (v[key] === "" && ini[key] === "") {
|
|
88
93
|
out ??= { ...v };
|
|
89
94
|
delete out[key];
|
|
90
95
|
}
|
|
@@ -152,10 +157,33 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
152
157
|
// Local shared implementations so submit() can call validate/rebase
|
|
153
158
|
// without the this-in-object-literal dance. Both also expose themselves
|
|
154
159
|
// as methods on the returned controller.
|
|
160
|
+
// Clears/replaces errors for a scoped run without touching fields outside
|
|
161
|
+
// the scope — a step-2 validate() that comes back clean must not wipe
|
|
162
|
+
// step-1's still-unresolved errors from an earlier submit() (or vice
|
|
163
|
+
// versa when step-2 newly has issues).
|
|
164
|
+
function replaceScopedErrors(
|
|
165
|
+
scopeSet: Set<string> | undefined,
|
|
166
|
+
grouped: Readonly<Record<string, readonly FieldIssue[]>>,
|
|
167
|
+
): void {
|
|
168
|
+
if (scopeSet === undefined) {
|
|
169
|
+
errors = Object.freeze(grouped);
|
|
170
|
+
// skip: unscoped replace — wholesale overwrite, nothing left to merge
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const merged: Record<string, readonly FieldIssue[]> = {};
|
|
174
|
+
for (const [path, issues] of Object.entries(errors)) {
|
|
175
|
+
if (!scopeSet.has(path)) merged[path] = issues;
|
|
176
|
+
}
|
|
177
|
+
Object.assign(merged, grouped);
|
|
178
|
+
errors = Object.freeze(merged);
|
|
179
|
+
}
|
|
180
|
+
|
|
155
181
|
function runValidate(scope?: readonly string[]): boolean {
|
|
182
|
+
// See the validate() doc comment in types.ts for the scope contract.
|
|
183
|
+
const scopeSet = scope === undefined ? undefined : new Set(scope);
|
|
156
184
|
if (!options.schema) {
|
|
157
185
|
if (Object.keys(errors).length > 0) {
|
|
158
|
-
|
|
186
|
+
replaceScopedErrors(scopeSet, {});
|
|
159
187
|
invalidate();
|
|
160
188
|
}
|
|
161
189
|
return true;
|
|
@@ -164,7 +192,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
164
192
|
const parsed = options.schema.safeParse(values);
|
|
165
193
|
if (parsed.success) {
|
|
166
194
|
if (Object.keys(errors).length > 0) {
|
|
167
|
-
|
|
195
|
+
replaceScopedErrors(scopeSet, {});
|
|
168
196
|
invalidate();
|
|
169
197
|
}
|
|
170
198
|
return true;
|
|
@@ -173,8 +201,6 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
173
201
|
for (const [fieldKey, state] of Object.entries(fieldStates)) {
|
|
174
202
|
if (!state.visible) hiddenFields.add(fieldKey);
|
|
175
203
|
}
|
|
176
|
-
// See the validate() doc comment in types.ts for the scope contract.
|
|
177
|
-
const scopeSet = scope === undefined ? undefined : new Set(scope);
|
|
178
204
|
const allIssues = zodErrorToFieldIssues(parsed.error);
|
|
179
205
|
const relevantIssues = allIssues.filter((issue) => {
|
|
180
206
|
const rootField = issue.path.split(".")[0] ?? "";
|
|
@@ -184,12 +210,12 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
184
210
|
});
|
|
185
211
|
if (relevantIssues.length === 0) {
|
|
186
212
|
if (Object.keys(errors).length > 0) {
|
|
187
|
-
|
|
213
|
+
replaceScopedErrors(scopeSet, {});
|
|
188
214
|
invalidate();
|
|
189
215
|
}
|
|
190
216
|
return true;
|
|
191
217
|
}
|
|
192
|
-
|
|
218
|
+
replaceScopedErrors(scopeSet, groupIssuesByPath(relevantIssues));
|
|
193
219
|
invalidate();
|
|
194
220
|
return false;
|
|
195
221
|
}
|
package/src/form/index.ts
CHANGED
package/src/form/types.ts
CHANGED
|
@@ -142,17 +142,9 @@ export type FormController<TValues extends FormValues> = {
|
|
|
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
|
|
146
|
-
//
|
|
147
|
-
//
|
|
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.
|
|
145
|
+
// `scope` restricts reported issues to those field names; root-level
|
|
146
|
+
// `.refine()` issues (path `(root)`) never match a scope and are dropped
|
|
147
|
+
// by design — omit `scope` (as submit() does) to see them.
|
|
156
148
|
validate(scope?: readonly string[]): boolean;
|
|
157
149
|
|
|
158
150
|
// Reverts values to `initial`, clears errors. Doesn't fire a new
|
package/src/form/zod-bridge.ts
CHANGED
|
@@ -49,19 +49,17 @@ export function zodErrorToFieldIssues(error: ZodError): FieldIssue[] {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
// `
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// mirror (packages/framework/src/errors/zod-bridge.ts) — a superRefine can
|
|
61
|
-
// run on either side.
|
|
52
|
+
// Same magic key both sides of a superRefine can set to opt out of the
|
|
53
|
+
// generic `errors.validation.custom` message — see form-schema.ts.
|
|
54
|
+
export const I18N_KEY_PARAM = "i18nKey";
|
|
55
|
+
|
|
56
|
+
// `code: "custom"` is zod's catch-all for every superRefine/refine check;
|
|
57
|
+
// left mechanical it'd collapse onto one generic key, so a superRefine can
|
|
58
|
+
// set `params[I18N_KEY_PARAM]` to override it. Keep in sync with the server
|
|
59
|
+
// mirror (packages/framework/src/errors/zod-bridge.ts).
|
|
62
60
|
function resolveI18nKey(issue: ZodIssue): string {
|
|
63
61
|
if (issue.code === "custom") {
|
|
64
|
-
const override = issue.params?.[
|
|
62
|
+
const override = issue.params?.[I18N_KEY_PARAM];
|
|
65
63
|
if (typeof override === "string") return override;
|
|
66
64
|
}
|
|
67
65
|
return `errors.validation.${issue.code}`;
|
package/src/index.ts
CHANGED
|
@@ -593,4 +593,42 @@ describe("computeEditViewModel — embedded-list cells (#1835)", () => {
|
|
|
593
593
|
const field = asFields(vm.sections[0]).fields[0];
|
|
594
594
|
expect(field?.imageVariant).toBeUndefined();
|
|
595
595
|
});
|
|
596
|
+
|
|
597
|
+
test("capture is forwarded for an image field that declares it", () => {
|
|
598
|
+
const entity = {
|
|
599
|
+
fields: {
|
|
600
|
+
avatar: { type: "image", capture: "environment" },
|
|
601
|
+
},
|
|
602
|
+
} as unknown as EntityDefinition;
|
|
603
|
+
|
|
604
|
+
const vm = computeEditViewModel({
|
|
605
|
+
screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
|
|
606
|
+
entity,
|
|
607
|
+
values: {},
|
|
608
|
+
translate,
|
|
609
|
+
featureName: "orders",
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const field = asFields(vm.sections[0]).fields[0];
|
|
613
|
+
expect(field?.capture).toBe("environment");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("capture is undefined for an image field without it", () => {
|
|
617
|
+
const entity = {
|
|
618
|
+
fields: {
|
|
619
|
+
avatar: { type: "image" },
|
|
620
|
+
},
|
|
621
|
+
} as unknown as EntityDefinition;
|
|
622
|
+
|
|
623
|
+
const vm = computeEditViewModel({
|
|
624
|
+
screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
|
|
625
|
+
entity,
|
|
626
|
+
values: {},
|
|
627
|
+
translate,
|
|
628
|
+
featureName: "orders",
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const field = asFields(vm.sections[0]).fields[0];
|
|
632
|
+
expect(field?.capture).toBeUndefined();
|
|
633
|
+
});
|
|
596
634
|
});
|
package/src/view-model/edit.ts
CHANGED
|
@@ -174,10 +174,12 @@ export function computeEditViewModel<
|
|
|
174
174
|
accept?: readonly string[];
|
|
175
175
|
maxSize?: string;
|
|
176
176
|
variants?: Readonly<Record<string, unknown>>;
|
|
177
|
+
capture?: "environment" | "user";
|
|
177
178
|
})
|
|
178
179
|
: undefined;
|
|
179
180
|
const imageVariant =
|
|
180
181
|
fieldDef.type === "image" ? Object.keys(fileDef?.variants ?? {})[0] : undefined;
|
|
182
|
+
const capture = fieldDef.type === "image" ? fileDef?.capture : undefined;
|
|
181
183
|
// Embedded-LIST field (`multiple: true`) — per-cell metadata for a
|
|
182
184
|
// renderer to draw one row per array item (invoice-positions-style
|
|
183
185
|
// table). A plain (non-list) embedded field emits none of this; the
|
|
@@ -268,6 +270,7 @@ export function computeEditViewModel<
|
|
|
268
270
|
...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
|
|
269
271
|
...(isFileType && { entityType: screen.entity, fieldName: normalized.field }),
|
|
270
272
|
...(imageVariant !== undefined && { imageVariant }),
|
|
273
|
+
...(capture !== undefined && { capture }),
|
|
271
274
|
...(normalized.icon !== undefined && { icon: normalized.icon }),
|
|
272
275
|
...(embeddedListCells !== undefined && { embeddedListCells }),
|
|
273
276
|
...(embeddedListDef?.minItems !== undefined && {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { EmbeddedDerivedCellDef } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
2
|
import type { FieldIssue } from "../dispatcher";
|
|
2
3
|
|
|
3
|
-
export type EmbeddedDerivedOp = "
|
|
4
|
+
export type EmbeddedDerivedOp = EmbeddedDerivedCellDef["op"];
|
|
4
5
|
|
|
5
6
|
export type { DerivedCellRoundingTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
7
|
// Canonical implementation moved to packages/framework/src/engine/embedded-derived.ts
|
package/src/view-model/types.ts
CHANGED
|
@@ -188,6 +188,9 @@ export type EditFieldViewModel = {
|
|
|
188
188
|
* is not a rule that always has an answer. Absent when the field declares
|
|
189
189
|
* no variants — the preview then loads the original. */
|
|
190
190
|
readonly imageVariant?: string;
|
|
191
|
+
/** Only for `type: "image"` — forwarded to the file input's `capture`
|
|
192
|
+
* attribute so a phone opens the camera instead of the file picker. */
|
|
193
|
+
readonly capture?: "environment" | "user";
|
|
191
194
|
/** Prefix-icon key from `EditFieldSpec.icon`, passed through unchanged
|
|
192
195
|
* (no i18n — it's a symbolic key, not a display string). The renderer
|
|
193
196
|
* resolves it against the FIELD_ICONS registry; unknown keys silently
|