@fogpipe/forma-react 0.16.0 → 0.17.1
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/README.md +29 -26
- package/dist/index.d.ts +46 -6
- package/dist/index.js +95 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FieldRenderer.tsx +34 -4
- package/src/FormRenderer.tsx +47 -9
- package/src/__tests__/FieldRenderer.test.tsx +186 -0
- package/src/__tests__/FormRenderer.test.tsx +146 -0
- package/src/__tests__/canProceed.test.ts +243 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/events.ts +4 -1
- package/src/index.ts +2 -0
- package/src/types.ts +43 -4
- package/src/useForma.ts +48 -34
package/src/FieldRenderer.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ArrayFieldProps,
|
|
25
25
|
ArrayHelpers,
|
|
26
26
|
DisplayFieldProps,
|
|
27
|
+
MatrixFieldProps,
|
|
27
28
|
} from "./types.js";
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -131,6 +132,9 @@ export function FieldRenderer({
|
|
|
131
132
|
|
|
132
133
|
const errors = forma.errors.filter((e) => e.field === fieldPath);
|
|
133
134
|
const touched = forma.touched[fieldPath] ?? false;
|
|
135
|
+
// FieldRenderer doesn't have access to validateOn (it's a FormRenderer prop),
|
|
136
|
+
// so it uses the default "blur" behavior: show errors after touch or submit.
|
|
137
|
+
const visibleErrors = touched || forma.isSubmitted ? errors : [];
|
|
134
138
|
const required = forma.required[fieldPath] ?? false;
|
|
135
139
|
const disabled = forma.enabled[fieldPath] === false;
|
|
136
140
|
|
|
@@ -147,6 +151,7 @@ export function FieldRenderer({
|
|
|
147
151
|
required,
|
|
148
152
|
disabled,
|
|
149
153
|
errors,
|
|
154
|
+
visibleErrors,
|
|
150
155
|
onChange: (value: unknown) => forma.setFieldValue(fieldPath, value),
|
|
151
156
|
onBlur: () => forma.setFieldTouched(fieldPath),
|
|
152
157
|
// Convenience properties
|
|
@@ -175,7 +180,8 @@ export function FieldRenderer({
|
|
|
175
180
|
| SelectFieldProps
|
|
176
181
|
| MultiSelectFieldProps
|
|
177
182
|
| ArrayFieldProps
|
|
178
|
-
| DisplayFieldProps
|
|
183
|
+
| DisplayFieldProps
|
|
184
|
+
| MatrixFieldProps = baseProps;
|
|
179
185
|
|
|
180
186
|
if (fieldType === "number") {
|
|
181
187
|
const constraints = getNumberConstraints(schemaProperty);
|
|
@@ -313,17 +319,41 @@ export function FieldRenderer({
|
|
|
313
319
|
minItems,
|
|
314
320
|
maxItems,
|
|
315
321
|
} as ArrayFieldProps;
|
|
322
|
+
} else if (fieldType === "matrix" && fieldDef.type === "matrix") {
|
|
323
|
+
// Matrix fields — compute visible rows from visibility engine
|
|
324
|
+
const matrixValue =
|
|
325
|
+
(baseProps.value as Record<
|
|
326
|
+
string,
|
|
327
|
+
string | number | string[] | number[]
|
|
328
|
+
> | null) ?? null;
|
|
329
|
+
const rows = fieldDef.rows.map((row) => ({
|
|
330
|
+
id: row.id,
|
|
331
|
+
label: row.label,
|
|
332
|
+
visible: forma.visibility[`${fieldPath}.${row.id}`] !== false,
|
|
333
|
+
}));
|
|
334
|
+
fieldProps = {
|
|
335
|
+
...baseProps,
|
|
336
|
+
fieldType: "matrix",
|
|
337
|
+
value: matrixValue,
|
|
338
|
+
onChange: baseProps.onChange as (
|
|
339
|
+
value: Record<string, string | number | string[] | number[]>,
|
|
340
|
+
) => void,
|
|
341
|
+
rows,
|
|
342
|
+
columns: fieldDef.columns,
|
|
343
|
+
multiSelect: fieldDef.multiSelect ?? false,
|
|
344
|
+
} as MatrixFieldProps;
|
|
316
345
|
} else if (fieldType === "display" && fieldDef.type === "display") {
|
|
317
346
|
// Display fields (read-only presentation content)
|
|
318
347
|
const sourceValue = fieldDef.source
|
|
319
348
|
? (forma.data[fieldDef.source] ?? forma.computed[fieldDef.source])
|
|
320
349
|
: undefined;
|
|
321
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
322
350
|
const {
|
|
323
|
-
onChange: _onChange,
|
|
324
|
-
value: _value,
|
|
351
|
+
onChange: _onChange, // omit from display props
|
|
352
|
+
value: _value, // omit from display props
|
|
325
353
|
...displayBaseProps
|
|
326
354
|
} = baseProps;
|
|
355
|
+
void _onChange;
|
|
356
|
+
void _value;
|
|
327
357
|
fieldProps = {
|
|
328
358
|
...displayBaseProps,
|
|
329
359
|
fieldType: "display",
|
package/src/FormRenderer.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import type {
|
|
|
34
34
|
ArrayFieldProps,
|
|
35
35
|
ArrayHelpers,
|
|
36
36
|
DisplayFieldProps,
|
|
37
|
+
MatrixFieldProps,
|
|
37
38
|
} from "./types.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -85,12 +86,7 @@ export interface FormRendererHandle {
|
|
|
85
86
|
*/
|
|
86
87
|
function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
|
|
87
88
|
return (
|
|
88
|
-
<form
|
|
89
|
-
onSubmit={(e) => {
|
|
90
|
-
e.preventDefault();
|
|
91
|
-
onSubmit();
|
|
92
|
-
}}
|
|
93
|
-
>
|
|
89
|
+
<form onSubmit={onSubmit}>
|
|
94
90
|
{children}
|
|
95
91
|
<button type="submit" disabled={isSubmitting}>
|
|
96
92
|
{isSubmitting ? "Submitting..." : "Submit"}
|
|
@@ -241,7 +237,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
241
237
|
layout: Layout = DefaultLayout,
|
|
242
238
|
fieldWrapper: FieldWrapper = DefaultFieldWrapper,
|
|
243
239
|
pageWrapper: PageWrapper = DefaultPageWrapper,
|
|
244
|
-
validateOn,
|
|
240
|
+
validateOn = "blur",
|
|
245
241
|
} = props;
|
|
246
242
|
|
|
247
243
|
const forma = useForma({
|
|
@@ -298,6 +294,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
298
294
|
optionsVisibility: formaOptionsVisibility,
|
|
299
295
|
touched: formaTouched,
|
|
300
296
|
errors: formaErrors,
|
|
297
|
+
isSubmitted: formaIsSubmitted,
|
|
301
298
|
setFieldValue,
|
|
302
299
|
setFieldTouched,
|
|
303
300
|
getArrayHelpers,
|
|
@@ -341,6 +338,11 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
341
338
|
|
|
342
339
|
const errors = formaErrors.filter((e) => e.field === fieldPath);
|
|
343
340
|
const touched = formaTouched[fieldPath] ?? false;
|
|
341
|
+
const showErrors =
|
|
342
|
+
validateOn === "change" ||
|
|
343
|
+
(validateOn === "blur" && touched) ||
|
|
344
|
+
formaIsSubmitted;
|
|
345
|
+
const visibleErrors = showErrors ? errors : [];
|
|
344
346
|
const required = formaRequired[fieldPath] ?? false;
|
|
345
347
|
const disabled = formaEnabled[fieldPath] === false;
|
|
346
348
|
|
|
@@ -366,6 +368,7 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
366
368
|
required,
|
|
367
369
|
disabled,
|
|
368
370
|
errors,
|
|
371
|
+
visibleErrors,
|
|
369
372
|
onChange: (value: unknown) => setFieldValue(fieldPath, value),
|
|
370
373
|
onBlur: () => setFieldTouched(fieldPath),
|
|
371
374
|
// Convenience properties
|
|
@@ -392,7 +395,8 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
392
395
|
| NumberFieldProps
|
|
393
396
|
| SelectFieldProps
|
|
394
397
|
| ArrayFieldProps
|
|
395
|
-
| DisplayFieldProps
|
|
398
|
+
| DisplayFieldProps
|
|
399
|
+
| MatrixFieldProps = baseProps;
|
|
396
400
|
|
|
397
401
|
if (fieldType === "number" || fieldType === "integer") {
|
|
398
402
|
const constraints = getNumberConstraints(schemaProperty);
|
|
@@ -483,6 +487,28 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
483
487
|
minItems,
|
|
484
488
|
maxItems,
|
|
485
489
|
} as ArrayFieldProps;
|
|
490
|
+
} else if (fieldType === "matrix" && fieldDef.type === "matrix") {
|
|
491
|
+
const matrixValue =
|
|
492
|
+
(baseProps.value as Record<
|
|
493
|
+
string,
|
|
494
|
+
string | number | string[] | number[]
|
|
495
|
+
> | null) ?? null;
|
|
496
|
+
const rows = fieldDef.rows.map((row) => ({
|
|
497
|
+
id: row.id,
|
|
498
|
+
label: row.label,
|
|
499
|
+
visible: formaVisibility[`${fieldPath}.${row.id}`] !== false,
|
|
500
|
+
}));
|
|
501
|
+
fieldProps = {
|
|
502
|
+
...baseProps,
|
|
503
|
+
fieldType: "matrix",
|
|
504
|
+
value: matrixValue,
|
|
505
|
+
onChange: baseProps.onChange as (
|
|
506
|
+
value: Record<string, string | number | string[] | number[]>,
|
|
507
|
+
) => void,
|
|
508
|
+
rows,
|
|
509
|
+
columns: fieldDef.columns,
|
|
510
|
+
multiSelect: fieldDef.multiSelect ?? false,
|
|
511
|
+
} as MatrixFieldProps;
|
|
486
512
|
} else if (fieldType === "display" && fieldDef.type === "display") {
|
|
487
513
|
// Display fields (read-only presentation content)
|
|
488
514
|
// Resolve source value if the display field has a source property
|
|
@@ -553,6 +579,8 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
553
579
|
formaOptionsVisibility,
|
|
554
580
|
formaTouched,
|
|
555
581
|
formaErrors,
|
|
582
|
+
formaIsSubmitted,
|
|
583
|
+
validateOn,
|
|
556
584
|
setFieldValue,
|
|
557
585
|
setFieldTouched,
|
|
558
586
|
getArrayHelpers,
|
|
@@ -586,10 +614,20 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
586
614
|
return <>{renderedFields}</>;
|
|
587
615
|
}, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
|
|
588
616
|
|
|
617
|
+
// Wrap submitForm to always call preventDefault when invoked from a form event.
|
|
618
|
+
// This prevents page refreshes when consumers put onSubmit on a <form> element.
|
|
619
|
+
const handleSubmit = useCallback(
|
|
620
|
+
(e?: React.FormEvent) => {
|
|
621
|
+
e?.preventDefault();
|
|
622
|
+
forma.submitForm();
|
|
623
|
+
},
|
|
624
|
+
[forma.submitForm],
|
|
625
|
+
);
|
|
626
|
+
|
|
589
627
|
return (
|
|
590
628
|
<FormaContext.Provider value={forma}>
|
|
591
629
|
<Layout
|
|
592
|
-
onSubmit={
|
|
630
|
+
onSubmit={handleSubmit}
|
|
593
631
|
isSubmitting={forma.isSubmitting}
|
|
594
632
|
isValid={forma.isValid}
|
|
595
633
|
>
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ArrayComponentProps,
|
|
23
23
|
ComponentMap,
|
|
24
24
|
LayoutProps,
|
|
25
|
+
MatrixComponentProps,
|
|
25
26
|
NumberComponentProps,
|
|
26
27
|
IntegerComponentProps,
|
|
27
28
|
} from "../types.js";
|
|
@@ -714,4 +715,189 @@ describe("FieldRenderer", () => {
|
|
|
714
715
|
});
|
|
715
716
|
});
|
|
716
717
|
});
|
|
718
|
+
|
|
719
|
+
// ============================================================================
|
|
720
|
+
// Matrix Field Rendering
|
|
721
|
+
// ============================================================================
|
|
722
|
+
|
|
723
|
+
describe("matrix field rendering", () => {
|
|
724
|
+
it("should pass matrix props to matrix component", () => {
|
|
725
|
+
let capturedProps: MatrixComponentProps["field"] | null = null;
|
|
726
|
+
|
|
727
|
+
const spec: Forma = {
|
|
728
|
+
version: "1.0",
|
|
729
|
+
meta: { id: "test", title: "Test" },
|
|
730
|
+
schema: {
|
|
731
|
+
type: "object",
|
|
732
|
+
properties: {
|
|
733
|
+
rating: {
|
|
734
|
+
type: "object",
|
|
735
|
+
properties: {
|
|
736
|
+
speed: { type: "integer", enum: [1, 2, 3] },
|
|
737
|
+
quality: { type: "integer", enum: [1, 2, 3] },
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
fields: {
|
|
743
|
+
rating: {
|
|
744
|
+
type: "matrix",
|
|
745
|
+
label: "Rating",
|
|
746
|
+
rows: [
|
|
747
|
+
{ id: "speed", label: "Speed" },
|
|
748
|
+
{ id: "quality", label: "Quality" },
|
|
749
|
+
],
|
|
750
|
+
columns: [
|
|
751
|
+
{ value: 1, label: "Poor" },
|
|
752
|
+
{ value: 2, label: "OK" },
|
|
753
|
+
{ value: 3, label: "Great" },
|
|
754
|
+
],
|
|
755
|
+
multiSelect: false,
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
fieldOrder: ["rating"],
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const components: ComponentMap = {
|
|
762
|
+
...createTestComponentMap(),
|
|
763
|
+
matrix: ({ field: props }: MatrixComponentProps) => {
|
|
764
|
+
capturedProps = props;
|
|
765
|
+
return <div data-testid="matrix-field">matrix</div>;
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
render(
|
|
770
|
+
<FormRenderer
|
|
771
|
+
spec={spec}
|
|
772
|
+
initialData={{ rating: { speed: 2, quality: 3 } }}
|
|
773
|
+
components={components}
|
|
774
|
+
/>,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
expect(capturedProps).not.toBeNull();
|
|
778
|
+
expect(capturedProps!.fieldType).toBe("matrix");
|
|
779
|
+
expect(capturedProps!.value).toEqual({ speed: 2, quality: 3 });
|
|
780
|
+
expect(capturedProps!.rows).toEqual([
|
|
781
|
+
{ id: "speed", label: "Speed", visible: true },
|
|
782
|
+
{ id: "quality", label: "Quality", visible: true },
|
|
783
|
+
]);
|
|
784
|
+
expect(capturedProps!.columns).toEqual([
|
|
785
|
+
{ value: 1, label: "Poor" },
|
|
786
|
+
{ value: 2, label: "OK" },
|
|
787
|
+
{ value: 3, label: "Great" },
|
|
788
|
+
]);
|
|
789
|
+
expect(capturedProps!.multiSelect).toBe(false);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("should compute row visibility from visibleWhen expressions", () => {
|
|
793
|
+
let capturedProps: MatrixComponentProps["field"] | null = null;
|
|
794
|
+
|
|
795
|
+
const spec: Forma = {
|
|
796
|
+
version: "1.0",
|
|
797
|
+
meta: { id: "test", title: "Test" },
|
|
798
|
+
schema: {
|
|
799
|
+
type: "object",
|
|
800
|
+
properties: {
|
|
801
|
+
show_quality: { type: "boolean" },
|
|
802
|
+
rating: {
|
|
803
|
+
type: "object",
|
|
804
|
+
properties: {
|
|
805
|
+
speed: { type: "integer", enum: [1, 2, 3] },
|
|
806
|
+
quality: { type: "integer", enum: [1, 2, 3] },
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
fields: {
|
|
812
|
+
show_quality: { type: "boolean", label: "Show Quality" },
|
|
813
|
+
rating: {
|
|
814
|
+
type: "matrix",
|
|
815
|
+
label: "Rating",
|
|
816
|
+
rows: [
|
|
817
|
+
{ id: "speed", label: "Speed" },
|
|
818
|
+
{
|
|
819
|
+
id: "quality",
|
|
820
|
+
label: "Quality",
|
|
821
|
+
visibleWhen: "show_quality = true",
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
columns: [
|
|
825
|
+
{ value: 1, label: "Poor" },
|
|
826
|
+
{ value: 2, label: "OK" },
|
|
827
|
+
{ value: 3, label: "Great" },
|
|
828
|
+
],
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
fieldOrder: ["show_quality", "rating"],
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const components: ComponentMap = {
|
|
835
|
+
...createTestComponentMap(),
|
|
836
|
+
matrix: ({ field: props }: MatrixComponentProps) => {
|
|
837
|
+
capturedProps = props;
|
|
838
|
+
return <div data-testid="matrix-field">matrix</div>;
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
render(
|
|
843
|
+
<FormRenderer
|
|
844
|
+
spec={spec}
|
|
845
|
+
initialData={{ show_quality: false }}
|
|
846
|
+
components={components}
|
|
847
|
+
/>,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
expect(capturedProps).not.toBeNull();
|
|
851
|
+
expect(capturedProps!.rows).toEqual([
|
|
852
|
+
{ id: "speed", label: "Speed", visible: true },
|
|
853
|
+
{ id: "quality", label: "Quality", visible: false },
|
|
854
|
+
]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("should default multiSelect to false when not specified", () => {
|
|
858
|
+
let capturedProps: MatrixComponentProps["field"] | null = null;
|
|
859
|
+
|
|
860
|
+
const spec: Forma = {
|
|
861
|
+
version: "1.0",
|
|
862
|
+
meta: { id: "test", title: "Test" },
|
|
863
|
+
schema: {
|
|
864
|
+
type: "object",
|
|
865
|
+
properties: {
|
|
866
|
+
rating: {
|
|
867
|
+
type: "object",
|
|
868
|
+
properties: {
|
|
869
|
+
a: { type: "integer", enum: [1, 2] },
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
fields: {
|
|
875
|
+
rating: {
|
|
876
|
+
type: "matrix",
|
|
877
|
+
label: "Rating",
|
|
878
|
+
rows: [{ id: "a", label: "A" }],
|
|
879
|
+
columns: [
|
|
880
|
+
{ value: 1, label: "1" },
|
|
881
|
+
{ value: 2, label: "2" },
|
|
882
|
+
],
|
|
883
|
+
// multiSelect not set
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
fieldOrder: ["rating"],
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const components: ComponentMap = {
|
|
890
|
+
...createTestComponentMap(),
|
|
891
|
+
matrix: ({ field: props }: MatrixComponentProps) => {
|
|
892
|
+
capturedProps = props;
|
|
893
|
+
return <div data-testid="matrix-field">matrix</div>;
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
render(<FormRenderer spec={spec} components={components} />);
|
|
898
|
+
|
|
899
|
+
expect(capturedProps).not.toBeNull();
|
|
900
|
+
expect(capturedProps!.multiSelect).toBe(false);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
717
903
|
});
|
|
@@ -1386,4 +1386,150 @@ describe("FormRenderer", () => {
|
|
|
1386
1386
|
});
|
|
1387
1387
|
});
|
|
1388
1388
|
});
|
|
1389
|
+
|
|
1390
|
+
// ============================================================================
|
|
1391
|
+
// onSubmit preventDefault
|
|
1392
|
+
// ============================================================================
|
|
1393
|
+
|
|
1394
|
+
describe("onSubmit preventDefault", () => {
|
|
1395
|
+
it("should call preventDefault when onSubmit is invoked with a form event", async () => {
|
|
1396
|
+
const submitHandler = vi.fn();
|
|
1397
|
+
|
|
1398
|
+
// Custom layout that puts onSubmit directly on a <form> element
|
|
1399
|
+
function CustomLayout({ children, onSubmit }: LayoutProps) {
|
|
1400
|
+
return (
|
|
1401
|
+
<form onSubmit={onSubmit} data-testid="form">
|
|
1402
|
+
{children}
|
|
1403
|
+
<button type="submit">Submit</button>
|
|
1404
|
+
</form>
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const spec = createTestSpec({
|
|
1409
|
+
fields: {
|
|
1410
|
+
name: { type: "text", label: "Name" },
|
|
1411
|
+
},
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
render(
|
|
1415
|
+
<FormRenderer
|
|
1416
|
+
spec={spec}
|
|
1417
|
+
components={createTestComponentMap()}
|
|
1418
|
+
layout={CustomLayout}
|
|
1419
|
+
onSubmit={submitHandler}
|
|
1420
|
+
initialData={{ name: "John" }}
|
|
1421
|
+
/>,
|
|
1422
|
+
);
|
|
1423
|
+
|
|
1424
|
+
const form = screen.getByTestId("form");
|
|
1425
|
+
|
|
1426
|
+
// Create a real submit event and spy on preventDefault
|
|
1427
|
+
const preventDefaultSpy = vi.fn();
|
|
1428
|
+
const submitEvent = new Event("submit", {
|
|
1429
|
+
bubbles: true,
|
|
1430
|
+
cancelable: true,
|
|
1431
|
+
});
|
|
1432
|
+
Object.defineProperty(submitEvent, "preventDefault", {
|
|
1433
|
+
value: preventDefaultSpy,
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
await act(async () => {
|
|
1437
|
+
form.dispatchEvent(submitEvent);
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
// preventDefault should have been called by the wrapper
|
|
1441
|
+
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it("should work when onSubmit is called without an event (programmatic)", async () => {
|
|
1445
|
+
const submitHandler = vi.fn();
|
|
1446
|
+
|
|
1447
|
+
// Custom layout that calls onSubmit programmatically (no event)
|
|
1448
|
+
function CustomLayout({ children, onSubmit }: LayoutProps) {
|
|
1449
|
+
return (
|
|
1450
|
+
<div>
|
|
1451
|
+
{children}
|
|
1452
|
+
<button
|
|
1453
|
+
type="button"
|
|
1454
|
+
onClick={() => onSubmit()}
|
|
1455
|
+
data-testid="submit-btn"
|
|
1456
|
+
>
|
|
1457
|
+
Submit
|
|
1458
|
+
</button>
|
|
1459
|
+
</div>
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const spec = createTestSpec({
|
|
1464
|
+
fields: {
|
|
1465
|
+
name: { type: "text", label: "Name" },
|
|
1466
|
+
},
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
render(
|
|
1470
|
+
<FormRenderer
|
|
1471
|
+
spec={spec}
|
|
1472
|
+
components={createTestComponentMap()}
|
|
1473
|
+
layout={CustomLayout}
|
|
1474
|
+
onSubmit={submitHandler}
|
|
1475
|
+
initialData={{ name: "John" }}
|
|
1476
|
+
/>,
|
|
1477
|
+
);
|
|
1478
|
+
|
|
1479
|
+
await act(async () => {
|
|
1480
|
+
screen.getByTestId("submit-btn").click();
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
await waitFor(() => {
|
|
1484
|
+
expect(submitHandler).toHaveBeenCalledWith({ name: "John" });
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it("LayoutProps.onSubmit accepts an optional event parameter", () => {
|
|
1489
|
+
// Type-level test: verify the signature compiles
|
|
1490
|
+
const _layout: React.FC<LayoutProps> = ({ onSubmit }) => {
|
|
1491
|
+
return (
|
|
1492
|
+
<form onSubmit={onSubmit}>
|
|
1493
|
+
<button onClick={() => onSubmit()}>Submit</button>
|
|
1494
|
+
</form>
|
|
1495
|
+
);
|
|
1496
|
+
};
|
|
1497
|
+
expect(_layout).toBeDefined();
|
|
1498
|
+
});
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
// ============================================================================
|
|
1502
|
+
// visibleErrors
|
|
1503
|
+
// ============================================================================
|
|
1504
|
+
|
|
1505
|
+
describe("visibleErrors", () => {
|
|
1506
|
+
it("FormRenderer passes visibleErrors to components", () => {
|
|
1507
|
+
const spec = createTestSpec({
|
|
1508
|
+
fields: {
|
|
1509
|
+
name: { type: "text", label: "Name", required: true },
|
|
1510
|
+
},
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
let capturedVisibleErrors: unknown[] | undefined;
|
|
1514
|
+
|
|
1515
|
+
const components = {
|
|
1516
|
+
...createTestComponentMap(),
|
|
1517
|
+
text: ({ field: props }: { field: { visibleErrors?: unknown[] } }) => {
|
|
1518
|
+
capturedVisibleErrors = props.visibleErrors;
|
|
1519
|
+
return <div data-testid="text-field">text</div>;
|
|
1520
|
+
},
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
render(
|
|
1524
|
+
<FormRenderer
|
|
1525
|
+
spec={spec}
|
|
1526
|
+
components={components as ReturnType<typeof createTestComponentMap>}
|
|
1527
|
+
validateOn="blur"
|
|
1528
|
+
/>,
|
|
1529
|
+
);
|
|
1530
|
+
|
|
1531
|
+
// visibleErrors should be an empty array (field not touched, validateOn=blur)
|
|
1532
|
+
expect(capturedVisibleErrors).toEqual([]);
|
|
1533
|
+
});
|
|
1534
|
+
});
|
|
1389
1535
|
});
|