@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 +5 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +13 -1
- package/src/app/__tests__/form-schema.test.ts +165 -0
- package/src/app/extension-sections.tsx +17 -0
- package/src/app/form-schema.ts +82 -0
- package/src/app/kumiko-screen.tsx +62 -27
- package/src/app/layout-fields.ts +27 -0
- package/src/components/__tests__/render-edit-logic.test.ts +48 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +189 -0
- package/src/components/reference-create-dialog.tsx +4 -1
- package/src/components/render-edit-logic.ts +27 -0
- package/src/components/render-edit.tsx +516 -58
- package/src/components/render-field.tsx +36 -6
- package/src/context/draft-storage-context.tsx +39 -0
- package/src/i18n-defaults.ts +15 -0
- package/src/index.ts +8 -1
- package/src/primitives.tsx +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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"
|
|
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] =
|
|
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: {
|
|
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
|
-
[
|
|
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
|
+
});
|