@cosmicdrift/kumiko-renderer-web 0.187.0 → 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.
@@ -6,13 +6,26 @@ import type {
6
6
  import type { Dispatcher, SubmitResult } from "@cosmicdrift/kumiko-headless";
7
7
  import {
8
8
  DispatcherProvider,
9
+ DraftStorageProvider,
9
10
  ExtensionSectionsProvider,
10
11
  type ExtensionSubmitContext,
11
12
  RenderEdit,
13
+ type RenderEditChangeState,
14
+ type RenderEditControls,
12
15
  useExtensionFormSubmit,
13
16
  } from "@cosmicdrift/kumiko-renderer";
14
17
  import { useState } from "react";
15
- import { act, createMockDispatcher, fireEvent, render, screen } from "./test-utils";
18
+ import { z } from "zod";
19
+ import {
20
+ act,
21
+ createFakeDraftStorage,
22
+ createMockDispatcher,
23
+ fireEvent,
24
+ render,
25
+ screen,
26
+ waitFor,
27
+ within,
28
+ } from "./test-utils";
16
29
 
17
30
  const orderEntity = {
18
31
  fields: {
@@ -440,6 +453,94 @@ describe("RenderEdit", () => {
440
453
  const placeholder = screen.getByTestId("section-extension-placeholder-Custom Fields");
441
454
  expect(placeholder.textContent).toContain("UnregisteredComp");
442
455
  });
456
+
457
+ // Issue #1888: ExtensionSectionProps.values/patch/validate pass-through —
458
+ // same controller functions as RenderEditControls (#1887), just handed to
459
+ // the extension section instead of onControlsReady.
460
+ test("extension section sees current form values and its patch(...) lands in the form + outer onChange", () => {
461
+ const screenDef: EntityEditScreenDefinition = {
462
+ id: "orders:screen:order-edit",
463
+ type: "entityEdit",
464
+ entity: "order",
465
+ layout: {
466
+ sections: [
467
+ { title: "Basics", columns: 2, fields: [{ field: "title", span: 2 }, "notes"] },
468
+ {
469
+ kind: "extension",
470
+ title: "VIN Decode",
471
+ component: { react: { __component: "VinDecodeSection" } },
472
+ },
473
+ ],
474
+ },
475
+ };
476
+ const VinDecodeSection = ({
477
+ values,
478
+ patch,
479
+ validate,
480
+ }: {
481
+ values?: Readonly<Record<string, unknown>>;
482
+ patch?: (partial: Readonly<Record<string, unknown>>) => void;
483
+ validate?: () => boolean;
484
+ }) => (
485
+ <div data-testid="vin-decode-section">
486
+ <span data-testid="vin-decode-sees-title">{String(values?.["title"])}</span>
487
+ <button type="button" onClick={() => patch?.({ notes: "decoded-from-vin" })}>
488
+ Decode
489
+ </button>
490
+ <button
491
+ type="button"
492
+ data-testid="vin-decode-validate"
493
+ onClick={() => {
494
+ lastValidateResult = validate?.() ?? true;
495
+ }}
496
+ >
497
+ Validate
498
+ </button>
499
+ </div>
500
+ );
501
+ const seen: RenderEditChangeState<TestValues>[] = [];
502
+ let lastValidateResult = true;
503
+ const schema = z.object({
504
+ title: z.string().min(1),
505
+ count: z.number().optional(),
506
+ isUrgent: z.boolean().optional(),
507
+ });
508
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
509
+ render(
510
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
511
+ <ExtensionSectionsProvider value={{ VinDecodeSection }}>
512
+ <RenderEdit<TestValues>
513
+ screen={screenDef}
514
+ entity={orderEntity}
515
+ featureName="orders"
516
+ initial={{ title: "", count: 0, isUrgent: false }}
517
+ writeCommand="order:create"
518
+ schema={schema}
519
+ onChange={(state) => seen.push(state)}
520
+ />
521
+ </ExtensionSectionsProvider>
522
+ </DispatcherProvider>,
523
+ );
524
+
525
+ act(() => {
526
+ fireEvent.click(screen.getByTestId("vin-decode-validate"));
527
+ });
528
+ expect(lastValidateResult).toBe(false);
529
+ expect(write).not.toHaveBeenCalled();
530
+ expect(screen.getByTestId("field-title-errors")).toBeTruthy();
531
+
532
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
533
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
534
+ expect(screen.getByTestId("vin-decode-sees-title").textContent).toBe("Acme");
535
+
536
+ act(() => {
537
+ fireEvent.click(screen.getByText("Decode"));
538
+ });
539
+
540
+ const notesInput = screen.getByTestId("field-notes")?.querySelector("input");
541
+ expect((notesInput as HTMLInputElement | null)?.value).toBe("decoded-from-vin");
542
+ expect(seen.at(-1)?.values.notes).toBe("decoded-from-vin");
543
+ });
443
544
  });
444
545
 
445
546
  describe("RenderEdit — composed extension save (Bug-Bash 3 #1)", () => {
@@ -519,3 +620,1269 @@ describe("RenderEdit — composed extension save (Bug-Bash 3 #1)", () => {
519
620
  expect(writeSpy).not.toHaveBeenCalled();
520
621
  });
521
622
  });
623
+
624
+ // Issue #1887: controlled mode. onChange reports values out, onControlsReady
625
+ // hands the caller patch()/validate()/getValues() bound to this instance —
626
+ // all without an entity-write and without remounting RenderEdit.
627
+ describe("RenderEdit — controlled mode (#1887)", () => {
628
+ test("onChange fires with the current values, the changes-delta, and dirty on typing", () => {
629
+ const seen: RenderEditChangeState<TestValues>[] = [];
630
+ render(
631
+ <DispatcherProvider dispatcher={makeDispatcher()}>
632
+ <RenderEdit<TestValues>
633
+ screen={makeScreen()}
634
+ entity={orderEntity}
635
+ featureName="orders"
636
+ initial={{ title: "", count: 0, isUrgent: false }}
637
+ writeCommand="order:create"
638
+ onChange={(state) => seen.push(state)}
639
+ />
640
+ </DispatcherProvider>,
641
+ );
642
+
643
+ // Fires once on mount with the pristine snapshot.
644
+ expect(seen).toHaveLength(1);
645
+ expect(seen[0]).toMatchObject({ changes: {}, dirty: false, valid: true });
646
+
647
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
648
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
649
+
650
+ const last = seen.at(-1);
651
+ expect(last?.values.title).toBe("Acme");
652
+ // changes is the delta against the initial values (payloadMode: "changes"
653
+ // semantics) — only the touched field appears, nothing else.
654
+ expect(last?.changes).toEqual({ title: "Acme" });
655
+ expect(last?.dirty).toBe(true);
656
+ });
657
+
658
+ test("onChange's valid reflects a schema dry-run and never paints field errors (banner or per-field)", () => {
659
+ const schema = z.object({
660
+ title: z.string().min(1),
661
+ count: z.number().optional(),
662
+ isUrgent: z.boolean().optional(),
663
+ });
664
+ const seen: RenderEditChangeState<TestValues>[] = [];
665
+ render(
666
+ <DispatcherProvider dispatcher={makeDispatcher()}>
667
+ <RenderEdit<TestValues>
668
+ screen={makeScreen()}
669
+ entity={orderEntity}
670
+ featureName="orders"
671
+ initial={{ title: "", count: 0, isUrgent: false }}
672
+ writeCommand="order:create"
673
+ schema={schema}
674
+ onChange={(state) => seen.push(state)}
675
+ />
676
+ </DispatcherProvider>,
677
+ );
678
+
679
+ // title is required by the schema and still empty — dry-run says invalid.
680
+ expect(seen.at(-1)?.valid).toBe(false);
681
+ // Dry-run parse never mutates snapshot.errors — no visible field error,
682
+ // no summary banner. Typing alone must not trigger validation display.
683
+ expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
684
+ expect(screen.queryByTestId("field-title-errors")).toBeNull();
685
+ });
686
+
687
+ test("a caller whose onChange calls patch() to derive a field settles instead of looping", () => {
688
+ let calls = 0;
689
+ let controls: RenderEditControls<TestValues> | undefined;
690
+ render(
691
+ <DispatcherProvider dispatcher={makeDispatcher()}>
692
+ <RenderEdit<TestValues>
693
+ screen={makeScreen()}
694
+ entity={orderEntity}
695
+ featureName="orders"
696
+ initial={{ title: "", count: 0, isUrgent: false }}
697
+ writeCommand="order:create"
698
+ onChange={(state) => {
699
+ calls += 1;
700
+ // The #1888 VIN-decode shape: a derived field is patched from
701
+ // inside onChange itself. `count` converges to title.length, so
702
+ // once patch() computes the same value again, setValues' no-op
703
+ // guard (reference-equal merge) stops the chain — a caller that
704
+ // instead patched a *fresh object reference* every time would
705
+ // loop forever, since Object.is would never match.
706
+ controls?.patch({ count: state.values.title.length });
707
+ }}
708
+ onControlsReady={(c) => {
709
+ controls = c;
710
+ }}
711
+ />
712
+ </DispatcherProvider>,
713
+ );
714
+
715
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
716
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
717
+
718
+ // Settles at a small finite count instead of looping: mount, the typing
719
+ // change, and the convergent patch() from inside onChange each fire
720
+ // onChange once — more than the no-patch case (1) but bounded, not
721
+ // unbounded.
722
+ expect(calls).toBeGreaterThan(1);
723
+ expect(calls).toBeLessThan(10);
724
+ expect(controls?.getValues().count).toBe("Acme".length);
725
+ });
726
+
727
+ test("controls.patch sets values from outside without losing edits already made in other fields", () => {
728
+ let controls: RenderEditControls<TestValues> | undefined;
729
+ render(
730
+ <DispatcherProvider dispatcher={makeDispatcher()}>
731
+ <RenderEdit<TestValues>
732
+ screen={makeScreen()}
733
+ entity={orderEntity}
734
+ featureName="orders"
735
+ initial={{ title: "", count: 0, isUrgent: false }}
736
+ writeCommand="order:create"
737
+ onControlsReady={(c) => {
738
+ controls = c;
739
+ }}
740
+ />
741
+ </DispatcherProvider>,
742
+ );
743
+
744
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
745
+ fireEvent.change(titleInput, { target: { value: "User-typed" } });
746
+ expect(titleInput.value).toBe("User-typed");
747
+
748
+ act(() => {
749
+ controls?.patch({ count: 42 });
750
+ });
751
+
752
+ // count updated, title (the user's own edit) untouched.
753
+ expect(controls?.getValues().count).toBe(42);
754
+ expect(controls?.getValues().title).toBe("User-typed");
755
+ expect(
756
+ (screen.getByTestId("field-title").querySelector("input") as HTMLInputElement).value,
757
+ ).toBe("User-typed");
758
+ });
759
+
760
+ test("controls.validate() reports field errors on the field, never as a summary banner, and writes nothing", () => {
761
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
762
+ const schema = z.object({
763
+ title: z.string().min(1),
764
+ count: z.number().optional(),
765
+ isUrgent: z.boolean().optional(),
766
+ });
767
+ let controls: RenderEditControls<TestValues> | undefined;
768
+ render(
769
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
770
+ <RenderEdit<TestValues>
771
+ screen={makeScreen()}
772
+ entity={orderEntity}
773
+ featureName="orders"
774
+ initial={{ title: "", count: 0, isUrgent: false }}
775
+ writeCommand="order:create"
776
+ schema={schema}
777
+ onControlsReady={(c) => {
778
+ controls = c;
779
+ }}
780
+ />
781
+ </DispatcherProvider>,
782
+ );
783
+
784
+ // Before validate(): no field error visible yet (mount alone never
785
+ // validates — matches "existing behaviour unchanged" for the schema path).
786
+ expect(screen.queryByTestId("field-title-errors")).toBeNull();
787
+
788
+ let isValid = true;
789
+ act(() => {
790
+ isValid = controls?.validate() ?? true;
791
+ });
792
+
793
+ expect(isValid).toBe(false);
794
+ expect(write).not.toHaveBeenCalled();
795
+ // Field-level error, not a form-wide summary banner.
796
+ expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
797
+ expect(screen.getByTestId("field-title-errors")).toBeTruthy();
798
+ });
799
+
800
+ test("without onChange/onControlsReady, existing single-field-per-keystroke behaviour is unchanged", () => {
801
+ render(
802
+ <DispatcherProvider dispatcher={makeDispatcher()}>
803
+ <RenderEdit<TestValues>
804
+ screen={makeScreen()}
805
+ entity={orderEntity}
806
+ featureName="orders"
807
+ initial={{ title: "", count: 0, isUrgent: false }}
808
+ writeCommand="order:create"
809
+ />
810
+ </DispatcherProvider>,
811
+ );
812
+
813
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
814
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
815
+ expect(titleInput.value).toBe("Acme");
816
+ expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
817
+ });
818
+ });
819
+
820
+ // Issue #1916: proves FieldConditions (visible/readOnly/required) react to
821
+ // values an extension section writes at runtime via patch() — not just to
822
+ // user keystrokes. Tests already merged behavior from #1887/#1888
823
+ // (controlled mode + extension-section pass-through); this is the missing
824
+ // coverage for the VIN-decode shape: a decode result reveals a different
825
+ // set of follow-up fields per car, and a field a previous decode revealed
826
+ // must fall back when a later decode clears it.
827
+ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", () => {
828
+ type VehicleValues = {
829
+ title: string;
830
+ vin?: string;
831
+ trim?: string;
832
+ decodeStatus?: string;
833
+ };
834
+ const vehicleEntity = {
835
+ fields: {
836
+ title: { type: "text", required: true },
837
+ vin: { type: "text" },
838
+ trim: { type: "text" },
839
+ },
840
+ } as unknown as EntityDefinition;
841
+
842
+ function makeVehicleScreen(): EntityEditScreenDefinition {
843
+ return {
844
+ id: "vehicles:screen:vehicle-edit",
845
+ type: "entityEdit",
846
+ entity: "vehicle",
847
+ layout: {
848
+ sections: [
849
+ {
850
+ title: "Basics",
851
+ columns: 2,
852
+ fields: [
853
+ { field: "title", span: 2 },
854
+ // Locks once the decode confirms a match — no point letting
855
+ // the user hand-edit a VIN the provider just validated.
856
+ { field: "vin", readOnly: { field: "decodeStatus", eq: "hit" } },
857
+ // A VIN hit reveals + requires the derived trim field; a car
858
+ // whose VIN the provider can't resolve never shows it.
859
+ {
860
+ field: "trim",
861
+ visible: { field: "decodeStatus", eq: "hit" },
862
+ required: { field: "decodeStatus", eq: "hit" },
863
+ },
864
+ ],
865
+ },
866
+ {
867
+ kind: "extension",
868
+ title: "VIN Decode",
869
+ component: { react: { __component: "VinDecodeSection" } },
870
+ },
871
+ ],
872
+ },
873
+ };
874
+ }
875
+
876
+ function VinDecodeSection({
877
+ values,
878
+ patch,
879
+ }: {
880
+ readonly values?: Readonly<Record<string, unknown>>;
881
+ readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
882
+ }) {
883
+ return (
884
+ <div data-testid="vin-decode-section">
885
+ <span data-testid="decode-status">{String(values?.["decodeStatus"])}</span>
886
+ <button
887
+ type="button"
888
+ data-testid="decode-hit"
889
+ onClick={() => patch?.({ decodeStatus: "hit", trim: "Sport" })}
890
+ >
891
+ Decode (match found)
892
+ </button>
893
+ <button
894
+ type="button"
895
+ data-testid="decode-miss"
896
+ onClick={() => patch?.({ decodeStatus: "miss" })}
897
+ >
898
+ Decode (no match)
899
+ </button>
900
+ <button
901
+ type="button"
902
+ data-testid="decode-clear"
903
+ onClick={() => patch?.({ decodeStatus: undefined, trim: undefined })}
904
+ >
905
+ Clear decode
906
+ </button>
907
+ </div>
908
+ );
909
+ }
910
+
911
+ function renderVehicleForm(): void {
912
+ render(
913
+ <DispatcherProvider dispatcher={makeDispatcher()}>
914
+ <ExtensionSectionsProvider value={{ VinDecodeSection }}>
915
+ <RenderEdit<VehicleValues>
916
+ screen={makeVehicleScreen()}
917
+ entity={vehicleEntity}
918
+ featureName="vehicles"
919
+ initial={{ title: "Listing", vin: "" }}
920
+ writeCommand="vehicle:create"
921
+ />
922
+ </ExtensionSectionsProvider>
923
+ </DispatcherProvider>,
924
+ );
925
+ }
926
+
927
+ function isRequired(testId: string): boolean {
928
+ const label = screen.getByTestId(testId).querySelector("label");
929
+ return (label?.textContent ?? "").includes("*");
930
+ }
931
+
932
+ function isDisabled(testId: string): boolean {
933
+ return (
934
+ (screen.getByTestId(testId).querySelector("input") as HTMLInputElement | null)?.disabled ??
935
+ false
936
+ );
937
+ }
938
+
939
+ test("provider delivers a value: the gated field becomes visible and required, the source field locks", () => {
940
+ renderVehicleForm();
941
+ expect(screen.queryByTestId("field-trim")).toBeNull();
942
+ expect(isDisabled("field-vin")).toBe(false);
943
+
944
+ act(() => {
945
+ fireEvent.click(screen.getByTestId("decode-hit"));
946
+ });
947
+
948
+ expect(screen.getByTestId("decode-status").textContent).toBe("hit");
949
+ const trimInput = screen.getByTestId("field-trim").querySelector("input") as HTMLInputElement;
950
+ expect(trimInput.value).toBe("Sport");
951
+ expect(isRequired("field-trim")).toBe(true);
952
+ expect(isDisabled("field-vin")).toBe(true);
953
+ });
954
+
955
+ test("provider delivers nothing (patch() without a matching condition value): gated fields stay normal", () => {
956
+ renderVehicleForm();
957
+
958
+ act(() => {
959
+ fireEvent.click(screen.getByTestId("decode-miss"));
960
+ });
961
+
962
+ // Proves the patch landed (decodeStatus really changed to "miss"),
963
+ // ruling out a false pass from a no-op patch that never re-rendered.
964
+ expect(screen.getByTestId("decode-status").textContent).toBe("miss");
965
+ expect(screen.queryByTestId("field-trim")).toBeNull();
966
+ expect(isDisabled("field-vin")).toBe(false);
967
+ });
968
+
969
+ test("a previously delivered value cleared by a later patch() falls the fields back", () => {
970
+ renderVehicleForm();
971
+
972
+ act(() => {
973
+ fireEvent.click(screen.getByTestId("decode-hit"));
974
+ });
975
+ expect(screen.getByTestId("field-trim")).toBeTruthy();
976
+ expect(isDisabled("field-vin")).toBe(true);
977
+
978
+ act(() => {
979
+ fireEvent.click(screen.getByTestId("decode-clear"));
980
+ });
981
+
982
+ expect(screen.getByTestId("decode-status").textContent).toBe("undefined");
983
+ expect(screen.queryByTestId("field-trim")).toBeNull();
984
+ expect(isDisabled("field-vin")).toBe(false);
985
+ });
986
+ });
987
+
988
+ describe("RenderEdit wizard mode", () => {
989
+ function makeWizardScreen(): EntityEditScreenDefinition {
990
+ return {
991
+ id: "orders:screen:order-wizard",
992
+ type: "entityEdit",
993
+ entity: "order",
994
+ layout: {
995
+ mode: "wizard",
996
+ sections: [
997
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
998
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
999
+ ],
1000
+ },
1001
+ };
1002
+ }
1003
+
1004
+ test("renders only the current step's section", () => {
1005
+ render(
1006
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1007
+ <RenderEdit<TestValues>
1008
+ screen={makeWizardScreen()}
1009
+ entity={orderEntity}
1010
+ featureName="orders"
1011
+ initial={{ title: "", count: 0 }}
1012
+ writeCommand="order:create"
1013
+ />
1014
+ </DispatcherProvider>,
1015
+ );
1016
+
1017
+ expect(screen.getByTestId("field-title")).toBeTruthy();
1018
+ expect(screen.queryByTestId("field-count")).toBeNull();
1019
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
1020
+ });
1021
+
1022
+ test("Weiter is blocked by a field validation error and does not advance the step", async () => {
1023
+ const schema = z.object({
1024
+ title: z.string().min(1),
1025
+ count: z.number().optional(),
1026
+ });
1027
+ render(
1028
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1029
+ <RenderEdit<TestValues>
1030
+ screen={makeWizardScreen()}
1031
+ entity={orderEntity}
1032
+ featureName="orders"
1033
+ initial={{ title: "", count: 0 }}
1034
+ writeCommand="order:create"
1035
+ schema={schema}
1036
+ />
1037
+ </DispatcherProvider>,
1038
+ );
1039
+
1040
+ const form = screen.getByTestId("render-edit-form");
1041
+ await act(async () => {
1042
+ fireEvent.submit(form);
1043
+ await Promise.resolve();
1044
+ });
1045
+
1046
+ expect(screen.getByTestId("field-title-errors")).toBeTruthy();
1047
+ expect(screen.getByTestId("field-title")).toBeTruthy();
1048
+ expect(screen.queryByTestId("field-count")).toBeNull();
1049
+ });
1050
+
1051
+ test("Weiter advances to the next step once the current step is valid; last step shows the submit button", async () => {
1052
+ const schema = z.object({
1053
+ title: z.string().min(1),
1054
+ count: z.number().optional(),
1055
+ });
1056
+ render(
1057
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1058
+ <RenderEdit<TestValues>
1059
+ screen={makeWizardScreen()}
1060
+ entity={orderEntity}
1061
+ featureName="orders"
1062
+ initial={{ title: "", count: 0 }}
1063
+ writeCommand="order:create"
1064
+ schema={schema}
1065
+ />
1066
+ </DispatcherProvider>,
1067
+ );
1068
+
1069
+ expect(screen.queryByTestId("render-edit-submit")).toBeNull();
1070
+
1071
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1072
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
1073
+
1074
+ const form = screen.getByTestId("render-edit-form");
1075
+ await act(async () => {
1076
+ fireEvent.submit(form);
1077
+ await Promise.resolve();
1078
+ });
1079
+
1080
+ expect(screen.queryByTestId("field-title")).toBeNull();
1081
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1082
+ expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
1083
+ expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
1084
+ });
1085
+
1086
+ test("Zurück preserves already-entered values without validating", async () => {
1087
+ // count.min(1) with initial count=0 makes step 2 invalid on arrival —
1088
+ // if Back ran validate() it would be blocked from returning to step 1.
1089
+ const schema = z.object({
1090
+ title: z.string().min(1),
1091
+ count: z.number().min(1),
1092
+ });
1093
+ render(
1094
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1095
+ <RenderEdit<TestValues>
1096
+ screen={makeWizardScreen()}
1097
+ entity={orderEntity}
1098
+ featureName="orders"
1099
+ initial={{ title: "", count: 0 }}
1100
+ writeCommand="order:create"
1101
+ schema={schema}
1102
+ />
1103
+ </DispatcherProvider>,
1104
+ );
1105
+
1106
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1107
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
1108
+
1109
+ const form = screen.getByTestId("render-edit-form");
1110
+ await act(async () => {
1111
+ fireEvent.submit(form);
1112
+ await Promise.resolve();
1113
+ });
1114
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1115
+
1116
+ fireEvent.click(screen.getByTestId("render-edit-wizard-back"));
1117
+
1118
+ const titleInputAgain = screen
1119
+ .getByTestId("field-title")
1120
+ .querySelector("input") as HTMLInputElement;
1121
+ expect(titleInputAgain.value).toBe("Acme");
1122
+ expect(screen.queryByTestId("field-title-errors")).toBeNull();
1123
+ });
1124
+ });
1125
+
1126
+ describe("RenderEdit wizard draft", () => {
1127
+ function makeDraftWizardScreen(draft: boolean): EntityEditScreenDefinition {
1128
+ return {
1129
+ id: "orders:screen:order-wizard-draft",
1130
+ type: "entityEdit",
1131
+ entity: "order",
1132
+ layout: {
1133
+ mode: "wizard",
1134
+ ...(draft && { draft: true }),
1135
+ sections: [
1136
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
1137
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1138
+ ],
1139
+ },
1140
+ };
1141
+ }
1142
+
1143
+ type DraftBlob = { readonly values: Record<string, unknown>; readonly stepIndex: number };
1144
+
1145
+ function isDraftSavePayload(payload: unknown): payload is DraftBlob {
1146
+ return (
1147
+ typeof payload === "object" &&
1148
+ payload !== null &&
1149
+ "values" in payload &&
1150
+ "stepIndex" in payload
1151
+ );
1152
+ }
1153
+
1154
+ // In-memory fake of the bundled form-draft feature's query/write handlers —
1155
+ // proves RenderEdit round-trips through the dispatcher (real values, real
1156
+ // step), not just that it calls the right command names.
1157
+ function makeDraftDispatcher(): {
1158
+ readonly dispatcher: Dispatcher;
1159
+ readonly store: { current: DraftBlob | null };
1160
+ readonly calls: string[];
1161
+ } {
1162
+ const store: { current: DraftBlob | null } = { current: null };
1163
+ const calls: string[] = [];
1164
+ const dispatcher = createMockDispatcher({
1165
+ query: (async (type: string) => {
1166
+ calls.push(type);
1167
+ if (type === "form-draft:query:get") {
1168
+ return { isSuccess: true, data: { draft: store.current } };
1169
+ }
1170
+ return { isSuccess: true, data: {} };
1171
+ }) as Dispatcher["query"],
1172
+ write: (async (type: string, payload: unknown) => {
1173
+ calls.push(type);
1174
+ if (type === "form-draft:write:save" && isDraftSavePayload(payload)) {
1175
+ store.current = { values: payload.values, stepIndex: payload.stepIndex };
1176
+ } else if (type === "form-draft:write:discard") {
1177
+ store.current = null;
1178
+ }
1179
+ return { isSuccess: true, data: { id: "1" } };
1180
+ }) as Dispatcher["write"],
1181
+ });
1182
+ return { dispatcher, store, calls };
1183
+ }
1184
+
1185
+ test("values and step survive a remount", async () => {
1186
+ const { dispatcher } = makeDraftDispatcher();
1187
+ // Simulates the browser: the same DraftStorage instance (sessionStorage
1188
+ // in production) survives the remount below, RenderEdit's in-memory
1189
+ // React state does not. Without it there's no draftId to resume from
1190
+ // and the mount would fall back to `form-draft:query:list` instead —
1191
+ // this test is specifically about the storage-resume path.
1192
+ const draftStorage = createFakeDraftStorage();
1193
+
1194
+ const first = render(
1195
+ <DispatcherProvider dispatcher={dispatcher}>
1196
+ <DraftStorageProvider value={draftStorage}>
1197
+ <RenderEdit<TestValues>
1198
+ screen={makeDraftWizardScreen(true)}
1199
+ entity={orderEntity}
1200
+ featureName="orders"
1201
+ initial={{ title: "", count: 0 }}
1202
+ writeCommand="order:create"
1203
+ />
1204
+ </DraftStorageProvider>
1205
+ </DispatcherProvider>,
1206
+ );
1207
+
1208
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1209
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
1210
+
1211
+ const form = screen.getByTestId("render-edit-form");
1212
+ await act(async () => {
1213
+ fireEvent.submit(form);
1214
+ await Promise.resolve();
1215
+ });
1216
+
1217
+ first.unmount();
1218
+
1219
+ render(
1220
+ <DispatcherProvider dispatcher={dispatcher}>
1221
+ <DraftStorageProvider value={draftStorage}>
1222
+ <RenderEdit<TestValues>
1223
+ screen={makeDraftWizardScreen(true)}
1224
+ entity={orderEntity}
1225
+ featureName="orders"
1226
+ initial={{ title: "", count: 0 }}
1227
+ writeCommand="order:create"
1228
+ />
1229
+ </DraftStorageProvider>
1230
+ </DispatcherProvider>,
1231
+ );
1232
+
1233
+ await waitFor(() => expect(screen.getByTestId("field-count")).toBeTruthy());
1234
+ expect(screen.queryByTestId("field-title")).toBeNull();
1235
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
1236
+
1237
+ fireEvent.click(screen.getByTestId("render-edit-wizard-back"));
1238
+ const titleInputAgain = screen
1239
+ .getByTestId("field-title")
1240
+ .querySelector("input") as HTMLInputElement;
1241
+ expect(titleInputAgain.value).toBe("Acme");
1242
+ });
1243
+
1244
+ test("a successful submit discards the draft", async () => {
1245
+ const { dispatcher, store, calls } = makeDraftDispatcher();
1246
+
1247
+ render(
1248
+ <DispatcherProvider dispatcher={dispatcher}>
1249
+ <RenderEdit<TestValues>
1250
+ screen={makeDraftWizardScreen(true)}
1251
+ entity={orderEntity}
1252
+ featureName="orders"
1253
+ initial={{ title: "", count: 0 }}
1254
+ writeCommand="order:create"
1255
+ />
1256
+ </DispatcherProvider>,
1257
+ );
1258
+
1259
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1260
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
1261
+
1262
+ const form = screen.getByTestId("render-edit-form");
1263
+ await act(async () => {
1264
+ fireEvent.submit(form);
1265
+ await Promise.resolve();
1266
+ });
1267
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1268
+ expect(store.current).not.toBeNull();
1269
+
1270
+ await act(async () => {
1271
+ fireEvent.submit(form);
1272
+ await Promise.resolve();
1273
+ });
1274
+
1275
+ expect(store.current).toBeNull();
1276
+ expect(calls).toContain("form-draft:write:discard");
1277
+ });
1278
+
1279
+ test("without layout.draft nothing hits the form-draft feature", async () => {
1280
+ const { dispatcher, calls } = makeDraftDispatcher();
1281
+
1282
+ render(
1283
+ <DispatcherProvider dispatcher={dispatcher}>
1284
+ <RenderEdit<TestValues>
1285
+ screen={makeDraftWizardScreen(false)}
1286
+ entity={orderEntity}
1287
+ featureName="orders"
1288
+ initial={{ title: "", count: 0 }}
1289
+ writeCommand="order:create"
1290
+ />
1291
+ </DispatcherProvider>,
1292
+ );
1293
+
1294
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1295
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
1296
+
1297
+ const form = screen.getByTestId("render-edit-form");
1298
+ await act(async () => {
1299
+ fireEvent.submit(form);
1300
+ await Promise.resolve();
1301
+ });
1302
+
1303
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1304
+ expect(calls.some((c) => c.startsWith("form-draft:"))).toBe(false);
1305
+ });
1306
+ });
1307
+
1308
+ describe("RenderEdit create-mode draftId (issue #1913)", () => {
1309
+ function makeDraftWizardScreen(): EntityEditScreenDefinition {
1310
+ return {
1311
+ id: "orders:screen:order-wizard-draftid",
1312
+ type: "entityEdit",
1313
+ entity: "order",
1314
+ layout: {
1315
+ mode: "wizard",
1316
+ draft: true,
1317
+ sections: [
1318
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
1319
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1320
+ ],
1321
+ },
1322
+ };
1323
+ }
1324
+
1325
+ type DraftBlob = { readonly values: Record<string, unknown>; readonly stepIndex: number };
1326
+ type StoredDraft = DraftBlob & { readonly savedAt: string };
1327
+
1328
+ // Per-draftKey fake (unlike the single-slot `makeDraftDispatcher` above) —
1329
+ // needed to prove two parallel create sessions land on two distinct rows,
1330
+ // and to back the `form-draft:query:list` fallback (multiple candidates,
1331
+ // picker, adopt).
1332
+ function makeMultiDraftDispatcher(): {
1333
+ readonly dispatcher: Dispatcher;
1334
+ readonly drafts: Map<string, StoredDraft>;
1335
+ } {
1336
+ const drafts = new Map<string, StoredDraft>();
1337
+ let seq = 0;
1338
+ const dispatcher = createMockDispatcher({
1339
+ query: (async (type: string, payload: unknown) => {
1340
+ if (type === "form-draft:query:get") {
1341
+ const { draftKey } = payload as { draftKey: string };
1342
+ return { isSuccess: true, data: { draft: drafts.get(draftKey) ?? null } };
1343
+ }
1344
+ if (type === "form-draft:query:list") {
1345
+ const { screenId } = payload as { screenId: string };
1346
+ const prefix = `${screenId}:`;
1347
+ const matches = [...drafts.entries()]
1348
+ .filter(([key]) => key.startsWith(prefix))
1349
+ .map(([key, draft]) => ({
1350
+ id: key,
1351
+ draftKey: key,
1352
+ stepIndex: draft.stepIndex,
1353
+ savedAt: draft.savedAt,
1354
+ }));
1355
+ return { isSuccess: true, data: { drafts: matches } };
1356
+ }
1357
+ return { isSuccess: true, data: {} };
1358
+ }) as Dispatcher["query"],
1359
+ write: (async (type: string, payload: unknown) => {
1360
+ if (type === "form-draft:write:save") {
1361
+ const { draftKey, values, stepIndex } = payload as {
1362
+ draftKey: string;
1363
+ values: Record<string, unknown>;
1364
+ stepIndex: number;
1365
+ };
1366
+ seq += 1;
1367
+ drafts.set(draftKey, {
1368
+ values,
1369
+ stepIndex,
1370
+ savedAt: `2026-01-01T00:00:${String(seq).padStart(2, "0")}Z`,
1371
+ });
1372
+ } else if (type === "form-draft:write:discard") {
1373
+ const { draftKey } = payload as { draftKey: string };
1374
+ drafts.delete(draftKey);
1375
+ }
1376
+ return { isSuccess: true, data: { id: "1" } };
1377
+ }) as Dispatcher["write"],
1378
+ });
1379
+ return { dispatcher, drafts };
1380
+ }
1381
+
1382
+ test("two parallel create sessions on the same screen get different draftKeys and don't overwrite each other (#1908)", async () => {
1383
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
1384
+ const screenDef = makeDraftWizardScreen();
1385
+
1386
+ const sessionA = render(
1387
+ <DispatcherProvider dispatcher={dispatcher}>
1388
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1389
+ <RenderEdit<TestValues>
1390
+ screen={screenDef}
1391
+ entity={orderEntity}
1392
+ featureName="orders"
1393
+ initial={{ title: "", count: 0 }}
1394
+ writeCommand="order:create"
1395
+ />
1396
+ </DraftStorageProvider>
1397
+ </DispatcherProvider>,
1398
+ );
1399
+ const sessionB = render(
1400
+ <DispatcherProvider dispatcher={dispatcher}>
1401
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1402
+ <RenderEdit<TestValues>
1403
+ screen={screenDef}
1404
+ entity={orderEntity}
1405
+ featureName="orders"
1406
+ initial={{ title: "", count: 0 }}
1407
+ writeCommand="order:create"
1408
+ />
1409
+ </DraftStorageProvider>
1410
+ </DispatcherProvider>,
1411
+ );
1412
+
1413
+ const titleA = within(sessionA.container)
1414
+ .getByTestId("field-title")
1415
+ .querySelector("input") as HTMLInputElement;
1416
+ fireEvent.change(titleA, { target: { value: "Session A" } });
1417
+ await act(async () => {
1418
+ fireEvent.submit(within(sessionA.container).getByTestId("render-edit-form"));
1419
+ await Promise.resolve();
1420
+ });
1421
+
1422
+ const titleB = within(sessionB.container)
1423
+ .getByTestId("field-title")
1424
+ .querySelector("input") as HTMLInputElement;
1425
+ fireEvent.change(titleB, { target: { value: "Session B" } });
1426
+ await act(async () => {
1427
+ fireEvent.submit(within(sessionB.container).getByTestId("render-edit-form"));
1428
+ await Promise.resolve();
1429
+ });
1430
+
1431
+ const prefix = `${screenDef.id}:new:`;
1432
+ const createDraftKeys = [...drafts.keys()].filter((k) => k.startsWith(prefix));
1433
+ expect(createDraftKeys).toHaveLength(2);
1434
+ const [keyA, keyB] = createDraftKeys as [string, string];
1435
+ expect(keyA).not.toBe(keyB);
1436
+ const titles = createDraftKeys.map((k) => drafts.get(k)?.values["title"]).sort();
1437
+ expect(titles).toEqual(["Session A", "Session B"]);
1438
+ });
1439
+
1440
+ test("cleared storage with exactly one open draft resumes it automatically via list", async () => {
1441
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
1442
+ const screenDef = makeDraftWizardScreen();
1443
+ // Pre-seed one existing create-mode draft, as if minted by an earlier,
1444
+ // now-storage-less session (new tab / cleared sessionStorage).
1445
+ drafts.set(`${screenDef.id}:new:existing-id`, {
1446
+ values: { title: "Resumed", count: 0 },
1447
+ stepIndex: 0,
1448
+ savedAt: "2026-01-01T00:00:00Z",
1449
+ });
1450
+
1451
+ render(
1452
+ <DispatcherProvider dispatcher={dispatcher}>
1453
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1454
+ <RenderEdit<TestValues>
1455
+ screen={screenDef}
1456
+ entity={orderEntity}
1457
+ featureName="orders"
1458
+ initial={{ title: "", count: 0 }}
1459
+ writeCommand="order:create"
1460
+ />
1461
+ </DraftStorageProvider>
1462
+ </DispatcherProvider>,
1463
+ );
1464
+
1465
+ await waitFor(() => {
1466
+ const titleInput = screen
1467
+ .getByTestId("field-title")
1468
+ .querySelector("input") as HTMLInputElement;
1469
+ expect(titleInput.value).toBe("Resumed");
1470
+ });
1471
+ expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
1472
+ });
1473
+
1474
+ test("cleared storage with multiple open drafts shows a picker; picking one resumes it", async () => {
1475
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
1476
+ const screenDef = makeDraftWizardScreen();
1477
+ drafts.set(`${screenDef.id}:new:draft-1`, {
1478
+ values: { title: "First draft", count: 0 },
1479
+ stepIndex: 0,
1480
+ savedAt: "2026-01-01T00:00:00Z",
1481
+ });
1482
+ drafts.set(`${screenDef.id}:new:draft-2`, {
1483
+ values: { title: "Second draft", count: 0 },
1484
+ stepIndex: 0,
1485
+ savedAt: "2026-01-02T00:00:00Z",
1486
+ });
1487
+
1488
+ render(
1489
+ <DispatcherProvider dispatcher={dispatcher}>
1490
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1491
+ <RenderEdit<TestValues>
1492
+ screen={screenDef}
1493
+ entity={orderEntity}
1494
+ featureName="orders"
1495
+ initial={{ title: "", count: 0 }}
1496
+ writeCommand="order:create"
1497
+ />
1498
+ </DraftStorageProvider>
1499
+ </DispatcherProvider>,
1500
+ );
1501
+
1502
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
1503
+ const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
1504
+ fireEvent.click(pickSecond);
1505
+
1506
+ await waitFor(() => {
1507
+ const titleInput = screen
1508
+ .getByTestId("field-title")
1509
+ .querySelector("input") as HTMLInputElement;
1510
+ expect(titleInput.value).toBe("Second draft");
1511
+ });
1512
+ expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
1513
+ });
1514
+
1515
+ test("minting a draftId clears a stale picker — a candidate can't hijack the just-minted key", async () => {
1516
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
1517
+ const screenDef = makeDraftWizardScreen();
1518
+ drafts.set(`${screenDef.id}:new:draft-1`, {
1519
+ values: { title: "First draft", count: 0 },
1520
+ stepIndex: 0,
1521
+ savedAt: "2026-01-01T00:00:00Z",
1522
+ });
1523
+ drafts.set(`${screenDef.id}:new:draft-2`, {
1524
+ values: { title: "Second draft", count: 0 },
1525
+ stepIndex: 0,
1526
+ savedAt: "2026-01-02T00:00:00Z",
1527
+ });
1528
+
1529
+ render(
1530
+ <DispatcherProvider dispatcher={dispatcher}>
1531
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1532
+ <RenderEdit<TestValues>
1533
+ screen={screenDef}
1534
+ entity={orderEntity}
1535
+ featureName="orders"
1536
+ initial={{ title: "", count: 0 }}
1537
+ writeCommand="order:create"
1538
+ />
1539
+ </DraftStorageProvider>
1540
+ </DispatcherProvider>,
1541
+ );
1542
+
1543
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
1544
+
1545
+ // The user ignores the picker and starts a genuinely new record instead.
1546
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1547
+ fireEvent.change(titleInput, { target: { value: "Fresh session" } });
1548
+ await act(async () => {
1549
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1550
+ await Promise.resolve();
1551
+ });
1552
+
1553
+ // The stale picker must not survive the mint — picking draft-1/draft-2
1554
+ // afterwards would repoint draftKey at an unrelated draft mid-edit.
1555
+ expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
1556
+
1557
+ const prefix = `${screenDef.id}:new:`;
1558
+ const mintedKeys = [...drafts.keys()].filter(
1559
+ (k) => k.startsWith(prefix) && k !== `${prefix}draft-1` && k !== `${prefix}draft-2`,
1560
+ );
1561
+ expect(mintedKeys).toHaveLength(1);
1562
+ const [mintedKey] = mintedKeys as [string];
1563
+ expect(drafts.get(mintedKey)?.values["title"]).toBe("Fresh session");
1564
+ // The two pre-existing candidates are untouched by the fresh mint.
1565
+ expect(drafts.get(`${prefix}draft-1`)?.values["title"]).toBe("First draft");
1566
+ expect(drafts.get(`${prefix}draft-2`)?.values["title"]).toBe("Second draft");
1567
+ });
1568
+
1569
+ test("discarding a draft doesn't re-arm the list fallback and silently adopt a parallel draft", async () => {
1570
+ const { dispatcher: baseDispatcher, drafts } = makeMultiDraftDispatcher();
1571
+ const screenDef = makeDraftWizardScreen();
1572
+ // A parallel create session on the same screen, still open.
1573
+ drafts.set(`${screenDef.id}:new:other-session`, {
1574
+ values: { title: "Someone else's draft", count: 0 },
1575
+ stepIndex: 0,
1576
+ savedAt: "2026-01-01T00:00:00Z",
1577
+ });
1578
+
1579
+ // Counts `form-draft:query:list` calls directly — the mechanism under
1580
+ // test (didListRef) gates exactly this call, so this is a more precise
1581
+ // signal than any DOM side effect of a (possibly delayed) re-adopt.
1582
+ let listCallCount = 0;
1583
+ const dispatcher: Dispatcher = {
1584
+ ...baseDispatcher,
1585
+ query: (async (type: string, payload: unknown) => {
1586
+ if (type === "form-draft:query:list") listCallCount += 1;
1587
+ return (baseDispatcher.query as (t: string, p: unknown) => Promise<unknown>)(type, payload);
1588
+ }) as Dispatcher["query"],
1589
+ };
1590
+
1591
+ render(
1592
+ <DispatcherProvider dispatcher={dispatcher}>
1593
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1594
+ <RenderEdit<TestValues>
1595
+ screen={screenDef}
1596
+ entity={orderEntity}
1597
+ featureName="orders"
1598
+ initial={{ title: "", count: 0 }}
1599
+ writeCommand="order:create"
1600
+ />
1601
+ </DraftStorageProvider>
1602
+ </DispatcherProvider>,
1603
+ );
1604
+
1605
+ // The lone open draft (`other-session`) auto-adopts on mount — the same
1606
+ // path a cleared-storage tab takes. Wait for that to settle first.
1607
+ await waitFor(() => {
1608
+ const titleInput = screen
1609
+ .getByTestId("field-title")
1610
+ .querySelector("input") as HTMLInputElement;
1611
+ expect(titleInput.value).toBe("Someone else's draft");
1612
+ });
1613
+ expect(listCallCount).toBe(1);
1614
+
1615
+ // The user overwrites the adopted draft with their own new record,
1616
+ // steps through the wizard, and submits on the last step — a real
1617
+ // submit (not just a draft-save Next), which discards the (now
1618
+ // theirs) draftId.
1619
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1620
+ fireEvent.change(titleInput, { target: { value: "My own record" } });
1621
+ await act(async () => {
1622
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1623
+ await Promise.resolve();
1624
+ });
1625
+ const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
1626
+ fireEvent.change(countInput, { target: { value: "5" } });
1627
+ await act(async () => {
1628
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1629
+ await Promise.resolve();
1630
+ });
1631
+
1632
+ expect(drafts.has(`${screenDef.id}:new:other-session`)).toBe(false);
1633
+
1634
+ // A second parallel draft appears on the same screen right after submit
1635
+ // (e.g. another tab). The just-discarded instance's draftId reset to
1636
+ // null must not re-run the list fallback and silently repopulate the
1637
+ // form with it — a re-arm would auto-adopt it, jump the wizard back to
1638
+ // its saved step (0) and overwrite the just-submitted values, which
1639
+ // would put step 0's "field-title" section back on screen.
1640
+ drafts.set(`${screenDef.id}:new:yet-another-session`, {
1641
+ values: { title: "A completely different draft", count: 0 },
1642
+ stepIndex: 0,
1643
+ savedAt: "2026-01-03T00:00:00Z",
1644
+ });
1645
+ // Flush thoroughly (not just one microtask tick) — a re-armed effect's
1646
+ // full chain (query → filter → setDraftId → re-render → GET restore →
1647
+ // setValues) needs several turns to complete, and this assertion must
1648
+ // hold even after every one of them ran.
1649
+ await act(async () => {
1650
+ await new Promise((resolve) => setTimeout(resolve, 20));
1651
+ });
1652
+
1653
+ expect(listCallCount).toBe(1);
1654
+ expect(screen.queryByTestId("field-title")).toBeNull();
1655
+ expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
1656
+ const countAfterSubmit = screen
1657
+ .getByTestId("field-count")
1658
+ .querySelector("input") as HTMLInputElement;
1659
+ expect(countAfterSubmit.value).toBe("5");
1660
+ });
1661
+
1662
+ test("edit-mode draftKey stays screenId:entityId — unaffected by create-mode draftId minting", async () => {
1663
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
1664
+ const screenDef = makeDraftWizardScreen();
1665
+
1666
+ render(
1667
+ <DispatcherProvider dispatcher={dispatcher}>
1668
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1669
+ <RenderEdit<TestValues>
1670
+ screen={screenDef}
1671
+ entity={orderEntity}
1672
+ featureName="orders"
1673
+ initial={{ title: "Existing", count: 5 }}
1674
+ entityId="order-77"
1675
+ writeCommand="order:update"
1676
+ />
1677
+ </DraftStorageProvider>
1678
+ </DispatcherProvider>,
1679
+ );
1680
+
1681
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1682
+ fireEvent.change(titleInput, { target: { value: "Existing edited" } });
1683
+ await act(async () => {
1684
+ fireEvent.submit(screen.getByTestId("render-edit-form"));
1685
+ await Promise.resolve();
1686
+ });
1687
+
1688
+ expect([...drafts.keys()]).toEqual([`${screenDef.id}:order-77`]);
1689
+ });
1690
+ });
1691
+
1692
+ describe("RenderEdit locked state (#1896)", () => {
1693
+ test("disabled renders every field inactive and the submit button inactive", () => {
1694
+ render(
1695
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1696
+ <RenderEdit<TestValues>
1697
+ screen={makeScreen()}
1698
+ entity={orderEntity}
1699
+ featureName="orders"
1700
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
1701
+ writeCommand="order:create"
1702
+ disabled
1703
+ />
1704
+ </DispatcherProvider>,
1705
+ );
1706
+
1707
+ const titleInput = screen.getByTestId("field-title").querySelector("input");
1708
+ expect((titleInput as HTMLInputElement).disabled).toBe(true);
1709
+ const countInput = screen.getByTestId("field-count").querySelector("input");
1710
+ expect((countInput as HTMLInputElement).disabled).toBe(true);
1711
+ const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
1712
+ expect((urgentCheckbox as HTMLButtonElement).disabled).toBe(true);
1713
+ expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
1714
+ });
1715
+
1716
+ test("disabled blocks the write even on a direct form submit (Enter key), not just via the button", async () => {
1717
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
1718
+ render(
1719
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
1720
+ <RenderEdit<TestValues>
1721
+ screen={makeScreen()}
1722
+ entity={orderEntity}
1723
+ featureName="orders"
1724
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
1725
+ writeCommand="order:create"
1726
+ disabled
1727
+ />
1728
+ </DispatcherProvider>,
1729
+ );
1730
+
1731
+ const form = screen.getByTestId("render-edit-form");
1732
+ await act(async () => {
1733
+ fireEvent.submit(form);
1734
+ await Promise.resolve();
1735
+ });
1736
+
1737
+ expect(write).not.toHaveBeenCalled();
1738
+ });
1739
+
1740
+ test("without disabled, fields and submit stay active (existing behaviour unchanged)", () => {
1741
+ render(
1742
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1743
+ <RenderEdit<TestValues>
1744
+ screen={makeScreen()}
1745
+ entity={orderEntity}
1746
+ featureName="orders"
1747
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
1748
+ writeCommand="order:create"
1749
+ />
1750
+ </DispatcherProvider>,
1751
+ );
1752
+
1753
+ const titleInput = screen.getByTestId("field-title").querySelector("input");
1754
+ expect((titleInput as HTMLInputElement).disabled).toBe(false);
1755
+ });
1756
+ });
1757
+
1758
+ describe("RenderEdit fields filter", () => {
1759
+ const filterEntity = {
1760
+ fields: {
1761
+ title: { type: "text", required: true },
1762
+ count: { type: "number" },
1763
+ notes: { type: "text" },
1764
+ },
1765
+ } as unknown as EntityDefinition;
1766
+
1767
+ function makeTwoSectionScreen(): EntityEditScreenDefinition {
1768
+ return {
1769
+ id: "orders:screen:order-edit-filter",
1770
+ type: "entityEdit",
1771
+ entity: "order",
1772
+ layout: {
1773
+ sections: [
1774
+ { title: "Basics", columns: 1, fields: ["title"] },
1775
+ { title: "Extra", columns: 2, fields: ["count", "notes"] },
1776
+ ],
1777
+ },
1778
+ };
1779
+ }
1780
+
1781
+ type FilterValues = { title: string; count?: number; notes?: string };
1782
+
1783
+ test("fields prop renders only the listed fields; others are absent from the DOM", () => {
1784
+ render(
1785
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1786
+ <RenderEdit<FilterValues>
1787
+ screen={makeTwoSectionScreen()}
1788
+ entity={filterEntity}
1789
+ featureName="orders"
1790
+ initial={{ title: "", count: 0, notes: "" }}
1791
+ writeCommand="order:create"
1792
+ fields={["title"]}
1793
+ />
1794
+ </DispatcherProvider>,
1795
+ );
1796
+
1797
+ expect(screen.getByTestId("field-title")).toBeTruthy();
1798
+ expect(screen.queryByTestId("field-count")).toBeNull();
1799
+ expect(screen.queryByTestId("field-notes")).toBeNull();
1800
+ });
1801
+
1802
+ test("a section with no fields left after filtering renders no section container at all", () => {
1803
+ render(
1804
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1805
+ <RenderEdit<FilterValues>
1806
+ screen={makeTwoSectionScreen()}
1807
+ entity={filterEntity}
1808
+ featureName="orders"
1809
+ initial={{ title: "", count: 0, notes: "" }}
1810
+ writeCommand="order:create"
1811
+ fields={["title"]}
1812
+ />
1813
+ </DispatcherProvider>,
1814
+ );
1815
+
1816
+ expect(screen.getByTestId("section-Basics")).toBeTruthy();
1817
+ expect(screen.queryByTestId("section-Extra")).toBeNull();
1818
+ });
1819
+
1820
+ test("a schema-required field outside `fields` does not block submit", async () => {
1821
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
1822
+ const schema = z.object({
1823
+ title: z.string().min(1),
1824
+ notes: z.string().min(1),
1825
+ });
1826
+
1827
+ render(
1828
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
1829
+ <RenderEdit<FilterValues>
1830
+ screen={makeTwoSectionScreen()}
1831
+ entity={filterEntity}
1832
+ featureName="orders"
1833
+ initial={{ title: "Acme", count: 0, notes: "" }}
1834
+ writeCommand="order:create"
1835
+ schema={schema}
1836
+ fields={["title"]}
1837
+ />
1838
+ </DispatcherProvider>,
1839
+ );
1840
+
1841
+ // `notes` is required by the schema and empty, but it's filtered out of
1842
+ // the rendered form — the user has no way to fix it, so it must not
1843
+ // block the submit.
1844
+ expect(screen.queryByTestId("field-notes")).toBeNull();
1845
+
1846
+ const form = screen.getByTestId("render-edit-form");
1847
+ await act(async () => {
1848
+ fireEvent.submit(form);
1849
+ await Promise.resolve();
1850
+ });
1851
+
1852
+ expect(write).toHaveBeenCalledTimes(1);
1853
+ expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
1854
+ });
1855
+
1856
+ test("a schema-required field inside `fields` still blocks submit", async () => {
1857
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
1858
+ const schema = z.object({
1859
+ title: z.string().min(1),
1860
+ notes: z.string().min(1),
1861
+ });
1862
+
1863
+ render(
1864
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
1865
+ <RenderEdit<FilterValues>
1866
+ screen={makeTwoSectionScreen()}
1867
+ entity={filterEntity}
1868
+ featureName="orders"
1869
+ initial={{ title: "Acme", count: 0, notes: "" }}
1870
+ writeCommand="order:create"
1871
+ schema={schema}
1872
+ fields={["title", "notes"]}
1873
+ />
1874
+ </DispatcherProvider>,
1875
+ );
1876
+
1877
+ expect(screen.getByTestId("field-notes")).toBeTruthy();
1878
+
1879
+ const form = screen.getByTestId("render-edit-form");
1880
+ await act(async () => {
1881
+ fireEvent.submit(form);
1882
+ await Promise.resolve();
1883
+ });
1884
+
1885
+ expect(write).not.toHaveBeenCalled();
1886
+ expect(screen.getByTestId("field-notes-errors")).toBeTruthy();
1887
+ });
1888
+ });