@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.
- package/package.json +4 -4
- package/src/__tests__/config-edit.test.tsx +66 -0
- package/src/__tests__/render-edit.test.tsx +1368 -1
- package/src/__tests__/test-utils.tsx +24 -0
- package/src/__tests__/wizard-form-validation.test.tsx +73 -0
- package/src/app/create-app.tsx +33 -20
- package/src/app/draft-storage.ts +59 -0
- package/src/index.ts +4 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +14 -1
- package/src/primitives/embedded-list-input.tsx +3 -2
- package/src/primitives/index.tsx +7 -0
- package/src/primitives/money-input.tsx +5 -9
|
@@ -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 {
|
|
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
|
+
});
|