@cosmicdrift/kumiko-renderer 0.193.0 → 0.194.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/__tests__/error-i18n-defaults.test.ts +9 -0
- package/src/app/__tests__/content-editors.test.tsx +8 -3
- package/src/app/__tests__/content-preview.test.tsx +23 -1
- package/src/app/__tests__/form-schema.test.ts +8 -12
- package/src/app/content-editors.tsx +1 -1
- package/src/app/extension-sections.tsx +5 -2
- package/src/app/form-schema.ts +13 -3
- package/src/app/kumiko-screen.tsx +2 -1
- package/src/components/__tests__/embedded-list-field.test.tsx +68 -1
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +212 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +14 -0
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +22 -1
- package/src/components/embedded-list-field.tsx +59 -7
- package/src/components/render-edit-logic.ts +3 -8
- package/src/components/render-edit.tsx +127 -65
- package/src/components/render-field.tsx +15 -3
- package/src/i18n-defaults.ts +23 -5
- package/src/index.ts +1 -0
- package/src/primitives.tsx +28 -7
|
@@ -5,11 +5,12 @@ import type {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-headless";
|
|
6
6
|
import {
|
|
7
7
|
computeDerivedCellValue,
|
|
8
|
+
currencyDecimals,
|
|
8
9
|
groupEmbeddedListIssues,
|
|
9
10
|
roundDerivedCellValue,
|
|
10
11
|
sumEmbeddedListColumn,
|
|
11
12
|
} from "@cosmicdrift/kumiko-headless";
|
|
12
|
-
import type
|
|
13
|
+
import { type ReactNode, useState } from "react";
|
|
13
14
|
import { toKebab } from "../app/qn";
|
|
14
15
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
15
16
|
import { useQuery } from "../hooks/use-query";
|
|
@@ -54,7 +55,7 @@ function withRecomputedDerived(
|
|
|
54
55
|
return result;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function coerceCellValue(column: EmbeddedListColumn, text: string): unknown {
|
|
58
|
+
function coerceCellValue(column: EmbeddedListColumn, text: string, currency: string): unknown {
|
|
58
59
|
switch (column.type) {
|
|
59
60
|
case "text":
|
|
60
61
|
return text;
|
|
@@ -66,10 +67,15 @@ function coerceCellValue(column: EmbeddedListColumn, text: string): unknown {
|
|
|
66
67
|
}
|
|
67
68
|
case "money": {
|
|
68
69
|
// Money cells are minor-unit integers (cents) in storage — paste
|
|
69
|
-
// arrives as a major-unit decimal string ("12,99"/"12.99"), so
|
|
70
|
+
// arrives as a major-unit decimal string ("12,99"/"12.99"), so scale
|
|
71
|
+
// by the currency's decimal places. Must agree with the typed-in path
|
|
72
|
+
// (renderCellControl's MoneyInput, which scales by the same
|
|
73
|
+
// currencyDecimals) — a hardcoded ×100 here diverged for zero-/three-
|
|
74
|
+
// decimal currencies (JPY, BHD, ...), landing a pasted value 100x off
|
|
75
|
+
// from the same value typed by hand (kumiko-framework#1972).
|
|
70
76
|
if (text.trim() === "") return undefined;
|
|
71
77
|
const n = Number(text.replace(",", "."));
|
|
72
|
-
return Number.isFinite(n) ? Math.round(n *
|
|
78
|
+
return Number.isFinite(n) ? Math.round(n * 10 ** currencyDecimals(currency)) : undefined;
|
|
73
79
|
}
|
|
74
80
|
case "boolean":
|
|
75
81
|
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
@@ -104,6 +110,10 @@ export function EmbeddedListField({
|
|
|
104
110
|
}: EmbeddedListFieldProps): ReactNode {
|
|
105
111
|
const { EmbeddedListInput } = usePrimitives();
|
|
106
112
|
const t = useTranslation();
|
|
113
|
+
const [pasteWarning, setPasteWarning] = useState<{
|
|
114
|
+
readonly droppedRows: number;
|
|
115
|
+
readonly unmatchedCells: number;
|
|
116
|
+
} | null>(null);
|
|
107
117
|
|
|
108
118
|
const cells = field.embeddedListCells ?? [];
|
|
109
119
|
const rows = Array.isArray(field.value) ? (field.value as readonly EmbeddedRow[]) : [];
|
|
@@ -163,6 +173,32 @@ export function EmbeddedListField({
|
|
|
163
173
|
});
|
|
164
174
|
|
|
165
175
|
const { listIssues, rowIssues, cellIssues } = groupEmbeddedListIssues(allIssues, field.field);
|
|
176
|
+
const combinedListIssues: readonly FieldIssue[] =
|
|
177
|
+
pasteWarning === null
|
|
178
|
+
? listIssues
|
|
179
|
+
: [
|
|
180
|
+
...listIssues,
|
|
181
|
+
...(pasteWarning.droppedRows > 0
|
|
182
|
+
? [
|
|
183
|
+
{
|
|
184
|
+
path: field.field,
|
|
185
|
+
code: "paste-rows-truncated",
|
|
186
|
+
i18nKey: "kumiko.field.embedded-list.paste-rows-truncated",
|
|
187
|
+
params: { count: pasteWarning.droppedRows },
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
: []),
|
|
191
|
+
...(pasteWarning.unmatchedCells > 0
|
|
192
|
+
? [
|
|
193
|
+
{
|
|
194
|
+
path: field.field,
|
|
195
|
+
code: "paste-cells-unmatched",
|
|
196
|
+
i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
|
|
197
|
+
params: { count: pasteWarning.unmatchedCells },
|
|
198
|
+
},
|
|
199
|
+
]
|
|
200
|
+
: []),
|
|
201
|
+
];
|
|
166
202
|
|
|
167
203
|
function replaceRow(rowIndex: number, updater: (row: EmbeddedRow) => EmbeddedRow): void {
|
|
168
204
|
const nextRows = rows.map((row, i) => (i === rowIndex ? updater(row) : row));
|
|
@@ -208,11 +244,16 @@ export function EmbeddedListField({
|
|
|
208
244
|
const maxItems = field.embeddedListMaxItems;
|
|
209
245
|
const nextRows = [...rows];
|
|
210
246
|
const touchedIndices = new Set<number>();
|
|
247
|
+
let droppedRows = 0;
|
|
248
|
+
let unmatchedCells = 0;
|
|
211
249
|
|
|
212
250
|
grid.forEach((gridRow, gridRowOffset) => {
|
|
213
251
|
const targetRowIndex = rowIndex + gridRowOffset;
|
|
214
252
|
if (targetRowIndex >= nextRows.length) {
|
|
215
|
-
if (maxItems !== undefined && nextRows.length >= maxItems)
|
|
253
|
+
if (maxItems !== undefined && nextRows.length >= maxItems) {
|
|
254
|
+
droppedRows += 1;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
216
257
|
nextRows.push({});
|
|
217
258
|
}
|
|
218
259
|
const targetRow = nextRows[targetRowIndex];
|
|
@@ -221,12 +262,23 @@ export function EmbeddedListField({
|
|
|
221
262
|
gridRow.forEach((text, gridColOffset) => {
|
|
222
263
|
const column = columns[columnIndex + gridColOffset];
|
|
223
264
|
if (column === undefined) return;
|
|
224
|
-
|
|
265
|
+
const coerced = coerceCellValue(column, text, field.embeddedListCurrency ?? "EUR");
|
|
266
|
+
const isUnmatchedChoice =
|
|
267
|
+
(column.type === "select" || column.type === "reference") &&
|
|
268
|
+
coerced === undefined &&
|
|
269
|
+
text.trim() !== "";
|
|
270
|
+
if (isUnmatchedChoice) {
|
|
271
|
+
unmatchedCells += 1;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
updatedRow = { ...updatedRow, [column.field]: coerced };
|
|
225
275
|
});
|
|
226
276
|
nextRows[targetRowIndex] = updatedRow;
|
|
227
277
|
touchedIndices.add(targetRowIndex);
|
|
228
278
|
});
|
|
229
279
|
|
|
280
|
+
setPasteWarning(droppedRows > 0 || unmatchedCells > 0 ? { droppedRows, unmatchedCells } : null);
|
|
281
|
+
|
|
230
282
|
const recomputed = nextRows.map((row, i) =>
|
|
231
283
|
touchedIndices.has(i) ? withRecomputedDerived(row, derived, cells) : row,
|
|
232
284
|
);
|
|
@@ -243,7 +295,7 @@ export function EmbeddedListField({
|
|
|
243
295
|
disabled={field.readOnly}
|
|
244
296
|
minItems={field.embeddedListMinItems}
|
|
245
297
|
maxItems={field.embeddedListMaxItems}
|
|
246
|
-
listIssues={
|
|
298
|
+
listIssues={combinedListIssues}
|
|
247
299
|
rowIssues={rowIssues}
|
|
248
300
|
cellIssues={cellIssues}
|
|
249
301
|
onCellChange={handleCellChange}
|
|
@@ -40,14 +40,9 @@ export function shouldNotifyCaller(
|
|
|
40
40
|
return !(result.isSuccess && !extensionsPersisted);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// section container would read as a broken layout, not "nothing to show
|
|
47
|
-
// here". Extension sections are never filtered — they carry their own field
|
|
48
|
-
// set, unrelated to the `field`-name filter. `fieldsFilter === undefined`
|
|
49
|
-
// (no prop passed) returns the same array reference, so callers that skip
|
|
50
|
-
// the prop keep unchanged render behavior.
|
|
43
|
+
// Extension sections skip the `fields` filter (their own field set, unrelated
|
|
44
|
+
// to `field`-name filtering); a `fields` section left with zero fields after
|
|
45
|
+
// filtering is dropped, not rendered empty.
|
|
51
46
|
export function filterEditSections(
|
|
52
47
|
sections: readonly EditSectionViewModel[],
|
|
53
48
|
fieldsFilter: readonly string[] | undefined,
|
|
@@ -47,6 +47,9 @@ const FORM_DRAFT_GET = "form-draft:query:get";
|
|
|
47
47
|
const FORM_DRAFT_SAVE = "form-draft:write:save";
|
|
48
48
|
const FORM_DRAFT_DISCARD = "form-draft:write:discard";
|
|
49
49
|
const FORM_DRAFT_LIST = "form-draft:query:list";
|
|
50
|
+
// Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
51
|
+
// — same hardcoding rationale as the QNs above (no dependency on that package).
|
|
52
|
+
const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
50
53
|
|
|
51
54
|
// Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
|
|
52
55
|
// patch() call (VIN-decode, an extension section) should not save immediately
|
|
@@ -149,7 +152,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
149
152
|
* reference on every call must not do so unconditionally: `setValues` is
|
|
150
153
|
* a no-op when the merged value is reference-equal to the current one,
|
|
151
154
|
* so only a converging patch settles instead of looping. Without this
|
|
152
|
-
* prop, existing behavior is unchanged.
|
|
155
|
+
* prop, existing behavior is unchanged. `onControlsReady` is guaranteed
|
|
156
|
+
* to have already fired by the time the mount-time `onChange` call
|
|
157
|
+
* happens, so a caller patching dependent fields from inside `onChange`
|
|
158
|
+
* never has to guard against `controls` being undefined. */
|
|
153
159
|
readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
|
|
154
160
|
/** Controlled mode (issue #1887): called once after mount, hands the
|
|
155
161
|
* caller `patch`/`validate`/`getValues` bound to this RenderEdit
|
|
@@ -175,7 +181,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
175
181
|
* moot — e.g. Solon's editor pointing at an existing record instead of
|
|
176
182
|
* creating a new one. Extension sections are out of scope: RenderEdit has
|
|
177
183
|
* no way to force-disable an arbitrary registered component. Omitting
|
|
178
|
-
* this prop keeps unchanged behavior.
|
|
184
|
+
* this prop keeps unchanged behavior.
|
|
185
|
+
*
|
|
186
|
+
* ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
|
|
187
|
+
* sites pass explicit prop lists without a spread and never forward
|
|
188
|
+
* `disabled`, so a screen-driven app can't set the locked state today.
|
|
189
|
+
* Upgrade path if that's needed: thread a screen-spec flag through to
|
|
190
|
+
* `EntityEditCreateBody`/`EntityEditEditBody`. */
|
|
179
191
|
readonly disabled?: boolean;
|
|
180
192
|
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
181
193
|
* delete, copy-link). For hosts that put those controls into their own
|
|
@@ -250,7 +262,6 @@ function ExtensionSectionMount({
|
|
|
250
262
|
values,
|
|
251
263
|
patch,
|
|
252
264
|
validate,
|
|
253
|
-
hidden,
|
|
254
265
|
}: {
|
|
255
266
|
readonly section: EditExtensionSectionViewModel;
|
|
256
267
|
readonly entityName: string;
|
|
@@ -259,7 +270,6 @@ function ExtensionSectionMount({
|
|
|
259
270
|
readonly values?: Readonly<Record<string, unknown>>;
|
|
260
271
|
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
261
272
|
readonly validate?: () => boolean;
|
|
262
|
-
readonly hidden?: boolean;
|
|
263
273
|
}): ReactNode {
|
|
264
274
|
const { Banner, Section, Text } = usePrimitives();
|
|
265
275
|
const name = extensionSectionName(section.component);
|
|
@@ -270,7 +280,6 @@ function ExtensionSectionMount({
|
|
|
270
280
|
key={section.title}
|
|
271
281
|
title={section.title}
|
|
272
282
|
testId={`section-extension-${section.title}`}
|
|
273
|
-
hidden={hidden}
|
|
274
283
|
>
|
|
275
284
|
<Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
|
|
276
285
|
<Text>
|
|
@@ -283,7 +292,7 @@ function ExtensionSectionMount({
|
|
|
283
292
|
);
|
|
284
293
|
}
|
|
285
294
|
return (
|
|
286
|
-
<Section title={section.title} testId={`section-extension-${section.title}`}
|
|
295
|
+
<Section title={section.title} testId={`section-extension-${section.title}`}>
|
|
287
296
|
<Component
|
|
288
297
|
entityName={entityName}
|
|
289
298
|
entityId={entityId}
|
|
@@ -378,23 +387,26 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
378
387
|
const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
|
|
379
388
|
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
|
|
380
389
|
useExtensionFormHost(setExtensionDirty);
|
|
381
|
-
const {
|
|
382
|
-
|
|
390
|
+
const {
|
|
391
|
+
Button,
|
|
392
|
+
Banner,
|
|
393
|
+
Dialog,
|
|
394
|
+
Form,
|
|
395
|
+
Section,
|
|
396
|
+
Grid,
|
|
397
|
+
GridCell,
|
|
398
|
+
Text,
|
|
399
|
+
Progress,
|
|
400
|
+
StepBar,
|
|
401
|
+
WizardStepGroup,
|
|
402
|
+
} = usePrimitives();
|
|
383
403
|
|
|
384
404
|
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
385
405
|
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// kann daher nicht von `vm`/`filteredSections` abgeleitet werden, die erst
|
|
391
|
-
// nach dem Controller (aus snapshot.values) existieren — die Feldnamen-Menge
|
|
392
|
-
// pro Section ist aber wertunabhängig (nur visible/readOnly/value hängen von
|
|
393
|
-
// `values` ab), also liefert diese Ableitung dieselbe Menge wie
|
|
394
|
-
// filterEditSections(vm.sections, fieldsFilter) es täte. undefined (= kein
|
|
395
|
-
// Filter aktiv) heißt unscoped validate/submit — auf "alle gerenderten
|
|
396
|
-
// Felder" scopen würde sonst root-level .refine()-Issues aus der
|
|
397
|
-
// unscoped-Validierung stillschweigend wegfiltern.
|
|
406
|
+
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
407
|
+
// freezes it on first render) — safe because a section's field-name set is
|
|
408
|
+
// value-independent, so it matches filterEditSections(vm.sections, fieldsFilter).
|
|
409
|
+
// undefined = unscoped, since scoping would silently drop root-level .refine() issues.
|
|
398
410
|
const scopeFieldNames = useMemo(
|
|
399
411
|
() =>
|
|
400
412
|
fieldsFilter === undefined ? undefined : fieldsFilter.filter((f) => Object.hasOwn(fields, f)),
|
|
@@ -453,23 +465,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
453
465
|
// on every parent render, not just on a snapshot change, risking a loop if
|
|
454
466
|
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
455
467
|
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
456
|
-
const schemaRef = useRef(schema);
|
|
457
|
-
schemaRef.current = schema;
|
|
458
|
-
useEffect(() => {
|
|
459
|
-
const cb = onChangeRef.current;
|
|
460
|
-
if (cb === undefined) return;
|
|
461
|
-
// Dry-run parse against `schema` — NOT controller.validate(). Calling
|
|
462
|
-
// validate() here would write field-level errors into snapshot.errors
|
|
463
|
-
// on every keystroke, painting error messages while the user is still
|
|
464
|
-
// typing. `valid` can therefore legitimately diverge from what's
|
|
465
|
-
// currently rendered under the fields (the last *mutating* validate()
|
|
466
|
-
// call, e.g. from controls.validate() or submit()).
|
|
467
|
-
const currentSchema = schemaRef.current;
|
|
468
|
-
const valid =
|
|
469
|
-
currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
|
|
470
|
-
cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
|
|
471
|
-
}, [snapshot]);
|
|
472
|
-
|
|
473
468
|
const onControlsReadyRef = useRef(onControlsReady);
|
|
474
469
|
onControlsReadyRef.current = onControlsReady;
|
|
475
470
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
@@ -517,6 +512,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
517
512
|
[controller, draftEnabled],
|
|
518
513
|
);
|
|
519
514
|
|
|
515
|
+
// Runs before the onChange effect below (declaration order = React
|
|
516
|
+
// execution order) so that an onChange fired on mount always sees
|
|
517
|
+
// `controls !== undefined` — both effects depend only on mount-stable
|
|
518
|
+
// values (controller/scopedValidate/patchAndScheduleDraftSave), so the
|
|
519
|
+
// swap is otherwise a no-op. See the #1888 VIN-decode test: an initial
|
|
520
|
+
// value that should derive dependent fields on mount needs controls.patch
|
|
521
|
+
// available on the very first onChange call, not just from the second
|
|
522
|
+
// keystroke onward.
|
|
520
523
|
useEffect(() => {
|
|
521
524
|
const cb = onControlsReadyRef.current;
|
|
522
525
|
if (cb === undefined) return;
|
|
@@ -532,6 +535,25 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
532
535
|
// RenderEdit mount in practice.
|
|
533
536
|
}, [controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
534
537
|
|
|
538
|
+
const schemaRef = useRef(schema);
|
|
539
|
+
schemaRef.current = schema;
|
|
540
|
+
// Controls are guaranteed ready by the time this fires (see the
|
|
541
|
+
// onControlsReady effect above, which is declared first on purpose).
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
const cb = onChangeRef.current;
|
|
544
|
+
if (cb === undefined) return;
|
|
545
|
+
// Dry-run parse against `schema` — NOT controller.validate(). Calling
|
|
546
|
+
// validate() here would write field-level errors into snapshot.errors
|
|
547
|
+
// on every keystroke, painting error messages while the user is still
|
|
548
|
+
// typing. `valid` can therefore legitimately diverge from what's
|
|
549
|
+
// currently rendered under the fields (the last *mutating* validate()
|
|
550
|
+
// call, e.g. from controls.validate() or submit()).
|
|
551
|
+
const currentSchema = schemaRef.current;
|
|
552
|
+
const valid =
|
|
553
|
+
currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
|
|
554
|
+
cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
|
|
555
|
+
}, [snapshot]);
|
|
556
|
+
|
|
535
557
|
useEffect(() => {
|
|
536
558
|
// skip: this screen does not persist a draft.
|
|
537
559
|
if (!draftEnabled) return;
|
|
@@ -665,10 +687,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
665
687
|
// Step transitions only — never per keystroke. Deliberately not awaited: a
|
|
666
688
|
// failed draft save must not block the step change.
|
|
667
689
|
//
|
|
668
|
-
// Create-mode, first step change
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
690
|
+
// Create-mode, first draft save (step change OR a debounced patch() from
|
|
691
|
+
// controlled mode / extension sections, see patchAndScheduleDraftSave
|
|
692
|
+
// above — both call this on step 0 too): mints the draftId here (issue
|
|
693
|
+
// #1913) rather than at mount, so a form nobody ever interacted with
|
|
694
|
+
// never claims a draftId or writes a row. `draftKey`/`draftId` state
|
|
695
|
+
// won't reflect the mint until the next render, so the just-minted key is
|
|
672
696
|
// computed inline instead of read from the memoized `draftKey`.
|
|
673
697
|
function saveDraft(stepIndex: number): void {
|
|
674
698
|
// skip: this screen does not persist a draft.
|
|
@@ -689,6 +713,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
689
713
|
// in practice (mint above always produces one), kept as a type-level
|
|
690
714
|
// guard against a stale `undefined` key ever reaching the write.
|
|
691
715
|
if (key === undefined) return;
|
|
716
|
+
// draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
|
|
717
|
+
// catch it here instead of losing the save silently to the fire-and-forget
|
|
718
|
+
// write below (the user would only notice on resume, with nothing to resume).
|
|
719
|
+
if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
|
|
720
|
+
// biome-ignore lint/suspicious/noConsole: no error-surfacing path exists for a fire-and-forget draft save.
|
|
721
|
+
console.warn(
|
|
722
|
+
`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`,
|
|
723
|
+
);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
692
726
|
void dispatcher.write(FORM_DRAFT_SAVE, {
|
|
693
727
|
draftKey: key,
|
|
694
728
|
values: controller.getSnapshot().values,
|
|
@@ -791,8 +825,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
791
825
|
setIsSubmitting(true);
|
|
792
826
|
setExtensionErrorKey(null);
|
|
793
827
|
try {
|
|
794
|
-
// Extension-only:
|
|
795
|
-
//
|
|
828
|
+
// Extension-only: only a section is dirty, the main form is unchanged.
|
|
829
|
+
// No entity write (would send an empty changes payload) — only
|
|
796
830
|
// die Section-Handler laufen lassen.
|
|
797
831
|
if (snapshot.isUnchanged && extensionDirty) {
|
|
798
832
|
// Same discard as the main path: an extension-only save is still a
|
|
@@ -802,27 +836,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
802
836
|
}
|
|
803
837
|
let result: SubmitResult<unknown>;
|
|
804
838
|
if (customSubmit !== undefined) {
|
|
805
|
-
// customSubmit
|
|
806
|
-
//
|
|
839
|
+
// customSubmit path (e.g. configEdit, which fires a separate write
|
|
840
|
+
// per field). Client-side validation first, then
|
|
807
841
|
// an den Caller; on-success rebased der Form-State explizit
|
|
808
|
-
//
|
|
809
|
-
//
|
|
842
|
+
// because controller.submit() normally does this itself and
|
|
843
|
+
// without customSubmit's help the controller knows nothing about
|
|
810
844
|
// erfolgreichen Submit (isUnchanged blieb sonst false).
|
|
811
845
|
//
|
|
812
846
|
// WICHTIG: snapshot direkt vom Controller holen statt aus
|
|
813
|
-
// React
|
|
814
|
-
//
|
|
815
|
-
//
|
|
847
|
+
// React state. On rapid fill→click, React batching may not have
|
|
848
|
+
// committed input state updates yet when the submit click fires.
|
|
849
|
+
// handleSubmit's closure would then run with
|
|
816
850
|
// stale snapshot.changes={} laufen, customSubmit fired keine
|
|
817
851
|
// Writes, returnt success, Form rebase → User glaubt "saved"
|
|
818
852
|
// aber gar nichts ist passiert. controller.getSnapshot() ist
|
|
819
853
|
// immer aktuell — der Controller ist die Source-of-Truth, die
|
|
820
|
-
// React
|
|
854
|
+
// React state is only a mirror for rendering.
|
|
821
855
|
const valid = controller.validate(scopeFieldNames);
|
|
822
856
|
if (!valid) {
|
|
823
|
-
// Field
|
|
824
|
-
//
|
|
825
|
-
//
|
|
857
|
+
// Field order matters: validationBlocked:true is its own
|
|
858
|
+
// variant in the SubmitResult union (NOT mixed with data/error);
|
|
859
|
+
// TS only narrows cleanly without a discriminator fight.
|
|
826
860
|
const blocked: SubmitResult<unknown> = {
|
|
827
861
|
validationBlocked: true,
|
|
828
862
|
isSuccess: false,
|
|
@@ -835,8 +869,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
835
869
|
} else {
|
|
836
870
|
result = await controller.submit();
|
|
837
871
|
}
|
|
838
|
-
// Form-level
|
|
839
|
-
// Field
|
|
872
|
+
// Form-level errors (without field-level details) go to the banner.
|
|
873
|
+
// Field errors flow via snapshot.errors into the individual fields.
|
|
840
874
|
let extensionsPersisted = true;
|
|
841
875
|
if (result.isSuccess) {
|
|
842
876
|
setFormError(null);
|
|
@@ -888,7 +922,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
888
922
|
|
|
889
923
|
// Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
|
|
890
924
|
// Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
|
|
891
|
-
// Save
|
|
925
|
+
// Save is large; red styling + confirm dialog are enough protection
|
|
892
926
|
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
893
927
|
const formActions = (
|
|
894
928
|
<>
|
|
@@ -957,7 +991,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
957
991
|
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
958
992
|
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
959
993
|
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
960
|
-
// title
|
|
994
|
+
// title falls back to screenId; subtitle to undefined (no subtitle).
|
|
961
995
|
const isCreate = (() => {
|
|
962
996
|
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
963
997
|
return id == null || id === "";
|
|
@@ -1049,9 +1083,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1049
1083
|
}
|
|
1050
1084
|
return (
|
|
1051
1085
|
<StepBar
|
|
1052
|
-
steps={filteredSections.map(
|
|
1053
|
-
(section, sectionIndex) => section.title ?? String(sectionIndex + 1),
|
|
1054
|
-
)}
|
|
1086
|
+
steps={filteredSections.map((section) => section.title ?? "")}
|
|
1055
1087
|
currentIndex={currentStep}
|
|
1056
1088
|
compactLabel={compactLabel}
|
|
1057
1089
|
testId="render-edit-wizard-steps"
|
|
@@ -1068,8 +1100,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1068
1100
|
// navigating past its step — otherwise Finish only ran the last
|
|
1069
1101
|
// mounted step's handler and silently dropped earlier steps' writes.
|
|
1070
1102
|
const stepHidden = isWizard && sectionIndex !== currentStep;
|
|
1103
|
+
// Off-screen wizard steps stay mounted (see comment above) but must
|
|
1104
|
+
// not participate in native constraint validation, or the Next
|
|
1105
|
+
// button's `type="submit"` triggers the browser's full-form check
|
|
1106
|
+
// — including required fields on unvisited steps, which are not
|
|
1107
|
+
// focusable while hidden, so the wizard silently stops navigating
|
|
1108
|
+
// (only a console warning, no visible error). A plain `hidden`
|
|
1109
|
+
// attribute does NOT bar descendants from constraint validation on
|
|
1110
|
+
// web — see WizardStepGroupProps for what implementations must
|
|
1111
|
+
// guarantee.
|
|
1071
1112
|
if (section.kind === "extension") {
|
|
1072
|
-
|
|
1113
|
+
const mount = (
|
|
1073
1114
|
<ExtensionSectionMount
|
|
1074
1115
|
key={section.title}
|
|
1075
1116
|
section={section}
|
|
@@ -1083,9 +1124,20 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1083
1124
|
patchAndScheduleDraftSave as (partial: Readonly<Record<string, unknown>>) => void
|
|
1084
1125
|
}
|
|
1085
1126
|
validate={scopedValidate}
|
|
1086
|
-
hidden={stepHidden}
|
|
1087
1127
|
/>
|
|
1088
1128
|
);
|
|
1129
|
+
if (!isWizard) return mount;
|
|
1130
|
+
if (WizardStepGroup === undefined) {
|
|
1131
|
+
// Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
return (
|
|
1137
|
+
<WizardStepGroup key={section.title} hidden={stepHidden}>
|
|
1138
|
+
{mount}
|
|
1139
|
+
</WizardStepGroup>
|
|
1140
|
+
);
|
|
1089
1141
|
}
|
|
1090
1142
|
if (!section.visible) return null;
|
|
1091
1143
|
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
@@ -1094,13 +1146,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1094
1146
|
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
1095
1147
|
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1096
1148
|
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1097
|
-
|
|
1149
|
+
const sectionEl = (
|
|
1098
1150
|
<Section
|
|
1099
1151
|
key={sectionKey}
|
|
1100
1152
|
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
1101
1153
|
{...(section.description !== undefined && { subtitle: section.description })}
|
|
1102
1154
|
testId={`section-${sectionKey}`}
|
|
1103
|
-
hidden={stepHidden}
|
|
1104
1155
|
>
|
|
1105
1156
|
<Grid columns={section.columns}>
|
|
1106
1157
|
{section.fields.map((field: EditFieldViewModel) => (
|
|
@@ -1126,6 +1177,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1126
1177
|
</Grid>
|
|
1127
1178
|
</Section>
|
|
1128
1179
|
);
|
|
1180
|
+
if (!isWizard) return sectionEl;
|
|
1181
|
+
if (WizardStepGroup === undefined) {
|
|
1182
|
+
throw new Error(
|
|
1183
|
+
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
return (
|
|
1187
|
+
<WizardStepGroup key={sectionKey} hidden={stepHidden}>
|
|
1188
|
+
{sectionEl}
|
|
1189
|
+
</WizardStepGroup>
|
|
1190
|
+
);
|
|
1129
1191
|
})}
|
|
1130
1192
|
{formError !== null && (
|
|
1131
1193
|
<Banner
|
|
@@ -23,6 +23,12 @@ import { ReferenceCreateDialog } from "./reference-create-dialog";
|
|
|
23
23
|
// Der field.type → Input-kind Mapping bleibt hier, weil es
|
|
24
24
|
// Domain-Logik ist (EntityDefinition-Feldtyp) und nicht Darstellung.
|
|
25
25
|
|
|
26
|
+
// No `hideLabel` prop here (fw#1870/#1871#3, deliberately out of scope):
|
|
27
|
+
// RenderField is driven entirely by EditFieldViewModel/EntityEditScreenDefinition,
|
|
28
|
+
// which have no per-field hideLabel slot — wiring it through would need a
|
|
29
|
+
// schema change, not just a prop. Declarative grid/table screens stay on
|
|
30
|
+
// visible labels until that schema work happens; imperative *Field widgets
|
|
31
|
+
// (form-fields.tsx, AiTextField) already support it.
|
|
26
32
|
export type RenderFieldProps = {
|
|
27
33
|
readonly field: EditFieldViewModel;
|
|
28
34
|
readonly issues?: readonly FieldIssue[];
|
|
@@ -53,7 +59,7 @@ export function RenderField({
|
|
|
53
59
|
fieldAppendix,
|
|
54
60
|
allIssues,
|
|
55
61
|
}: RenderFieldProps): ReactNode {
|
|
56
|
-
const { Field, Input, Banner } = usePrimitives();
|
|
62
|
+
const { Field, Input, Banner, Text } = usePrimitives();
|
|
57
63
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
58
64
|
// navigator.language (Browser-Sprache) zurück statt der gewählten
|
|
59
65
|
// App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
|
|
@@ -88,7 +94,7 @@ export function RenderField({
|
|
|
88
94
|
featureName={featureName ?? ""}
|
|
89
95
|
/>
|
|
90
96
|
) : (
|
|
91
|
-
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, t })
|
|
97
|
+
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
|
|
92
98
|
);
|
|
93
99
|
|
|
94
100
|
return (
|
|
@@ -303,6 +309,7 @@ function renderInput({
|
|
|
303
309
|
Input,
|
|
304
310
|
appLocale,
|
|
305
311
|
Banner,
|
|
312
|
+
Text,
|
|
306
313
|
t,
|
|
307
314
|
}: {
|
|
308
315
|
readonly field: EditFieldViewModel;
|
|
@@ -312,6 +319,7 @@ function renderInput({
|
|
|
312
319
|
readonly Input: ReturnType<typeof usePrimitives>["Input"];
|
|
313
320
|
readonly appLocale: string;
|
|
314
321
|
readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
|
|
322
|
+
readonly Text: ReturnType<typeof usePrimitives>["Text"];
|
|
315
323
|
readonly t: ReturnType<typeof useTranslation>;
|
|
316
324
|
}): ReactNode {
|
|
317
325
|
const common = {
|
|
@@ -476,6 +484,7 @@ function renderInput({
|
|
|
476
484
|
{...(field.entityType !== undefined && { entityType: field.entityType })}
|
|
477
485
|
{...(field.fieldName !== undefined && { fieldName: field.fieldName })}
|
|
478
486
|
{...(field.imageVariant !== undefined && { imageVariant: field.imageVariant })}
|
|
487
|
+
{...(field.capture !== undefined && { capture: field.capture })}
|
|
479
488
|
/>
|
|
480
489
|
);
|
|
481
490
|
}
|
|
@@ -492,12 +501,15 @@ function renderInput({
|
|
|
492
501
|
case "embedded":
|
|
493
502
|
case "jsonb":
|
|
494
503
|
case "files":
|
|
495
|
-
case "images":
|
|
504
|
+
case "images": {
|
|
505
|
+
const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
|
|
496
506
|
return (
|
|
497
507
|
<Banner id={id} variant="info">
|
|
498
508
|
{t("kumiko.field.unsupported")}
|
|
509
|
+
{hasValue && <Text variant="code">{JSON.stringify(field.value)}</Text>}
|
|
499
510
|
</Banner>
|
|
500
511
|
);
|
|
512
|
+
}
|
|
501
513
|
default: {
|
|
502
514
|
// text + unknown scalar type → text input. If TextFieldDef.multiline
|
|
503
515
|
// is set (the view-model carries it), the renderer switches to
|