@cosmicdrift/kumiko-renderer 0.187.0 → 0.189.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.
@@ -21,20 +21,62 @@ import type {
21
21
  Translate,
22
22
  } from "@cosmicdrift/kumiko-headless";
23
23
  import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
24
- import { type ReactNode, useMemo, useState } from "react";
24
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
25
25
  import type { z } from "zod";
26
26
  import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
27
27
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
28
+ import { useDispatcher } from "../context/dispatcher-context";
29
+ import { useDraftStorage } from "../context/draft-storage-context";
30
+ import { formatWhen } from "../format-when";
28
31
  import { useForm } from "../hooks/use-form";
29
32
  import { useTranslation } from "../i18n";
30
33
  import { usePrimitives } from "../primitives";
31
34
  import {
35
+ filterEditSections,
32
36
  hasEditableSection,
33
37
  resolveExtensionEntityId,
34
38
  shouldNotifyCaller,
35
39
  } from "./render-edit-logic";
36
40
  import { RenderField } from "./render-field";
37
41
 
42
+ // Qualified names of the bundled `form-draft` feature. Hardcoded because the
43
+ // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
44
+ // with `layout.draft: true` and that feature unmounted is a boot error, so
45
+ // these can never dangle silently.
46
+ const FORM_DRAFT_GET = "form-draft:query:get";
47
+ const FORM_DRAFT_SAVE = "form-draft:write:save";
48
+ const FORM_DRAFT_DISCARD = "form-draft:write:discard";
49
+ const FORM_DRAFT_LIST = "form-draft:query:list";
50
+
51
+ // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
52
+ // patch() call (VIN-decode, an extension section) should not save immediately
53
+ // per call, but patch() can also fire from inside onChange on every keystroke
54
+ // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
55
+ // save instead of one per keystroke, while still saving well before a user
56
+ // abandons the tab.
57
+ const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
58
+
59
+ type FormDraftBlob = {
60
+ readonly values: Record<string, unknown>;
61
+ readonly stepIndex: number;
62
+ };
63
+
64
+ type DraftCandidate = {
65
+ readonly id: string;
66
+ readonly draftKey: string;
67
+ readonly stepIndex: number;
68
+ readonly savedAt: string;
69
+ };
70
+
71
+ // draftKey convention for a create-mode draft (framework-wizard-mode.md,
72
+ // lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
73
+ // fallback (strip the prefix to recover a candidate's draftId) and the
74
+ // list query itself (filter out edit-mode drafts, which share the same
75
+ // `${screenId}:%` LIKE-prefix scan server-side).
76
+ function newDraftPrefix(screenId: string): string {
77
+ return `${screenId}:new:`;
78
+ }
79
+
38
80
  // End-to-end renderer für einen entityEdit screen. Rendert aus-
39
81
  // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
40
82
  // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
@@ -96,6 +138,58 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
96
138
  * Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
97
139
  * undefined. */
98
140
  readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
141
+ /** Controlled mode (issue #1887): fires on every values-snapshot change
142
+ * (typing, `patch(...)` from outside) with the current values. `changes`
143
+ * is the delta against the initial values — same semantics as
144
+ * `payloadMode: "changes"` — so a caller never overwrites unseen fields.
145
+ * `valid` is a pure dry-run parse against `schema` (not a
146
+ * `controller.validate()` call), so it does not paint field errors into
147
+ * the UI and can diverge from the currently rendered `snapshot.errors` —
148
+ * always `true` without `schema`. A caller that patches a fresh object
149
+ * reference on every call must not do so unconditionally: `setValues` is
150
+ * a no-op when the merged value is reference-equal to the current one,
151
+ * so only a converging patch settles instead of looping. Without this
152
+ * prop, existing behavior is unchanged. */
153
+ readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
154
+ /** Controlled mode (issue #1887): called once after mount, hands the
155
+ * caller `patch`/`validate`/`getValues` bound to this RenderEdit
156
+ * instance — addressable from outside without a remount. `patch` merges
157
+ * only the given keys (existing `controller.setValues` semantics),
158
+ * values on unmentioned fields stay untouched. `validate` runs without a
159
+ * write and reports field issues via `snapshot.errors` on the field
160
+ * itself rather than as a summary banner. Without this prop, existing
161
+ * behavior is unchanged. */
162
+ readonly onControlsReady?: (controls: RenderEditControls<TValues>) => void;
163
+ /** Renders only these fields (by `field` name from the layout) — section
164
+ * order, title, and visibility still come from the layout, so the caller
165
+ * doesn't duplicate its shape. A section with no fields left after
166
+ * filtering is dropped entirely (not rendered empty). Submit validation
167
+ * is scoped to the actually-rendered fields the same way — a required
168
+ * field outside this list doesn't block submit. Omitting this prop keeps
169
+ * unchanged behavior. Read once at mount for `controller.submit()`'s
170
+ * validation scope (the underlying `useForm` controller is mount-lived);
171
+ * rendering and `controls.validate()` do stay reactive to later changes. */
172
+ readonly fields?: readonly string[];
173
+ /** Locked state (issue #1896): every rendered field and the submit button
174
+ * go visibly inactive, no write possible. For cases where input becomes
175
+ * moot — e.g. Solon's editor pointing at an existing record instead of
176
+ * creating a new one. Extension sections are out of scope: RenderEdit has
177
+ * no way to force-disable an arbitrary registered component. Omitting
178
+ * this prop keeps unchanged behavior. */
179
+ readonly disabled?: boolean;
180
+ };
181
+
182
+ export type RenderEditChangeState<TValues extends FormValues> = {
183
+ readonly values: TValues;
184
+ readonly changes: Partial<TValues>;
185
+ readonly dirty: boolean;
186
+ readonly valid: boolean;
187
+ };
188
+
189
+ export type RenderEditControls<TValues extends FormValues> = {
190
+ readonly patch: (partial: Partial<TValues>) => void;
191
+ readonly validate: () => boolean;
192
+ readonly getValues: () => TValues;
99
193
  };
100
194
 
101
195
  function toConditionValue<TValues extends FormValues, TCtx>(
@@ -141,11 +235,17 @@ function ExtensionSectionMount({
141
235
  entityName,
142
236
  entityId,
143
237
  initialValues,
238
+ values,
239
+ patch,
240
+ validate,
144
241
  }: {
145
242
  readonly section: EditExtensionSectionViewModel;
146
243
  readonly entityName: string;
147
244
  readonly entityId: string | null;
148
245
  readonly initialValues?: Readonly<Record<string, unknown>>;
246
+ readonly values?: Readonly<Record<string, unknown>>;
247
+ readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
248
+ readonly validate?: () => boolean;
149
249
  }): ReactNode {
150
250
  const { Banner, Section, Text } = usePrimitives();
151
251
  const name = extensionSectionName(section.component);
@@ -169,7 +269,14 @@ function ExtensionSectionMount({
169
269
  }
170
270
  return (
171
271
  <Section title={section.title} testId={`section-extension-${section.title}`}>
172
- <Component entityName={entityName} entityId={entityId} initialValues={initialValues} />
272
+ <Component
273
+ entityName={entityName}
274
+ entityId={entityId}
275
+ initialValues={initialValues}
276
+ values={values}
277
+ patch={patch}
278
+ validate={validate}
279
+ />
173
280
  </Section>
174
281
  );
175
282
  }
@@ -198,6 +305,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
198
305
  fieldAppendix,
199
306
  entityId: entityIdProp,
200
307
  extensionInitialValues,
308
+ onChange,
309
+ onControlsReady,
310
+ fields: fieldsFilter,
311
+ disabled = false,
201
312
  } = props;
202
313
  const { customSubmit } = props;
203
314
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -207,23 +318,72 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
207
318
  // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
208
319
  const t = useTranslation();
209
320
  const translate = translateProp ?? t;
321
+ const dispatcher = useDispatcher();
210
322
 
323
+ const isWizard = screen.layout.mode === "wizard";
324
+ const draftEnabled = isWizard && screen.layout.draft === true;
325
+ const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
326
+ const draftStorage = useDraftStorage();
211
327
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
212
328
  const [linkCopied, setLinkCopied] = useState(false);
213
329
  const [isSubmitting, setIsSubmitting] = useState(false);
214
330
  const [formError, setFormError] = useState<DispatcherError | null>(null);
331
+ const [rawStep, setRawStep] = useState(0);
332
+ // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
333
+ // on mount so a same-tab reload finds the right one of several parallel
334
+ // create-sessions on this screen; `null` until the first step change
335
+ // mints one (saveDraft), or the mount-time `form-draft:query:list`
336
+ // fallback below adopts an existing one. Edit-mode never touches this —
337
+ // its draftKey is `${screen.id}:${entityIdProp}` unconditionally.
338
+ const [draftId, setDraftId] = useState<string | null>(() =>
339
+ isCreateMode ? draftStorage.getDraftId(screen.id) : null,
340
+ );
341
+ // Marks a draftId this instance minted itself (saveDraft, first step
342
+ // change) so the restore effect below doesn't immediately re-fetch what
343
+ // it just wrote — there is nothing to restore, the row is brand new.
344
+ const mintedDraftIdRef = useRef<string | null>(null);
345
+ // Marks that the mount-time `form-draft:query:list` fallback already ran
346
+ // once for this mount. Without this, a create-mode discard resets `draftId`
347
+ // to `null` (see discardDraft below) and would re-arm the list effect —
348
+ // silently adopting an unrelated parallel draft on the same screen right
349
+ // after the user just submitted (#1908).
350
+ const didListRef = useRef(false);
351
+ const [draftCandidates, setDraftCandidates] = useState<readonly DraftCandidate[] | null>(null);
215
352
  // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
216
353
  // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
217
354
  // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
218
355
  // i18n-Key einer fehlgeschlagenen Section-Persistierung.
356
+ //
357
+ // Not a second write path for saveDraft()'s data (#1914): deriveFormFields
358
+ // (above) skips extension sections entirely, so extension-owned field state
359
+ // never enters `fields`/`controller.getSnapshot().values` — there is no
360
+ // draft-blob-covered state left for persistExtensions() to compete over.
219
361
  const [extensionDirty, setExtensionDirty] = useState(false);
220
362
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
221
363
  const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
222
364
  useExtensionFormHost(setExtensionDirty);
223
- const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text } = usePrimitives();
365
+ const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text, Progress } = usePrimitives();
224
366
 
225
367
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
226
368
 
369
+ // Scope für validate()/submit() — nur die tatsächlich gerenderten Feldnamen
370
+ // aus `fields` (bereits non-extension-only, siehe deriveFormFields), auf den
371
+ // `fields`-Filter-Prop eingeschränkt. Muss VOR submitConfig/useForm stehen
372
+ // (der Controller bakt submitConfig beim ersten Render dauerhaft ein) und
373
+ // kann daher nicht von `vm`/`filteredSections` abgeleitet werden, die erst
374
+ // nach dem Controller (aus snapshot.values) existieren — die Feldnamen-Menge
375
+ // pro Section ist aber wertunabhängig (nur visible/readOnly/value hängen von
376
+ // `values` ab), also liefert diese Ableitung dieselbe Menge wie
377
+ // filterEditSections(vm.sections, fieldsFilter) es täte. undefined (= kein
378
+ // Filter aktiv) heißt unscoped validate/submit — auf "alle gerenderten
379
+ // Felder" scopen würde sonst root-level .refine()-Issues aus der
380
+ // unscoped-Validierung stillschweigend wegfiltern.
381
+ const scopeFieldNames = useMemo(
382
+ () =>
383
+ fieldsFilter === undefined ? undefined : fieldsFilter.filter((f) => Object.hasOwn(fields, f)),
384
+ [fieldsFilter, fields],
385
+ );
386
+
227
387
  // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
228
388
  // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
229
389
  // weil wir controller.submit() eh nicht rufen.
@@ -233,6 +393,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
233
393
  type: writeCommand,
234
394
  payloadMode,
235
395
  ...(buildPayload !== undefined && { buildPayload }),
396
+ ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
236
397
  }
237
398
  : undefined;
238
399
 
@@ -244,6 +405,190 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
244
405
  ...(submitConfig !== undefined && { submit: submitConfig }),
245
406
  });
246
407
 
408
+ // Derived from the screen id + the host entity id only — never from
409
+ // `vm.id`, which lives in the form values a restore mutates (load under one
410
+ // key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
411
+ // unchanged. Create-mode: `${screen.id}:new:${draftId}` once a draftId
412
+ // exists (resumed, adopted, or minted), `undefined` before that — two
413
+ // parallel create sessions on the same screen must never collapse onto
414
+ // the same key (kumiko-framework#1908).
415
+ const draftKey = useMemo(
416
+ () =>
417
+ isCreateMode
418
+ ? draftId !== null
419
+ ? `${screen.id}:new:${draftId}`
420
+ : undefined
421
+ : `${screen.id}:${entityIdProp}`,
422
+ [screen.id, entityIdProp, isCreateMode, draftId],
423
+ );
424
+
425
+ // Controlled mode (Issue #1887). Both callbacks live in refs so a
426
+ // re-rendering caller (identity-unstable `onChange`/`onControlsReady`
427
+ // closures, e.g. inline arrows) doesn't retrigger these effects — only a
428
+ // real snapshot mutation (typing, patch()) does. Without this, a caller
429
+ // whose onChange calls patch() to fill other fields (the VIN-decode use
430
+ // case from #1888) would loop: patch → new snapshot → effect
431
+ // re-subscribes with a "new" onChange → refires.
432
+ const onChangeRef = useRef(onChange);
433
+ onChangeRef.current = onChange;
434
+ useEffect(() => {
435
+ const cb = onChangeRef.current;
436
+ if (cb === undefined) return;
437
+ // Dry-run parse against `schema` — NOT controller.validate(). Calling
438
+ // validate() here would write field-level errors into snapshot.errors
439
+ // on every keystroke, painting error messages while the user is still
440
+ // typing. `valid` can therefore legitimately diverge from what's
441
+ // currently rendered under the fields (the last *mutating* validate()
442
+ // call, e.g. from controls.validate() or submit()).
443
+ const valid = schema === undefined ? true : schema.safeParse(snapshot.values).success;
444
+ cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
445
+ }, [snapshot, schema]);
446
+
447
+ const onControlsReadyRef = useRef(onControlsReady);
448
+ onControlsReadyRef.current = onControlsReady;
449
+ const scopeFieldNamesRef = useRef(scopeFieldNames);
450
+ scopeFieldNamesRef.current = scopeFieldNames;
451
+ const scopedValidate = useCallback(
452
+ () => controller.validate(scopeFieldNamesRef.current),
453
+ [controller],
454
+ );
455
+
456
+ // `saveDraft` (defined below) is a fresh closure every render, over that
457
+ // render's `draftKey`/`draftId` — the ref lets the debounce timer below
458
+ // always call the CURRENT one even though the timer was scheduled by an
459
+ // earlier render's patch() call. `currentStepRef` gives it the current
460
+ // wizard step without depending on `currentStep` (computed further down,
461
+ // from `filteredSections`) at the point patchAndScheduleDraftSave itself
462
+ // is defined — kept fresh where `currentStep` is computed below.
463
+ const saveDraftRef = useRef(saveDraft);
464
+ saveDraftRef.current = saveDraft;
465
+ const currentStepRef = useRef(0);
466
+ const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
467
+ useEffect(() => {
468
+ return () => {
469
+ if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
470
+ };
471
+ }, []);
472
+
473
+ // patch() (controlled mode, extension sections) applies immediately —
474
+ // only the resulting draft save is debounced (#1914), so a patch() burst
475
+ // (VIN-decode filling several fields, or onChange fanning a keystroke out
476
+ // through patch(), see the #1888 test above) collapses into one save.
477
+ const patchAndScheduleDraftSave = useCallback(
478
+ (partial: Partial<TValues>) => {
479
+ controller.setValues(partial);
480
+ if (!draftEnabled) return;
481
+ if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
482
+ draftSaveTimerRef.current = setTimeout(() => {
483
+ draftSaveTimerRef.current = null;
484
+ saveDraftRef.current(currentStepRef.current);
485
+ }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
486
+ },
487
+ [controller, draftEnabled],
488
+ );
489
+
490
+ useEffect(() => {
491
+ const cb = onControlsReadyRef.current;
492
+ if (cb === undefined) return;
493
+ cb({
494
+ patch: patchAndScheduleDraftSave,
495
+ validate: scopedValidate,
496
+ getValues: () => controller.getSnapshot().values,
497
+ });
498
+ // controller is mount-lifetime-stable (see useForm's comment on its own
499
+ // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
500
+ // useCallback over mount-stable deps) — this fires exactly once per
501
+ // RenderEdit mount in practice.
502
+ }, [controller, scopedValidate, patchAndScheduleDraftSave]);
503
+
504
+ useEffect(() => {
505
+ // skip: this screen does not persist a draft.
506
+ if (!draftEnabled) return;
507
+ // skip: create-mode with no draftId yet — nothing to restore, either
508
+ // the list-fallback effect below finds one or the first step change
509
+ // mints a fresh one.
510
+ if (draftKey === undefined) return;
511
+ // skip: this draftId was just minted by saveDraft — the row it wrote
512
+ // is exactly the current form state, re-fetching it would be a no-op
513
+ // round-trip at best and a stale-echo clobber at worst.
514
+ if (draftId !== null && draftId === mintedDraftIdRef.current) return;
515
+ let cancelled = false;
516
+ void (async () => {
517
+ const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
518
+ FORM_DRAFT_GET,
519
+ { draftKey },
520
+ );
521
+ // skip: superseded by a newer mount, or the draft lookup failed — an
522
+ // unreachable draft store must not break the form itself.
523
+ if (cancelled || !result.isSuccess) return;
524
+ const draft = result.data?.draft ?? null;
525
+ // skip: nothing saved yet for this key.
526
+ if (draft === null) return;
527
+ // skip: the user already typed while the lookup was in flight — their
528
+ // input wins over the stored draft.
529
+ if (controller.getSnapshot().isDirty) return;
530
+ // @cast-boundary form-draft blob: `values` round-trips through an opaque
531
+ // jsonb column and comes back untyped.
532
+ controller.setValues(draft.values as Partial<TValues>);
533
+ setRawStep(Math.max(draft.stepIndex, 0));
534
+ })();
535
+ return () => {
536
+ cancelled = true;
537
+ };
538
+ }, [draftEnabled, draftKey, draftId, dispatcher, controller]);
539
+
540
+ // Mount-time fallback (issue #1913) for create-mode when no draftId
541
+ // survived in storage (new tab, cleared storage): ask the server for
542
+ // this screen's open drafts. Exactly one → adopt it silently (same
543
+ // effect as if storage had it). Multiple → render a simple picker
544
+ // (below) and let the user choose. Zero → stay null, saveDraft mints a
545
+ // fresh draftId on the first step change same as any other fresh create.
546
+ useEffect(() => {
547
+ // skip: this screen does not persist a draft, this is edit-mode, a
548
+ // draftId is already known (from storage or an earlier adoption), or
549
+ // this mount already ran the list lookup once (didListRef).
550
+ if (!draftEnabled || !isCreateMode || draftId !== null || didListRef.current) return;
551
+ didListRef.current = true;
552
+ let cancelled = false;
553
+ void (async () => {
554
+ const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
555
+ FORM_DRAFT_LIST,
556
+ { screenId: screen.id },
557
+ );
558
+ // skip: superseded by a newer mount, or the lookup failed — an
559
+ // unreachable draft store must not break a fresh create.
560
+ if (cancelled || !result.isSuccess) return;
561
+ const prefix = newDraftPrefix(screen.id);
562
+ // list's LIKE-prefix scan also matches edit-mode drafts
563
+ // (`${screenId}:${entityId}`) — keep only create-mode ones.
564
+ const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
565
+ // skip: no open drafts for this screen — stay null, saveDraft mints a
566
+ // fresh draftId on the first step change same as any other create.
567
+ if (candidates.length === 0) return;
568
+ const [only] = candidates;
569
+ if (candidates.length === 1 && only !== undefined) {
570
+ const adoptedId = only.draftKey.slice(prefix.length);
571
+ draftStorage.setDraftId(screen.id, adoptedId);
572
+ setDraftId(adoptedId);
573
+ return;
574
+ }
575
+ setDraftCandidates(candidates);
576
+ })();
577
+ return () => {
578
+ cancelled = true;
579
+ };
580
+ }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id, draftStorage]);
581
+
582
+ // User picked one of several open drafts from the mount-time picker
583
+ // (see draftCandidates below) — adopt it the same way a single
584
+ // auto-adopted candidate would be.
585
+ function adoptDraft(candidate: DraftCandidate): void {
586
+ const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
587
+ draftStorage.setDraftId(screen.id, adoptedId);
588
+ setDraftId(adoptedId);
589
+ setDraftCandidates(null);
590
+ }
591
+
247
592
  const vm = useMemo(
248
593
  () =>
249
594
  computeEditViewModel({
@@ -256,8 +601,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
256
601
  [screen, entity, snapshot.values, translate, featureName],
257
602
  );
258
603
 
604
+ const filteredSections = useMemo(
605
+ () => filterEditSections(vm.sections, fieldsFilter),
606
+ [vm.sections, fieldsFilter],
607
+ );
608
+
259
609
  // true for an extension section with no fields of its own too (it carries its own dirty/save).
260
- const isFormEditable = hasEditableSection(vm.sections);
610
+ const isFormEditable = hasEditableSection(filteredSections);
261
611
 
262
612
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
263
613
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
@@ -274,7 +624,105 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
274
624
  return true;
275
625
  }
276
626
 
627
+ const lastStepIndex = Math.max(filteredSections.length - 1, 0);
628
+ // Clamped on read, not on write: section visibility is value-dependent, so a
629
+ // stored stepIndex can point past what is currently rendered — that lands on
630
+ // the last step instead of an empty one.
631
+ const currentStep = Math.min(rawStep, lastStepIndex);
632
+ // Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
633
+ // is defined before `currentStep` exists (it depends on `filteredSections`,
634
+ // computed further up from `vm`) and so cannot close over it directly.
635
+ currentStepRef.current = currentStep;
636
+ const isLastWizardStep = currentStep >= lastStepIndex;
637
+
638
+ // Step transitions only — never per keystroke. Deliberately not awaited: a
639
+ // failed draft save must not block the step change.
640
+ //
641
+ // Create-mode, first step change: mints the draftId here (issue #1913)
642
+ // rather than at mount, so a form abandoned on step 0 never claims a
643
+ // draftId or writes a row at all. `draftKey`/`draftId` state won't
644
+ // reflect the mint until the next render, so the just-minted key is
645
+ // computed inline instead of read from the memoized `draftKey`.
646
+ function saveDraft(stepIndex: number): void {
647
+ // skip: this screen does not persist a draft.
648
+ if (!draftEnabled) return;
649
+ let key = draftKey;
650
+ if (isCreateMode && draftId === null) {
651
+ const mintedId = crypto.randomUUID();
652
+ mintedDraftIdRef.current = mintedId;
653
+ draftStorage.setDraftId(screen.id, mintedId);
654
+ setDraftId(mintedId);
655
+ key = `${screen.id}:new:${mintedId}`;
656
+ // A stale picker from the mount-time list fallback must not survive a
657
+ // mint — picking a candidate afterwards would repoint draftKey at an
658
+ // unrelated draft mid-edit and overwrite it (#1908).
659
+ setDraftCandidates(null);
660
+ }
661
+ // skip: create-mode with a draftId that failed to mint — unreachable
662
+ // in practice (mint above always produces one), kept as a type-level
663
+ // guard against a stale `undefined` key ever reaching the write.
664
+ if (key === undefined) return;
665
+ void dispatcher.write(FORM_DRAFT_SAVE, {
666
+ draftKey: key,
667
+ values: controller.getSnapshot().values,
668
+ stepIndex,
669
+ });
670
+ }
671
+
672
+ // Next: scoped validate() on the current step's fields — errors stay
673
+ // attached to the field and block the step transition. Extension steps
674
+ // have no field scope here (they validate via the controlled-mode
675
+ // controls.validate() API), so no call, no clearing of unrelated errors.
676
+ function handleWizardNext(): void {
677
+ const section = filteredSections[currentStep];
678
+ const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
679
+ // skip: the current step has field errors — no transition, no draft save.
680
+ if (fieldNames.length > 0 && !controller.validate(fieldNames)) return;
681
+ const next = Math.min(currentStep + 1, lastStepIndex);
682
+ setRawStep(next);
683
+ saveDraft(next);
684
+ }
685
+
686
+ function handleWizardBack(): void {
687
+ const previous = Math.max(currentStep - 1, 0);
688
+ setRawStep(previous);
689
+ saveDraft(previous);
690
+ }
691
+
692
+ async function discardDraft(): Promise<void> {
693
+ // skip: this screen does not persist a draft.
694
+ if (!draftEnabled) return;
695
+ // A pending debounced patch-save must not fire after discard — it would
696
+ // resurrect the draft it just deleted.
697
+ if (draftSaveTimerRef.current !== null) {
698
+ clearTimeout(draftSaveTimerRef.current);
699
+ draftSaveTimerRef.current = null;
700
+ }
701
+ // skip: create-mode, no step change happened yet — no draftId was ever
702
+ // minted, so no row exists to discard.
703
+ if (draftKey === undefined) return;
704
+ await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
705
+ // A successful submit ends this draftId's life — a subsequent create on
706
+ // the same screen (new mount) must mint its own, not resume this one.
707
+ if (isCreateMode) {
708
+ draftStorage.clearDraftId(screen.id);
709
+ mintedDraftIdRef.current = null;
710
+ setDraftId(null);
711
+ }
712
+ }
713
+
277
714
  async function handleSubmit(): Promise<void> {
715
+ // Locked state (#1896): the submit button is visibly disabled, but a
716
+ // native form submit (Enter key) reaches this handler regardless of the
717
+ // button's disabled attribute — block it here too, not just in the UI.
718
+ if (disabled) return;
719
+ // Enter in the active step triggers the native form submit (Next is
720
+ // type="submit" for Enter support) — on intermediate steps that means
721
+ // "Next", not "Save".
722
+ if (isWizard && !isLastWizardStep) {
723
+ handleWizardNext();
724
+ return;
725
+ }
278
726
  setIsSubmitting(true);
279
727
  setExtensionErrorKey(null);
280
728
  try {
@@ -282,7 +730,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
282
730
  // Kein Entity-Write (würde einen leeren changes-Payload schreiben) — nur
283
731
  // die Section-Handler laufen lassen.
284
732
  if (snapshot.isUnchanged && extensionDirty) {
285
- await persistExtensions();
733
+ // Same discard as the main path: an extension-only save is still a
734
+ // successful submit, so the draft must not survive it.
735
+ if (await persistExtensions()) await discardDraft();
286
736
  return;
287
737
  }
288
738
  let result: SubmitResult<unknown>;
@@ -303,7 +753,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
303
753
  // aber gar nichts ist passiert. controller.getSnapshot() ist
304
754
  // immer aktuell — der Controller ist die Source-of-Truth, die
305
755
  // React-State ist nur ein Mirror für's Rendering.
306
- const valid = controller.validate();
756
+ const valid = controller.validate(scopeFieldNames);
307
757
  if (!valid) {
308
758
  // Field-Order matters: validationBlocked-true ist eine eigene
309
759
  // Variante in der SubmitResult-Union (NICHT mit data/error
@@ -325,6 +775,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
325
775
  let extensionsPersisted = true;
326
776
  if (result.isSuccess) {
327
777
  setFormError(null);
778
+ // Awaited, not fire-and-forget: `onSubmit` typically navigates away and
779
+ // unmounts this form, which would abort an in-flight discard and leave
780
+ // the draft behind after a successful submit.
781
+ await discardDraft();
328
782
  extensionsPersisted = await persistExtensions();
329
783
  } else if (!result.validationBlocked) {
330
784
  const fieldIssues = result.error.details?.fields ?? [];
@@ -378,15 +832,35 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
378
832
  {translate("kumiko.actions.cancel")}
379
833
  </Button>
380
834
  )}
381
- {isFormEditable && (
835
+ {isWizard && currentStep > 0 && (
836
+ <Button
837
+ type="button"
838
+ variant="secondary"
839
+ onClick={handleWizardBack}
840
+ testId="render-edit-wizard-back"
841
+ >
842
+ {translate("kumiko.actions.back")}
843
+ </Button>
844
+ )}
845
+ {isWizard && !isLastWizardStep && (
382
846
  <Button
383
847
  type="submit"
384
- disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
848
+ disabled={disabled}
849
+ variant="primary"
850
+ testId="render-edit-wizard-next"
851
+ >
852
+ {translate("kumiko.actions.next")}
853
+ </Button>
854
+ )}
855
+ {isFormEditable && (!isWizard || isLastWizardStep) && (
856
+ <Button
857
+ type="submit"
858
+ disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
385
859
  loading={isSubmitting}
386
860
  variant="primary"
387
861
  testId="render-edit-submit"
388
862
  >
389
- {translate(submitLabel ?? "kumiko.actions.save")}
863
+ {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
390
864
  </Button>
391
865
  )}
392
866
  </>
@@ -422,59 +896,106 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
422
896
  {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
423
897
  actions={formActions}
424
898
  testId="render-edit-form"
899
+ stickyActions={isWizard}
425
900
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
426
901
  >
427
- {vm.sections.map((section: EditSectionViewModel, sectionIndex: number) => {
428
- if (section.kind === "extension") {
429
- return (
430
- <ExtensionSectionMount
431
- key={section.title}
432
- section={section}
433
- entityName={vm.entityName}
434
- entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
435
- initialValues={extensionInitialValues}
902
+ {draftCandidates !== null && (
903
+ <Banner
904
+ variant="info"
905
+ testId="render-edit-draft-picker"
906
+ actions={draftCandidates.map((candidate) => (
907
+ <Button
908
+ key={candidate.id}
909
+ type="button"
910
+ variant="link"
911
+ onClick={() => adoptDraft(candidate)}
912
+ testId={`render-edit-draft-pick-${candidate.id}`}
913
+ >
914
+ {formatWhen(candidate.savedAt)}
915
+ </Button>
916
+ ))}
917
+ >
918
+ <Text>{translate("kumiko.form.draft.resume-multiple")}</Text>
919
+ </Banner>
920
+ )}
921
+ {isWizard && (
922
+ <>
923
+ {Progress !== undefined && (
924
+ <Progress
925
+ value={(currentStep + 1) / (lastStepIndex + 1)}
926
+ testId="render-edit-wizard-progress"
436
927
  />
928
+ )}
929
+ <Text variant="small" testId="render-edit-wizard-step-label">
930
+ {translate("kumiko.wizard.step", {
931
+ current: currentStep + 1,
932
+ total: lastStepIndex + 1,
933
+ })}
934
+ </Text>
935
+ </>
936
+ )}
937
+ {(isWizard ? filteredSections.filter((_, i) => i === currentStep) : filteredSections).map(
938
+ (section: EditSectionViewModel, sectionIndex: number) => {
939
+ if (section.kind === "extension") {
940
+ return (
941
+ <ExtensionSectionMount
942
+ key={section.title}
943
+ section={section}
944
+ entityName={vm.entityName}
945
+ entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
946
+ initialValues={extensionInitialValues}
947
+ values={snapshot.values}
948
+ // @cast-boundary form-values: ExtensionSectionProps is not generic
949
+ // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
950
+ patch={
951
+ patchAndScheduleDraftSave as (
952
+ partial: Readonly<Record<string, unknown>>,
953
+ ) => void
954
+ }
955
+ validate={scopedValidate}
956
+ />
957
+ );
958
+ }
959
+ if (!section.visible) return null;
960
+ // Section-Header unterdrücken wenn er den Form-Titel der
961
+ // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
962
+ // ActionForms, deren Section-Label = Screen-Titel ist).
963
+ const sectionTitle = section.title === formTitle ? undefined : section.title;
964
+ // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
965
+ const sectionKey = section.title ?? `section-${sectionIndex}`;
966
+ return (
967
+ <Section
968
+ key={sectionKey}
969
+ {...(sectionTitle !== undefined && { title: sectionTitle })}
970
+ {...(section.description !== undefined && { subtitle: section.description })}
971
+ testId={`section-${sectionKey}`}
972
+ >
973
+ <Grid columns={section.columns}>
974
+ {section.fields.map((field: EditFieldViewModel) => (
975
+ <GridCellForField
976
+ key={field.field}
977
+ field={disabled ? { ...field, readOnly: true } : field}
978
+ columns={section.columns}
979
+ issues={snapshot.errors[field.field]}
980
+ onChange={(v) => {
981
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
982
+ }}
983
+ GridCell={GridCell}
984
+ featureName={featureName}
985
+ {...(labelAppendix !== undefined && {
986
+ labelAppendix: labelAppendix(field.field),
987
+ })}
988
+ {...(fieldAppendix !== undefined && {
989
+ fieldAppendix: fieldAppendix(field.field),
990
+ })}
991
+ allIssues={snapshot.errors}
992
+ />
993
+ ))}
994
+ </Grid>
995
+ </Section>
437
996
  );
438
- }
439
- if (!section.visible) return null;
440
- // Section-Header unterdrücken wenn er den Form-Titel der
441
- // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
442
- // ActionForms, deren Section-Label = Screen-Titel ist).
443
- const sectionTitle = section.title === formTitle ? undefined : section.title;
444
- // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
445
- const sectionKey = section.title ?? `section-${sectionIndex}`;
446
- return (
447
- <Section
448
- key={sectionKey}
449
- {...(sectionTitle !== undefined && { title: sectionTitle })}
450
- {...(section.description !== undefined && { subtitle: section.description })}
451
- testId={`section-${sectionKey}`}
452
- >
453
- <Grid columns={section.columns}>
454
- {section.fields.map((field: EditFieldViewModel) => (
455
- <GridCellForField
456
- key={field.field}
457
- field={field}
458
- columns={section.columns}
459
- issues={snapshot.errors[field.field]}
460
- onChange={(v) => {
461
- (controller.setField as (k: string, v: unknown) => void)(field.field, v);
462
- }}
463
- GridCell={GridCell}
464
- featureName={featureName}
465
- {...(labelAppendix !== undefined && {
466
- labelAppendix: labelAppendix(field.field),
467
- })}
468
- {...(fieldAppendix !== undefined && {
469
- fieldAppendix: fieldAppendix(field.field),
470
- })}
471
- allIssues={snapshot.errors}
472
- />
473
- ))}
474
- </Grid>
475
- </Section>
476
- );
477
- })}
997
+ },
998
+ )}
478
999
  {formError !== null && (
479
1000
  <Banner
480
1001
  variant="error"