@cosmicdrift/kumiko-headless 0.187.0 → 0.188.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.188.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.188.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,
@@ -122,3 +122,106 @@ describe("createFormController — validate()", () => {
122
122
  expect(form.getSnapshot().errors["address.city"]).toBeDefined();
123
123
  });
124
124
  });
125
+
126
+ describe("createFormController — validate(scope)", () => {
127
+ test("scoped run reports only fields inside scope, drops issues outside it", () => {
128
+ const schema = z.object({
129
+ a: z.string().min(3),
130
+ b: z.string().min(3),
131
+ });
132
+ const form = createFormController({ initial: { a: "x", b: "y" }, schema });
133
+
134
+ const ok = form.validate(["a"]);
135
+ const snap = form.getSnapshot();
136
+
137
+ expect(ok).toBe(false);
138
+ expect(snap.errors["a"]).toBeDefined();
139
+ expect(snap.errors["b"]).toBeUndefined();
140
+ });
141
+
142
+ test("scoped run returns true when every field in scope is valid, even if fields outside scope are not", () => {
143
+ const schema = z.object({
144
+ a: z.string().min(1),
145
+ b: z.string().min(3),
146
+ });
147
+ const form = createFormController({ initial: { a: "ok", b: "y" }, schema });
148
+
149
+ const ok = form.validate(["a"]);
150
+
151
+ expect(ok).toBe(true);
152
+ expect(form.getSnapshot().errors).toEqual({});
153
+ });
154
+
155
+ // Hard rule (kumiko-framework#1885): an object-level .refine() issue has
156
+ // path "(root)", which never matches a field name — every scoped
157
+ // validate() call must ignore it, and only the unscoped final-submit
158
+ // validate() may surface it. Otherwise a step-boundary check would block
159
+ // a wizard step forever on an invariant no single step owns.
160
+ describe("root-path .refine() issues", () => {
161
+ const schema = z
162
+ .object({
163
+ start: z.string(),
164
+ end: z.string(),
165
+ })
166
+ .refine((v) => v.end >= v.start, { message: "end must be >= start" });
167
+
168
+ test("scoped validate() ignores the root issue regardless of which fields are in scope", () => {
169
+ const form = createFormController({ initial: { start: "b", end: "a" }, schema });
170
+
171
+ expect(form.validate(["start"])).toBe(true);
172
+ expect(form.validate(["end"])).toBe(true);
173
+ expect(form.validate(["start", "end"])).toBe(true);
174
+ expect(form.getSnapshot().errors).toEqual({});
175
+ });
176
+
177
+ test("unscoped validate() (final submit) reports the root issue under '(root)'", () => {
178
+ const form = createFormController({ initial: { start: "b", end: "a" }, schema });
179
+
180
+ const ok = form.validate();
181
+
182
+ expect(ok).toBe(false);
183
+ expect(form.getSnapshot().errors["(root)"]).toBeDefined();
184
+ });
185
+ });
186
+
187
+ test("hidden fields stay filtered in scoped mode even when the hidden field is itself in scope", () => {
188
+ // Discriminating case: field `b` is both hidden AND inside `scope`, so
189
+ // the assertion actually exercises the hidden-field filter — not just
190
+ // the scope filter dropping an out-of-scope field.
191
+ const schema = z.object({
192
+ a: z.string().min(3),
193
+ b: z.string().min(3),
194
+ });
195
+ const form = createFormController({
196
+ initial: { a: "x", b: "y", showB: false },
197
+ schema,
198
+ fields: { b: { visible: (values) => values.showB } },
199
+ });
200
+
201
+ const ok = form.validate(["a", "b"]);
202
+ const snap = form.getSnapshot();
203
+
204
+ expect(ok).toBe(false);
205
+ expect(snap.errors["a"]).toBeDefined();
206
+ expect(snap.errors["b"]).toBeUndefined();
207
+ });
208
+
209
+ test("hidden fields stay filtered in unscoped mode (regression guard alongside the scoped case)", () => {
210
+ const schema = z.object({
211
+ a: z.string().min(3),
212
+ b: z.string().min(3),
213
+ });
214
+ const form = createFormController({
215
+ initial: { a: "x", b: "y", showB: false },
216
+ schema,
217
+ fields: { b: { visible: (values) => values.showB } },
218
+ });
219
+
220
+ const ok = form.validate();
221
+ const snap = form.getSnapshot();
222
+
223
+ expect(ok).toBe(false);
224
+ expect(snap.errors["a"]).toBeDefined();
225
+ expect(snap.errors["b"]).toBeUndefined();
226
+ });
227
+ });
@@ -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
@@ -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,
@@ -163,6 +163,11 @@ export function computeEditViewModel<
163
163
  // file/image: accept/maxSize ins ViewModel + entityType/fieldName für
164
164
  // den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
165
165
  const isFileType = fieldDef.type === "file" || fieldDef.type === "image";
166
+ // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
167
+ // framework/src/engine/field-helpers.ts — headless has no dependency
168
+ // on that module, so the literal is duplicated here instead of
169
+ // importing it just for one fallback string.
170
+ const resolvedCurrency = entity.defaultCurrency ?? "EUR";
166
171
  const fileDef = isFileType
167
172
  ? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
168
173
  : undefined;
@@ -269,15 +274,10 @@ export function computeEditViewModel<
269
274
  ...(embeddedListDef?.totals !== undefined && {
270
275
  embeddedListTotals: embeddedListDef.totals,
271
276
  }),
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
- }),
277
+ // Currency lives on the head aggregate (entity.defaultCurrency), not
278
+ // per row one value for the whole embedded list.
279
+ ...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
280
+ ...(fieldDef.type === "money" && { currency: resolvedCurrency }),
281
281
  };
282
282
  return view;
283
283
  });
@@ -204,6 +204,11 @@ export type EditFieldViewModel = {
204
204
  * currency lives on the head aggregate (EntityDefinition.defaultCurrency),
205
205
  * not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
206
206
  readonly embeddedListCurrency?: string;
207
+ /** Only set for a plain (non-list, non-embedded) `type: "money"` field —
208
+ * same source as embeddedListCurrency (EntityDefinition.defaultCurrency,
209
+ * falls back to "EUR"). The renderer needs this to build the payload's
210
+ * `{amount, currency}` shape (kumiko-framework#1923). */
211
+ readonly currency?: string;
207
212
  };
208
213
 
209
214
  // Discriminated by `kind` — mirrors EditSectionSpec on the engine side.