@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.
@@ -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 { ReactNode } from "react";
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 ×100.
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 * 100) : undefined;
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) return;
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
- updatedRow = { ...updatedRow, [column.field]: coerceCellValue(column, text) };
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={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
- // Restricts rendered sections to the caller's `fields` filter, keeping
44
- // section order/title/visibility from the layout unchanged. A `fields`
45
- // section whose filtered field list is empty is dropped entirely — an empty
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}`} hidden={hidden}>
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 { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar } =
382
- usePrimitives();
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
- // Scope für validate()/submit() — nur die tatsächlich gerenderten Feldnamen
387
- // aus `fields` (bereits non-extension-only, siehe deriveFormFields), auf den
388
- // `fields`-Filter-Prop eingeschränkt. Muss VOR submitConfig/useForm stehen
389
- // (der Controller bakt submitConfig beim ersten Render dauerhaft ein) und
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: mints the draftId here (issue #1913)
669
- // rather than at mount, so a form abandoned on step 0 never claims a
670
- // draftId or writes a row at all. `draftKey`/`draftId` state won't
671
- // reflect the mint until the next render, so the just-minted key is
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: nur eine Section ist dirty, das Haupt-Form unverändert.
795
- // Kein Entity-Write (würde einen leeren changes-Payload schreiben) — nur
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-Pfad (z.B. configEdit, das pro Field einen
806
- // separaten Write feuert). Erst client-side Validation, dann
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
- // weil controller.submit() das normalerweise selbst macht und
809
- // ohne customSubmit's Hilfe weiß der Controller nichts vom
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-State. Bei rapid fill→click kann React-Batching die
814
- // Input-State-Updates noch nicht commited haben, wenn der
815
- // submit-Click fire'd. handleSubmit's Closure würde dann mit
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-State ist nur ein Mirror für's Rendering.
854
+ // React state is only a mirror for rendering.
821
855
  const valid = controller.validate(scopeFieldNames);
822
856
  if (!valid) {
823
- // Field-Order matters: validationBlocked-true ist eine eigene
824
- // Variante in der SubmitResult-Union (NICHT mit data/error
825
- // gemixt), TS narrowt das nur ohne den Discriminator-Fight.
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 Errors (ohne field-level details) landen im Banner.
839
- // Field-Errors fließen über snapshot.errors in die einzelnen Fields.
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 groß ist; rot + Confirm-Dialog sind ausreichend Schutz
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 fällt zuletzt auf screenId, subtitle auf undefined (kein Untertitel).
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
- return (
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
- return (
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