@cosmicdrift/kumiko-headless 0.334.0 → 0.335.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.
@@ -1,5 +1,5 @@
1
1
  import { createStore } from "../store/index.js";
2
- import { groupIssuesByPath, zodErrorToFieldIssues } from "./zod-bridge.js";
2
+ import { groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues } from "./zod-bridge.js";
3
3
  // Paths without the prefix (hook-level errors, "(root)") pass through unchanged.
4
4
  function stripServerFieldPathPrefix(issues, prefix) {
5
5
  return issues.map((issue) => issue.path.startsWith(prefix) ? { ...issue, path: issue.path.slice(prefix.length) } : issue);
@@ -139,15 +139,15 @@ export function createFormController(options) {
139
139
  // the scope — a step-2 validate() that comes back clean must not wipe
140
140
  // step-1's still-unresolved errors from an earlier submit() (or vice
141
141
  // versa when step-2 newly has issues).
142
- function replaceScopedErrors(scopeSet, grouped) {
143
- if (scopeSet === undefined) {
142
+ function replaceScopedErrors(isInScope, grouped) {
143
+ if (isInScope === undefined) {
144
144
  errors = Object.freeze(grouped);
145
145
  // skip: unscoped replace — wholesale overwrite, nothing left to merge
146
146
  return;
147
147
  }
148
148
  const merged = {};
149
149
  for (const [path, issues] of Object.entries(errors)) {
150
- if (!scopeSet.has(path))
150
+ if (!isInScope(path))
151
151
  merged[path] = issues;
152
152
  }
153
153
  Object.assign(merged, grouped);
@@ -155,13 +155,15 @@ export function createFormController(options) {
155
155
  }
156
156
  function runValidate(scope, opts) {
157
157
  // See the validate() doc comment in types.ts for the scope contract.
158
- // Normalize nested scope entries (`address.city` → `address`) so wizard
159
- // callers that pass dotted paths still match issue root segments (#1898).
160
- const scopeSet = scope === undefined ? undefined : new Set(scope.map((s) => s.split(".")[0] ?? s));
158
+ // A scope entry matches its exact path and everything below it, but not
159
+ // siblings: `address.city` must not pull in `address.zip` (#1898).
160
+ const isInScope = scope === undefined
161
+ ? undefined
162
+ : (path) => scope.some((entry) => path === entry || path.startsWith(`${entry}.`));
161
163
  const includeRoot = opts?.includeRoot === true;
162
164
  if (!options.schema) {
163
165
  if (Object.keys(errors).length > 0) {
164
- replaceScopedErrors(scopeSet, {});
166
+ replaceScopedErrors(isInScope, {});
165
167
  invalidate();
166
168
  }
167
169
  return true;
@@ -170,7 +172,7 @@ export function createFormController(options) {
170
172
  const parsed = options.schema.safeParse(values);
171
173
  if (parsed.success) {
172
174
  if (Object.keys(errors).length > 0) {
173
- replaceScopedErrors(scopeSet, {});
175
+ replaceScopedErrors(isInScope, {});
174
176
  invalidate();
175
177
  }
176
178
  return true;
@@ -180,28 +182,22 @@ export function createFormController(options) {
180
182
  if (!state.visible)
181
183
  hiddenFields.add(fieldKey);
182
184
  }
183
- const allIssues = zodErrorToFieldIssues(parsed.error);
184
- const relevantIssues = allIssues.filter((issue) => {
185
- const rootField = issue.path.split(".")[0] ?? "";
186
- if (hiddenFields.has(rootField))
187
- return false;
188
- // Root-level .refine() issues use path "(root)". Wizard step validate()
189
- // still drops them (#1885); submit() passes includeRoot so RenderEdit
190
- // fields={…} cannot silently skip cross-field rules (#1907).
191
- const isRootIssue = issue.path === "(root)";
192
- if (scopeSet && !(includeRoot && isRootIssue) && !scopeSet.has(rootField)) {
193
- return false;
194
- }
195
- return true;
185
+ // Root-level .refine() issues use path "(root)". Wizard step validate()
186
+ // still drops them (#1885); submit() passes includeRoot so RenderEdit
187
+ // fields={…} cannot silently skip cross-field rules (#1907).
188
+ const relevantIssues = relevantFieldIssues(zodErrorToFieldIssues(parsed.error), {
189
+ hiddenFields,
190
+ ...(scope !== undefined && { scope }),
191
+ includeRoot,
196
192
  });
197
193
  if (relevantIssues.length === 0) {
198
194
  if (Object.keys(errors).length > 0) {
199
- replaceScopedErrors(scopeSet, {});
195
+ replaceScopedErrors(isInScope, {});
200
196
  invalidate();
201
197
  }
202
198
  return true;
203
199
  }
204
- replaceScopedErrors(scopeSet, groupIssuesByPath(relevantIssues));
200
+ replaceScopedErrors(isInScope, groupIssuesByPath(relevantIssues));
205
201
  invalidate();
206
202
  return false;
207
203
  }
@@ -1,3 +1,3 @@
1
1
  export { createFormController } from "./form-controller.js";
2
2
  export type { FieldConditionPredicate, FieldConditions, FieldConditionValue, FieldState, FormController, FormControllerOptions, FormSnapshot, FormValues, SubmitConfig, SubmitPayloadMode, SubmitResult, } from "./types.js";
3
- export { groupIssuesByPath, I18N_KEY_PARAM, zodErrorToFieldIssues } from "./zod-bridge.js";
3
+ export { groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./zod-bridge.js";
@@ -1,2 +1,2 @@
1
1
  export { createFormController } from "./form-controller.js";
2
- export { groupIssuesByPath, I18N_KEY_PARAM, zodErrorToFieldIssues } from "./zod-bridge.js";
2
+ export { groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./zod-bridge.js";
@@ -1,5 +1,10 @@
1
1
  import type { ZodError } from "zod";
2
2
  import type { FieldIssue } from "../dispatcher/index.js";
3
3
  export declare function zodErrorToFieldIssues(error: ZodError): FieldIssue[];
4
+ export declare function relevantFieldIssues(issues: readonly FieldIssue[], opts: {
5
+ readonly hiddenFields: ReadonlySet<string>;
6
+ readonly scope?: readonly string[];
7
+ readonly includeRoot?: boolean;
8
+ }): FieldIssue[];
4
9
  export declare const I18N_KEY_PARAM = "i18nKey";
5
10
  export declare function groupIssuesByPath(issues: readonly FieldIssue[]): Record<string, readonly FieldIssue[]>;
@@ -44,6 +44,24 @@ export function zodErrorToFieldIssues(error) {
44
44
  };
45
45
  });
46
46
  }
47
+ // The issues that count for validity: hidden fields never block, and a
48
+ // scope narrows to the listed paths (an entry matches itself and everything
49
+ // below it, not siblings — #1898). `includeRoot` keeps "(root)" refine issues
50
+ // inside a scope (submit passes it, #1907). Shared by the controller's
51
+ // validate() and RenderEdit's `valid` dry-run so the two cannot drift.
52
+ export function relevantFieldIssues(issues, opts) {
53
+ const { hiddenFields, scope, includeRoot } = opts;
54
+ return issues.filter((issue) => {
55
+ const rootField = issue.path.split(".")[0] ?? "";
56
+ if (hiddenFields.has(rootField))
57
+ return false;
58
+ if (scope === undefined)
59
+ return true;
60
+ if (includeRoot === true && issue.path === "(root)")
61
+ return true;
62
+ return scope.some((entry) => issue.path === entry || issue.path.startsWith(`${entry}.`));
63
+ });
64
+ }
47
65
  // Same magic key both sides of a superRefine can set to opt out of the
48
66
  // generic `errors.validation.custom` message — see form-schema.ts.
49
67
  export const I18N_KEY_PARAM = "i18nKey";
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export type { AssetResolution, AssetResolveContext, AssetResolver, BadgeProps, B
4
4
  export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./dispatcher/index.js";
5
5
  export { StreamFrame, type StreamFrameEvent } from "./dispatcher/index.js";
6
6
  export type { FieldConditionPredicate, FieldConditions, FieldConditionValue, FieldState, FormController, FormControllerOptions, FormSnapshot, FormValues, SubmitConfig, SubmitPayloadMode, SubmitResult, } from "./form/index.js";
7
- export { createFormController, I18N_KEY_PARAM } from "./form/index.js";
7
+ export { createFormController, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.js";
8
8
  export { applyFormatSpec, currencyDecimals, escapeHtml, escapeHtmlAttr, escapeXml, type HtmlValue, html, isSafeHref, RawHtml, raw, stripControlChars, toInstant, } from "./format/index.js";
9
9
  export type { NavDefinition, NavNode, NavRegistrySlice, NavTree, ResolveNavigationOptions, } from "./nav/index.js";
10
10
  export { resolveNavigation } from "./nav/index.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
3
3
  export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
4
  export { StreamFrame } from "./dispatcher/index.js";
5
- export { createFormController, I18N_KEY_PARAM } from "./form/index.js";
5
+ export { createFormController, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.js";
6
6
  export { applyFormatSpec, currencyDecimals, escapeHtml, escapeHtmlAttr, escapeXml, html, isSafeHref, RawHtml, raw, stripControlChars, toInstant, } from "./format/index.js";
7
7
  export { resolveNavigation } from "./nav/index.js";
8
8
  export { createStore, shallowEqual } from "./store/index.js";
@@ -25,7 +25,9 @@ export type WrapUrlLocaleResolverOptions = {
25
25
  resolvePage: (pathname: string) => string | undefined;
26
26
  detectLang: (pathname: string) => string;
27
27
  pathname?: () => string | undefined;
28
- /** Optional pathname-change subscription (browser default: popstate). */
28
+ /** Optional pathname-change subscription. Browser default listens to `popstate` only,
29
+ * which does not fire on `history.pushState`/`replaceState`: apps with a client-side
30
+ * router must supply this themselves. */
29
31
  subscribePathname?: (listener: () => void) => () => void;
30
32
  /** When set, translate against the URL-resolved locale instead of base.translate
31
33
  * (needed for fixed-T / cloneInstance resolvers that are not locale-live). */
@@ -95,9 +95,7 @@ function deriveSelectOptionsQuery(fieldDef) {
95
95
  : {};
96
96
  }
97
97
  function deriveSelectFieldHints(fieldDef, translate, featureName, entityName, fieldName) {
98
- const options = fieldDef.type === "select" || fieldDef.type === "multiSelect"
99
- ? (fieldDef.options ?? [])
100
- : undefined;
98
+ const options = fieldDef.type === "select" || fieldDef.type === "multiSelect" ? fieldDef.options : undefined;
101
99
  const optionLabels = options !== undefined
102
100
  ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, entityName, fieldName, value), options)
103
101
  : undefined;
@@ -264,13 +262,17 @@ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, f
264
262
  };
265
263
  }
266
264
  function buildExtensionSectionViewModel(sectionSpec, translate) {
265
+ const entityNameOverride = sectionSpec.entityName?.trim();
267
266
  return {
268
267
  kind: "extension",
269
268
  ...(sectionSpec.id !== undefined && { id: sectionSpec.id }),
270
269
  title: translate(sectionSpec.title),
271
270
  component: sectionSpec.component,
272
271
  contributesToFormSubmit: sectionSpec.contributesToFormSubmit === true,
273
- ...(sectionSpec.entityName !== undefined && { entityName: sectionSpec.entityName }),
272
+ // Blank override must fall back to the host value: the renderer's `??`
273
+ // would otherwise pass "" through to the mounted extension.
274
+ ...(entityNameOverride !== undefined &&
275
+ entityNameOverride.length > 0 && { entityName: entityNameOverride }),
274
276
  ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
275
277
  };
276
278
  }
@@ -117,7 +117,7 @@ export function computeListViewModel(input) {
117
117
  // form. A missing key returns the key itself per convention; the
118
118
  // renderer then falls back to humanizeSlug.
119
119
  const optionLabels = fieldDef.type === "select" || fieldDef.type === "multiSelect"
120
- ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value), fieldDef.options ?? [])
120
+ ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value), fieldDef.options)
121
121
  : undefined;
122
122
  const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
123
123
  const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.334.0",
3
+ "version": "0.335.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.334.0",
39
+ "@cosmicdrift/kumiko-framework": "0.335.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },