@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.
|
|
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.
|
|
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", () => {
|
package/src/form/zod-bridge.ts
CHANGED
|
@@ -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:
|
|
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
|
package/src/view-model/edit.ts
CHANGED
|
@@ -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
|
|
117
|
-
//
|
|
118
|
-
//
|
|
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
|
package/src/view-model/types.ts
CHANGED
|
@@ -128,18 +128,22 @@ export type EditFieldViewModel = {
|
|
|
128
128
|
readonly required: boolean;
|
|
129
129
|
readonly span?: number;
|
|
130
130
|
readonly renderer?: FieldRenderer;
|
|
131
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
* SelectFieldDef.options
|
|
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
|
-
/**
|
|
136
|
-
* keyed
|
|
137
|
-
*
|
|
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
|
-
/**
|
|
141
|
-
*
|
|
142
|
-
* `
|
|
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
|