@cosmicdrift/kumiko-headless 0.324.0 → 0.325.1
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.325.1",
|
|
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.325.1",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -172,6 +172,67 @@ describe("createFormController — submit()", () => {
|
|
|
172
172
|
expect(form.getSnapshot().errors["title"]?.[0]?.code).toBe("too_small");
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
test("server validation failure with serverFieldPathPrefix: paths are stripped before mapping to form fields", async () => {
|
|
176
|
+
const disp = makeDispatcher({
|
|
177
|
+
isSuccess: false,
|
|
178
|
+
error: {
|
|
179
|
+
code: "validation_error",
|
|
180
|
+
httpStatus: 400,
|
|
181
|
+
i18nKey: "errors.validation",
|
|
182
|
+
message: "Validation failed",
|
|
183
|
+
details: {
|
|
184
|
+
fields: [
|
|
185
|
+
{ path: "changes.count", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
186
|
+
{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const form = createFormController({
|
|
192
|
+
initial: { title: "hello", count: 1 },
|
|
193
|
+
submit: {
|
|
194
|
+
dispatcher: disp,
|
|
195
|
+
type: "app:write:task:update",
|
|
196
|
+
serverFieldPathPrefix: "changes.",
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const result = await form.submit();
|
|
201
|
+
|
|
202
|
+
expect(result.isSuccess).toBe(false);
|
|
203
|
+
expect(form.getSnapshot().errors["count"]).toBeDefined();
|
|
204
|
+
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
205
|
+
expect(form.getSnapshot().errors["changes.count"]).toBeUndefined();
|
|
206
|
+
if (result.validationBlocked || result.isSuccess) throw new Error("expected a server failure");
|
|
207
|
+
expect(result.error.details?.fields?.map((f) => f.path)).toEqual(["count", "title"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("server validation failure without serverFieldPathPrefix: prefixed paths pass through unchanged", async () => {
|
|
211
|
+
const disp = makeDispatcher({
|
|
212
|
+
isSuccess: false,
|
|
213
|
+
error: {
|
|
214
|
+
code: "validation_error",
|
|
215
|
+
httpStatus: 400,
|
|
216
|
+
i18nKey: "errors.validation",
|
|
217
|
+
message: "Validation failed",
|
|
218
|
+
details: {
|
|
219
|
+
fields: [
|
|
220
|
+
{ path: "changes.count", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const form = createFormController({
|
|
226
|
+
initial: { title: "hello", count: 1 },
|
|
227
|
+
submit: { dispatcher: disp, type: "app:write:task:update" },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const result = await form.submit();
|
|
231
|
+
|
|
232
|
+
expect(result.isSuccess).toBe(false);
|
|
233
|
+
expect(form.getSnapshot().errors["changes.count"]).toBeDefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
175
236
|
test("non-validation server error: form state is left alone, error passes through", async () => {
|
|
176
237
|
const disp = makeDispatcher({
|
|
177
238
|
isSuccess: false,
|
|
@@ -12,6 +12,16 @@ import type {
|
|
|
12
12
|
} from "./types";
|
|
13
13
|
import { groupIssuesByPath, zodErrorToFieldIssues } from "./zod-bridge";
|
|
14
14
|
|
|
15
|
+
// Paths without the prefix (hook-level errors, "(root)") pass through unchanged.
|
|
16
|
+
function stripServerFieldPathPrefix(
|
|
17
|
+
issues: readonly FieldIssue[],
|
|
18
|
+
prefix: string,
|
|
19
|
+
): readonly FieldIssue[] {
|
|
20
|
+
return issues.map((issue) =>
|
|
21
|
+
issue.path.startsWith(prefix) ? { ...issue, path: issue.path.slice(prefix.length) } : issue,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
// Resolve one condition-value (boolean | predicate) against current values + ctx.
|
|
16
26
|
// `undefined` means "condition not declared"; the caller substitutes the
|
|
17
27
|
// field-default (visible:true, readonly:false, required:false).
|
|
@@ -423,8 +433,16 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
423
433
|
|
|
424
434
|
const serverFields = result.error.details?.fields;
|
|
425
435
|
if (serverFields && serverFields.length > 0) {
|
|
426
|
-
|
|
436
|
+
const prefix = submitCfg.serverFieldPathPrefix;
|
|
437
|
+
const mappedFields = prefix
|
|
438
|
+
? stripServerFieldPathPrefix(serverFields, prefix)
|
|
439
|
+
: serverFields;
|
|
440
|
+
errors = Object.freeze(groupIssuesByPath(mappedFields));
|
|
427
441
|
invalidate();
|
|
442
|
+
const error = prefix
|
|
443
|
+
? { ...result.error, details: { ...result.error.details, fields: mappedFields } }
|
|
444
|
+
: result.error;
|
|
445
|
+
return { validationBlocked: false, isSuccess: false, isNoOp: false, error };
|
|
428
446
|
}
|
|
429
447
|
return { validationBlocked: false, isSuccess: false, isNoOp: false, error: result.error };
|
|
430
448
|
};
|
package/src/form/types.ts
CHANGED
|
@@ -272,6 +272,9 @@ export type SubmitConfig<TValues extends FormValues = FormValues> = {
|
|
|
272
272
|
// That's intentional: a caller choosing to write a transformer is
|
|
273
273
|
// making an explicit statement about payload shape.
|
|
274
274
|
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
275
|
+
// When buildPayload nests values under a key, server issue paths carry it
|
|
276
|
+
// ("changes.vin"); submit() strips this prefix so errors map onto form fields.
|
|
277
|
+
readonly serverFieldPathPrefix?: string;
|
|
275
278
|
/** Restricts submit()'s internal validate() call to these field names —
|
|
276
279
|
* same contract as validate()'s `scope` param. Set by RenderEdit when the
|
|
277
280
|
* caller's `fields` prop narrows which fields are rendered, so an
|