@cosmicdrift/kumiko-renderer 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-renderer",
3
- "version": "0.187.0",
3
+ "version": "0.189.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,10 +15,11 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.187.0",
19
- "@cosmicdrift/kumiko-headless": "0.187.0",
18
+ "@cosmicdrift/kumiko-framework": "0.189.0",
19
+ "@cosmicdrift/kumiko-headless": "0.189.0",
20
20
  "react": "^19.2.6",
21
- "temporal-polyfill": "^0.3.2"
21
+ "temporal-polyfill": "^0.3.2",
22
+ "zod": "^4.4.3"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@testing-library/react": "^16.3.2",
@@ -40,12 +40,24 @@ describe("mergeSearchParamsIntoInitial", () => {
40
40
  expect(result["total"]).toBe(100);
41
41
  });
42
42
 
43
- test("money-type field coerces a numeric string", () => {
43
+ test("money-type field without a defaultCurrency coerces a bare numeric string (legacy callers, e.g. config-edit/action-form)", () => {
44
44
  const fields: Record<string, FieldDef> = { price: { type: "money" } };
45
45
  const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
46
46
  expect(result["price"]).toBe(19.99);
47
47
  });
48
48
 
49
+ test("money-type field WITH a defaultCurrency merges the entityEdit payload shape (#1923)", () => {
50
+ const fields: Record<string, FieldDef> = { price: { type: "money" } };
51
+ const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" }, undefined, "USD");
52
+ expect(result["price"]).toEqual({ amount: 19.99, currency: "USD" });
53
+ });
54
+
55
+ test("money-type field WITH a defaultCurrency but no matching searchParam still defaults to the object shape", () => {
56
+ const fields: Record<string, FieldDef> = { price: { type: "money" } };
57
+ const result = mergeSearchParamsIntoInitial(fields, {}, undefined, "USD");
58
+ expect(result["price"]).toEqual({ amount: 0, currency: "USD" });
59
+ });
60
+
49
61
  test("renderableFields set given: searchParam for a non-rendered field is ignored (#1708)", () => {
50
62
  const fields: Record<string, FieldDef> = {
51
63
  status: { type: "text", default: "draft" },
@@ -0,0 +1,227 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ EditFieldSpec,
4
+ EntityDefinition,
5
+ EntityEditScreenDefinition,
6
+ } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import { createFormController } from "@cosmicdrift/kumiko-headless";
8
+ import { buildFormSchema } from "../form-schema";
9
+
10
+ function screenWith(fields: readonly EditFieldSpec[]): EntityEditScreenDefinition {
11
+ return {
12
+ id: "s",
13
+ type: "entityEdit",
14
+ entity: "e",
15
+ layout: { sections: [{ fields }] },
16
+ };
17
+ }
18
+
19
+ function entityWith(fields: EntityDefinition["fields"]): EntityDefinition {
20
+ return { fields } as EntityDefinition;
21
+ }
22
+
23
+ describe("buildFormSchema", () => {
24
+ describe("required field missing → issue on that field", () => {
25
+ const entity = entityWith({ name: { type: "text", required: true } });
26
+ const screen = screenWith(["name"]);
27
+
28
+ for (const [label, value] of [
29
+ ['""', ""],
30
+ ["null", null],
31
+ ["undefined", undefined],
32
+ ["[]", [] as unknown[]],
33
+ ] as const) {
34
+ test(label, () => {
35
+ const result = buildFormSchema(entity, screen).safeParse({ name: value });
36
+ expect(result.success).toBe(false);
37
+ if (result.success) return;
38
+ expect(result.error.issues).toHaveLength(1);
39
+ expect(result.error.issues[0]?.path).toEqual(["name"]);
40
+ });
41
+ }
42
+ });
43
+
44
+ // kumiko-framework#1927: a bare presence issue used to render as
45
+ // "Invalid value." — pin the params.i18nKey override so the resolved
46
+ // FieldIssue points at "kumiko.validation.required" ("Pflichtfeld.")
47
+ // instead of the generic errors.validation.custom fallback.
48
+ test("required field missing → issue carries the required-field i18nKey override", () => {
49
+ const entity = entityWith({ name: { type: "text", required: true } });
50
+ const screen = screenWith(["name"]);
51
+
52
+ const result = buildFormSchema(entity, screen).safeParse({ name: "" });
53
+ expect(result.success).toBe(false);
54
+ if (result.success) return;
55
+ const issue = result.error.issues[0];
56
+ if (issue?.code !== "custom") throw new Error("expected a custom issue");
57
+ expect(issue.params).toMatchObject({ i18nKey: "kumiko.validation.required" });
58
+ });
59
+
60
+ // kumiko-framework#1927: the seam the bug actually lived in — a schema
61
+ // built here only round-trips through createFormController's validate(),
62
+ // which is what feeds FormSnapshot.errors that render-edit.tsx passes to
63
+ // RenderField. A unit test on buildFormSchema() alone can't catch a break
64
+ // in that hand-off (e.g. zodErrorToFieldIssues not honoring the override).
65
+ test("end-to-end via createFormController: required field left empty → snapshot error carries kumiko.validation.required", () => {
66
+ const entity = entityWith({ name: { type: "text", required: true } });
67
+ const screen = screenWith(["name"]);
68
+
69
+ const form = createFormController({
70
+ initial: { name: "" },
71
+ schema: buildFormSchema(entity, screen),
72
+ });
73
+
74
+ expect(form.validate()).toBe(false);
75
+ const fieldErrors = form.getSnapshot().errors["name"];
76
+ expect(fieldErrors?.[0]?.i18nKey).toBe("kumiko.validation.required");
77
+ });
78
+
79
+ describe("required field present → no issue", () => {
80
+ const entity = entityWith({ name: { type: "number", required: true } });
81
+ const screen = screenWith(["name"]);
82
+
83
+ for (const [label, value] of [
84
+ ["0", 0],
85
+ ["false", false],
86
+ ['"Ada"', "Ada"],
87
+ ] as const) {
88
+ test(label, () => {
89
+ const result = buildFormSchema(entity, screen).safeParse({ name: value });
90
+ expect(result.success).toBe(true);
91
+ });
92
+ }
93
+ });
94
+
95
+ test("optional field left empty → no issue", () => {
96
+ const entity = entityWith({ name: { type: "text", required: false } });
97
+ const screen = screenWith(["name"]);
98
+ expect(buildFormSchema(entity, screen).safeParse({ name: "" }).success).toBe(true);
99
+ });
100
+
101
+ test("required field not rendered by the layout → no issue", () => {
102
+ const entity = entityWith({
103
+ name: { type: "text", required: true },
104
+ hidden: { type: "text", required: true },
105
+ });
106
+ const screen = screenWith(["name"]);
107
+ const result = buildFormSchema(entity, screen).safeParse({ name: "Ada", hidden: "" });
108
+ expect(result.success).toBe(true);
109
+ });
110
+
111
+ test("required multiSelect, empty array → issue on that field (#1925: has a combobox widget now)", () => {
112
+ const entity = entityWith({
113
+ tags: { type: "multiSelect", required: true, options: ["a", "b"] },
114
+ });
115
+ const screen = screenWith(["tags"]);
116
+ const result = buildFormSchema(entity, screen).safeParse({ tags: [] });
117
+ expect(result.success).toBe(false);
118
+ if (result.success) return;
119
+ expect(result.error.issues).toHaveLength(1);
120
+ expect(result.error.issues[0]?.path).toEqual(["tags"]);
121
+ });
122
+
123
+ test("required multiSelect, non-empty array → no issue", () => {
124
+ const entity = entityWith({
125
+ tags: { type: "multiSelect", required: true, options: ["a", "b"] },
126
+ });
127
+ const screen = screenWith(["tags"]);
128
+ expect(buildFormSchema(entity, screen).safeParse({ tags: ["a"] }).success).toBe(true);
129
+ });
130
+
131
+ test("jsonb field → no issue (no editable widget on the auto-wired path)", () => {
132
+ const entity = entityWith({ data: { type: "jsonb" } });
133
+ const screen = screenWith(["data"]);
134
+ expect(buildFormSchema(entity, screen).safeParse({ data: undefined }).success).toBe(true);
135
+ });
136
+
137
+ test("required embedded field → no issue (no editable widget on the auto-wired path)", () => {
138
+ const entity = entityWith({
139
+ lines: { type: "embedded", required: true, schema: {} },
140
+ });
141
+ const screen = screenWith(["lines"]);
142
+ expect(buildFormSchema(entity, screen).safeParse({ lines: undefined }).success).toBe(true);
143
+ });
144
+
145
+ test("required files field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
146
+ const entity = entityWith({ attachments: { type: "files" } });
147
+ const screen = screenWith([{ field: "attachments", required: true }]);
148
+ expect(buildFormSchema(entity, screen).safeParse({ attachments: undefined }).success).toBe(
149
+ true,
150
+ );
151
+ });
152
+
153
+ test("required images field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
154
+ const entity = entityWith({ gallery: { type: "images" } });
155
+ const screen = screenWith([{ field: "gallery", required: true }]);
156
+ expect(buildFormSchema(entity, screen).safeParse({ gallery: undefined }).success).toBe(true);
157
+ });
158
+
159
+ test("required money — bare number (create-form representation) → no issue", () => {
160
+ const entity = entityWith({ price: { type: "money", required: true } });
161
+ const screen = screenWith(["price"]);
162
+ expect(buildFormSchema(entity, screen).safeParse({ price: 1000 }).success).toBe(true);
163
+ });
164
+
165
+ test("required money — {amount,currency} (update-form representation) → no issue", () => {
166
+ const entity = entityWith({ price: { type: "money", required: true } });
167
+ const screen = screenWith(["price"]);
168
+ const result = buildFormSchema(entity, screen).safeParse({
169
+ price: { amount: 10, currency: "EUR" },
170
+ });
171
+ expect(result.success).toBe(true);
172
+ });
173
+
174
+ test("required money — undefined → issue on that field (has a widget)", () => {
175
+ const entity = entityWith({ price: { type: "money", required: true } });
176
+ const screen = screenWith(["price"]);
177
+ const result = buildFormSchema(entity, screen).safeParse({ price: undefined });
178
+ expect(result.success).toBe(false);
179
+ if (result.success) return;
180
+ expect(result.error.issues).toHaveLength(1);
181
+ expect(result.error.issues[0]?.path).toEqual(["price"]);
182
+ });
183
+
184
+ describe("screen-spec required/readOnly override the entity default", () => {
185
+ test("spec required:false on an entity-required field, left empty → no issue", () => {
186
+ const entity = entityWith({ x: { type: "text", required: true } });
187
+ const screen = screenWith([{ field: "x", required: false }]);
188
+ expect(buildFormSchema(entity, screen).safeParse({ x: "" }).success).toBe(true);
189
+ });
190
+
191
+ test("spec required:true on an entity-optional field, left empty → issue on that field", () => {
192
+ const entity = entityWith({ x: { type: "text", required: false } });
193
+ const screen = screenWith([{ field: "x", required: true }]);
194
+ const result = buildFormSchema(entity, screen).safeParse({ x: "" });
195
+ expect(result.success).toBe(false);
196
+ if (result.success) return;
197
+ expect(result.error.issues).toHaveLength(1);
198
+ expect(result.error.issues[0]?.path).toEqual(["x"]);
199
+ });
200
+
201
+ test("readOnly field, entity-required and left empty → no issue (unresolvable by the user)", () => {
202
+ const entity = entityWith({ x: { type: "text", required: true } });
203
+ const screen = screenWith([{ field: "x", readOnly: true }]);
204
+ expect(buildFormSchema(entity, screen).safeParse({ x: "" }).success).toBe(true);
205
+ });
206
+
207
+ test("conditional required, entity-optional field", () => {
208
+ const entity = entityWith({
209
+ kind: { type: "text", required: false },
210
+ x: { type: "text", required: false },
211
+ });
212
+ const screen = screenWith([
213
+ "kind",
214
+ { field: "x", required: { field: "kind", eq: "business" } },
215
+ ]);
216
+
217
+ const notTriggered = buildFormSchema(entity, screen).safeParse({ kind: "private", x: "" });
218
+ expect(notTriggered.success).toBe(true);
219
+
220
+ const triggered = buildFormSchema(entity, screen).safeParse({ kind: "business", x: "" });
221
+ expect(triggered.success).toBe(false);
222
+ if (triggered.success) return;
223
+ expect(triggered.error.issues).toHaveLength(1);
224
+ expect(triggered.error.issues[0]?.path).toEqual(["x"]);
225
+ });
226
+ });
227
+ });
@@ -46,6 +46,23 @@ export type ExtensionSectionProps = {
46
46
  * Panels haben keine Entity — entityName/entityId tragen dort die
47
47
  * screen.id bzw. null, siehe CustomPanelBody in dashboard-body.tsx. */
48
48
  readonly filterParams?: Readonly<Record<string, unknown>>;
49
+ /** Current form values of the host RenderEdit (controlled mode,
50
+ * issue #1887/#1888) — same snapshot reference as
51
+ * `RenderEditChangeState.values`. Lets the section read the host
52
+ * form's live state (e.g. for a review step). Only set in
53
+ * entityEdit sections; undefined in list-header and dashboard
54
+ * mounts, where no host form exists. */
55
+ readonly values?: Readonly<Record<string, unknown>>;
56
+ /** Sets host RenderEdit form values from outside (e.g. a VIN-decode
57
+ * roundtrip that fills other fields) — same function as
58
+ * `RenderEditControls.patch`, merges only the given keys. Undefined
59
+ * outside entityEdit sections. */
60
+ readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
61
+ /** Validates the host form without writing — same function as
62
+ * `RenderEditControls.validate`; field errors land in
63
+ * `snapshot.errors` on the field instead of a collective message.
64
+ * Undefined outside entityEdit sections. */
65
+ readonly validate?: () => boolean;
49
66
  };
50
67
 
51
68
  export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;
@@ -0,0 +1,90 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import { z } from "zod";
7
+ import { layoutEditFields } from "./layout-fields";
8
+
9
+ // `required` means "has a value", not "is truthy" — `false` and `0` count
10
+ // as present, only the actually-empty representations don't.
11
+ function isPresent(value: unknown): boolean {
12
+ if (value === undefined || value === null || value === "") return false;
13
+ if (Array.isArray(value) && value.length === 0) return false;
14
+ return true;
15
+ }
16
+
17
+ // Field types without a bound, editable widget on the auto-wired
18
+ // entityEdit path (render-field.tsx renders a read-only banner instead) —
19
+ // a presence error on one of them would be unresolvable by the user.
20
+ // #1925 gave multiSelect/decimal/bigInt/tz/longText real widgets, so they
21
+ // dropped out of this set; files/images stay out of scope (deferred, no
22
+ // multi-upload widget yet) alongside jsonb/embedded (structural types with
23
+ // no editor at all). A statically-`required: true` field of one of these
24
+ // types is caught loudly at boot — validateNoWidgetRequiredField in
25
+ // packages/framework/src/engine/boot-validator/screens.ts, which mirrors
26
+ // this set (framework can't import renderer, so it can't import this
27
+ // constant directly — keep both in sync).
28
+ const FIELD_TYPES_WITHOUT_WIDGET = new Set(["jsonb", "embedded", "files", "images"]);
29
+
30
+ // Client-side presence validation for the auto-wired entityEdit path —
31
+ // checks that every rendered required field HAS a value, not that the
32
+ // value has the right shape. Format/range/type validation stays server-
33
+ // authoritative (buildInsertSchema/buildUpdateSchema): the form-state
34
+ // representation of a value can diverge from the server-payload shape (e.g.
35
+ // money is `{amount,currency}`, not a bare number), so a format check here
36
+ // would either reject valid values or need per-representation branches that
37
+ // rot the moment either side changes.
38
+ //
39
+ // One `superRefine` instead of a per-field shape: a field-level `.refine()`
40
+ // wouldn't run at all for a key that's simply absent from `values` —
41
+ // `superRefine` sees the whole object and catches that case too.
42
+ // `.passthrough()` is load-bearing: the default `z.object({})` STRIPS every
43
+ // key (there's no declared shape), so `superRefine` would see an empty
44
+ // object regardless of what was actually submitted — every required field
45
+ // would misreport as missing.
46
+ //
47
+ // Iterates the screen's layout field specs, not `entity.fields` — `required`
48
+ // and `readOnly` are per-spec, evaluated against the current values the
49
+ // same way `view-model/edit.ts` resolves them for the rendered form. Not
50
+ // checked here: `visible`. `runValidate` already filters issues on hidden
51
+ // fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
52
+ // fed by `deriveFormFields(screen)` in render-edit.tsx.
53
+ export function buildFormSchema(
54
+ entity: EntityDefinition,
55
+ screen: EntityEditScreenDefinition,
56
+ ): z.ZodType {
57
+ const fields = layoutEditFields(screen);
58
+ return z
59
+ .object({})
60
+ .passthrough()
61
+ .superRefine((values, ctx) => {
62
+ // `.passthrough()` types `values` as a plain object but doesn't declare
63
+ // its keys — the runtime object always carries every form field.
64
+ // @cast-boundary form-values
65
+ const record = values as Record<string, unknown>;
66
+ for (const spec of fields) {
67
+ const field = entity.fields[spec.field];
68
+ if (!field) continue;
69
+ // Not operable by the user — a presence error would be unresolvable,
70
+ // same reason as the FIELD_TYPES_WITHOUT_WIDGET check below.
71
+ if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) continue;
72
+ // Screen-spec `required` overrides the entity default, mirroring
73
+ // `view-model/edit.ts` — the rendered form is the reference, and a
74
+ // presence check stricter than the form blocks the user for nothing.
75
+ const entityRequired = "required" in field && field.required === true;
76
+ const isRequired =
77
+ spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
78
+ if (!isRequired) continue;
79
+ if (FIELD_TYPES_WITHOUT_WIDGET.has(field.type)) continue;
80
+ if (isPresent(record[spec.field])) continue;
81
+ ctx.addIssue({
82
+ code: "custom",
83
+ path: [spec.field],
84
+ message: `"${spec.field}" is required.`,
85
+ // `params.i18nKey` override, see packages/headless/src/form/zod-bridge.ts.
86
+ params: { i18nKey: "kumiko.validation.required" },
87
+ });
88
+ }
89
+ });
90
+ }