@cosmicdrift/kumiko-headless 0.187.0 → 0.189.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.187.0",
3
+ "version": "0.189.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.187.0",
39
+ "@cosmicdrift/kumiko-framework": "0.189.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -73,6 +73,40 @@ describe("createFormController — submit()", () => {
73
73
  expect(form.getSnapshot().errors["title"]).toBeDefined();
74
74
  });
75
75
 
76
+ test("validateScope: a required field outside the scope missing/empty does not block submit", async () => {
77
+ // RenderEdit's `fields` filter narrows what's rendered; a schema-required
78
+ // field the user never sees (outside validateScope) must not be able to
79
+ // block a submit they have no way to fix.
80
+ const disp = makeDispatcher();
81
+ const form = createFormController({
82
+ initial: { title: "hello", notes: "" },
83
+ schema: z.object({ title: z.string().min(1), notes: z.string().min(1) }),
84
+ submit: { dispatcher: disp, type: "app:write:task:create", validateScope: ["title"] },
85
+ });
86
+
87
+ const result = await form.submit();
88
+
89
+ expect(result.validationBlocked).toBe(false);
90
+ expect(disp.writeSpy).toHaveBeenCalledTimes(1);
91
+ const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
92
+ expect(call["title"]).toBe("hello");
93
+ });
94
+
95
+ test("validateScope: a required field inside the scope missing/empty still blocks submit", async () => {
96
+ const disp = makeDispatcher();
97
+ const form = createFormController({
98
+ initial: { title: "", notes: "filled" },
99
+ schema: z.object({ title: z.string().min(1), notes: z.string().min(1) }),
100
+ submit: { dispatcher: disp, type: "app:write:task:create", validateScope: ["title"] },
101
+ });
102
+
103
+ const result = await form.submit();
104
+
105
+ expect(result.validationBlocked).toBe(true);
106
+ expect(disp.writeSpy).not.toHaveBeenCalled();
107
+ expect(form.getSnapshot().errors["title"]).toBeDefined();
108
+ });
109
+
76
110
  test("server validation failure: field errors land on the form", async () => {
77
111
  const disp = makeDispatcher({
78
112
  isSuccess: false,
@@ -45,6 +45,45 @@ describe("zodErrorToFieldIssues", () => {
45
45
  expect(issues[0]?.params).toBeDefined();
46
46
  expect(issues[0]?.params?.["minimum"]).toBe(10);
47
47
  });
48
+
49
+ // kumiko-framework#1927: a bare `code:"custom"` issue (superRefine's only
50
+ // code) would otherwise always resolve to the generic "Invalid value."
51
+ // key — `params.i18nKey` lets a check like form-schema.ts's presence
52
+ // check point at its own key instead.
53
+ test("custom issue with params.i18nKey overrides the mechanical errors.validation.custom key", () => {
54
+ const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
55
+ if (values.name === "") {
56
+ ctx.addIssue({
57
+ code: "custom",
58
+ path: ["name"],
59
+ message: '"name" is required.',
60
+ params: { i18nKey: "kumiko.validation.required" },
61
+ });
62
+ }
63
+ });
64
+ const result = schema.safeParse({ name: "" });
65
+
66
+ expect(result.success).toBe(false);
67
+ if (result.success) return;
68
+ const issues = zodErrorToFieldIssues(result.error);
69
+
70
+ expect(issues[0]?.i18nKey).toBe("kumiko.validation.required");
71
+ });
72
+
73
+ test("custom issue without params.i18nKey still falls back to errors.validation.custom", () => {
74
+ const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
75
+ if (values.name === "bad") {
76
+ ctx.addIssue({ code: "custom", path: ["name"], message: "not allowed" });
77
+ }
78
+ });
79
+ const result = schema.safeParse({ name: "bad" });
80
+
81
+ expect(result.success).toBe(false);
82
+ if (result.success) return;
83
+ const issues = zodErrorToFieldIssues(result.error);
84
+
85
+ expect(issues[0]?.i18nKey).toBe("errors.validation.custom");
86
+ });
48
87
  });
49
88
 
50
89
  describe("groupIssuesByPath", () => {
@@ -122,3 +161,106 @@ describe("createFormController — validate()", () => {
122
161
  expect(form.getSnapshot().errors["address.city"]).toBeDefined();
123
162
  });
124
163
  });
164
+
165
+ describe("createFormController — validate(scope)", () => {
166
+ test("scoped run reports only fields inside scope, drops issues outside it", () => {
167
+ const schema = z.object({
168
+ a: z.string().min(3),
169
+ b: z.string().min(3),
170
+ });
171
+ const form = createFormController({ initial: { a: "x", b: "y" }, schema });
172
+
173
+ const ok = form.validate(["a"]);
174
+ const snap = form.getSnapshot();
175
+
176
+ expect(ok).toBe(false);
177
+ expect(snap.errors["a"]).toBeDefined();
178
+ expect(snap.errors["b"]).toBeUndefined();
179
+ });
180
+
181
+ test("scoped run returns true when every field in scope is valid, even if fields outside scope are not", () => {
182
+ const schema = z.object({
183
+ a: z.string().min(1),
184
+ b: z.string().min(3),
185
+ });
186
+ const form = createFormController({ initial: { a: "ok", b: "y" }, schema });
187
+
188
+ const ok = form.validate(["a"]);
189
+
190
+ expect(ok).toBe(true);
191
+ expect(form.getSnapshot().errors).toEqual({});
192
+ });
193
+
194
+ // Hard rule (kumiko-framework#1885): an object-level .refine() issue has
195
+ // path "(root)", which never matches a field name — every scoped
196
+ // validate() call must ignore it, and only the unscoped final-submit
197
+ // validate() may surface it. Otherwise a step-boundary check would block
198
+ // a wizard step forever on an invariant no single step owns.
199
+ describe("root-path .refine() issues", () => {
200
+ const schema = z
201
+ .object({
202
+ start: z.string(),
203
+ end: z.string(),
204
+ })
205
+ .refine((v) => v.end >= v.start, { message: "end must be >= start" });
206
+
207
+ test("scoped validate() ignores the root issue regardless of which fields are in scope", () => {
208
+ const form = createFormController({ initial: { start: "b", end: "a" }, schema });
209
+
210
+ expect(form.validate(["start"])).toBe(true);
211
+ expect(form.validate(["end"])).toBe(true);
212
+ expect(form.validate(["start", "end"])).toBe(true);
213
+ expect(form.getSnapshot().errors).toEqual({});
214
+ });
215
+
216
+ test("unscoped validate() (final submit) reports the root issue under '(root)'", () => {
217
+ const form = createFormController({ initial: { start: "b", end: "a" }, schema });
218
+
219
+ const ok = form.validate();
220
+
221
+ expect(ok).toBe(false);
222
+ expect(form.getSnapshot().errors["(root)"]).toBeDefined();
223
+ });
224
+ });
225
+
226
+ test("hidden fields stay filtered in scoped mode even when the hidden field is itself in scope", () => {
227
+ // Discriminating case: field `b` is both hidden AND inside `scope`, so
228
+ // the assertion actually exercises the hidden-field filter — not just
229
+ // the scope filter dropping an out-of-scope field.
230
+ const schema = z.object({
231
+ a: z.string().min(3),
232
+ b: z.string().min(3),
233
+ });
234
+ const form = createFormController({
235
+ initial: { a: "x", b: "y", showB: false },
236
+ schema,
237
+ fields: { b: { visible: (values) => values.showB } },
238
+ });
239
+
240
+ const ok = form.validate(["a", "b"]);
241
+ const snap = form.getSnapshot();
242
+
243
+ expect(ok).toBe(false);
244
+ expect(snap.errors["a"]).toBeDefined();
245
+ expect(snap.errors["b"]).toBeUndefined();
246
+ });
247
+
248
+ test("hidden fields stay filtered in unscoped mode (regression guard alongside the scoped case)", () => {
249
+ const schema = z.object({
250
+ a: z.string().min(3),
251
+ b: z.string().min(3),
252
+ });
253
+ const form = createFormController({
254
+ initial: { a: "x", b: "y", showB: false },
255
+ schema,
256
+ fields: { b: { visible: (values) => values.showB } },
257
+ });
258
+
259
+ const ok = form.validate();
260
+ const snap = form.getSnapshot();
261
+
262
+ expect(ok).toBe(false);
263
+ expect(snap.errors["a"]).toBeDefined();
264
+ expect(snap.errors["b"]).toBeUndefined();
265
+ });
266
+ });
@@ -152,7 +152,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
152
152
  // Local shared implementations so submit() can call validate/rebase
153
153
  // without the this-in-object-literal dance. Both also expose themselves
154
154
  // as methods on the returned controller.
155
- function runValidate(): boolean {
155
+ function runValidate(scope?: readonly string[]): boolean {
156
156
  if (!options.schema) {
157
157
  if (Object.keys(errors).length > 0) {
158
158
  errors = Object.freeze({});
@@ -173,10 +173,14 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
173
173
  for (const [fieldKey, state] of Object.entries(fieldStates)) {
174
174
  if (!state.visible) hiddenFields.add(fieldKey);
175
175
  }
176
+ // See the validate() doc comment in types.ts for the scope contract.
177
+ const scopeSet = scope === undefined ? undefined : new Set(scope);
176
178
  const allIssues = zodErrorToFieldIssues(parsed.error);
177
179
  const relevantIssues = allIssues.filter((issue) => {
178
180
  const rootField = issue.path.split(".")[0] ?? "";
179
- return !hiddenFields.has(rootField);
181
+ if (hiddenFields.has(rootField)) return false;
182
+ if (scopeSet && !scopeSet.has(rootField)) return false;
183
+ return true;
180
184
  });
181
185
  if (relevantIssues.length === 0) {
182
186
  if (Object.keys(errors).length > 0) {
@@ -292,7 +296,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
292
296
  // pattern the server-side event-dispatcher uses (passInFlight).
293
297
  if (submitInFlight) return submitInFlight as Promise<SubmitResult<TData>>;
294
298
 
295
- if (!runValidate()) {
299
+ if (!runValidate(submitCfg.validateScope)) {
296
300
  return { validationBlocked: true, isSuccess: false };
297
301
  }
298
302
 
package/src/form/types.ts CHANGED
@@ -141,7 +141,19 @@ export type FormController<TValues extends FormValues> = {
141
141
  // values. Populates errors and returns true iff valid. Noop-returns
142
142
  // `true` when no schema was wired — a controller without a schema
143
143
  // relies entirely on server-side validation via the submit() path.
144
- validate(): boolean;
144
+ //
145
+ // `scope` restricts which fields' issues get reported — pass the current
146
+ // wizard step's field names to validate only that step (used on
147
+ // "Weiter"). Issues on fields outside `scope` are silently dropped, and
148
+ // so are root-level issues from an object-level `.refine()` (their path
149
+ // is `(root)`, which never matches a real field name) — a scoped run can
150
+ // never surface those. That's deliberate: a step-boundary check exists
151
+ // to gate moving forward, not to catch whole-form invariants. Omit
152
+ // `scope` (as submit() does) to validate everything, including root
153
+ // issues — the final gate before dispatch must not let them through
154
+ // unnoticed. Hidden fields are filtered out in both modes regardless of
155
+ // scope.
156
+ validate(scope?: readonly string[]): boolean;
145
157
 
146
158
  // Reverts values to `initial`, clears errors. Doesn't fire a new
147
159
  // "initial" baseline — to adopt the current values as the new baseline
@@ -251,6 +263,12 @@ export type SubmitConfig<TValues extends FormValues = FormValues> = {
251
263
  // That's intentional: a caller choosing to write a transformer is
252
264
  // making an explicit statement about payload shape.
253
265
  readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
266
+ /** Restricts submit()'s internal validate() call to these field names —
267
+ * same contract as validate()'s `scope` param. Set by RenderEdit when the
268
+ * caller's `fields` prop narrows which fields are rendered, so an
269
+ * unrendered required field can't block a submit the user can't fix.
270
+ * Omitted = unscoped (existing behavior, validates everything). */
271
+ readonly validateScope?: readonly string[];
254
272
  };
255
273
 
256
274
  // What submit() returns. Mirrors WriteResult so a failed submit can
@@ -43,12 +43,30 @@ export function zodErrorToFieldIssues(error: ZodError): FieldIssue[] {
43
43
  // malformed" errors under — matches what the server does.
44
44
  path: issue.path.map(String).join(".") || "(root)",
45
45
  code: issue.code,
46
- i18nKey: `errors.validation.${issue.code}`,
46
+ i18nKey: resolveI18nKey(issue),
47
47
  ...(params && { params }),
48
48
  };
49
49
  });
50
50
  }
51
51
 
52
+ // Every zod code maps mechanically to `errors.validation.<code>` — except
53
+ // `code: "custom"`, which is zod's one-size-fits-all bucket for every
54
+ // `superRefine`/`refine` check in the codebase. Left mechanical, ALL of them
55
+ // would collapse onto the same `errors.validation.custom` ("Invalid
56
+ // value.") key, which is wrong for a check that has a more specific message
57
+ // (e.g. form-schema.ts's presence check wants "Pflichtfeld." / "Required.").
58
+ // A `superRefine` that needs its own key sets `params.i18nKey` on the issue;
59
+ // this is the one place that honors it. Keep in sync with the server-side
60
+ // mirror (packages/framework/src/errors/zod-bridge.ts) — a superRefine can
61
+ // run on either side.
62
+ function resolveI18nKey(issue: ZodIssue): string {
63
+ if (issue.code === "custom") {
64
+ const override = issue.params?.["i18nKey"];
65
+ if (typeof override === "string") return override;
66
+ }
67
+ return `errors.validation.${issue.code}`;
68
+ }
69
+
52
70
  function extractIssueParams(issue: ZodIssue): Readonly<Record<string, unknown>> | undefined {
53
71
  const out: Record<string, unknown> = {};
54
72
  const bag = issue as unknown as Record<string, unknown>; // @cast-boundary zod-issue
@@ -69,6 +69,7 @@ function formatDateCell(
69
69
 
70
70
  export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape";
71
71
  export { type HtmlValue, html, RawHtml, raw } from "./html-template";
72
+ export { currencyDecimals } from "./money";
72
73
  export function applyFormatSpec(
73
74
  spec: { format: string } & Record<string, unknown>,
74
75
  value: unknown,
@@ -0,0 +1,11 @@
1
+ // Currency decimal places — covers the world's zero- and three-decimal
2
+ // outliers, defaults to 2 for everything else. Shared between renderer-web's
3
+ // MoneyInput (minor-unit display factor) and RenderField's minor/major-unit
4
+ // conversion (kumiko-framework#1923) — a single source keeps both sides
5
+ // scaling the same currency the same way.
6
+ export function currencyDecimals(code: string): number {
7
+ if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
8
+ if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
9
+ return 3;
10
+ return 2;
11
+ }
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export type {
55
55
  export { createFormController } from "./form";
56
56
  export {
57
57
  applyFormatSpec,
58
+ currencyDecimals,
58
59
  escapeHtml,
59
60
  escapeHtmlAttr,
60
61
  escapeXml,
@@ -102,7 +102,7 @@ export function computeEditViewModel<
102
102
  // List-Builder), damit Form-Selects und List-Cells dieselbe
103
103
  // i18n-Quelle teilen.
104
104
  const options =
105
- fieldDef.type === "select"
105
+ fieldDef.type === "select" || fieldDef.type === "multiSelect"
106
106
  ? ((fieldDef as unknown as { options?: readonly string[] }).options ?? [])
107
107
  : undefined;
108
108
  const optionLabels =
@@ -113,11 +113,12 @@ export function computeEditViewModel<
113
113
  options,
114
114
  )
115
115
  : undefined;
116
- // Multiline-Hint bei `type: "text"` — der Renderer wechselt
117
- // dann auf textarea. ViewModel hält die Form-Render-Decision
118
- // damit der Renderer nicht selbst auf die FieldDefinition greift.
116
+ // Multiline hint for `type: "text"` — the renderer then switches to a
117
+ // textarea. `type: "longText"` always renders a textarea regardless
118
+ // of this hint; it's only carried through as an optional `{ rows }`
119
+ // override (#1925).
119
120
  const multiline =
120
- fieldDef.type === "text"
121
+ fieldDef.type === "text" || fieldDef.type === "longText"
121
122
  ? (fieldDef as unknown as { multiline?: boolean | { rows?: number } }).multiline
122
123
  : undefined;
123
124
  // Wall-Clock-Hint bei `type: "timestamp"` mit locatedBy — der
@@ -163,6 +164,11 @@ export function computeEditViewModel<
163
164
  // file/image: accept/maxSize ins ViewModel + entityType/fieldName für
164
165
  // den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
165
166
  const isFileType = fieldDef.type === "file" || fieldDef.type === "image";
167
+ // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
168
+ // framework/src/engine/field-helpers.ts — headless has no dependency
169
+ // on that module, so the literal is duplicated here instead of
170
+ // importing it just for one fallback string.
171
+ const resolvedCurrency = entity.defaultCurrency ?? "EUR";
166
172
  const fileDef = isFileType
167
173
  ? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
168
174
  : undefined;
@@ -269,15 +275,10 @@ export function computeEditViewModel<
269
275
  ...(embeddedListDef?.totals !== undefined && {
270
276
  embeddedListTotals: embeddedListDef.totals,
271
277
  }),
272
- // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
273
- // framework/src/engine/field-helpers.tsheadless has no dependency
274
- // on that module, so the literal is duplicated here instead of
275
- // importing it just for one fallback string. Currency lives on the
276
- // head aggregate (entity.defaultCurrency), not per row — one value
277
- // for the whole embedded list.
278
- ...(embeddedListDef !== undefined && {
279
- embeddedListCurrency: entity.defaultCurrency ?? "EUR",
280
- }),
278
+ // Currency lives on the head aggregate (entity.defaultCurrency), not
279
+ // per row one value for the whole embedded list.
280
+ ...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
281
+ ...(fieldDef.type === "money" && { currency: resolvedCurrency }),
281
282
  };
282
283
  return view;
283
284
  });
@@ -128,18 +128,22 @@ export type EditFieldViewModel = {
128
128
  readonly required: boolean;
129
129
  readonly span?: number;
130
130
  readonly renderer?: FieldRenderer;
131
- /** Nur bei `type: "select"` gesetzt die zugelassenen Werte. Wird
132
- * vom Renderer als Dropdown-Optionen genutzt. Quelle ist
133
- * SelectFieldDef.options aus der EntityDefinition. */
131
+ /** Set for `type: "select"` and `type: "multiSelect"` the allowed
132
+ * values. The renderer uses these as dropdown/combobox options.
133
+ * Source is SelectFieldDef.options / MultiSelectFieldDef.options on
134
+ * the EntityDefinition. */
134
135
  readonly options?: readonly string[];
135
- /** Nur bei `type: "select"` gesetzt — translated Labels pro Option,
136
- * keyed nach raw value. Renderer zeigt `optionLabels[value]` als
137
- * Dropdown-Label statt raw value. Convention-Key:
136
+ /** Set for `type: "select"` and `type: "multiSelect"` — translated
137
+ * labels per option, keyed by raw value. The renderer shows
138
+ * `optionLabels[value]` as the dropdown/combobox label instead of the
139
+ * raw value. Convention key:
138
140
  * `<feature>:entity:<entity>:field:<field>:option:<value>`. */
139
141
  readonly optionLabels?: Readonly<Record<string, string>>;
140
- /** Nur bei `type: "text"` gesetzt wenn TextFieldDef.multiline true
141
- * ist dann rendert der Renderer textarea statt single-line input.
142
- * `true` = Default-Zeilen, `{ rows }` = explizite Höhe. */
142
+ /** For `type: "text"`, set when TextFieldDef.multiline is true — the
143
+ * renderer then renders a textarea instead of a single-line input.
144
+ * `type: "longText"` always renders a textarea regardless of this
145
+ * value; here it only carries an optional `{ rows }` override.
146
+ * `true` = default rows, `{ rows }` = explicit height. */
143
147
  readonly multiline?: boolean | { readonly rows?: number };
144
148
  /** Nur bei `type: "timestamp"` gesetzt wenn TimestampFieldDef.locatedBy
145
149
  * existiert — Wall-Clock-Zeit ohne Offset. Der Renderer emittiert
@@ -204,6 +208,11 @@ export type EditFieldViewModel = {
204
208
  * currency lives on the head aggregate (EntityDefinition.defaultCurrency),
205
209
  * not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
206
210
  readonly embeddedListCurrency?: string;
211
+ /** Only set for a plain (non-list, non-embedded) `type: "money"` field —
212
+ * same source as embeddedListCurrency (EntityDefinition.defaultCurrency,
213
+ * falls back to "EUR"). The renderer needs this to build the payload's
214
+ * `{amount, currency}` shape (kumiko-framework#1923). */
215
+ readonly currency?: string;
207
216
  };
208
217
 
209
218
  // Discriminated by `kind` — mirrors EditSectionSpec on the engine side.