@cosmicdrift/kumiko-headless 0.193.1 → 0.195.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.193.1",
3
+ "version": "0.195.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.193.1",
39
+ "@cosmicdrift/kumiko-framework": "0.195.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { createFormController } from "../form-controller";
3
4
 
4
5
  describe("createFormController — core state machine", () => {
@@ -193,3 +194,38 @@ describe("createFormController — core state machine", () => {
193
194
  expect(snap.changes["title"]).toBeUndefined();
194
195
  });
195
196
  });
197
+
198
+ describe("validate() scoping", () => {
199
+ const schema = z.object({ step1: z.string().min(1), step2: z.string().min(1) });
200
+
201
+ test("a clean scoped validate() doesn't wipe an out-of-scope field's existing errors", () => {
202
+ const form = createFormController({ initial: { step1: "", step2: "filled" }, schema });
203
+
204
+ // Unscoped run (e.g. submit) surfaces errors on both fields.
205
+ expect(form.validate()).toBe(false);
206
+ expect(form.getSnapshot().errors["step1"]).toBeDefined();
207
+
208
+ // step2 is now fixed; a scoped validate() for step2 alone must not
209
+ // clear step1's still-outstanding error.
210
+ form.setField("step2", "filled");
211
+ expect(form.validate(["step2"])).toBe(true);
212
+ const snap = form.getSnapshot();
213
+ expect(snap.errors["step2"]).toBeUndefined();
214
+ expect(snap.errors["step1"]).toBeDefined();
215
+ });
216
+
217
+ test("a failing scoped validate() doesn't overwrite an out-of-scope field's existing errors", () => {
218
+ const form = createFormController({ initial: { step1: "", step2: "" }, schema });
219
+
220
+ expect(form.validate()).toBe(false);
221
+ expect(form.getSnapshot().errors["step1"]).toBeDefined();
222
+ expect(form.getSnapshot().errors["step2"]).toBeDefined();
223
+
224
+ // Re-running validate scoped to step1 only (still empty) must refresh
225
+ // step1's error without touching step2's independently-set error.
226
+ expect(form.validate(["step1"])).toBe(false);
227
+ const snap = form.getSnapshot();
228
+ expect(snap.errors["step1"]).toBeDefined();
229
+ expect(snap.errors["step2"]).toBeDefined();
230
+ });
231
+ });
@@ -231,6 +231,44 @@ describe("createFormController — submit()", () => {
231
231
  });
232
232
  });
233
233
 
234
+ test("payloadMode: 'values' — strips a field that was touched and returned to \"\" (value-based, not touch-based)", async () => {
235
+ // stripUntouchedEmptyStrings only compares current vs. initial value —
236
+ // it has no concept of "touched". A field the user typed into and then
237
+ // cleared back to "" is dropped exactly like one nobody ever touched.
238
+ const disp = makeDispatcher();
239
+ const form = createFormController({
240
+ initial: { title: "hello", dueDate: "" },
241
+ submit: { dispatcher: disp, type: "app:write:task:create" },
242
+ });
243
+ form.setField("dueDate", "x");
244
+ form.setField("dueDate", "");
245
+
246
+ await form.submit();
247
+
248
+ const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
249
+ expect("dueDate" in call).toBe(false);
250
+ });
251
+
252
+ test("second submit after a rebase diffs against the new baseline, not the stale original initial", async () => {
253
+ const disp = makeDispatcher();
254
+ const form = createFormController({
255
+ initial: { title: "hello" },
256
+ submit: { dispatcher: disp, type: "app:write:task:update", payloadMode: "changes" },
257
+ });
258
+
259
+ form.setField("title", "world");
260
+ await form.submit();
261
+ expect(form.getSnapshot().initial.title).toBe("world"); // baseline rebased
262
+
263
+ // Revert to the ORIGINAL initial value — clean against the stale
264
+ // baseline, but a real change against the rebased one.
265
+ form.setField("title", "hello");
266
+ await form.submit();
267
+
268
+ expect(disp.writeSpy).toHaveBeenCalledTimes(2);
269
+ expect(disp.writeSpy.mock.calls[1]).toEqual(["app:write:task:update", { title: "hello" }]);
270
+ });
271
+
234
272
  test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
235
273
  // User submits "hello", the network takes 50ms. During those 50ms the
236
274
  // user types "world" into the same field. The server sees "hello"
@@ -46,10 +46,6 @@ describe("zodErrorToFieldIssues", () => {
46
46
  expect(issues[0]?.params?.["minimum"]).toBe(10);
47
47
  });
48
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
49
  test("custom issue with params.i18nKey overrides the mechanical errors.validation.custom key", () => {
54
50
  const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
55
51
  if (values.name === "") {
@@ -76,6 +76,11 @@ function valuesDiff<TValues extends FormValues>(
76
76
 
77
77
  // buildInitialValues seeds untouched optional fields with "" for controlled
78
78
  // inputs; a `.optional()` server schema accepts undefined but not "".
79
+ // String-only: number/money seed to 0 and boolean seeds to false, and an
80
+ // untouched optional field of those types still submits that seed instead
81
+ // of being omitted — no validation error surfaces it, unlike the "" case.
82
+ // Fixing that needs real per-field seed-tracking (which value was actually
83
+ // user-set vs. defaulted), not just a wider strip predicate here.
79
84
  function stripUntouchedEmptyStrings<TValues extends FormValues>(
80
85
  values: TValues,
81
86
  initial: TValues,
@@ -84,7 +89,7 @@ function stripUntouchedEmptyStrings<TValues extends FormValues>(
84
89
  const ini = initial as Record<string, unknown>; // @cast-boundary form-values
85
90
  let out: Record<string, unknown> | undefined;
86
91
  for (const key of Object.keys(v)) {
87
- if (v[key] === "" && Object.is(v[key], ini[key])) {
92
+ if (v[key] === "" && ini[key] === "") {
88
93
  out ??= { ...v };
89
94
  delete out[key];
90
95
  }
@@ -152,10 +157,33 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
152
157
  // Local shared implementations so submit() can call validate/rebase
153
158
  // without the this-in-object-literal dance. Both also expose themselves
154
159
  // as methods on the returned controller.
160
+ // Clears/replaces errors for a scoped run without touching fields outside
161
+ // the scope — a step-2 validate() that comes back clean must not wipe
162
+ // step-1's still-unresolved errors from an earlier submit() (or vice
163
+ // versa when step-2 newly has issues).
164
+ function replaceScopedErrors(
165
+ scopeSet: Set<string> | undefined,
166
+ grouped: Readonly<Record<string, readonly FieldIssue[]>>,
167
+ ): void {
168
+ if (scopeSet === undefined) {
169
+ errors = Object.freeze(grouped);
170
+ // skip: unscoped replace — wholesale overwrite, nothing left to merge
171
+ return;
172
+ }
173
+ const merged: Record<string, readonly FieldIssue[]> = {};
174
+ for (const [path, issues] of Object.entries(errors)) {
175
+ if (!scopeSet.has(path)) merged[path] = issues;
176
+ }
177
+ Object.assign(merged, grouped);
178
+ errors = Object.freeze(merged);
179
+ }
180
+
155
181
  function runValidate(scope?: readonly string[]): boolean {
182
+ // See the validate() doc comment in types.ts for the scope contract.
183
+ const scopeSet = scope === undefined ? undefined : new Set(scope);
156
184
  if (!options.schema) {
157
185
  if (Object.keys(errors).length > 0) {
158
- errors = Object.freeze({});
186
+ replaceScopedErrors(scopeSet, {});
159
187
  invalidate();
160
188
  }
161
189
  return true;
@@ -164,7 +192,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
164
192
  const parsed = options.schema.safeParse(values);
165
193
  if (parsed.success) {
166
194
  if (Object.keys(errors).length > 0) {
167
- errors = Object.freeze({});
195
+ replaceScopedErrors(scopeSet, {});
168
196
  invalidate();
169
197
  }
170
198
  return true;
@@ -173,8 +201,6 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
173
201
  for (const [fieldKey, state] of Object.entries(fieldStates)) {
174
202
  if (!state.visible) hiddenFields.add(fieldKey);
175
203
  }
176
- // See the validate() doc comment in types.ts for the scope contract.
177
- const scopeSet = scope === undefined ? undefined : new Set(scope);
178
204
  const allIssues = zodErrorToFieldIssues(parsed.error);
179
205
  const relevantIssues = allIssues.filter((issue) => {
180
206
  const rootField = issue.path.split(".")[0] ?? "";
@@ -184,12 +210,12 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
184
210
  });
185
211
  if (relevantIssues.length === 0) {
186
212
  if (Object.keys(errors).length > 0) {
187
- errors = Object.freeze({});
213
+ replaceScopedErrors(scopeSet, {});
188
214
  invalidate();
189
215
  }
190
216
  return true;
191
217
  }
192
- errors = Object.freeze(groupIssuesByPath(relevantIssues));
218
+ replaceScopedErrors(scopeSet, groupIssuesByPath(relevantIssues));
193
219
  invalidate();
194
220
  return false;
195
221
  }
package/src/form/index.ts CHANGED
@@ -12,4 +12,4 @@ export type {
12
12
  SubmitPayloadMode,
13
13
  SubmitResult,
14
14
  } from "./types";
15
- export { groupIssuesByPath, zodErrorToFieldIssues } from "./zod-bridge";
15
+ export { groupIssuesByPath, I18N_KEY_PARAM, zodErrorToFieldIssues } from "./zod-bridge";
package/src/form/types.ts CHANGED
@@ -142,17 +142,9 @@ export type FormController<TValues extends FormValues> = {
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
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.
145
+ // `scope` restricts reported issues to those field names; root-level
146
+ // `.refine()` issues (path `(root)`) never match a scope and are dropped
147
+ // by design omit `scope` (as submit() does) to see them.
156
148
  validate(scope?: readonly string[]): boolean;
157
149
 
158
150
  // Reverts values to `initial`, clears errors. Doesn't fire a new
@@ -49,19 +49,17 @@ export function zodErrorToFieldIssues(error: ZodError): FieldIssue[] {
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.
52
+ // Same magic key both sides of a superRefine can set to opt out of the
53
+ // generic `errors.validation.custom` message see form-schema.ts.
54
+ export const I18N_KEY_PARAM = "i18nKey";
55
+
56
+ // `code: "custom"` is zod's catch-all for every superRefine/refine check;
57
+ // left mechanical it'd collapse onto one generic key, so a superRefine can
58
+ // set `params[I18N_KEY_PARAM]` to override it. Keep in sync with the server
59
+ // mirror (packages/framework/src/errors/zod-bridge.ts).
62
60
  function resolveI18nKey(issue: ZodIssue): string {
63
61
  if (issue.code === "custom") {
64
- const override = issue.params?.["i18nKey"];
62
+ const override = issue.params?.[I18N_KEY_PARAM];
65
63
  if (typeof override === "string") return override;
66
64
  }
67
65
  return `errors.validation.${issue.code}`;
package/src/index.ts CHANGED
@@ -52,7 +52,7 @@ export type {
52
52
  SubmitPayloadMode,
53
53
  SubmitResult,
54
54
  } from "./form";
55
- export { createFormController } from "./form";
55
+ export { createFormController, I18N_KEY_PARAM } from "./form";
56
56
  export {
57
57
  applyFormatSpec,
58
58
  currencyDecimals,
@@ -593,4 +593,42 @@ describe("computeEditViewModel — embedded-list cells (#1835)", () => {
593
593
  const field = asFields(vm.sections[0]).fields[0];
594
594
  expect(field?.imageVariant).toBeUndefined();
595
595
  });
596
+
597
+ test("capture is forwarded for an image field that declares it", () => {
598
+ const entity = {
599
+ fields: {
600
+ avatar: { type: "image", capture: "environment" },
601
+ },
602
+ } as unknown as EntityDefinition;
603
+
604
+ const vm = computeEditViewModel({
605
+ screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
606
+ entity,
607
+ values: {},
608
+ translate,
609
+ featureName: "orders",
610
+ });
611
+
612
+ const field = asFields(vm.sections[0]).fields[0];
613
+ expect(field?.capture).toBe("environment");
614
+ });
615
+
616
+ test("capture is undefined for an image field without it", () => {
617
+ const entity = {
618
+ fields: {
619
+ avatar: { type: "image" },
620
+ },
621
+ } as unknown as EntityDefinition;
622
+
623
+ const vm = computeEditViewModel({
624
+ screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
625
+ entity,
626
+ values: {},
627
+ translate,
628
+ featureName: "orders",
629
+ });
630
+
631
+ const field = asFields(vm.sections[0]).fields[0];
632
+ expect(field?.capture).toBeUndefined();
633
+ });
596
634
  });
@@ -174,10 +174,12 @@ export function computeEditViewModel<
174
174
  accept?: readonly string[];
175
175
  maxSize?: string;
176
176
  variants?: Readonly<Record<string, unknown>>;
177
+ capture?: "environment" | "user";
177
178
  })
178
179
  : undefined;
179
180
  const imageVariant =
180
181
  fieldDef.type === "image" ? Object.keys(fileDef?.variants ?? {})[0] : undefined;
182
+ const capture = fieldDef.type === "image" ? fileDef?.capture : undefined;
181
183
  // Embedded-LIST field (`multiple: true`) — per-cell metadata for a
182
184
  // renderer to draw one row per array item (invoice-positions-style
183
185
  // table). A plain (non-list) embedded field emits none of this; the
@@ -268,6 +270,7 @@ export function computeEditViewModel<
268
270
  ...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
269
271
  ...(isFileType && { entityType: screen.entity, fieldName: normalized.field }),
270
272
  ...(imageVariant !== undefined && { imageVariant }),
273
+ ...(capture !== undefined && { capture }),
271
274
  ...(normalized.icon !== undefined && { icon: normalized.icon }),
272
275
  ...(embeddedListCells !== undefined && { embeddedListCells }),
273
276
  ...(embeddedListDef?.minItems !== undefined && {
@@ -1,6 +1,7 @@
1
+ import type { EmbeddedDerivedCellDef } from "@cosmicdrift/kumiko-framework/ui-types";
1
2
  import type { FieldIssue } from "../dispatcher";
2
3
 
3
- export type EmbeddedDerivedOp = "multiply" | "sum" | "subtract";
4
+ export type EmbeddedDerivedOp = EmbeddedDerivedCellDef["op"];
4
5
 
5
6
  export type { DerivedCellRoundingTarget } from "@cosmicdrift/kumiko-framework/ui-types";
6
7
  // Canonical implementation moved to packages/framework/src/engine/embedded-derived.ts
@@ -188,6 +188,9 @@ export type EditFieldViewModel = {
188
188
  * is not a rule that always has an answer. Absent when the field declares
189
189
  * no variants — the preview then loads the original. */
190
190
  readonly imageVariant?: string;
191
+ /** Only for `type: "image"` — forwarded to the file input's `capture`
192
+ * attribute so a phone opens the camera instead of the file picker. */
193
+ readonly capture?: "environment" | "user";
191
194
  /** Prefix-icon key from `EditFieldSpec.icon`, passed through unchanged
192
195
  * (no i18n — it's a symbolic key, not a display string). The renderer
193
196
  * resolves it against the FIELD_ICONS registry; unknown keys silently