@cosmicdrift/kumiko-headless 0.333.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,11 +4,11 @@ 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";
11
11
  export type { Store, WritableStore } from "./store/index.js";
12
12
  export { createStore, shallowEqual } from "./store/index.js";
13
13
  export type { ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.js";
14
- export { computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
14
+ export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@
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";
9
- export { computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
9
+ export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/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;
@@ -120,9 +118,11 @@ function deriveTextFieldHints(fieldDef) {
120
118
  const format = fieldDef.type === "text"
121
119
  ? fieldDef.format
122
120
  : undefined;
121
+ const writeOnly = fieldDef.type === "text" && fieldDef.writeOnly === true;
123
122
  return {
124
123
  ...(multiline !== undefined && { multiline }),
125
124
  ...(format !== undefined && { format }),
125
+ ...(writeOnly && { writeOnly: true }),
126
126
  };
127
127
  }
128
128
  function deriveDateFieldHints(fieldDef) {
@@ -262,13 +262,17 @@ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, f
262
262
  };
263
263
  }
264
264
  function buildExtensionSectionViewModel(sectionSpec, translate) {
265
+ const entityNameOverride = sectionSpec.entityName?.trim();
265
266
  return {
266
267
  kind: "extension",
267
268
  ...(sectionSpec.id !== undefined && { id: sectionSpec.id }),
268
269
  title: translate(sectionSpec.title),
269
270
  component: sectionSpec.component,
270
271
  contributesToFormSubmit: sectionSpec.contributesToFormSubmit === true,
271
- ...(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 }),
272
276
  ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
273
277
  };
274
278
  }
@@ -3,5 +3,5 @@ export { computeEditViewModel, computeRelatedListSectionViewModel } from "./edit
3
3
  export type { DerivedCellRoundingTarget, EmbeddedDerivedOp, EmbeddedListIssueGroups, } from "./embedded-list.js";
4
4
  export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.js";
5
5
  export type { ComputeListViewModelInput } from "./list.js";
6
- export { computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
6
+ export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
7
7
  export type { EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedListCellViewModel, FieldConditionCtx, FieldRenderer, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./types.js";
@@ -1,3 +1,3 @@
1
1
  export { computeEditViewModel, computeRelatedListSectionViewModel } from "./edit.js";
2
2
  export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.js";
3
- export { computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
3
+ export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
@@ -1,4 +1,5 @@
1
1
  import { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ const WRITE_ONLY_SET_LABEL_KEY = "kumiko.field.writeOnly.set";
2
3
  // Pure transform: takes the declared screen + entity + incoming rows, spits
3
4
  // out the flat shape the renderer draws. No conditions, no access-checks —
4
5
  // those are list-level decisions that the caller makes BEFORE calling
@@ -116,7 +117,7 @@ export function computeListViewModel(input) {
116
117
  // form. A missing key returns the key itself per convention; the
117
118
  // renderer then falls back to humanizeSlug.
118
119
  const optionLabels = fieldDef.type === "select" || fieldDef.type === "multiSelect"
119
- ? 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)
120
121
  : undefined;
121
122
  const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
122
123
  const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
@@ -136,9 +137,24 @@ export function computeListViewModel(input) {
136
137
  };
137
138
  columns.push(column);
138
139
  }
140
+ const writeOnlyFieldNames = screen.columns
141
+ .map((spec) => normalizeListColumn(spec).field)
142
+ .filter((name) => {
143
+ const fieldDef = entity.fields[name];
144
+ return fieldDef?.type === "text" && fieldDef.writeOnly === true;
145
+ });
146
+ // The wire carries `true` (set) / `null` (empty) for writeOnly fields; the cell shows a word, not "true".
147
+ const showWriteOnlyAsWord = (row) => {
148
+ const shown = { ...row };
149
+ for (const name of writeOnlyFieldNames) {
150
+ if (row[name] === true)
151
+ shown[name] = translate(WRITE_ONLY_SET_LABEL_KEY);
152
+ }
153
+ return shown;
154
+ };
139
155
  const listRows = rows.map((row) => ({
140
156
  id: String(row["id"] ?? ""),
141
- values: row,
157
+ values: showWriteOnlyAsWord(row),
142
158
  }));
143
159
  return {
144
160
  screenId: screen.id,
@@ -1,4 +1,4 @@
1
- import type { EditFieldSpec, EditSectionSpec, FieldIconKey, FieldRenderer, IconKey, ListColumnSpec, ListFacetSpec, ListSortSpec, PlatformComponent, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EditFieldSpec, EditSectionSpec, FieldIconKey, FieldRenderer, IconKey, ListColumnSpec, ListFacetSpec, ListSortSpec, OptionsQueryPayload, PlatformComponent, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
3
3
  export type ListColumnViewModel = {
4
4
  readonly field: string;
@@ -85,12 +85,13 @@ export type EditFieldViewModel = {
85
85
  * `<feature>:entity:<entity>:field:<field>:option:<value>`. */
86
86
  readonly optionLabels?: Readonly<Record<string, string>>;
87
87
  /** Set for `type: "select"` when SelectFieldDef.optionsQuery is set: the
88
- * renderer loads the options from this query (`{ rows: { value, label }[] }`,
89
- * labels verbatim) instead of `options`. `payload` is the static
90
- * optionsQueryPayload, `{}` when absent. */
88
+ * renderer loads the options from this query (`{ rows: { value, label,
89
+ * description?, group? }[] }`, labels verbatim) instead of `options`. `payload`
90
+ * is optionsQueryPayload (`{ field }` refs are resolved by the renderer against
91
+ * the live form values), `{}` when absent. */
91
92
  readonly selectOptionsQuery?: {
92
93
  readonly query: string;
93
- readonly payload: Readonly<Record<string, string | number | boolean>>;
94
+ readonly payload: OptionsQueryPayload;
94
95
  };
95
96
  /** Set for `type: "multiSelect"` when MultiSelectFieldDef.display is
96
97
  * "checkboxes" — the renderer shows a checkbox grid with a select-all
@@ -116,6 +117,8 @@ export type EditFieldViewModel = {
116
117
  };
117
118
  /** From TextFieldDef.format — `password` makes the renderer mask the input. */
118
119
  readonly format?: "email" | "url" | "phone" | "password";
120
+ /** From TextFieldDef.writeOnly — value is `true` (set) / `null` (empty), never plaintext. */
121
+ readonly writeOnly?: true;
119
122
  /** Nur bei `type: "timestamp"` gesetzt wenn TimestampFieldDef.locatedBy
120
123
  * existiert — Wall-Clock-Zeit ohne Offset. Der Renderer emittiert
121
124
  * dann lokale Zeit ohne `Z` statt eines UTC-Instants. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.333.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.333.0",
39
+ "@cosmicdrift/kumiko-framework": "0.335.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },