@cosmicdrift/kumiko-headless 0.188.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.188.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.188.0",
39
+ "@cosmicdrift/kumiko-framework": "0.189.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -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", () => {
@@ -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
@@ -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
@@ -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