@cosmicdrift/kumiko-renderer 0.187.0 → 0.188.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.188.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.188.0",
19
+ "@cosmicdrift/kumiko-headless": "0.188.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,165 @@
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 { buildFormSchema } from "../form-schema";
8
+
9
+ function screenWith(fields: readonly EditFieldSpec[]): EntityEditScreenDefinition {
10
+ return {
11
+ id: "s",
12
+ type: "entityEdit",
13
+ entity: "e",
14
+ layout: { sections: [{ fields }] },
15
+ };
16
+ }
17
+
18
+ function entityWith(fields: EntityDefinition["fields"]): EntityDefinition {
19
+ return { fields } as EntityDefinition;
20
+ }
21
+
22
+ describe("buildFormSchema", () => {
23
+ describe("required field missing → issue on that field", () => {
24
+ const entity = entityWith({ name: { type: "text", required: true } });
25
+ const screen = screenWith(["name"]);
26
+
27
+ for (const [label, value] of [
28
+ ['""', ""],
29
+ ["null", null],
30
+ ["undefined", undefined],
31
+ ["[]", [] as unknown[]],
32
+ ] as const) {
33
+ test(label, () => {
34
+ const result = buildFormSchema(entity, screen).safeParse({ name: value });
35
+ expect(result.success).toBe(false);
36
+ if (result.success) return;
37
+ expect(result.error.issues).toHaveLength(1);
38
+ expect(result.error.issues[0]?.path).toEqual(["name"]);
39
+ });
40
+ }
41
+ });
42
+
43
+ describe("required field present → no issue", () => {
44
+ const entity = entityWith({ name: { type: "number", required: true } });
45
+ const screen = screenWith(["name"]);
46
+
47
+ for (const [label, value] of [
48
+ ["0", 0],
49
+ ["false", false],
50
+ ['"Ada"', "Ada"],
51
+ ] as const) {
52
+ test(label, () => {
53
+ const result = buildFormSchema(entity, screen).safeParse({ name: value });
54
+ expect(result.success).toBe(true);
55
+ });
56
+ }
57
+ });
58
+
59
+ test("optional field left empty → no issue", () => {
60
+ const entity = entityWith({ name: { type: "text", required: false } });
61
+ const screen = screenWith(["name"]);
62
+ expect(buildFormSchema(entity, screen).safeParse({ name: "" }).success).toBe(true);
63
+ });
64
+
65
+ test("required field not rendered by the layout → no issue", () => {
66
+ const entity = entityWith({
67
+ name: { type: "text", required: true },
68
+ hidden: { type: "text", required: true },
69
+ });
70
+ const screen = screenWith(["name"]);
71
+ const result = buildFormSchema(entity, screen).safeParse({ name: "Ada", hidden: "" });
72
+ expect(result.success).toBe(true);
73
+ });
74
+
75
+ test("required multiSelect → no issue (no editable widget on the auto-wired path)", () => {
76
+ const entity = entityWith({
77
+ tags: { type: "multiSelect", required: true, options: ["a", "b"] },
78
+ });
79
+ const screen = screenWith(["tags"]);
80
+ expect(buildFormSchema(entity, screen).safeParse({ tags: undefined }).success).toBe(true);
81
+ });
82
+
83
+ test("jsonb field → no issue (no editable widget on the auto-wired path)", () => {
84
+ const entity = entityWith({ data: { type: "jsonb" } });
85
+ const screen = screenWith(["data"]);
86
+ expect(buildFormSchema(entity, screen).safeParse({ data: undefined }).success).toBe(true);
87
+ });
88
+
89
+ test("required embedded field → no issue (no editable widget on the auto-wired path)", () => {
90
+ const entity = entityWith({
91
+ lines: { type: "embedded", required: true, schema: {} },
92
+ });
93
+ const screen = screenWith(["lines"]);
94
+ expect(buildFormSchema(entity, screen).safeParse({ lines: undefined }).success).toBe(true);
95
+ });
96
+
97
+ test("required money — bare number (create-form representation) → no issue", () => {
98
+ const entity = entityWith({ price: { type: "money", required: true } });
99
+ const screen = screenWith(["price"]);
100
+ expect(buildFormSchema(entity, screen).safeParse({ price: 1000 }).success).toBe(true);
101
+ });
102
+
103
+ test("required money — {amount,currency} (update-form representation) → no issue", () => {
104
+ const entity = entityWith({ price: { type: "money", required: true } });
105
+ const screen = screenWith(["price"]);
106
+ const result = buildFormSchema(entity, screen).safeParse({
107
+ price: { amount: 10, currency: "EUR" },
108
+ });
109
+ expect(result.success).toBe(true);
110
+ });
111
+
112
+ test("required money — undefined → issue on that field (has a widget)", () => {
113
+ const entity = entityWith({ price: { type: "money", required: true } });
114
+ const screen = screenWith(["price"]);
115
+ const result = buildFormSchema(entity, screen).safeParse({ price: undefined });
116
+ expect(result.success).toBe(false);
117
+ if (result.success) return;
118
+ expect(result.error.issues).toHaveLength(1);
119
+ expect(result.error.issues[0]?.path).toEqual(["price"]);
120
+ });
121
+
122
+ describe("screen-spec required/readOnly override the entity default", () => {
123
+ test("spec required:false on an entity-required field, left empty → no issue", () => {
124
+ const entity = entityWith({ x: { type: "text", required: true } });
125
+ const screen = screenWith([{ field: "x", required: false }]);
126
+ expect(buildFormSchema(entity, screen).safeParse({ x: "" }).success).toBe(true);
127
+ });
128
+
129
+ test("spec required:true on an entity-optional field, left empty → issue on that field", () => {
130
+ const entity = entityWith({ x: { type: "text", required: false } });
131
+ const screen = screenWith([{ field: "x", required: true }]);
132
+ const result = buildFormSchema(entity, screen).safeParse({ x: "" });
133
+ expect(result.success).toBe(false);
134
+ if (result.success) return;
135
+ expect(result.error.issues).toHaveLength(1);
136
+ expect(result.error.issues[0]?.path).toEqual(["x"]);
137
+ });
138
+
139
+ test("readOnly field, entity-required and left empty → no issue (unresolvable by the user)", () => {
140
+ const entity = entityWith({ x: { type: "text", required: true } });
141
+ const screen = screenWith([{ field: "x", readOnly: true }]);
142
+ expect(buildFormSchema(entity, screen).safeParse({ x: "" }).success).toBe(true);
143
+ });
144
+
145
+ test("conditional required, entity-optional field", () => {
146
+ const entity = entityWith({
147
+ kind: { type: "text", required: false },
148
+ x: { type: "text", required: false },
149
+ });
150
+ const screen = screenWith([
151
+ "kind",
152
+ { field: "x", required: { field: "kind", eq: "business" } },
153
+ ]);
154
+
155
+ const notTriggered = buildFormSchema(entity, screen).safeParse({ kind: "private", x: "" });
156
+ expect(notTriggered.success).toBe(true);
157
+
158
+ const triggered = buildFormSchema(entity, screen).safeParse({ kind: "business", x: "" });
159
+ expect(triggered.success).toBe(false);
160
+ if (triggered.success) return;
161
+ expect(triggered.error.issues).toHaveLength(1);
162
+ expect(triggered.error.issues[0]?.path).toEqual(["x"]);
163
+ });
164
+ });
165
+ });
@@ -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,82 @@
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
+ // Tracked in #1925 (field types without an operable widget). `money` has an
21
+ // operable widget (MoneyInput) and stays out of this set.
22
+ const FIELD_TYPES_WITHOUT_WIDGET = new Set(["multiSelect", "jsonb", "embedded"]);
23
+
24
+ // Client-side presence validation for the auto-wired entityEdit path —
25
+ // checks that every rendered required field HAS a value, not that the
26
+ // value has the right shape. Format/range/type validation stays server-
27
+ // authoritative (buildInsertSchema/buildUpdateSchema): the form-state
28
+ // representation of a value can diverge from the server-payload shape (e.g.
29
+ // money is `{amount,currency}`, not a bare number), so a format check here
30
+ // would either reject valid values or need per-representation branches that
31
+ // rot the moment either side changes.
32
+ //
33
+ // One `superRefine` instead of a per-field shape: a field-level `.refine()`
34
+ // wouldn't run at all for a key that's simply absent from `values` —
35
+ // `superRefine` sees the whole object and catches that case too.
36
+ // `.passthrough()` is load-bearing: the default `z.object({})` STRIPS every
37
+ // key (there's no declared shape), so `superRefine` would see an empty
38
+ // object regardless of what was actually submitted — every required field
39
+ // would misreport as missing.
40
+ //
41
+ // Iterates the screen's layout field specs, not `entity.fields` — `required`
42
+ // and `readOnly` are per-spec, evaluated against the current values the
43
+ // same way `view-model/edit.ts` resolves them for the rendered form. Not
44
+ // checked here: `visible`. `runValidate` already filters issues on hidden
45
+ // fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
46
+ // fed by `deriveFormFields(screen)` in render-edit.tsx.
47
+ export function buildFormSchema(
48
+ entity: EntityDefinition,
49
+ screen: EntityEditScreenDefinition,
50
+ ): z.ZodType {
51
+ const fields = layoutEditFields(screen);
52
+ return z
53
+ .object({})
54
+ .passthrough()
55
+ .superRefine((values, ctx) => {
56
+ // `.passthrough()` types `values` as a plain object but doesn't declare
57
+ // its keys — the runtime object always carries every form field.
58
+ // @cast-boundary form-values
59
+ const record = values as Record<string, unknown>;
60
+ for (const spec of fields) {
61
+ const field = entity.fields[spec.field];
62
+ if (!field) continue;
63
+ // Not operable by the user — a presence error would be unresolvable,
64
+ // same reason as the FIELD_TYPES_WITHOUT_WIDGET check below.
65
+ if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) continue;
66
+ // Screen-spec `required` overrides the entity default, mirroring
67
+ // `view-model/edit.ts` — the rendered form is the reference, and a
68
+ // presence check stricter than the form blocks the user for nothing.
69
+ const entityRequired = "required" in field && field.required === true;
70
+ const isRequired =
71
+ spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
72
+ if (!isRequired) continue;
73
+ if (FIELD_TYPES_WITHOUT_WIDGET.has(field.type)) continue;
74
+ if (isPresent(record[spec.field])) continue;
75
+ ctx.addIssue({
76
+ code: "custom",
77
+ path: [spec.field],
78
+ message: `"${spec.field}" is required.`,
79
+ });
80
+ }
81
+ });
82
+ }
@@ -15,11 +15,7 @@ import type {
15
15
  ScreenDefinition,
16
16
  ToolbarAction,
17
17
  } from "@cosmicdrift/kumiko-framework/ui-types";
18
- import {
19
- evalFieldCondition,
20
- isExtensionEditSection,
21
- normalizeEditField,
22
- } from "@cosmicdrift/kumiko-framework/ui-types";
18
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
23
19
  import type {
24
20
  Command,
25
21
  FormSnapshot,
@@ -43,6 +39,8 @@ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config
43
39
  import { useCustomScreenComponent } from "./custom-screens";
44
40
  import { useDashboardBody } from "./dashboard-body";
45
41
  import type { FeatureSchema } from "./feature-schema";
42
+ import { buildFormSchema } from "./form-schema";
43
+ import { layoutFieldNames } from "./layout-fields";
46
44
  import { useNav } from "./nav";
47
45
  import {
48
46
  synthesizeProjectionDetailEntity,
@@ -310,8 +308,15 @@ function useNavigateToCreateFor(
310
308
  // with a `default: true`/`default: 5` would show the form in a state
311
309
  // the entity didn't ask for — subtle and easy to miss until a user
312
310
  // submits and is surprised.
311
+ // `defaultCurrency` is only passed by entityEdit call sites — the money
312
+ // payload shape it enables (`{amount, currency}`) matches the entity's
313
+ // write schema (schema-builder.ts, kumiko-framework#1923). Callers outside
314
+ // that path (config-edit, action-form) synthesize their own entity and use
315
+ // money as a plain number against a different write contract, so they
316
+ // deliberately keep the old bare-`0` default by omitting the argument.
313
317
  export function buildInitialValues(
314
318
  fields: Readonly<Record<string, unknown>>,
319
+ defaultCurrency?: string,
315
320
  ): Readonly<Record<string, unknown>> {
316
321
  const out: Record<string, unknown> = {};
317
322
  for (const [name, def] of Object.entries(fields)) {
@@ -320,33 +325,23 @@ export function buildInitialValues(
320
325
  out[name] = shape.default;
321
326
  continue;
322
327
  }
328
+ if (shape.type === "money" && defaultCurrency !== undefined) {
329
+ out[name] = { amount: 0, currency: defaultCurrency };
330
+ continue;
331
+ }
323
332
  out[name] =
324
333
  shape.type === "boolean" ? false : shape.type === "number" || shape.type === "money" ? 0 : "";
325
334
  }
326
335
  return out;
327
336
  }
328
337
 
329
- // Field names actually rendered by the screen's layout — a search-param
330
- // merge must not set fields the form never shows the user (#1708:
331
- // unrendered fields get no client-side validation and no chance to
332
- // review/correct the injected value).
333
- function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
334
- const names = new Set<string>();
335
- for (const section of screen.layout.sections) {
336
- if (isExtensionEditSection(section)) continue;
337
- for (const spec of section.fields) {
338
- names.add(normalizeEditField(spec).field);
339
- }
340
- }
341
- return names;
342
- }
343
-
344
338
  export function mergeSearchParamsIntoInitial(
345
339
  fields: Readonly<Record<string, unknown>>,
346
340
  searchParams: Readonly<Record<string, string>>,
347
341
  renderableFields?: ReadonlySet<string>,
342
+ defaultCurrency?: string,
348
343
  ): Record<string, unknown> {
349
- const defaults = buildInitialValues(fields) as Record<string, unknown>;
344
+ const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
350
345
  const merged: Record<string, unknown> = { ...defaults };
351
346
  for (const [name, fieldDef] of Object.entries(fields)) {
352
347
  if (renderableFields !== undefined && !renderableFields.has(name)) continue;
@@ -354,9 +349,16 @@ export function mergeSearchParamsIntoInitial(
354
349
  if (shape.sensitive === true) continue;
355
350
  const raw = searchParams[name];
356
351
  if (raw === undefined) continue;
357
- if (shape.type === "number" || shape.type === "money") {
352
+ if (shape.type === "number") {
358
353
  const parsed = Number(raw);
359
354
  merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
355
+ } else if (shape.type === "money") {
356
+ const parsed = Number(raw);
357
+ merged[name] = Number.isNaN(parsed)
358
+ ? defaults[name]
359
+ : defaultCurrency !== undefined
360
+ ? { amount: parsed, currency: defaultCurrency }
361
+ : parsed;
360
362
  } else if (shape.type === "boolean") {
361
363
  merged[name] = raw === "true";
362
364
  } else {
@@ -443,9 +445,11 @@ function EntityEditCreateBody({
443
445
  entity.fields,
444
446
  nav.searchParams,
445
447
  layoutFieldNames(screen),
448
+ entity.defaultCurrency ?? "EUR",
446
449
  ) as FormValues,
447
- [entity.fields, nav.searchParams, screen],
450
+ [entity.fields, nav.searchParams, screen, entity.defaultCurrency],
448
451
  );
452
+ const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
449
453
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
450
454
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
451
455
  const handleSubmitted = useCallback(
@@ -460,6 +464,7 @@ function EntityEditCreateBody({
460
464
  entity={entity}
461
465
  featureName={schema.featureName}
462
466
  initial={initial}
467
+ schema={formSchema}
463
468
  writeCommand={writeCommand}
464
469
  onSubmit={handleSubmitted}
465
470
  onCancel={navigateToList}
@@ -566,11 +571,15 @@ function EntityEditUpdateForm({
566
571
  const recordVersion = (record as { version?: number }).version ?? 1;
567
572
  const initial = useMemo(() => {
568
573
  const out: Record<string, unknown> = {};
574
+ const defaultCurrency = entity.defaultCurrency ?? "EUR";
569
575
  for (const name of Object.keys(entity.fields)) {
570
- out[name] = record[name] ?? buildInitialValues({ [name]: entity.fields[name] })[name];
576
+ out[name] =
577
+ record[name] ?? buildInitialValues({ [name]: entity.fields[name] }, defaultCurrency)[name];
571
578
  }
572
579
  return out as FormValues;
573
- }, [entity.fields, record]);
580
+ }, [entity.fields, entity.defaultCurrency, record]);
581
+
582
+ const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
574
583
 
575
584
  // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
576
585
  // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
@@ -619,6 +628,7 @@ function EntityEditUpdateForm({
619
628
  // customFields-Bestand an die extension-section, damit sie beim Edit
620
629
  // die gespeicherten Werte zeigt (nicht write-only).
621
630
  extensionInitialValues={extensionInitialValues}
631
+ schema={formSchema}
622
632
  writeCommand={writeCommand}
623
633
  payloadMode="changes"
624
634
  buildPayload={buildPayload}
@@ -1513,6 +1523,19 @@ type ConfigValueResponse = Readonly<
1513
1523
  Record<string, { value: string | number | boolean | undefined; scope: string; source: string }>
1514
1524
  >;
1515
1525
 
1526
+ // A money-typed config-edit field renders through RenderField's entityEdit
1527
+ // `{amount, currency}` payload shape (render-field.tsx, #1923), but
1528
+ // `ConfigKeyType` (write-helpers.ts validateType) only ever knows
1529
+ // number/boolean/text/select — a config value is always a bare scalar.
1530
+ // Unwrap back to the amount before it hits config:write:set.
1531
+ function unwrapMoneyValue(value: unknown): unknown {
1532
+ if (typeof value === "object" && value !== null && "amount" in value) {
1533
+ const amount = (value as { amount?: unknown }).amount;
1534
+ if (typeof amount === "number") return amount;
1535
+ }
1536
+ return value;
1537
+ }
1538
+
1516
1539
  function ConfigEditBody({
1517
1540
  schema,
1518
1541
  screen,
@@ -1604,9 +1627,14 @@ function ConfigEditBody({
1604
1627
  for (const [shortName, value] of Object.entries(snapshot.changes)) {
1605
1628
  const qualified = screen.configKeys[shortName];
1606
1629
  if (qualified === undefined) continue;
1630
+ const ftype = (screen.fields[shortName] as { type?: string } | undefined)?.type;
1607
1631
  commands.push({
1608
1632
  type: "config:write:set",
1609
- payload: { key: qualified, value, scope: screen.scope },
1633
+ payload: {
1634
+ key: qualified,
1635
+ value: ftype === "money" ? unwrapMoneyValue(value) : value,
1636
+ scope: screen.scope,
1637
+ },
1610
1638
  });
1611
1639
  }
1612
1640
  if (commands.length === 0) {
@@ -1623,7 +1651,14 @@ function ConfigEditBody({
1623
1651
  await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
1624
1652
  return { validationBlocked: false, isSuccess: true, data: undefined };
1625
1653
  },
1626
- [dispatcher, screen.configKeys, screen.scope, valuesQuery.refetch, cascadeQuery.refetch],
1654
+ [
1655
+ dispatcher,
1656
+ screen.configKeys,
1657
+ screen.fields,
1658
+ screen.scope,
1659
+ valuesQuery.refetch,
1660
+ cascadeQuery.refetch,
1661
+ ],
1627
1662
  );
1628
1663
 
1629
1664
  // Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
@@ -0,0 +1,27 @@
1
+ import type {
2
+ EditFieldSpec,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
+
7
+ // Normalized field specs actually rendered by the screen's layout, extension
8
+ // sections skipped. Both this and `layoutFieldNames` key off "rendered by
9
+ // the layout" for the same reason: a field the user never sees gets no
10
+ // chance to review/correct a value nor to fix a presence error
11
+ // (search-param merge, #1708; presence schema in form-schema.ts).
12
+ export function layoutEditFields(
13
+ screen: EntityEditScreenDefinition,
14
+ ): readonly Exclude<EditFieldSpec, string>[] {
15
+ const specs: Exclude<EditFieldSpec, string>[] = [];
16
+ for (const section of screen.layout.sections) {
17
+ if (isExtensionEditSection(section)) continue;
18
+ for (const spec of section.fields) {
19
+ specs.push(normalizeEditField(spec));
20
+ }
21
+ }
22
+ return specs;
23
+ }
24
+
25
+ export function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
26
+ return new Set(layoutEditFields(screen).map((spec) => spec.field));
27
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  SubmitResult,
7
7
  } from "@cosmicdrift/kumiko-headless";
8
8
  import {
9
+ filterEditSections,
9
10
  hasEditableSection,
10
11
  resolveExtensionEntityId,
11
12
  shouldNotifyCaller,
@@ -177,3 +178,50 @@ describe("hasEditableSection", () => {
177
178
  expect(hasEditableSection([section])).toBe(false);
178
179
  });
179
180
  });
181
+
182
+ const namedField = (name: string): EditFieldViewModel => ({
183
+ field: name,
184
+ label: name,
185
+ type: "text",
186
+ value: "",
187
+ visible: true,
188
+ readOnly: false,
189
+ required: false,
190
+ });
191
+ const namedFieldsSection = (...names: string[]): EditSectionViewModel => ({
192
+ kind: "fields",
193
+ columns: 1,
194
+ visible: true,
195
+ fields: names.map(namedField),
196
+ });
197
+
198
+ describe("filterEditSections", () => {
199
+ test("fieldsFilter undefined → returns the same array reference (unchanged behavior)", () => {
200
+ const sections = [namedFieldsSection("a", "b")];
201
+ expect(filterEditSections(sections, undefined)).toBe(sections);
202
+ });
203
+
204
+ test("mixed section → only the filtered-in fields remain in section.fields", () => {
205
+ const sections = [namedFieldsSection("a", "b", "c")];
206
+ const result = filterEditSections(sections, ["a", "c"]);
207
+ expect(result).toHaveLength(1);
208
+ expect(result[0]?.kind).toBe("fields");
209
+ expect(result[0]?.kind === "fields" ? result[0].fields.map((f) => f.field) : []).toEqual([
210
+ "a",
211
+ "c",
212
+ ]);
213
+ });
214
+
215
+ test("section whose filtered field list becomes empty is dropped entirely, not rendered empty", () => {
216
+ const sections = [namedFieldsSection("a", "b"), namedFieldsSection("c")];
217
+ const result = filterEditSections(sections, ["c"]);
218
+ expect(result).toHaveLength(1);
219
+ expect(result[0]?.kind === "fields" ? result[0].fields.map((f) => f.field) : []).toEqual(["c"]);
220
+ });
221
+
222
+ test("extension section always survives the filter regardless of its content", () => {
223
+ const sections = [extensionSection, namedFieldsSection("a")];
224
+ const result = filterEditSections(sections, ["zzz"]);
225
+ expect(result).toEqual([extensionSection]);
226
+ });
227
+ });