@cosmicdrift/kumiko-renderer 0.192.0 → 0.193.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 +3 -3
- package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +239 -0
- package/src/app/__tests__/entity-list-create-singleton.test.tsx +128 -0
- package/src/app/kumiko-screen.tsx +7 -2
- package/src/components/__tests__/embedded-list-field.test.tsx +62 -0
- package/src/components/embedded-list-field.tsx +6 -1
- package/src/components/render-edit.tsx +201 -99
- package/src/components/render-field.tsx +19 -4
- package/src/primitives.tsx +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.193.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,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.193.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.193.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Finding: render-field-unsupported-types.test.tsx only proves jsonb/embedded
|
|
2
|
+
// (no embeddedListCells) fields render read-only (Banner, no Input wired up).
|
|
3
|
+
// It never proves the actual claim that matters: saving the form after
|
|
4
|
+
// editing an UNRELATED field does not corrupt or overwrite those fields'
|
|
5
|
+
// real data. Because RenderField never wires an onChange for them, their
|
|
6
|
+
// value in the form controller stays reference-identical to the record it
|
|
7
|
+
// was seeded from — the update payload's `changes` diff (reference-equality
|
|
8
|
+
// based, see form-controller.ts valuesDiff) must therefore omit them
|
|
9
|
+
// entirely, never re-include them as e.g. a stringified copy.
|
|
10
|
+
//
|
|
11
|
+
// This renders the real update path (KumikoScreen → EntityEditScreen →
|
|
12
|
+
// EntityEditUpdateBody → RenderEdit → RenderField) under a stub dispatcher
|
|
13
|
+
// and inspects the actual write() payload, not just the render tree.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import type {
|
|
17
|
+
EntityDefinition,
|
|
18
|
+
EntityEditScreenDefinition,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
21
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
22
|
+
import type { ComponentType, FormEvent, ReactNode } from "react";
|
|
23
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
24
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
25
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
26
|
+
import {
|
|
27
|
+
type CorePrimitives,
|
|
28
|
+
type FormProps,
|
|
29
|
+
type InputProps,
|
|
30
|
+
PrimitivesProvider,
|
|
31
|
+
} from "../../primitives";
|
|
32
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
33
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
34
|
+
import { NavProvider } from "../nav";
|
|
35
|
+
|
|
36
|
+
const capturedInputs: Record<string, InputProps | undefined> = {};
|
|
37
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
38
|
+
capturedInputs[props.name] = props;
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
let capturedFormSubmit: ((e?: FormEvent) => void) | undefined;
|
|
42
|
+
const captureForm: ComponentType<FormProps> = (props) => {
|
|
43
|
+
capturedFormSubmit = props.onSubmit;
|
|
44
|
+
return <>{props.children}</>;
|
|
45
|
+
};
|
|
46
|
+
const noop = (): ReactNode => null;
|
|
47
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
48
|
+
|
|
49
|
+
const testPrimitives: CorePrimitives = {
|
|
50
|
+
Button: noop,
|
|
51
|
+
Banner: passChildren,
|
|
52
|
+
Field: passChildren,
|
|
53
|
+
Input: captureInput,
|
|
54
|
+
DataTable: noop,
|
|
55
|
+
Form: captureForm,
|
|
56
|
+
Section: passChildren,
|
|
57
|
+
Card: passChildren,
|
|
58
|
+
Grid: passChildren,
|
|
59
|
+
GridCell: passChildren,
|
|
60
|
+
Text: passChildren,
|
|
61
|
+
Heading: noop,
|
|
62
|
+
Dialog: noop,
|
|
63
|
+
Modal: noop,
|
|
64
|
+
Lightbox: noop,
|
|
65
|
+
ConfigSourceBadge: noop,
|
|
66
|
+
ConfigCascadeView: noop,
|
|
67
|
+
Link: noop,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let lastWrite: { type: string; payload: unknown } | undefined;
|
|
71
|
+
|
|
72
|
+
// The annotation resets TS's flow-narrowing, which only tracks the
|
|
73
|
+
// straight-line `lastWrite = undefined` reset, not the reassignment inside the dispatcher closure.
|
|
74
|
+
function requireLastWrite(): { type: string; payload: unknown } {
|
|
75
|
+
const write: { type: string; payload: unknown } | undefined = lastWrite;
|
|
76
|
+
if (!write) throw new Error("expected a write() call");
|
|
77
|
+
return write;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stubDispatcher(record: Readonly<Record<string, unknown>>): Dispatcher {
|
|
81
|
+
return {
|
|
82
|
+
write: (async (type: string, payload: unknown) => {
|
|
83
|
+
lastWrite = { type, payload };
|
|
84
|
+
return { isSuccess: true, data: {} };
|
|
85
|
+
}) as unknown as Dispatcher["write"],
|
|
86
|
+
query: (async () => ({ isSuccess: true, data: record })) as unknown as Dispatcher["query"],
|
|
87
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
88
|
+
statusStore: {
|
|
89
|
+
getState: () => "online",
|
|
90
|
+
subscribe: () => () => {},
|
|
91
|
+
} as unknown as Dispatcher["statusStore"],
|
|
92
|
+
async *stream() {},
|
|
93
|
+
pendingWrites: () => [],
|
|
94
|
+
pendingFiles: () => [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildSchema(): FeatureSchema {
|
|
99
|
+
const entity: EntityDefinition = {
|
|
100
|
+
fields: {
|
|
101
|
+
name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
|
|
102
|
+
extra: { type: "jsonb" },
|
|
103
|
+
meta: {
|
|
104
|
+
type: "embedded",
|
|
105
|
+
schema: { note: { type: "text" } },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const screen: EntityEditScreenDefinition = {
|
|
110
|
+
id: "widget-edit",
|
|
111
|
+
type: "entityEdit",
|
|
112
|
+
entity: "widget",
|
|
113
|
+
layout: { sections: [{ columns: 1, fields: ["name", "extra", "meta"] }] },
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
featureName: "widgets",
|
|
117
|
+
entities: { widget: entity },
|
|
118
|
+
screens: [screen],
|
|
119
|
+
} as FeatureSchema;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("EntityEditUpdateForm — untouched jsonb/embedded fields survive submit", () => {
|
|
123
|
+
test("changing an unrelated field and submitting keeps jsonb/embedded fields out of the diff (not stringified, not overwritten)", async () => {
|
|
124
|
+
lastWrite = undefined;
|
|
125
|
+
capturedFormSubmit = undefined;
|
|
126
|
+
for (const key of Object.keys(capturedInputs)) delete capturedInputs[key];
|
|
127
|
+
|
|
128
|
+
const record = {
|
|
129
|
+
id: "w1",
|
|
130
|
+
version: 3,
|
|
131
|
+
name: "Old name",
|
|
132
|
+
extra: { a: 1 },
|
|
133
|
+
meta: { note: "keep me" },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
render(
|
|
137
|
+
<LocaleProvider
|
|
138
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
139
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
140
|
+
>
|
|
141
|
+
<DispatcherProvider dispatcher={stubDispatcher(record)}>
|
|
142
|
+
<NavProvider
|
|
143
|
+
value={{
|
|
144
|
+
route: { screenId: "widgets:widget-edit" },
|
|
145
|
+
navigate: () => {},
|
|
146
|
+
replace: () => {},
|
|
147
|
+
hrefFor: () => "",
|
|
148
|
+
searchParams: {},
|
|
149
|
+
setSearchParams: () => {},
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
153
|
+
<KumikoScreen schema={buildSchema()} qn="widgets:screen:widget-edit" entityId="w1" />
|
|
154
|
+
</PrimitivesProvider>
|
|
155
|
+
</NavProvider>
|
|
156
|
+
</DispatcherProvider>
|
|
157
|
+
</LocaleProvider>,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(capturedInputs["name"]).toBeDefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const nameInput = capturedInputs["name"];
|
|
165
|
+
if (nameInput?.kind !== "text") throw new Error("expected a text input for the name field");
|
|
166
|
+
act(() => nameInput.onChange("New name"));
|
|
167
|
+
|
|
168
|
+
await waitFor(() => {
|
|
169
|
+
expect(capturedFormSubmit).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
act(() => capturedFormSubmit?.());
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(lastWrite).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const payload = requireLastWrite().payload as { changes: Record<string, unknown> };
|
|
178
|
+
expect(payload.changes["name"]).toBe("New name");
|
|
179
|
+
// The untouched jsonb/embedded fields must never resurface in the diff —
|
|
180
|
+
// if they did (the pre-fix bug this guards against), it would mean the
|
|
181
|
+
// update overwrites them, and the old code path could send a corrupted
|
|
182
|
+
// (e.g. stringified) copy instead of the real object.
|
|
183
|
+
expect(Object.hasOwn(payload.changes, "extra")).toBe(false);
|
|
184
|
+
expect(Object.hasOwn(payload.changes, "meta")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("touching the jsonb field itself keeps it a real object in the diff, never a string", async () => {
|
|
188
|
+
lastWrite = undefined;
|
|
189
|
+
capturedFormSubmit = undefined;
|
|
190
|
+
for (const key of Object.keys(capturedInputs)) delete capturedInputs[key];
|
|
191
|
+
|
|
192
|
+
const record = {
|
|
193
|
+
id: "w1",
|
|
194
|
+
version: 3,
|
|
195
|
+
name: "Old name",
|
|
196
|
+
extra: { a: 1 },
|
|
197
|
+
meta: { note: "keep me" },
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
render(
|
|
201
|
+
<LocaleProvider
|
|
202
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
203
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
204
|
+
>
|
|
205
|
+
<DispatcherProvider dispatcher={stubDispatcher(record)}>
|
|
206
|
+
<NavProvider
|
|
207
|
+
value={{
|
|
208
|
+
route: { screenId: "widgets:widget-edit" },
|
|
209
|
+
navigate: () => {},
|
|
210
|
+
replace: () => {},
|
|
211
|
+
hrefFor: () => "",
|
|
212
|
+
searchParams: {},
|
|
213
|
+
setSearchParams: () => {},
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
217
|
+
<KumikoScreen schema={buildSchema()} qn="widgets:screen:widget-edit" entityId="w1" />
|
|
218
|
+
</PrimitivesProvider>
|
|
219
|
+
</NavProvider>
|
|
220
|
+
</DispatcherProvider>
|
|
221
|
+
</LocaleProvider>,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(capturedFormSubmit).toBeDefined();
|
|
226
|
+
});
|
|
227
|
+
act(() => capturedFormSubmit?.());
|
|
228
|
+
|
|
229
|
+
await waitFor(() => {
|
|
230
|
+
expect(lastWrite).toBeDefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const payload = requireLastWrite().payload as { changes: Record<string, unknown> };
|
|
234
|
+
// Nothing was touched at all — the diff must be empty, and specifically
|
|
235
|
+
// must not contain a stringified copy of extra/meta.
|
|
236
|
+
expect(Object.hasOwn(payload.changes, "extra")).toBe(false);
|
|
237
|
+
expect(Object.hasOwn(payload.changes, "meta")).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Guard-evasion-adjacent bug: useNavigateToCreateFor picked the first
|
|
2
|
+
// entityEdit screen with allowCreate !== false as the "+ Neu" target,
|
|
3
|
+
// without excluding singleton:true screens. A singleton screen opened
|
|
4
|
+
// without an entityId resolves the existing record (EntityEditSingletonBody)
|
|
5
|
+
// instead of a blank create form — so "+ Neu" silently opened a prefilled
|
|
6
|
+
// update form. This test renders the real list path (KumikoScreen →
|
|
7
|
+
// EntityListScreen → EntityListBody → RenderList) for an entity whose only
|
|
8
|
+
// registered entityEdit screen is a singleton, and asserts no create button
|
|
9
|
+
// renders — not just the hook in isolation.
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import type {
|
|
12
|
+
EntityDefinition,
|
|
13
|
+
EntityEditScreenDefinition,
|
|
14
|
+
EntityListScreenDefinition,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
16
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
17
|
+
import { render } from "@testing-library/react";
|
|
18
|
+
import type { ComponentType, ReactNode } from "react";
|
|
19
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
20
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
21
|
+
import { type ButtonProps, type CorePrimitives, PrimitivesProvider } from "../../primitives";
|
|
22
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
23
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
24
|
+
import { NavProvider } from "../nav";
|
|
25
|
+
|
|
26
|
+
const capturedButtonTestIds: string[] = [];
|
|
27
|
+
const captureButton: ComponentType<ButtonProps> = (props) => {
|
|
28
|
+
if (props.testId !== undefined) capturedButtonTestIds.push(props.testId);
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
const noop = (): ReactNode => null;
|
|
32
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
33
|
+
|
|
34
|
+
const testPrimitives: CorePrimitives = {
|
|
35
|
+
Button: captureButton,
|
|
36
|
+
Banner: passChildren,
|
|
37
|
+
Field: passChildren,
|
|
38
|
+
Input: noop,
|
|
39
|
+
DataTable: noop,
|
|
40
|
+
Form: passChildren,
|
|
41
|
+
Section: passChildren,
|
|
42
|
+
Card: passChildren,
|
|
43
|
+
Grid: passChildren,
|
|
44
|
+
GridCell: passChildren,
|
|
45
|
+
Text: passChildren,
|
|
46
|
+
Heading: noop,
|
|
47
|
+
Dialog: noop,
|
|
48
|
+
Modal: noop,
|
|
49
|
+
Lightbox: noop,
|
|
50
|
+
ConfigSourceBadge: noop,
|
|
51
|
+
ConfigCascadeView: noop,
|
|
52
|
+
Link: noop,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function stubDispatcher(): Dispatcher {
|
|
56
|
+
return {
|
|
57
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
58
|
+
query: (async () => ({
|
|
59
|
+
isSuccess: true,
|
|
60
|
+
data: { rows: [], total: 0 },
|
|
61
|
+
})) as unknown as Dispatcher["query"],
|
|
62
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
63
|
+
statusStore: {
|
|
64
|
+
getState: () => "online",
|
|
65
|
+
subscribe: () => () => {},
|
|
66
|
+
} as unknown as Dispatcher["statusStore"],
|
|
67
|
+
async *stream() {},
|
|
68
|
+
pendingWrites: () => [],
|
|
69
|
+
pendingFiles: () => [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildSchema(): FeatureSchema {
|
|
74
|
+
const entity: EntityDefinition = {
|
|
75
|
+
fields: {
|
|
76
|
+
name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const listScreen: EntityListScreenDefinition = {
|
|
80
|
+
id: "org-list",
|
|
81
|
+
type: "entityList",
|
|
82
|
+
entity: "org",
|
|
83
|
+
columns: ["name"],
|
|
84
|
+
};
|
|
85
|
+
// The only entityEdit screen for "org" is a singleton — there is
|
|
86
|
+
// deliberately no separate non-singleton entityEdit screen.
|
|
87
|
+
const editScreen: EntityEditScreenDefinition = {
|
|
88
|
+
id: "org-edit",
|
|
89
|
+
type: "entityEdit",
|
|
90
|
+
entity: "org",
|
|
91
|
+
singleton: true,
|
|
92
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
featureName: "orgs",
|
|
96
|
+
entities: { org: entity },
|
|
97
|
+
screens: [listScreen, editScreen],
|
|
98
|
+
} as FeatureSchema;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("entityList '+ Neu' target excludes singleton entityEdit screens", () => {
|
|
102
|
+
test("no create button renders when the only entityEdit screen is singleton:true", () => {
|
|
103
|
+
capturedButtonTestIds.length = 0;
|
|
104
|
+
render(
|
|
105
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
|
|
106
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
107
|
+
<NavProvider
|
|
108
|
+
value={{
|
|
109
|
+
route: { screenId: "orgs:org-list" },
|
|
110
|
+
navigate: () => {},
|
|
111
|
+
replace: () => {},
|
|
112
|
+
hrefFor: () => "",
|
|
113
|
+
searchParams: {},
|
|
114
|
+
setSearchParams: () => {},
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
118
|
+
<KumikoScreen schema={buildSchema()} qn="orgs:screen:org-list" />
|
|
119
|
+
</PrimitivesProvider>
|
|
120
|
+
</NavProvider>
|
|
121
|
+
</DispatcherProvider>
|
|
122
|
+
</LocaleProvider>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(capturedButtonTestIds).not.toContain("render-list-create");
|
|
126
|
+
expect(capturedButtonTestIds).not.toContain("render-list-empty-create");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -289,10 +289,15 @@ function useNavigateToCreateFor(
|
|
|
289
289
|
const nav = useNav();
|
|
290
290
|
const editScreenId = useMemo(() => {
|
|
291
291
|
// allowCreate:false = update-only Edit-Screen (Create läuft über einen
|
|
292
|
-
// Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel.
|
|
292
|
+
// Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
|
|
293
|
+
// öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
|
|
294
|
+
// statt eines leeren Create-Forms — zählt aus demselben Grund nicht.
|
|
293
295
|
const edit = schema.screens.find(
|
|
294
296
|
(s: ScreenDefinition) =>
|
|
295
|
-
s.type === "entityEdit" &&
|
|
297
|
+
s.type === "entityEdit" &&
|
|
298
|
+
s.entity === entityName &&
|
|
299
|
+
s.allowCreate !== false &&
|
|
300
|
+
s.singleton !== true,
|
|
296
301
|
);
|
|
297
302
|
return edit !== undefined ? lastSegment(edit.id) : undefined;
|
|
298
303
|
}, [schema.screens, entityName]);
|
|
@@ -166,6 +166,68 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
|
166
166
|
{ product: "p1", unit: "pcs", quantity: 2.5, unitPrice: 923, amount: 2308 },
|
|
167
167
|
]);
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
// Regression: withRecomputedDerived used to read source values from
|
|
171
|
+
// `result` (mutated in-place during the loop), so a chained derived cell
|
|
172
|
+
// (gross depends on net, net depends on raw qty/price) got the FRESH net
|
|
173
|
+
// when "net" happened to iterate before "gross" in embeddedListDerived's
|
|
174
|
+
// key order, but the STALE pre-edit net otherwise — the client preview
|
|
175
|
+
// drifted from the server (withDerivedCells always reads the untouched
|
|
176
|
+
// source row) purely based on object-key order. Fixed by reading from a
|
|
177
|
+
// frozen `source` snapshot, same as the server. Two field configs with
|
|
178
|
+
// "net"/"gross" declared in opposite key order must therefore produce the
|
|
179
|
+
// identical, order-independent result.
|
|
180
|
+
test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", () => {
|
|
181
|
+
const cells: EditFieldViewModel["embeddedListCells"] = [
|
|
182
|
+
{ field: "qty", label: "Qty", type: "number", required: true },
|
|
183
|
+
{ field: "price", label: "Price", type: "number", required: true },
|
|
184
|
+
{ field: "tax", label: "Tax", type: "number", required: true },
|
|
185
|
+
{ field: "net", label: "Net", type: "number", required: false },
|
|
186
|
+
{ field: "gross", label: "Gross", type: "number", required: false },
|
|
187
|
+
];
|
|
188
|
+
// Pre-edit, already-settled row: net = 2*3 = 6, gross = 6+1 = 7.
|
|
189
|
+
const rows = [{ qty: 2, price: 3, tax: 1, net: 6, gross: 7 }];
|
|
190
|
+
// A fixed reference calculation for "qty changes from 2 to 4": net
|
|
191
|
+
// recomputes from the fresh raw inputs (qty=4, price=3) to 12; gross
|
|
192
|
+
// reads net from the row as it was BEFORE this edit (still 6, same as
|
|
193
|
+
// the server would), giving 6+1=7 — not 12+1=13.
|
|
194
|
+
const expected = { qty: 4, price: 3, tax: 1, net: 12, gross: 7 };
|
|
195
|
+
|
|
196
|
+
let netFirstResult: unknown;
|
|
197
|
+
renderEmbeddedListField(
|
|
198
|
+
{
|
|
199
|
+
...invoiceLinesField({ value: rows }),
|
|
200
|
+
embeddedListCells: cells,
|
|
201
|
+
embeddedListDerived: {
|
|
202
|
+
net: { op: "multiply", from: ["qty", "price"] },
|
|
203
|
+
gross: { op: "sum", from: ["net", "tax"] },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
(v) => {
|
|
207
|
+
netFirstResult = v;
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
captured?.onCellChange(0, "qty", 4);
|
|
211
|
+
|
|
212
|
+
let grossFirstResult: unknown;
|
|
213
|
+
renderEmbeddedListField(
|
|
214
|
+
{
|
|
215
|
+
...invoiceLinesField({ value: rows }),
|
|
216
|
+
embeddedListCells: cells,
|
|
217
|
+
embeddedListDerived: {
|
|
218
|
+
gross: { op: "sum", from: ["net", "tax"] },
|
|
219
|
+
net: { op: "multiply", from: ["qty", "price"] },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
(v) => {
|
|
223
|
+
grossFirstResult = v;
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
captured?.onCellChange(0, "qty", 4);
|
|
227
|
+
|
|
228
|
+
expect(netFirstResult).toEqual([expected]);
|
|
229
|
+
expect(grossFirstResult).toEqual([expected]);
|
|
230
|
+
});
|
|
169
231
|
});
|
|
170
232
|
|
|
171
233
|
describe("EmbeddedListField — row operations", () => {
|
|
@@ -33,10 +33,15 @@ function withRecomputedDerived(
|
|
|
33
33
|
cells: readonly EmbeddedListCellViewModel[],
|
|
34
34
|
): EmbeddedRow {
|
|
35
35
|
if (derived === undefined) return row;
|
|
36
|
+
// Read sources from an unchanged snapshot, never from `result` (which this
|
|
37
|
+
// loop mutates) — mirrors withDerivedCells server-side so chained derived
|
|
38
|
+
// cells (e.g. net=qty*price, then gross=sum(net,tax)) can't drift between
|
|
39
|
+
// client preview and server depending on Object.entries(derived) order.
|
|
40
|
+
const source = { ...row };
|
|
36
41
|
const result: Record<string, unknown> = { ...row };
|
|
37
42
|
for (const [derivedField, def] of Object.entries(derived)) {
|
|
38
43
|
const values = def.from.map((src) => {
|
|
39
|
-
const v =
|
|
44
|
+
const v = source[src];
|
|
40
45
|
return typeof v === "number" ? v : undefined;
|
|
41
46
|
});
|
|
42
47
|
const computed = computeDerivedCellValue(def.op, values);
|
|
@@ -177,6 +177,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
177
177
|
* no way to force-disable an arbitrary registered component. Omitting
|
|
178
178
|
* this prop keeps unchanged behavior. */
|
|
179
179
|
readonly disabled?: boolean;
|
|
180
|
+
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
181
|
+
* delete, copy-link). For hosts that put those controls into their own
|
|
182
|
+
* chrome — a drawer footer, a wizard shell — and drive the write through
|
|
183
|
+
* `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
|
|
184
|
+
* behavior. */
|
|
185
|
+
readonly hideActions?: boolean;
|
|
180
186
|
};
|
|
181
187
|
|
|
182
188
|
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
@@ -190,6 +196,12 @@ export type RenderEditControls<TValues extends FormValues> = {
|
|
|
190
196
|
readonly patch: (partial: Partial<TValues>) => void;
|
|
191
197
|
readonly validate: () => boolean;
|
|
192
198
|
readonly getValues: () => TValues;
|
|
199
|
+
/** Runs the same pipeline the built-in save button runs: validation,
|
|
200
|
+
* `customSubmit`/`writeCommand`, extension-section persistence, draft
|
|
201
|
+
* discard, state rebase. Unlike the button it carries no unchanged-form
|
|
202
|
+
* guard — a host showing a pre-filled proposal must be able to accept it
|
|
203
|
+
* untouched. */
|
|
204
|
+
readonly submit: () => Promise<void>;
|
|
193
205
|
};
|
|
194
206
|
|
|
195
207
|
function toConditionValue<TValues extends FormValues, TCtx>(
|
|
@@ -238,6 +250,7 @@ function ExtensionSectionMount({
|
|
|
238
250
|
values,
|
|
239
251
|
patch,
|
|
240
252
|
validate,
|
|
253
|
+
hidden,
|
|
241
254
|
}: {
|
|
242
255
|
readonly section: EditExtensionSectionViewModel;
|
|
243
256
|
readonly entityName: string;
|
|
@@ -246,6 +259,7 @@ function ExtensionSectionMount({
|
|
|
246
259
|
readonly values?: Readonly<Record<string, unknown>>;
|
|
247
260
|
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
248
261
|
readonly validate?: () => boolean;
|
|
262
|
+
readonly hidden?: boolean;
|
|
249
263
|
}): ReactNode {
|
|
250
264
|
const { Banner, Section, Text } = usePrimitives();
|
|
251
265
|
const name = extensionSectionName(section.component);
|
|
@@ -256,6 +270,7 @@ function ExtensionSectionMount({
|
|
|
256
270
|
key={section.title}
|
|
257
271
|
title={section.title}
|
|
258
272
|
testId={`section-extension-${section.title}`}
|
|
273
|
+
hidden={hidden}
|
|
259
274
|
>
|
|
260
275
|
<Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
|
|
261
276
|
<Text>
|
|
@@ -268,7 +283,7 @@ function ExtensionSectionMount({
|
|
|
268
283
|
);
|
|
269
284
|
}
|
|
270
285
|
return (
|
|
271
|
-
<Section title={section.title} testId={`section-extension-${section.title}`}>
|
|
286
|
+
<Section title={section.title} testId={`section-extension-${section.title}`} hidden={hidden}>
|
|
272
287
|
<Component
|
|
273
288
|
entityName={entityName}
|
|
274
289
|
entityId={entityId}
|
|
@@ -309,6 +324,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
309
324
|
onControlsReady,
|
|
310
325
|
fields: fieldsFilter,
|
|
311
326
|
disabled = false,
|
|
327
|
+
hideActions,
|
|
312
328
|
} = props;
|
|
313
329
|
const { customSubmit } = props;
|
|
314
330
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -432,6 +448,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
432
448
|
// re-subscribes with a "new" onChange → refires.
|
|
433
449
|
const onChangeRef = useRef(onChange);
|
|
434
450
|
onChangeRef.current = onChange;
|
|
451
|
+
// `schema` is typically an inline caller prop (`schema={z.object({...})}`)
|
|
452
|
+
// with unstable identity across renders — as an effect dep it would refire
|
|
453
|
+
// on every parent render, not just on a snapshot change, risking a loop if
|
|
454
|
+
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
455
|
+
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
456
|
+
const schemaRef = useRef(schema);
|
|
457
|
+
schemaRef.current = schema;
|
|
435
458
|
useEffect(() => {
|
|
436
459
|
const cb = onChangeRef.current;
|
|
437
460
|
if (cb === undefined) return;
|
|
@@ -441,9 +464,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
441
464
|
// typing. `valid` can therefore legitimately diverge from what's
|
|
442
465
|
// currently rendered under the fields (the last *mutating* validate()
|
|
443
466
|
// call, e.g. from controls.validate() or submit()).
|
|
444
|
-
const
|
|
467
|
+
const currentSchema = schemaRef.current;
|
|
468
|
+
const valid =
|
|
469
|
+
currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
|
|
445
470
|
cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
|
|
446
|
-
}, [snapshot
|
|
471
|
+
}, [snapshot]);
|
|
447
472
|
|
|
448
473
|
const onControlsReadyRef = useRef(onControlsReady);
|
|
449
474
|
onControlsReadyRef.current = onControlsReady;
|
|
@@ -463,6 +488,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
463
488
|
// is defined — kept fresh where `currentStep` is computed below.
|
|
464
489
|
const saveDraftRef = useRef(saveDraft);
|
|
465
490
|
saveDraftRef.current = saveDraft;
|
|
491
|
+
// `handleSubmit` (defined below) is a fresh closure over this render's
|
|
492
|
+
// snapshot/extensionDirty, while the onControlsReady effect fires once per
|
|
493
|
+
// mount — the ref lets controls.submit() always reach the current one.
|
|
494
|
+
const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
|
|
466
495
|
const currentStepRef = useRef(0);
|
|
467
496
|
const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
468
497
|
useEffect(() => {
|
|
@@ -495,6 +524,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
495
524
|
patch: patchAndScheduleDraftSave,
|
|
496
525
|
validate: scopedValidate,
|
|
497
526
|
getValues: () => controller.getSnapshot().values,
|
|
527
|
+
submit: () => handleSubmitRef.current(),
|
|
498
528
|
});
|
|
499
529
|
// controller is mount-lifetime-stable (see useForm's comment on its own
|
|
500
530
|
// useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
|
|
@@ -539,11 +569,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
539
569
|
}, [draftEnabled, draftKey, draftId, dispatcher, controller]);
|
|
540
570
|
|
|
541
571
|
// Mount-time fallback (issue #1913) for create-mode when no draftId
|
|
542
|
-
// survived in storage (new tab, cleared storage): ask the server for
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
572
|
+
// survived in storage (new tab, cleared storage): ask the server for this
|
|
573
|
+
// screen's open drafts and let the user choose via a picker (below) —
|
|
574
|
+
// even for exactly one candidate. Auto-adopting a lone candidate would
|
|
575
|
+
// silently hand this tab someone else's in-progress draft from a parallel
|
|
576
|
+
// session on the same screen (cross-tab hijack); the picker's "start new"
|
|
577
|
+
// action covers the common case (it really was this tab's own draft) with
|
|
578
|
+
// one extra click. Zero candidates → stay null, saveDraft mints a fresh
|
|
579
|
+
// draftId on the first step change same as any other fresh create.
|
|
547
580
|
useEffect(() => {
|
|
548
581
|
// skip: this screen does not persist a draft, this is edit-mode, a
|
|
549
582
|
// draftId is already known (from storage or an earlier adoption), or
|
|
@@ -566,19 +599,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
566
599
|
// skip: no open drafts for this screen — stay null, saveDraft mints a
|
|
567
600
|
// fresh draftId on the first step change same as any other create.
|
|
568
601
|
if (candidates.length === 0) return;
|
|
569
|
-
const [only] = candidates;
|
|
570
|
-
if (candidates.length === 1 && only !== undefined) {
|
|
571
|
-
const adoptedId = only.draftKey.slice(prefix.length);
|
|
572
|
-
draftStorage.setDraftId(screen.id, adoptedId);
|
|
573
|
-
setDraftId(adoptedId);
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
602
|
setDraftCandidates(candidates);
|
|
577
603
|
})();
|
|
578
604
|
return () => {
|
|
579
605
|
cancelled = true;
|
|
580
606
|
};
|
|
581
|
-
}, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id
|
|
607
|
+
}, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id]);
|
|
582
608
|
|
|
583
609
|
// User picked one of several open drafts from the mount-time picker
|
|
584
610
|
// (see draftCandidates below) — adopt it the same way a single
|
|
@@ -675,11 +701,20 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
675
701
|
// have no field scope here (they validate via the controlled-mode
|
|
676
702
|
// controls.validate() API), so no call, no clearing of unrelated errors.
|
|
677
703
|
function handleWizardNext(): void {
|
|
704
|
+
const next = Math.min(currentStep + 1, lastStepIndex);
|
|
705
|
+
// Locked state (#1896): `disabled` means "no input/no write", not "no
|
|
706
|
+
// navigation" — a disabled wizard must still be steppable so Back/Next
|
|
707
|
+
// reach every step. But neither validate() (would block on an empty
|
|
708
|
+
// required field nobody can fill in) nor saveDraft() (would mint a
|
|
709
|
+
// draftId / dispatch a write — exactly what `disabled` forbids) may run.
|
|
710
|
+
if (disabled) {
|
|
711
|
+
setRawStep(next);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
678
714
|
const section = filteredSections[currentStep];
|
|
679
715
|
const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
|
|
680
716
|
// skip: the current step has field errors — no transition, no draft save.
|
|
681
717
|
if (fieldNames.length > 0 && !controller.validate(fieldNames)) return;
|
|
682
|
-
const next = Math.min(currentStep + 1, lastStepIndex);
|
|
683
718
|
setRawStep(next);
|
|
684
719
|
saveDraft(next);
|
|
685
720
|
}
|
|
@@ -702,7 +737,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
702
737
|
// skip: create-mode, no step change happened yet — no draftId was ever
|
|
703
738
|
// minted, so no row exists to discard.
|
|
704
739
|
if (draftKey === undefined) return;
|
|
705
|
-
|
|
740
|
+
// Best-effort: a rejected write (e.g. network error) must not propagate
|
|
741
|
+
// out of handleSubmit AFTER the entity write already succeeded — that
|
|
742
|
+
// would break onSubmit/navigation/toast and risk a duplicate submit on
|
|
743
|
+
// retry. An orphaned draft row is swept later by cleanup.job.ts. Local
|
|
744
|
+
// cleanup below still runs on failure so this instance doesn't try to
|
|
745
|
+
// resume a draft it already considers submitted.
|
|
746
|
+
try {
|
|
747
|
+
await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
|
|
748
|
+
} catch {
|
|
749
|
+
// best-effort, see comment above.
|
|
750
|
+
}
|
|
706
751
|
// A successful submit ends this draftId's life — a subsequent create on
|
|
707
752
|
// the same screen (new mount) must mint its own, not resume this one.
|
|
708
753
|
if (isCreateMode) {
|
|
@@ -712,18 +757,37 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
712
757
|
}
|
|
713
758
|
}
|
|
714
759
|
|
|
760
|
+
// Wizard mode: locates the first step whose "fields" section contains one
|
|
761
|
+
// of the given field paths — matched on the path's first segment, since
|
|
762
|
+
// `FieldIssue.path`/`snapshot.errors` keys can be dotted (embedded-list
|
|
763
|
+
// rows, e.g. `tasks.2.title`) while a section's field name is always the
|
|
764
|
+
// plain top-level name. Returns undefined for a root-level issue (path
|
|
765
|
+
// `"(root)"`, see zod-bridge.ts) or one that matches no rendered field —
|
|
766
|
+
// callers must NOT suppress the error banner in that case, there is
|
|
767
|
+
// nowhere to jump the user to.
|
|
768
|
+
function findFirstErroringStep(fieldPaths: readonly string[]): number | undefined {
|
|
769
|
+
const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
|
|
770
|
+
const idx = filteredSections.findIndex(
|
|
771
|
+
(s) => s.kind === "fields" && s.fields.some((f) => erroredFields.has(f.field)),
|
|
772
|
+
);
|
|
773
|
+
return idx === -1 ? undefined : idx;
|
|
774
|
+
}
|
|
775
|
+
|
|
715
776
|
async function handleSubmit(): Promise<void> {
|
|
716
|
-
// Locked state (#1896): the submit button is visibly disabled, but a
|
|
717
|
-
// native form submit (Enter key) reaches this handler regardless of the
|
|
718
|
-
// button's disabled attribute — block it here too, not just in the UI.
|
|
719
|
-
if (disabled) return;
|
|
720
777
|
// Enter in the active step triggers the native form submit (Next is
|
|
721
778
|
// type="submit" for Enter support) — on intermediate steps that means
|
|
722
|
-
// "Next", not "Save".
|
|
779
|
+
// "Next", not "Save". Checked BEFORE the `disabled` guard below:
|
|
780
|
+
// `disabled` means "no input/no write", not "no navigation" — a
|
|
781
|
+
// disabled wizard must still be steppable (handleWizardNext itself
|
|
782
|
+
// blocks validate()/saveDraft() while disabled).
|
|
723
783
|
if (isWizard && !isLastWizardStep) {
|
|
724
784
|
handleWizardNext();
|
|
725
785
|
return;
|
|
726
786
|
}
|
|
787
|
+
// Locked state (#1896): the submit button is visibly disabled, but a
|
|
788
|
+
// native form submit (Enter key) reaches this handler regardless of the
|
|
789
|
+
// button's disabled attribute — block it here too, not just in the UI.
|
|
790
|
+
if (disabled) return;
|
|
727
791
|
setIsSubmitting(true);
|
|
728
792
|
setExtensionErrorKey(null);
|
|
729
793
|
try {
|
|
@@ -781,9 +845,36 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
781
845
|
// the draft behind after a successful submit.
|
|
782
846
|
await discardDraft();
|
|
783
847
|
extensionsPersisted = await persistExtensions();
|
|
784
|
-
} else if (
|
|
848
|
+
} else if (result.validationBlocked) {
|
|
849
|
+
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
850
|
+
// — same "jump to the erroring step" treatment as a server field
|
|
851
|
+
// error below, sourced from the freshly-validated snapshot.
|
|
852
|
+
if (isWizard) {
|
|
853
|
+
const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
|
|
854
|
+
if (step !== undefined) setRawStep(step);
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
785
857
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
786
|
-
|
|
858
|
+
// A server field error on a step the wizard isn't currently showing
|
|
859
|
+
// is otherwise invisible — jump to the first step that contains one
|
|
860
|
+
// of the errored fields. Non-wizard forms render every field at
|
|
861
|
+
// once, so the original suppress-when-field-issues-exist rule still
|
|
862
|
+
// applies there (the field itself already shows the error inline).
|
|
863
|
+
if (isWizard) {
|
|
864
|
+
const step = findFirstErroringStep(fieldIssues.map((i) => i.path));
|
|
865
|
+
if (step !== undefined) {
|
|
866
|
+
setRawStep(step);
|
|
867
|
+
setFormError(null);
|
|
868
|
+
} else {
|
|
869
|
+
// No rendered step contains any of the errored fields (a
|
|
870
|
+
// root-level issue, or a field outside every step) — nothing
|
|
871
|
+
// will show this error inline, so the banner must not be
|
|
872
|
+
// suppressed.
|
|
873
|
+
setFormError(result.error);
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
setFormError(fieldIssues.length === 0 ? result.error : null);
|
|
877
|
+
}
|
|
787
878
|
}
|
|
788
879
|
// skip: on entity-success-but-extension-failure the extension-error
|
|
789
880
|
// banner is showing — don't notify (the caller navigates away on success
|
|
@@ -793,6 +884,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
793
884
|
setIsSubmitting(false);
|
|
794
885
|
}
|
|
795
886
|
}
|
|
887
|
+
handleSubmitRef.current = handleSubmit;
|
|
796
888
|
|
|
797
889
|
// Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
|
|
798
890
|
// Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
|
|
@@ -844,12 +936,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
844
936
|
</Button>
|
|
845
937
|
)}
|
|
846
938
|
{isWizard && !isLastWizardStep && (
|
|
847
|
-
<Button
|
|
848
|
-
type="submit"
|
|
849
|
-
disabled={disabled}
|
|
850
|
-
variant="primary"
|
|
851
|
-
testId="render-edit-wizard-next"
|
|
852
|
-
>
|
|
939
|
+
<Button type="submit" variant="primary" testId="render-edit-wizard-next">
|
|
853
940
|
{translate("kumiko.actions.next")}
|
|
854
941
|
</Button>
|
|
855
942
|
)}
|
|
@@ -895,7 +982,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
895
982
|
onSubmit={() => void handleSubmit()}
|
|
896
983
|
title={formTitle}
|
|
897
984
|
{...(formSubtitle !== undefined && { subtitle: formSubtitle })}
|
|
898
|
-
actions
|
|
985
|
+
{...(hideActions !== true && { actions: formActions })}
|
|
899
986
|
testId="render-edit-form"
|
|
900
987
|
stickyActions={isWizard}
|
|
901
988
|
{...(screen.layout.width !== undefined && { width: screen.layout.width })}
|
|
@@ -904,17 +991,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
904
991
|
<Banner
|
|
905
992
|
variant="info"
|
|
906
993
|
testId="render-edit-draft-picker"
|
|
907
|
-
actions={
|
|
994
|
+
actions={[
|
|
995
|
+
...draftCandidates.map((candidate) => (
|
|
996
|
+
<Button
|
|
997
|
+
key={candidate.id}
|
|
998
|
+
type="button"
|
|
999
|
+
variant="link"
|
|
1000
|
+
onClick={() => adoptDraft(candidate)}
|
|
1001
|
+
testId={`render-edit-draft-pick-${candidate.id}`}
|
|
1002
|
+
>
|
|
1003
|
+
{formatWhen(candidate.savedAt)}
|
|
1004
|
+
</Button>
|
|
1005
|
+
)),
|
|
908
1006
|
<Button
|
|
909
|
-
key=
|
|
1007
|
+
key="start-new"
|
|
910
1008
|
type="button"
|
|
911
1009
|
variant="link"
|
|
912
|
-
onClick={() =>
|
|
913
|
-
testId=
|
|
1010
|
+
onClick={() => setDraftCandidates(null)}
|
|
1011
|
+
testId="render-edit-draft-start-new"
|
|
914
1012
|
>
|
|
915
|
-
{
|
|
916
|
-
</Button
|
|
917
|
-
|
|
1013
|
+
{translate("kumiko.form.draft.start-new")}
|
|
1014
|
+
</Button>,
|
|
1015
|
+
]}
|
|
918
1016
|
>
|
|
919
1017
|
<Text>{translate("kumiko.form.draft.resume-multiple")}</Text>
|
|
920
1018
|
</Banner>
|
|
@@ -963,68 +1061,72 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
963
1061
|
})()}
|
|
964
1062
|
</>
|
|
965
1063
|
)}
|
|
966
|
-
{
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
975
|
-
initialValues={extensionInitialValues}
|
|
976
|
-
values={snapshot.values}
|
|
977
|
-
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
978
|
-
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
979
|
-
patch={
|
|
980
|
-
patchAndScheduleDraftSave as (
|
|
981
|
-
partial: Readonly<Record<string, unknown>>,
|
|
982
|
-
) => void
|
|
983
|
-
}
|
|
984
|
-
validate={scopedValidate}
|
|
985
|
-
/>
|
|
986
|
-
);
|
|
987
|
-
}
|
|
988
|
-
if (!section.visible) return null;
|
|
989
|
-
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
990
|
-
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
991
|
-
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
992
|
-
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
993
|
-
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
994
|
-
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1064
|
+
{filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
|
|
1065
|
+
// Wizard steps stay mounted while off-screen (native `hidden`, not
|
|
1066
|
+
// unmounted) so an extension section's submit-registry entry
|
|
1067
|
+
// (useExtensionFormSubmit → registry.remove on unmount) survives
|
|
1068
|
+
// navigating past its step — otherwise Finish only ran the last
|
|
1069
|
+
// mounted step's handler and silently dropped earlier steps' writes.
|
|
1070
|
+
const stepHidden = isWizard && sectionIndex !== currentStep;
|
|
1071
|
+
if (section.kind === "extension") {
|
|
995
1072
|
return (
|
|
996
|
-
<
|
|
997
|
-
key={
|
|
998
|
-
{
|
|
999
|
-
{
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}}
|
|
1012
|
-
GridCell={GridCell}
|
|
1013
|
-
featureName={featureName}
|
|
1014
|
-
{...(labelAppendix !== undefined && {
|
|
1015
|
-
labelAppendix: labelAppendix(field.field),
|
|
1016
|
-
})}
|
|
1017
|
-
{...(fieldAppendix !== undefined && {
|
|
1018
|
-
fieldAppendix: fieldAppendix(field.field),
|
|
1019
|
-
})}
|
|
1020
|
-
allIssues={snapshot.errors}
|
|
1021
|
-
/>
|
|
1022
|
-
))}
|
|
1023
|
-
</Grid>
|
|
1024
|
-
</Section>
|
|
1073
|
+
<ExtensionSectionMount
|
|
1074
|
+
key={section.title}
|
|
1075
|
+
section={section}
|
|
1076
|
+
entityName={vm.entityName}
|
|
1077
|
+
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
1078
|
+
initialValues={extensionInitialValues}
|
|
1079
|
+
values={snapshot.values}
|
|
1080
|
+
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
1081
|
+
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
1082
|
+
patch={
|
|
1083
|
+
patchAndScheduleDraftSave as (partial: Readonly<Record<string, unknown>>) => void
|
|
1084
|
+
}
|
|
1085
|
+
validate={scopedValidate}
|
|
1086
|
+
hidden={stepHidden}
|
|
1087
|
+
/>
|
|
1025
1088
|
);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1089
|
+
}
|
|
1090
|
+
if (!section.visible) return null;
|
|
1091
|
+
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
1092
|
+
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
1093
|
+
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
1094
|
+
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
1095
|
+
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1096
|
+
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1097
|
+
return (
|
|
1098
|
+
<Section
|
|
1099
|
+
key={sectionKey}
|
|
1100
|
+
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
1101
|
+
{...(section.description !== undefined && { subtitle: section.description })}
|
|
1102
|
+
testId={`section-${sectionKey}`}
|
|
1103
|
+
hidden={stepHidden}
|
|
1104
|
+
>
|
|
1105
|
+
<Grid columns={section.columns}>
|
|
1106
|
+
{section.fields.map((field: EditFieldViewModel) => (
|
|
1107
|
+
<GridCellForField
|
|
1108
|
+
key={field.field}
|
|
1109
|
+
field={disabled ? { ...field, readOnly: true } : field}
|
|
1110
|
+
columns={section.columns}
|
|
1111
|
+
issues={snapshot.errors[field.field]}
|
|
1112
|
+
onChange={(v) => {
|
|
1113
|
+
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
1114
|
+
}}
|
|
1115
|
+
GridCell={GridCell}
|
|
1116
|
+
featureName={featureName}
|
|
1117
|
+
{...(labelAppendix !== undefined && {
|
|
1118
|
+
labelAppendix: labelAppendix(field.field),
|
|
1119
|
+
})}
|
|
1120
|
+
{...(fieldAppendix !== undefined && {
|
|
1121
|
+
fieldAppendix: fieldAppendix(field.field),
|
|
1122
|
+
})}
|
|
1123
|
+
allIssues={snapshot.errors}
|
|
1124
|
+
/>
|
|
1125
|
+
))}
|
|
1126
|
+
</Grid>
|
|
1127
|
+
</Section>
|
|
1128
|
+
);
|
|
1129
|
+
})}
|
|
1028
1130
|
{formError !== null && (
|
|
1029
1131
|
<Banner
|
|
1030
1132
|
variant="error"
|
|
@@ -328,7 +328,6 @@ function renderInput({
|
|
|
328
328
|
// already emits `number | undefined`, so no extra coercion is needed
|
|
329
329
|
// beyond what "number" already does (#1925).
|
|
330
330
|
case "number":
|
|
331
|
-
case "decimal":
|
|
332
331
|
case "bigInt":
|
|
333
332
|
return (
|
|
334
333
|
<Input
|
|
@@ -339,6 +338,20 @@ function renderInput({
|
|
|
339
338
|
{...(field.icon !== undefined && { icon: field.icon })}
|
|
340
339
|
/>
|
|
341
340
|
);
|
|
341
|
+
case "decimal":
|
|
342
|
+
// step="any" disables the native stepMismatch constraint — without it
|
|
343
|
+
// <input type="number"> defaults to step=1 and blocks form submit on
|
|
344
|
+
// any fractional value via silent browser-native validation.
|
|
345
|
+
return (
|
|
346
|
+
<Input
|
|
347
|
+
kind="number"
|
|
348
|
+
{...common}
|
|
349
|
+
value={numberValue(field.value)}
|
|
350
|
+
onChange={(v) => onChange(v)}
|
|
351
|
+
step="any"
|
|
352
|
+
{...(field.icon !== undefined && { icon: field.icon })}
|
|
353
|
+
/>
|
|
354
|
+
);
|
|
342
355
|
case "tz":
|
|
343
356
|
return (
|
|
344
357
|
<Input
|
|
@@ -532,11 +545,13 @@ function numberValue(v: unknown): number | "" {
|
|
|
532
545
|
// MINOR_UNIT_SCALE=100 (money.ts), which disagrees with currencyDecimals for
|
|
533
546
|
// zero-decimal currencies like JPY, so it would double-scale JPY amounts.
|
|
534
547
|
// Deriving from `amount * 10**currencyDecimals(currency)` keeps this the
|
|
535
|
-
// exact inverse of moneyPayload below. A bare number
|
|
536
|
-
//
|
|
548
|
+
// exact inverse of moneyPayload below. A bare number (e.g. ConfigEditBody's
|
|
549
|
+
// stored-config coercion) is MAJOR units too — every producer in this repo
|
|
550
|
+
// hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
|
|
551
|
+
// pre-scaled minor units.
|
|
537
552
|
function moneyMinorValue(v: unknown, currency: string): number | "" {
|
|
538
553
|
if (v === undefined || v === null || v === "") return "";
|
|
539
|
-
if (typeof v === "number") return v;
|
|
554
|
+
if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
|
|
540
555
|
if (typeof v === "object") {
|
|
541
556
|
const amount = (v as { amount?: unknown }).amount;
|
|
542
557
|
if (typeof amount === "number") return Math.round(amount * 10 ** currencyDecimals(currency));
|
package/src/primitives.tsx
CHANGED
|
@@ -223,6 +223,9 @@ export type InputProps =
|
|
|
223
223
|
/** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
|
|
224
224
|
* as a prefix on the input. Unknown key → no icon (no boot-fail). */
|
|
225
225
|
readonly icon?: string;
|
|
226
|
+
/** `<input step>`. "any" disables the native stepMismatch constraint
|
|
227
|
+
* (needed for decimal fields — integer fields leave this unset). */
|
|
228
|
+
readonly step?: number | "any";
|
|
226
229
|
}
|
|
227
230
|
| {
|
|
228
231
|
readonly kind: "range";
|
|
@@ -720,6 +723,11 @@ export type SectionProps = {
|
|
|
720
723
|
* Default "default" (normal card border). */
|
|
721
724
|
readonly variant?: "default" | "destructive";
|
|
722
725
|
readonly testId?: string;
|
|
726
|
+
/** Keeps the section mounted while visually hiding it (native `hidden`
|
|
727
|
+
* attribute where supported) — used by wizard mode to keep off-screen
|
|
728
|
+
* steps mounted so their state (e.g. an extension section's submit
|
|
729
|
+
* registration) survives step navigation instead of unmounting. */
|
|
730
|
+
readonly hidden?: boolean;
|
|
723
731
|
};
|
|
724
732
|
|
|
725
733
|
/** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
|