@cosmicdrift/kumiko-renderer 0.186.3 → 0.188.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,20 +21,54 @@ 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
+ type FormDraftBlob = {
52
+ readonly values: Record<string, unknown>;
53
+ readonly stepIndex: number;
54
+ };
55
+
56
+ type DraftCandidate = {
57
+ readonly id: string;
58
+ readonly draftKey: string;
59
+ readonly stepIndex: number;
60
+ readonly savedAt: string;
61
+ };
62
+
63
+ // draftKey convention for a create-mode draft (framework-wizard-mode.md,
64
+ // lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
65
+ // fallback (strip the prefix to recover a candidate's draftId) and the
66
+ // list query itself (filter out edit-mode drafts, which share the same
67
+ // `${screenId}:%` LIKE-prefix scan server-side).
68
+ function newDraftPrefix(screenId: string): string {
69
+ return `${screenId}:new:`;
70
+ }
71
+
38
72
  // End-to-end renderer für einen entityEdit screen. Rendert aus-
39
73
  // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
40
74
  // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
@@ -96,6 +130,58 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
96
130
  * Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
97
131
  * undefined. */
98
132
  readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
133
+ /** Controlled mode (issue #1887): fires on every values-snapshot change
134
+ * (typing, `patch(...)` from outside) with the current values. `changes`
135
+ * is the delta against the initial values — same semantics as
136
+ * `payloadMode: "changes"` — so a caller never overwrites unseen fields.
137
+ * `valid` is a pure dry-run parse against `schema` (not a
138
+ * `controller.validate()` call), so it does not paint field errors into
139
+ * the UI and can diverge from the currently rendered `snapshot.errors` —
140
+ * always `true` without `schema`. A caller that patches a fresh object
141
+ * reference on every call must not do so unconditionally: `setValues` is
142
+ * a no-op when the merged value is reference-equal to the current one,
143
+ * so only a converging patch settles instead of looping. Without this
144
+ * prop, existing behavior is unchanged. */
145
+ readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
146
+ /** Controlled mode (issue #1887): called once after mount, hands the
147
+ * caller `patch`/`validate`/`getValues` bound to this RenderEdit
148
+ * instance — addressable from outside without a remount. `patch` merges
149
+ * only the given keys (existing `controller.setValues` semantics),
150
+ * values on unmentioned fields stay untouched. `validate` runs without a
151
+ * write and reports field issues via `snapshot.errors` on the field
152
+ * itself rather than as a summary banner. Without this prop, existing
153
+ * behavior is unchanged. */
154
+ readonly onControlsReady?: (controls: RenderEditControls<TValues>) => void;
155
+ /** Renders only these fields (by `field` name from the layout) — section
156
+ * order, title, and visibility still come from the layout, so the caller
157
+ * doesn't duplicate its shape. A section with no fields left after
158
+ * filtering is dropped entirely (not rendered empty). Submit validation
159
+ * is scoped to the actually-rendered fields the same way — a required
160
+ * field outside this list doesn't block submit. Omitting this prop keeps
161
+ * unchanged behavior. Read once at mount for `controller.submit()`'s
162
+ * validation scope (the underlying `useForm` controller is mount-lived);
163
+ * rendering and `controls.validate()` do stay reactive to later changes. */
164
+ readonly fields?: readonly string[];
165
+ /** Locked state (issue #1896): every rendered field and the submit button
166
+ * go visibly inactive, no write possible. For cases where input becomes
167
+ * moot — e.g. Solon's editor pointing at an existing record instead of
168
+ * creating a new one. Extension sections are out of scope: RenderEdit has
169
+ * no way to force-disable an arbitrary registered component. Omitting
170
+ * this prop keeps unchanged behavior. */
171
+ readonly disabled?: boolean;
172
+ };
173
+
174
+ export type RenderEditChangeState<TValues extends FormValues> = {
175
+ readonly values: TValues;
176
+ readonly changes: Partial<TValues>;
177
+ readonly dirty: boolean;
178
+ readonly valid: boolean;
179
+ };
180
+
181
+ export type RenderEditControls<TValues extends FormValues> = {
182
+ readonly patch: (partial: Partial<TValues>) => void;
183
+ readonly validate: () => boolean;
184
+ readonly getValues: () => TValues;
99
185
  };
100
186
 
101
187
  function toConditionValue<TValues extends FormValues, TCtx>(
@@ -141,11 +227,17 @@ function ExtensionSectionMount({
141
227
  entityName,
142
228
  entityId,
143
229
  initialValues,
230
+ values,
231
+ patch,
232
+ validate,
144
233
  }: {
145
234
  readonly section: EditExtensionSectionViewModel;
146
235
  readonly entityName: string;
147
236
  readonly entityId: string | null;
148
237
  readonly initialValues?: Readonly<Record<string, unknown>>;
238
+ readonly values?: Readonly<Record<string, unknown>>;
239
+ readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
240
+ readonly validate?: () => boolean;
149
241
  }): ReactNode {
150
242
  const { Banner, Section, Text } = usePrimitives();
151
243
  const name = extensionSectionName(section.component);
@@ -169,7 +261,14 @@ function ExtensionSectionMount({
169
261
  }
170
262
  return (
171
263
  <Section title={section.title} testId={`section-extension-${section.title}`}>
172
- <Component entityName={entityName} entityId={entityId} initialValues={initialValues} />
264
+ <Component
265
+ entityName={entityName}
266
+ entityId={entityId}
267
+ initialValues={initialValues}
268
+ values={values}
269
+ patch={patch}
270
+ validate={validate}
271
+ />
173
272
  </Section>
174
273
  );
175
274
  }
@@ -198,6 +297,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
198
297
  fieldAppendix,
199
298
  entityId: entityIdProp,
200
299
  extensionInitialValues,
300
+ onChange,
301
+ onControlsReady,
302
+ fields: fieldsFilter,
303
+ disabled = false,
201
304
  } = props;
202
305
  const { customSubmit } = props;
203
306
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -207,11 +310,37 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
207
310
  // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
208
311
  const t = useTranslation();
209
312
  const translate = translateProp ?? t;
313
+ const dispatcher = useDispatcher();
210
314
 
315
+ const isWizard = screen.layout.mode === "wizard";
316
+ const draftEnabled = isWizard && screen.layout.draft === true;
317
+ const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
318
+ const draftStorage = useDraftStorage();
211
319
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
212
320
  const [linkCopied, setLinkCopied] = useState(false);
213
321
  const [isSubmitting, setIsSubmitting] = useState(false);
214
322
  const [formError, setFormError] = useState<DispatcherError | null>(null);
323
+ const [rawStep, setRawStep] = useState(0);
324
+ // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
325
+ // on mount so a same-tab reload finds the right one of several parallel
326
+ // create-sessions on this screen; `null` until the first step change
327
+ // mints one (saveDraft), or the mount-time `form-draft:query:list`
328
+ // fallback below adopts an existing one. Edit-mode never touches this —
329
+ // its draftKey is `${screen.id}:${entityIdProp}` unconditionally.
330
+ const [draftId, setDraftId] = useState<string | null>(() =>
331
+ isCreateMode ? draftStorage.getDraftId(screen.id) : null,
332
+ );
333
+ // Marks a draftId this instance minted itself (saveDraft, first step
334
+ // change) so the restore effect below doesn't immediately re-fetch what
335
+ // it just wrote — there is nothing to restore, the row is brand new.
336
+ const mintedDraftIdRef = useRef<string | null>(null);
337
+ // Marks that the mount-time `form-draft:query:list` fallback already ran
338
+ // once for this mount. Without this, a create-mode discard resets `draftId`
339
+ // to `null` (see discardDraft below) and would re-arm the list effect —
340
+ // silently adopting an unrelated parallel draft on the same screen right
341
+ // after the user just submitted (#1908).
342
+ const didListRef = useRef(false);
343
+ const [draftCandidates, setDraftCandidates] = useState<readonly DraftCandidate[] | null>(null);
215
344
  // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
216
345
  // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
217
346
  // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
@@ -220,10 +349,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
220
349
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
221
350
  const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
222
351
  useExtensionFormHost(setExtensionDirty);
223
- const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text } = usePrimitives();
352
+ const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text, Progress } = usePrimitives();
224
353
 
225
354
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
226
355
 
356
+ // Scope für validate()/submit() — nur die tatsächlich gerenderten Feldnamen
357
+ // aus `fields` (bereits non-extension-only, siehe deriveFormFields), auf den
358
+ // `fields`-Filter-Prop eingeschränkt. Muss VOR submitConfig/useForm stehen
359
+ // (der Controller bakt submitConfig beim ersten Render dauerhaft ein) und
360
+ // kann daher nicht von `vm`/`filteredSections` abgeleitet werden, die erst
361
+ // nach dem Controller (aus snapshot.values) existieren — die Feldnamen-Menge
362
+ // pro Section ist aber wertunabhängig (nur visible/readOnly/value hängen von
363
+ // `values` ab), also liefert diese Ableitung dieselbe Menge wie
364
+ // filterEditSections(vm.sections, fieldsFilter) es täte. undefined (= kein
365
+ // Filter aktiv) heißt unscoped validate/submit — auf "alle gerenderten
366
+ // Felder" scopen würde sonst root-level .refine()-Issues aus der
367
+ // unscoped-Validierung stillschweigend wegfiltern.
368
+ const scopeFieldNames = useMemo(
369
+ () =>
370
+ fieldsFilter === undefined ? undefined : fieldsFilter.filter((f) => Object.hasOwn(fields, f)),
371
+ [fieldsFilter, fields],
372
+ );
373
+
227
374
  // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
228
375
  // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
229
376
  // weil wir controller.submit() eh nicht rufen.
@@ -233,6 +380,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
233
380
  type: writeCommand,
234
381
  payloadMode,
235
382
  ...(buildPayload !== undefined && { buildPayload }),
383
+ ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
236
384
  }
237
385
  : undefined;
238
386
 
@@ -244,6 +392,153 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
244
392
  ...(submitConfig !== undefined && { submit: submitConfig }),
245
393
  });
246
394
 
395
+ // Derived from the screen id + the host entity id only — never from
396
+ // `vm.id`, which lives in the form values a restore mutates (load under one
397
+ // key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
398
+ // unchanged. Create-mode: `${screen.id}:new:${draftId}` once a draftId
399
+ // exists (resumed, adopted, or minted), `undefined` before that — two
400
+ // parallel create sessions on the same screen must never collapse onto
401
+ // the same key (kumiko-framework#1908).
402
+ const draftKey = useMemo(
403
+ () =>
404
+ isCreateMode
405
+ ? draftId !== null
406
+ ? `${screen.id}:new:${draftId}`
407
+ : undefined
408
+ : `${screen.id}:${entityIdProp}`,
409
+ [screen.id, entityIdProp, isCreateMode, draftId],
410
+ );
411
+
412
+ // Controlled mode (Issue #1887). Both callbacks live in refs so a
413
+ // re-rendering caller (identity-unstable `onChange`/`onControlsReady`
414
+ // closures, e.g. inline arrows) doesn't retrigger these effects — only a
415
+ // real snapshot mutation (typing, patch()) does. Without this, a caller
416
+ // whose onChange calls patch() to fill other fields (the VIN-decode use
417
+ // case from #1888) would loop: patch → new snapshot → effect
418
+ // re-subscribes with a "new" onChange → refires.
419
+ const onChangeRef = useRef(onChange);
420
+ onChangeRef.current = onChange;
421
+ useEffect(() => {
422
+ const cb = onChangeRef.current;
423
+ if (cb === undefined) return;
424
+ // Dry-run parse against `schema` — NOT controller.validate(). Calling
425
+ // validate() here would write field-level errors into snapshot.errors
426
+ // on every keystroke, painting error messages while the user is still
427
+ // typing. `valid` can therefore legitimately diverge from what's
428
+ // currently rendered under the fields (the last *mutating* validate()
429
+ // call, e.g. from controls.validate() or submit()).
430
+ const valid = schema === undefined ? true : schema.safeParse(snapshot.values).success;
431
+ cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
432
+ }, [snapshot, schema]);
433
+
434
+ const onControlsReadyRef = useRef(onControlsReady);
435
+ onControlsReadyRef.current = onControlsReady;
436
+ const scopeFieldNamesRef = useRef(scopeFieldNames);
437
+ scopeFieldNamesRef.current = scopeFieldNames;
438
+ const scopedValidate = useCallback(
439
+ () => controller.validate(scopeFieldNamesRef.current),
440
+ [controller],
441
+ );
442
+ useEffect(() => {
443
+ const cb = onControlsReadyRef.current;
444
+ if (cb === undefined) return;
445
+ cb({
446
+ patch: controller.setValues,
447
+ validate: scopedValidate,
448
+ getValues: () => controller.getSnapshot().values,
449
+ });
450
+ // controller is mount-lifetime-stable (see useForm's comment on its
451
+ // own useMemo) — this fires exactly once per RenderEdit mount.
452
+ }, [controller, scopedValidate]);
453
+
454
+ useEffect(() => {
455
+ // skip: this screen does not persist a draft.
456
+ if (!draftEnabled) return;
457
+ // skip: create-mode with no draftId yet — nothing to restore, either
458
+ // the list-fallback effect below finds one or the first step change
459
+ // mints a fresh one.
460
+ if (draftKey === undefined) return;
461
+ // skip: this draftId was just minted by saveDraft — the row it wrote
462
+ // is exactly the current form state, re-fetching it would be a no-op
463
+ // round-trip at best and a stale-echo clobber at worst.
464
+ if (draftId !== null && draftId === mintedDraftIdRef.current) return;
465
+ let cancelled = false;
466
+ void (async () => {
467
+ const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
468
+ FORM_DRAFT_GET,
469
+ { draftKey },
470
+ );
471
+ // skip: superseded by a newer mount, or the draft lookup failed — an
472
+ // unreachable draft store must not break the form itself.
473
+ if (cancelled || !result.isSuccess) return;
474
+ const draft = result.data?.draft ?? null;
475
+ // skip: nothing saved yet for this key.
476
+ if (draft === null) return;
477
+ // skip: the user already typed while the lookup was in flight — their
478
+ // input wins over the stored draft.
479
+ if (controller.getSnapshot().isDirty) return;
480
+ // @cast-boundary form-draft blob: `values` round-trips through an opaque
481
+ // jsonb column and comes back untyped.
482
+ controller.setValues(draft.values as Partial<TValues>);
483
+ setRawStep(Math.max(draft.stepIndex, 0));
484
+ })();
485
+ return () => {
486
+ cancelled = true;
487
+ };
488
+ }, [draftEnabled, draftKey, draftId, dispatcher, controller]);
489
+
490
+ // Mount-time fallback (issue #1913) for create-mode when no draftId
491
+ // survived in storage (new tab, cleared storage): ask the server for
492
+ // this screen's open drafts. Exactly one → adopt it silently (same
493
+ // effect as if storage had it). Multiple → render a simple picker
494
+ // (below) and let the user choose. Zero → stay null, saveDraft mints a
495
+ // fresh draftId on the first step change same as any other fresh create.
496
+ useEffect(() => {
497
+ // skip: this screen does not persist a draft, this is edit-mode, a
498
+ // draftId is already known (from storage or an earlier adoption), or
499
+ // this mount already ran the list lookup once (didListRef).
500
+ if (!draftEnabled || !isCreateMode || draftId !== null || didListRef.current) return;
501
+ didListRef.current = true;
502
+ let cancelled = false;
503
+ void (async () => {
504
+ const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
505
+ FORM_DRAFT_LIST,
506
+ { screenId: screen.id },
507
+ );
508
+ // skip: superseded by a newer mount, or the lookup failed — an
509
+ // unreachable draft store must not break a fresh create.
510
+ if (cancelled || !result.isSuccess) return;
511
+ const prefix = newDraftPrefix(screen.id);
512
+ // list's LIKE-prefix scan also matches edit-mode drafts
513
+ // (`${screenId}:${entityId}`) — keep only create-mode ones.
514
+ const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
515
+ // skip: no open drafts for this screen — stay null, saveDraft mints a
516
+ // fresh draftId on the first step change same as any other create.
517
+ if (candidates.length === 0) return;
518
+ const [only] = candidates;
519
+ if (candidates.length === 1 && only !== undefined) {
520
+ const adoptedId = only.draftKey.slice(prefix.length);
521
+ draftStorage.setDraftId(screen.id, adoptedId);
522
+ setDraftId(adoptedId);
523
+ return;
524
+ }
525
+ setDraftCandidates(candidates);
526
+ })();
527
+ return () => {
528
+ cancelled = true;
529
+ };
530
+ }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id, draftStorage]);
531
+
532
+ // User picked one of several open drafts from the mount-time picker
533
+ // (see draftCandidates below) — adopt it the same way a single
534
+ // auto-adopted candidate would be.
535
+ function adoptDraft(candidate: DraftCandidate): void {
536
+ const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
537
+ draftStorage.setDraftId(screen.id, adoptedId);
538
+ setDraftId(adoptedId);
539
+ setDraftCandidates(null);
540
+ }
541
+
247
542
  const vm = useMemo(
248
543
  () =>
249
544
  computeEditViewModel({
@@ -256,8 +551,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
256
551
  [screen, entity, snapshot.values, translate, featureName],
257
552
  );
258
553
 
554
+ const filteredSections = useMemo(
555
+ () => filterEditSections(vm.sections, fieldsFilter),
556
+ [vm.sections, fieldsFilter],
557
+ );
558
+
259
559
  // true for an extension section with no fields of its own too (it carries its own dirty/save).
260
- const isFormEditable = hasEditableSection(vm.sections);
560
+ const isFormEditable = hasEditableSection(filteredSections);
261
561
 
262
562
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
263
563
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
@@ -274,7 +574,95 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
274
574
  return true;
275
575
  }
276
576
 
577
+ const lastStepIndex = Math.max(filteredSections.length - 1, 0);
578
+ // Clamped on read, not on write: section visibility is value-dependent, so a
579
+ // stored stepIndex can point past what is currently rendered — that lands on
580
+ // the last step instead of an empty one.
581
+ const currentStep = Math.min(rawStep, lastStepIndex);
582
+ const isLastWizardStep = currentStep >= lastStepIndex;
583
+
584
+ // Step transitions only — never per keystroke. Deliberately not awaited: a
585
+ // failed draft save must not block the step change.
586
+ //
587
+ // Create-mode, first step change: mints the draftId here (issue #1913)
588
+ // rather than at mount, so a form abandoned on step 0 never claims a
589
+ // draftId or writes a row at all. `draftKey`/`draftId` state won't
590
+ // reflect the mint until the next render, so the just-minted key is
591
+ // computed inline instead of read from the memoized `draftKey`.
592
+ function saveDraft(stepIndex: number): void {
593
+ // skip: this screen does not persist a draft.
594
+ if (!draftEnabled) return;
595
+ let key = draftKey;
596
+ if (isCreateMode && draftId === null) {
597
+ const mintedId = crypto.randomUUID();
598
+ mintedDraftIdRef.current = mintedId;
599
+ draftStorage.setDraftId(screen.id, mintedId);
600
+ setDraftId(mintedId);
601
+ key = `${screen.id}:new:${mintedId}`;
602
+ // A stale picker from the mount-time list fallback must not survive a
603
+ // mint — picking a candidate afterwards would repoint draftKey at an
604
+ // unrelated draft mid-edit and overwrite it (#1908).
605
+ setDraftCandidates(null);
606
+ }
607
+ // skip: create-mode with a draftId that failed to mint — unreachable
608
+ // in practice (mint above always produces one), kept as a type-level
609
+ // guard against a stale `undefined` key ever reaching the write.
610
+ if (key === undefined) return;
611
+ void dispatcher.write(FORM_DRAFT_SAVE, {
612
+ draftKey: key,
613
+ values: controller.getSnapshot().values,
614
+ stepIndex,
615
+ });
616
+ }
617
+
618
+ // Next: scoped validate() on the current step's fields — errors stay
619
+ // attached to the field and block the step transition. Extension steps
620
+ // have no field scope here (they validate via the controlled-mode
621
+ // controls.validate() API), so no call, no clearing of unrelated errors.
622
+ function handleWizardNext(): void {
623
+ const section = filteredSections[currentStep];
624
+ const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
625
+ // skip: the current step has field errors — no transition, no draft save.
626
+ if (fieldNames.length > 0 && !controller.validate(fieldNames)) return;
627
+ const next = Math.min(currentStep + 1, lastStepIndex);
628
+ setRawStep(next);
629
+ saveDraft(next);
630
+ }
631
+
632
+ function handleWizardBack(): void {
633
+ const previous = Math.max(currentStep - 1, 0);
634
+ setRawStep(previous);
635
+ saveDraft(previous);
636
+ }
637
+
638
+ async function discardDraft(): Promise<void> {
639
+ // skip: this screen does not persist a draft.
640
+ if (!draftEnabled) return;
641
+ // skip: create-mode, no step change happened yet — no draftId was ever
642
+ // minted, so no row exists to discard.
643
+ if (draftKey === undefined) return;
644
+ await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
645
+ // A successful submit ends this draftId's life — a subsequent create on
646
+ // the same screen (new mount) must mint its own, not resume this one.
647
+ if (isCreateMode) {
648
+ draftStorage.clearDraftId(screen.id);
649
+ mintedDraftIdRef.current = null;
650
+ setDraftId(null);
651
+ }
652
+ }
653
+
277
654
  async function handleSubmit(): Promise<void> {
655
+ // Locked state (#1896): the submit button is visibly disabled, but a
656
+ // native form submit (Enter key) reaches this handler regardless of the
657
+ // button's disabled attribute — block it here too, not just in the UI.
658
+ if (disabled) return;
659
+ // Enter in the active step triggers the native form submit (Next is
660
+ // type="submit" for Enter support) — on intermediate steps that means
661
+ // "Next", not "Save".
662
+ if (isWizard && !isLastWizardStep) {
663
+ handleWizardNext();
664
+ return;
665
+ }
278
666
  setIsSubmitting(true);
279
667
  setExtensionErrorKey(null);
280
668
  try {
@@ -282,7 +670,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
282
670
  // Kein Entity-Write (würde einen leeren changes-Payload schreiben) — nur
283
671
  // die Section-Handler laufen lassen.
284
672
  if (snapshot.isUnchanged && extensionDirty) {
285
- await persistExtensions();
673
+ // Same discard as the main path: an extension-only save is still a
674
+ // successful submit, so the draft must not survive it.
675
+ if (await persistExtensions()) await discardDraft();
286
676
  return;
287
677
  }
288
678
  let result: SubmitResult<unknown>;
@@ -303,7 +693,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
303
693
  // aber gar nichts ist passiert. controller.getSnapshot() ist
304
694
  // immer aktuell — der Controller ist die Source-of-Truth, die
305
695
  // React-State ist nur ein Mirror für's Rendering.
306
- const valid = controller.validate();
696
+ const valid = controller.validate(scopeFieldNames);
307
697
  if (!valid) {
308
698
  // Field-Order matters: validationBlocked-true ist eine eigene
309
699
  // Variante in der SubmitResult-Union (NICHT mit data/error
@@ -325,6 +715,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
325
715
  let extensionsPersisted = true;
326
716
  if (result.isSuccess) {
327
717
  setFormError(null);
718
+ // Awaited, not fire-and-forget: `onSubmit` typically navigates away and
719
+ // unmounts this form, which would abort an in-flight discard and leave
720
+ // the draft behind after a successful submit.
721
+ await discardDraft();
328
722
  extensionsPersisted = await persistExtensions();
329
723
  } else if (!result.validationBlocked) {
330
724
  const fieldIssues = result.error.details?.fields ?? [];
@@ -378,15 +772,35 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
378
772
  {translate("kumiko.actions.cancel")}
379
773
  </Button>
380
774
  )}
381
- {isFormEditable && (
775
+ {isWizard && currentStep > 0 && (
776
+ <Button
777
+ type="button"
778
+ variant="secondary"
779
+ onClick={handleWizardBack}
780
+ testId="render-edit-wizard-back"
781
+ >
782
+ {translate("kumiko.actions.back")}
783
+ </Button>
784
+ )}
785
+ {isWizard && !isLastWizardStep && (
382
786
  <Button
383
787
  type="submit"
384
- disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
788
+ disabled={disabled}
789
+ variant="primary"
790
+ testId="render-edit-wizard-next"
791
+ >
792
+ {translate("kumiko.actions.next")}
793
+ </Button>
794
+ )}
795
+ {isFormEditable && (!isWizard || isLastWizardStep) && (
796
+ <Button
797
+ type="submit"
798
+ disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
385
799
  loading={isSubmitting}
386
800
  variant="primary"
387
801
  testId="render-edit-submit"
388
802
  >
389
- {translate(submitLabel ?? "kumiko.actions.save")}
803
+ {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
390
804
  </Button>
391
805
  )}
392
806
  </>
@@ -424,57 +838,101 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
424
838
  testId="render-edit-form"
425
839
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
426
840
  >
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}
841
+ {draftCandidates !== null && (
842
+ <Banner
843
+ variant="info"
844
+ testId="render-edit-draft-picker"
845
+ actions={draftCandidates.map((candidate) => (
846
+ <Button
847
+ key={candidate.id}
848
+ type="button"
849
+ variant="link"
850
+ onClick={() => adoptDraft(candidate)}
851
+ testId={`render-edit-draft-pick-${candidate.id}`}
852
+ >
853
+ {formatWhen(candidate.savedAt)}
854
+ </Button>
855
+ ))}
856
+ >
857
+ <Text>{translate("kumiko.form.draft.resume-multiple")}</Text>
858
+ </Banner>
859
+ )}
860
+ {isWizard && (
861
+ <>
862
+ {Progress !== undefined && (
863
+ <Progress
864
+ value={(currentStep + 1) / (lastStepIndex + 1)}
865
+ testId="render-edit-wizard-progress"
436
866
  />
867
+ )}
868
+ <Text variant="small" testId="render-edit-wizard-step-label">
869
+ {translate("kumiko.wizard.step", {
870
+ current: currentStep + 1,
871
+ total: lastStepIndex + 1,
872
+ })}
873
+ </Text>
874
+ </>
875
+ )}
876
+ {(isWizard ? filteredSections.filter((_, i) => i === currentStep) : filteredSections).map(
877
+ (section: EditSectionViewModel, sectionIndex: number) => {
878
+ if (section.kind === "extension") {
879
+ return (
880
+ <ExtensionSectionMount
881
+ key={section.title}
882
+ section={section}
883
+ entityName={vm.entityName}
884
+ entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
885
+ initialValues={extensionInitialValues}
886
+ values={snapshot.values}
887
+ // @cast-boundary form-values: ExtensionSectionProps is not generic
888
+ // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
889
+ patch={
890
+ controller.setValues as (partial: Readonly<Record<string, unknown>>) => void
891
+ }
892
+ validate={scopedValidate}
893
+ />
894
+ );
895
+ }
896
+ if (!section.visible) return null;
897
+ // Section-Header unterdrücken wenn er den Form-Titel der
898
+ // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
899
+ // ActionForms, deren Section-Label = Screen-Titel ist).
900
+ const sectionTitle = section.title === formTitle ? undefined : section.title;
901
+ // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
902
+ const sectionKey = section.title ?? `section-${sectionIndex}`;
903
+ return (
904
+ <Section
905
+ key={sectionKey}
906
+ {...(sectionTitle !== undefined && { title: sectionTitle })}
907
+ {...(section.description !== undefined && { subtitle: section.description })}
908
+ testId={`section-${sectionKey}`}
909
+ >
910
+ <Grid columns={section.columns}>
911
+ {section.fields.map((field: EditFieldViewModel) => (
912
+ <GridCellForField
913
+ key={field.field}
914
+ field={disabled ? { ...field, readOnly: true } : field}
915
+ columns={section.columns}
916
+ issues={snapshot.errors[field.field]}
917
+ onChange={(v) => {
918
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
919
+ }}
920
+ GridCell={GridCell}
921
+ featureName={featureName}
922
+ {...(labelAppendix !== undefined && {
923
+ labelAppendix: labelAppendix(field.field),
924
+ })}
925
+ {...(fieldAppendix !== undefined && {
926
+ fieldAppendix: fieldAppendix(field.field),
927
+ })}
928
+ allIssues={snapshot.errors}
929
+ />
930
+ ))}
931
+ </Grid>
932
+ </Section>
437
933
  );
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
- })}
934
+ },
935
+ )}
478
936
  {formError !== null && (
479
937
  <Banner
480
938
  variant="error"