@cosmicdrift/kumiko-headless 0.325.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.325.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.325.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
- errors = Object.freeze(groupIssuesByPath(serverFields));
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