@cosmicdrift/kumiko-headless 0.221.0 → 0.222.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.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.222.0",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -474,4 +474,41 @@ describe("createFormController — submit()", () => {
|
|
|
474
474
|
expect(result.validationBlocked).toBe(true);
|
|
475
475
|
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
476
476
|
});
|
|
477
|
+
|
|
478
|
+
test("cross-field refine still blocks submit when validateScope is set (#1907)", async () => {
|
|
479
|
+
const schema = z
|
|
480
|
+
.object({ title: z.string().min(1), note: z.string() })
|
|
481
|
+
.refine((v) => v.title !== v.note, { message: "title and note must differ" });
|
|
482
|
+
const disp = makeDispatcher();
|
|
483
|
+
const form = createFormController({
|
|
484
|
+
initial: { title: "same", note: "same" },
|
|
485
|
+
schema,
|
|
486
|
+
submit: {
|
|
487
|
+
dispatcher: disp,
|
|
488
|
+
type: "app:write:task:create",
|
|
489
|
+
validateScope: ["title"],
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
const result = await form.submit();
|
|
493
|
+
expect(result.validationBlocked).toBe(true);
|
|
494
|
+
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("intentional empty clear survives second submit after rebase (#1858)", async () => {
|
|
498
|
+
const schema = z.object({ title: z.string().min(1), note: z.string().optional() });
|
|
499
|
+
const disp = makeDispatcher();
|
|
500
|
+
const form = createFormController({
|
|
501
|
+
initial: { title: "t", note: "x" },
|
|
502
|
+
schema,
|
|
503
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
504
|
+
});
|
|
505
|
+
form.setField("note", "");
|
|
506
|
+
const first = await form.submit();
|
|
507
|
+
expect(first.isSuccess).toBe(true);
|
|
508
|
+
expect(disp.writeSpy.mock.calls[0]?.[1]).toMatchObject({ note: "" });
|
|
509
|
+
form.setField("title", "t2");
|
|
510
|
+
const second = await form.submit();
|
|
511
|
+
expect(second.isSuccess).toBe(true);
|
|
512
|
+
expect(disp.writeSpy.mock.calls[1]?.[1]).toMatchObject({ note: "" });
|
|
513
|
+
});
|
|
477
514
|
});
|
|
@@ -187,6 +187,18 @@ describe("createFormController — validate(scope)", () => {
|
|
|
187
187
|
expect(form.getSnapshot().errors).toEqual({});
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
test("nested scope path matches issue root segment (#1898)", () => {
|
|
191
|
+
const schema = z.object({
|
|
192
|
+
address: z.object({ city: z.string().min(1) }),
|
|
193
|
+
});
|
|
194
|
+
const form = createFormController({
|
|
195
|
+
initial: { address: { city: "" } },
|
|
196
|
+
schema,
|
|
197
|
+
});
|
|
198
|
+
expect(form.validate(["address.city"])).toBe(false);
|
|
199
|
+
expect(form.getSnapshot().errors["address.city"]).toBeDefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
190
202
|
// Hard rule (kumiko-framework#1885): an object-level .refine() issue has
|
|
191
203
|
// path "(root)", which never matches a field name — every scoped
|
|
192
204
|
// validate() call must ignore it, and only the unscoped final-submit
|
|
@@ -84,12 +84,15 @@ function valuesDiff<TValues extends FormValues>(
|
|
|
84
84
|
function stripUntouchedEmptyStrings<TValues extends FormValues>(
|
|
85
85
|
values: TValues,
|
|
86
86
|
initial: TValues,
|
|
87
|
+
forceIncludeEmpty: ReadonlySet<string>,
|
|
87
88
|
): TValues {
|
|
88
89
|
const v = values as Record<string, unknown>; // @cast-boundary form-values
|
|
89
90
|
const ini = initial as Record<string, unknown>; // @cast-boundary form-values
|
|
90
91
|
let out: Record<string, unknown> | undefined;
|
|
91
92
|
for (const key of Object.keys(v)) {
|
|
92
|
-
|
|
93
|
+
// Value-based seed strip, except keys previously submitted as "" after a
|
|
94
|
+
// real clear — those must survive post-rebase submits (#1858).
|
|
95
|
+
if (!forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "") {
|
|
93
96
|
out ??= { ...v };
|
|
94
97
|
delete out[key];
|
|
95
98
|
}
|
|
@@ -118,6 +121,9 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
118
121
|
let values: TValues = { ...options.initial };
|
|
119
122
|
let initial: TValues = { ...options.initial };
|
|
120
123
|
let errors: Readonly<Record<string, readonly FieldIssue[]>> = Object.freeze({});
|
|
124
|
+
// Keys last submitted as "" (intentional clear). Survives rebase so a later
|
|
125
|
+
// submit of other fields does not drop them (#1858).
|
|
126
|
+
const forceIncludeEmpty = new Set<string>();
|
|
121
127
|
// ctx is cast-held as TCtx; `undefined` is valid when callers don't
|
|
122
128
|
// declare conditional predicates that depend on it.
|
|
123
129
|
let ctx: TCtx = (options.ctx as TCtx) ?? (undefined as TCtx);
|
|
@@ -178,9 +184,16 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
178
184
|
errors = Object.freeze(merged);
|
|
179
185
|
}
|
|
180
186
|
|
|
181
|
-
function runValidate(
|
|
187
|
+
function runValidate(
|
|
188
|
+
scope?: readonly string[],
|
|
189
|
+
opts?: { readonly includeRoot?: boolean },
|
|
190
|
+
): boolean {
|
|
182
191
|
// See the validate() doc comment in types.ts for the scope contract.
|
|
183
|
-
|
|
192
|
+
// Normalize nested scope entries (`address.city` → `address`) so wizard
|
|
193
|
+
// callers that pass dotted paths still match issue root segments (#1898).
|
|
194
|
+
const scopeSet =
|
|
195
|
+
scope === undefined ? undefined : new Set(scope.map((s) => s.split(".")[0] ?? s));
|
|
196
|
+
const includeRoot = opts?.includeRoot === true;
|
|
184
197
|
if (!options.schema) {
|
|
185
198
|
if (Object.keys(errors).length > 0) {
|
|
186
199
|
replaceScopedErrors(scopeSet, {});
|
|
@@ -205,7 +218,13 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
205
218
|
const relevantIssues = allIssues.filter((issue) => {
|
|
206
219
|
const rootField = issue.path.split(".")[0] ?? "";
|
|
207
220
|
if (hiddenFields.has(rootField)) return false;
|
|
208
|
-
|
|
221
|
+
// Root-level .refine() issues use path "(root)". Wizard step validate()
|
|
222
|
+
// still drops them (#1885); submit() passes includeRoot so RenderEdit
|
|
223
|
+
// fields={…} cannot silently skip cross-field rules (#1907).
|
|
224
|
+
const isRootIssue = issue.path === "(root)";
|
|
225
|
+
if (scopeSet && !(includeRoot && isRootIssue) && !scopeSet.has(rootField)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
209
228
|
return true;
|
|
210
229
|
});
|
|
211
230
|
if (relevantIssues.length === 0) {
|
|
@@ -295,6 +314,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
295
314
|
if (alreadyClean) return;
|
|
296
315
|
values = { ...initial };
|
|
297
316
|
errors = Object.freeze({});
|
|
317
|
+
forceIncludeEmpty.clear();
|
|
298
318
|
invalidate();
|
|
299
319
|
},
|
|
300
320
|
rebase: runRebase,
|
|
@@ -322,7 +342,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
322
342
|
// pattern the server-side event-dispatcher uses (passInFlight).
|
|
323
343
|
if (submitInFlight) return submitInFlight as Promise<SubmitResult<TData>>;
|
|
324
344
|
|
|
325
|
-
if (!runValidate(submitCfg.validateScope)) {
|
|
345
|
+
if (!runValidate(submitCfg.validateScope, { includeRoot: true })) {
|
|
326
346
|
return { validationBlocked: true, isSuccess: false };
|
|
327
347
|
}
|
|
328
348
|
|
|
@@ -355,7 +375,11 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
355
375
|
} else if (submitCfg.stripEmptySeeds === false) {
|
|
356
376
|
payload = submittedValues;
|
|
357
377
|
} else {
|
|
358
|
-
payload = stripUntouchedEmptyStrings(
|
|
378
|
+
payload = stripUntouchedEmptyStrings(
|
|
379
|
+
submittedValues,
|
|
380
|
+
submittedSnapshot.initial,
|
|
381
|
+
forceIncludeEmpty,
|
|
382
|
+
);
|
|
359
383
|
}
|
|
360
384
|
|
|
361
385
|
// Only enforced here, right before the write actually happens — a
|
|
@@ -384,6 +408,13 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
384
408
|
const result = await dispatcher.write<TData>(submitCfg.type, payload);
|
|
385
409
|
|
|
386
410
|
if (result.isSuccess) {
|
|
411
|
+
// Remember intentional empty clears so post-rebase submits keep them (#1858).
|
|
412
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
413
|
+
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
|
|
414
|
+
if (v === "") forceIncludeEmpty.add(k);
|
|
415
|
+
else forceIncludeEmpty.delete(k);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
387
418
|
// Rebase to the SNAPSHOT, not to the current values — see
|
|
388
419
|
// runRebaseToSnapshot comment for why.
|
|
389
420
|
runRebaseToSnapshot(submittedValues);
|
package/src/form/types.ts
CHANGED
|
@@ -143,9 +143,11 @@ export type FormController<TValues extends FormValues> = {
|
|
|
143
143
|
// relies entirely on server-side validation via the submit() path.
|
|
144
144
|
//
|
|
145
145
|
// `scope` restricts reported issues to those field names; root-level
|
|
146
|
-
// `.refine()` issues (path `(root)`)
|
|
147
|
-
//
|
|
148
|
-
|
|
146
|
+
// `.refine()` issues (path `(root)`) are dropped on scoped `validate()`
|
|
147
|
+
// calls (wizard steps, #1885). `submit()` always re-includes them even
|
|
148
|
+
// when `validateScope` is set so RenderEdit `fields={…}` cannot skip
|
|
149
|
+
// cross-field rules (#1907).
|
|
150
|
+
validate(scope?: readonly string[]): boolean;
|
|
149
151
|
|
|
150
152
|
// Reverts values to `initial`, clears errors. Doesn't fire a new
|
|
151
153
|
// "initial" baseline — to adopt the current values as the new baseline
|