@cosmicdrift/kumiko-headless 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 +2 -2
- package/src/form/__tests__/submit.test.ts +34 -0
- package/src/form/__tests__/validation.test.ts +142 -0
- package/src/form/form-controller.ts +7 -3
- package/src/form/types.ts +19 -1
- package/src/form/zod-bridge.ts +19 -1
- package/src/format/index.ts +1 -0
- package/src/format/money.ts +11 -0
- package/src/index.ts +1 -0
- package/src/view-model/edit.ts +15 -14
- package/src/view-model/types.ts +18 -9
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
|
},
|
|
@@ -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,
|
|
@@ -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", () => {
|
|
@@ -122,3 +161,106 @@ describe("createFormController — validate()", () => {
|
|
|
122
161
|
expect(form.getSnapshot().errors["address.city"]).toBeDefined();
|
|
123
162
|
});
|
|
124
163
|
});
|
|
164
|
+
|
|
165
|
+
describe("createFormController — validate(scope)", () => {
|
|
166
|
+
test("scoped run reports only fields inside scope, drops issues outside it", () => {
|
|
167
|
+
const schema = z.object({
|
|
168
|
+
a: z.string().min(3),
|
|
169
|
+
b: z.string().min(3),
|
|
170
|
+
});
|
|
171
|
+
const form = createFormController({ initial: { a: "x", b: "y" }, schema });
|
|
172
|
+
|
|
173
|
+
const ok = form.validate(["a"]);
|
|
174
|
+
const snap = form.getSnapshot();
|
|
175
|
+
|
|
176
|
+
expect(ok).toBe(false);
|
|
177
|
+
expect(snap.errors["a"]).toBeDefined();
|
|
178
|
+
expect(snap.errors["b"]).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("scoped run returns true when every field in scope is valid, even if fields outside scope are not", () => {
|
|
182
|
+
const schema = z.object({
|
|
183
|
+
a: z.string().min(1),
|
|
184
|
+
b: z.string().min(3),
|
|
185
|
+
});
|
|
186
|
+
const form = createFormController({ initial: { a: "ok", b: "y" }, schema });
|
|
187
|
+
|
|
188
|
+
const ok = form.validate(["a"]);
|
|
189
|
+
|
|
190
|
+
expect(ok).toBe(true);
|
|
191
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Hard rule (kumiko-framework#1885): an object-level .refine() issue has
|
|
195
|
+
// path "(root)", which never matches a field name — every scoped
|
|
196
|
+
// validate() call must ignore it, and only the unscoped final-submit
|
|
197
|
+
// validate() may surface it. Otherwise a step-boundary check would block
|
|
198
|
+
// a wizard step forever on an invariant no single step owns.
|
|
199
|
+
describe("root-path .refine() issues", () => {
|
|
200
|
+
const schema = z
|
|
201
|
+
.object({
|
|
202
|
+
start: z.string(),
|
|
203
|
+
end: z.string(),
|
|
204
|
+
})
|
|
205
|
+
.refine((v) => v.end >= v.start, { message: "end must be >= start" });
|
|
206
|
+
|
|
207
|
+
test("scoped validate() ignores the root issue regardless of which fields are in scope", () => {
|
|
208
|
+
const form = createFormController({ initial: { start: "b", end: "a" }, schema });
|
|
209
|
+
|
|
210
|
+
expect(form.validate(["start"])).toBe(true);
|
|
211
|
+
expect(form.validate(["end"])).toBe(true);
|
|
212
|
+
expect(form.validate(["start", "end"])).toBe(true);
|
|
213
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("unscoped validate() (final submit) reports the root issue under '(root)'", () => {
|
|
217
|
+
const form = createFormController({ initial: { start: "b", end: "a" }, schema });
|
|
218
|
+
|
|
219
|
+
const ok = form.validate();
|
|
220
|
+
|
|
221
|
+
expect(ok).toBe(false);
|
|
222
|
+
expect(form.getSnapshot().errors["(root)"]).toBeDefined();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("hidden fields stay filtered in scoped mode even when the hidden field is itself in scope", () => {
|
|
227
|
+
// Discriminating case: field `b` is both hidden AND inside `scope`, so
|
|
228
|
+
// the assertion actually exercises the hidden-field filter — not just
|
|
229
|
+
// the scope filter dropping an out-of-scope field.
|
|
230
|
+
const schema = z.object({
|
|
231
|
+
a: z.string().min(3),
|
|
232
|
+
b: z.string().min(3),
|
|
233
|
+
});
|
|
234
|
+
const form = createFormController({
|
|
235
|
+
initial: { a: "x", b: "y", showB: false },
|
|
236
|
+
schema,
|
|
237
|
+
fields: { b: { visible: (values) => values.showB } },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const ok = form.validate(["a", "b"]);
|
|
241
|
+
const snap = form.getSnapshot();
|
|
242
|
+
|
|
243
|
+
expect(ok).toBe(false);
|
|
244
|
+
expect(snap.errors["a"]).toBeDefined();
|
|
245
|
+
expect(snap.errors["b"]).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("hidden fields stay filtered in unscoped mode (regression guard alongside the scoped case)", () => {
|
|
249
|
+
const schema = z.object({
|
|
250
|
+
a: z.string().min(3),
|
|
251
|
+
b: z.string().min(3),
|
|
252
|
+
});
|
|
253
|
+
const form = createFormController({
|
|
254
|
+
initial: { a: "x", b: "y", showB: false },
|
|
255
|
+
schema,
|
|
256
|
+
fields: { b: { visible: (values) => values.showB } },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const ok = form.validate();
|
|
260
|
+
const snap = form.getSnapshot();
|
|
261
|
+
|
|
262
|
+
expect(ok).toBe(false);
|
|
263
|
+
expect(snap.errors["a"]).toBeDefined();
|
|
264
|
+
expect(snap.errors["b"]).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
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/format/index.ts
CHANGED
|
@@ -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
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
|
|
@@ -163,6 +164,11 @@ export function computeEditViewModel<
|
|
|
163
164
|
// file/image: accept/maxSize ins ViewModel + entityType/fieldName für
|
|
164
165
|
// den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
|
|
165
166
|
const isFileType = fieldDef.type === "file" || fieldDef.type === "image";
|
|
167
|
+
// ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
|
|
168
|
+
// framework/src/engine/field-helpers.ts — headless has no dependency
|
|
169
|
+
// on that module, so the literal is duplicated here instead of
|
|
170
|
+
// importing it just for one fallback string.
|
|
171
|
+
const resolvedCurrency = entity.defaultCurrency ?? "EUR";
|
|
166
172
|
const fileDef = isFileType
|
|
167
173
|
? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
|
|
168
174
|
: undefined;
|
|
@@ -269,15 +275,10 @@ export function computeEditViewModel<
|
|
|
269
275
|
...(embeddedListDef?.totals !== undefined && {
|
|
270
276
|
embeddedListTotals: embeddedListDef.totals,
|
|
271
277
|
}),
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
}),
|
|
278
|
+
// Currency lives on the head aggregate (entity.defaultCurrency), not
|
|
279
|
+
// per row — one value for the whole embedded list.
|
|
280
|
+
...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
|
|
281
|
+
...(fieldDef.type === "money" && { currency: resolvedCurrency }),
|
|
281
282
|
};
|
|
282
283
|
return view;
|
|
283
284
|
});
|
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
|
|
@@ -204,6 +208,11 @@ export type EditFieldViewModel = {
|
|
|
204
208
|
* currency lives on the head aggregate (EntityDefinition.defaultCurrency),
|
|
205
209
|
* not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
|
|
206
210
|
readonly embeddedListCurrency?: string;
|
|
211
|
+
/** Only set for a plain (non-list, non-embedded) `type: "money"` field —
|
|
212
|
+
* same source as embeddedListCurrency (EntityDefinition.defaultCurrency,
|
|
213
|
+
* falls back to "EUR"). The renderer needs this to build the payload's
|
|
214
|
+
* `{amount, currency}` shape (kumiko-framework#1923). */
|
|
215
|
+
readonly currency?: string;
|
|
207
216
|
};
|
|
208
217
|
|
|
209
218
|
// Discriminated by `kind` — mirrors EditSectionSpec on the engine side.
|