@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
|
@@ -915,4 +915,247 @@ describe("canProceed", () => {
|
|
|
915
915
|
expect(result.current.wizard?.currentPage?.id).toBe("page1");
|
|
916
916
|
});
|
|
917
917
|
});
|
|
918
|
+
|
|
919
|
+
describe("handleNext - safe wizard navigation", () => {
|
|
920
|
+
it("handleNext advances page but does NOT call onSubmit", async () => {
|
|
921
|
+
const submitHandler = vi.fn();
|
|
922
|
+
|
|
923
|
+
const spec = createTestSpec({
|
|
924
|
+
fields: {
|
|
925
|
+
name: { type: "text", label: "Name" },
|
|
926
|
+
email: { type: "email", label: "Email" },
|
|
927
|
+
phone: { type: "text", label: "Phone" },
|
|
928
|
+
},
|
|
929
|
+
pages: [
|
|
930
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
931
|
+
{ id: "page2", title: "Page 2", fields: ["email"] },
|
|
932
|
+
{ id: "page3", title: "Page 3", fields: ["phone"] },
|
|
933
|
+
],
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
const { result } = renderHook(() =>
|
|
937
|
+
useForma({
|
|
938
|
+
spec,
|
|
939
|
+
initialData: {
|
|
940
|
+
name: "John",
|
|
941
|
+
email: "john@test.com",
|
|
942
|
+
phone: "123",
|
|
943
|
+
},
|
|
944
|
+
onSubmit: submitHandler,
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
// On page 1
|
|
949
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
950
|
+
expect(result.current.wizard?.isLastPage).toBe(false);
|
|
951
|
+
|
|
952
|
+
// Use handleNext from page 1 → page 2
|
|
953
|
+
act(() => {
|
|
954
|
+
result.current.wizard?.handleNext();
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
958
|
+
expect(submitHandler).not.toHaveBeenCalled();
|
|
959
|
+
|
|
960
|
+
// Use handleNext from page 2 → page 3 (last page)
|
|
961
|
+
act(() => {
|
|
962
|
+
result.current.wizard?.handleNext();
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
expect(result.current.wizard?.currentPageIndex).toBe(2);
|
|
966
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
967
|
+
// Critically: onSubmit was NOT called
|
|
968
|
+
expect(submitHandler).not.toHaveBeenCalled();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("handleNext does nothing on the last page", () => {
|
|
972
|
+
const spec = createTestSpec({
|
|
973
|
+
fields: {
|
|
974
|
+
name: { type: "text", label: "Name" },
|
|
975
|
+
email: { type: "email", label: "Email" },
|
|
976
|
+
},
|
|
977
|
+
pages: [
|
|
978
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
979
|
+
{ id: "page2", title: "Page 2", fields: ["email"] },
|
|
980
|
+
],
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
984
|
+
|
|
985
|
+
// Navigate to last page
|
|
986
|
+
act(() => {
|
|
987
|
+
result.current.wizard?.nextPage();
|
|
988
|
+
});
|
|
989
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
990
|
+
|
|
991
|
+
// handleNext on last page — should stay on last page
|
|
992
|
+
act(() => {
|
|
993
|
+
result.current.wizard?.handleNext();
|
|
994
|
+
});
|
|
995
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
996
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("nextPage() from second-to-last page does not trigger submitForm", () => {
|
|
1000
|
+
const submitHandler = vi.fn();
|
|
1001
|
+
|
|
1002
|
+
const spec = createTestSpec({
|
|
1003
|
+
fields: {
|
|
1004
|
+
name: { type: "text", label: "Name" },
|
|
1005
|
+
email: { type: "email", label: "Email" },
|
|
1006
|
+
phone: { type: "text", label: "Phone" },
|
|
1007
|
+
},
|
|
1008
|
+
pages: [
|
|
1009
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
1010
|
+
{ id: "page2", title: "Page 2", fields: ["email"] },
|
|
1011
|
+
{ id: "page3", title: "Page 3", fields: ["phone"] },
|
|
1012
|
+
],
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const { result } = renderHook(() =>
|
|
1016
|
+
useForma({
|
|
1017
|
+
spec,
|
|
1018
|
+
initialData: { name: "John", email: "test@test.com" },
|
|
1019
|
+
onSubmit: submitHandler,
|
|
1020
|
+
}),
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
// Navigate to page 2 (second-to-last)
|
|
1024
|
+
act(() => {
|
|
1025
|
+
result.current.wizard?.nextPage();
|
|
1026
|
+
});
|
|
1027
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
1028
|
+
|
|
1029
|
+
// Navigate from page 2 → page 3 (last)
|
|
1030
|
+
act(() => {
|
|
1031
|
+
result.current.wizard?.nextPage();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Should be on last page but NOT have submitted
|
|
1035
|
+
expect(result.current.wizard?.currentPageIndex).toBe(2);
|
|
1036
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
1037
|
+
expect(submitHandler).not.toHaveBeenCalled();
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
describe("untouched required fields", () => {
|
|
1042
|
+
it("canProceed is false when required fields have no initialData", () => {
|
|
1043
|
+
const spec = createTestSpec({
|
|
1044
|
+
fields: {
|
|
1045
|
+
name: { type: "text", label: "Name", required: true },
|
|
1046
|
+
email: { type: "email", label: "Email", required: true },
|
|
1047
|
+
},
|
|
1048
|
+
pages: [{ id: "page1", title: "Page 1", fields: ["name", "email"] }],
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// No initialData at all
|
|
1052
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1053
|
+
|
|
1054
|
+
// canProceed should be false immediately — fields are untouched and empty
|
|
1055
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1056
|
+
expect(result.current.touched.name).toBeUndefined();
|
|
1057
|
+
expect(result.current.touched.email).toBeUndefined();
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it("canProceed is false with explicit empty initialData", () => {
|
|
1061
|
+
const spec = createTestSpec({
|
|
1062
|
+
fields: {
|
|
1063
|
+
name: { type: "text", label: "Name", required: true },
|
|
1064
|
+
email: { type: "email", label: "Email", required: true },
|
|
1065
|
+
},
|
|
1066
|
+
pages: [{ id: "page1", title: "Page 1", fields: ["name", "email"] }],
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
const { result } = renderHook(() => useForma({ spec, initialData: {} }));
|
|
1070
|
+
|
|
1071
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("canProceed becomes true when user fills all required fields", () => {
|
|
1075
|
+
const spec = createTestSpec({
|
|
1076
|
+
fields: {
|
|
1077
|
+
name: { type: "text", label: "Name", required: true },
|
|
1078
|
+
email: { type: "email", label: "Email", required: true },
|
|
1079
|
+
},
|
|
1080
|
+
pages: [{ id: "page1", title: "Page 1", fields: ["name", "email"] }],
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1084
|
+
|
|
1085
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1086
|
+
|
|
1087
|
+
act(() => {
|
|
1088
|
+
result.current.setFieldValue("name", "John");
|
|
1089
|
+
});
|
|
1090
|
+
// Still false — email is empty
|
|
1091
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1092
|
+
|
|
1093
|
+
act(() => {
|
|
1094
|
+
result.current.setFieldValue("email", "john@example.com");
|
|
1095
|
+
});
|
|
1096
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("canProceed is false for required number field with undefined value", () => {
|
|
1100
|
+
const spec = createTestSpec({
|
|
1101
|
+
fields: {
|
|
1102
|
+
age: { type: "number", label: "Age", required: true },
|
|
1103
|
+
},
|
|
1104
|
+
pages: [{ id: "page1", title: "Page 1", fields: ["age"] }],
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1108
|
+
|
|
1109
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it("canProceed is false for required select field with no selection", () => {
|
|
1113
|
+
const spec = createTestSpec({
|
|
1114
|
+
fields: {
|
|
1115
|
+
country: {
|
|
1116
|
+
type: "select",
|
|
1117
|
+
label: "Country",
|
|
1118
|
+
required: true,
|
|
1119
|
+
options: [
|
|
1120
|
+
{ label: "USA", value: "us" },
|
|
1121
|
+
{ label: "Canada", value: "ca" },
|
|
1122
|
+
],
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
pages: [{ id: "page1", title: "Page 1", fields: ["country"] }],
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1129
|
+
|
|
1130
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("touchCurrentPageFields then canProceed still works as expected", () => {
|
|
1134
|
+
const spec = createTestSpec({
|
|
1135
|
+
fields: {
|
|
1136
|
+
name: { type: "text", label: "Name", required: true },
|
|
1137
|
+
},
|
|
1138
|
+
pages: [{ id: "page1", title: "Page 1", fields: ["name"] }],
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const { result } = renderHook(() =>
|
|
1142
|
+
useForma({ spec, validateOn: "blur" }),
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
// canProceed is false before touching
|
|
1146
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1147
|
+
|
|
1148
|
+
// Touch all fields — canProceed should still be false (empty field)
|
|
1149
|
+
act(() => {
|
|
1150
|
+
result.current.wizard?.touchCurrentPageFields();
|
|
1151
|
+
});
|
|
1152
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
1153
|
+
|
|
1154
|
+
// Fill the field
|
|
1155
|
+
act(() => {
|
|
1156
|
+
result.current.setFieldValue("name", "John");
|
|
1157
|
+
});
|
|
1158
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
918
1161
|
});
|
|
@@ -37,9 +37,15 @@ describe("FormaEventEmitter", () => {
|
|
|
37
37
|
|
|
38
38
|
it("should fire multiple listeners in registration order", () => {
|
|
39
39
|
const calls: number[] = [];
|
|
40
|
-
emitter.on("fieldChanged", () => {
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
emitter.on("fieldChanged", () => {
|
|
41
|
+
calls.push(1);
|
|
42
|
+
});
|
|
43
|
+
emitter.on("fieldChanged", () => {
|
|
44
|
+
calls.push(2);
|
|
45
|
+
});
|
|
46
|
+
emitter.on("fieldChanged", () => {
|
|
47
|
+
calls.push(3);
|
|
48
|
+
});
|
|
43
49
|
|
|
44
50
|
emitter.fire("fieldChanged", {
|
|
45
51
|
path: "x",
|
|
@@ -582,8 +588,12 @@ describe("formReset event", () => {
|
|
|
582
588
|
spec,
|
|
583
589
|
initialData: { name: "initial" },
|
|
584
590
|
on: {
|
|
585
|
-
fieldChanged: () => {
|
|
586
|
-
|
|
591
|
+
fieldChanged: () => {
|
|
592
|
+
events.push("fieldChanged");
|
|
593
|
+
},
|
|
594
|
+
formReset: () => {
|
|
595
|
+
events.push("formReset");
|
|
596
|
+
},
|
|
587
597
|
},
|
|
588
598
|
}),
|
|
589
599
|
);
|
|
@@ -612,10 +612,14 @@ describe("useForma", () => {
|
|
|
612
612
|
|
|
613
613
|
const { result } = renderHook(() => useForma({ spec }));
|
|
614
614
|
|
|
615
|
-
//
|
|
616
|
-
expect(
|
|
615
|
+
// errors always contains all validation errors (eager validation)
|
|
616
|
+
expect(
|
|
617
|
+
result.current.getFieldProps("name").errors.length,
|
|
618
|
+
).toBeGreaterThan(0);
|
|
619
|
+
// visibleErrors should be empty (not touched, default validateOn=blur)
|
|
620
|
+
expect(result.current.getFieldProps("name").visibleErrors).toEqual([]);
|
|
617
621
|
|
|
618
|
-
// After submit,
|
|
622
|
+
// After submit, visibleErrors should show
|
|
619
623
|
act(() => {
|
|
620
624
|
result.current.submitForm();
|
|
621
625
|
});
|
|
@@ -636,9 +640,12 @@ describe("useForma", () => {
|
|
|
636
640
|
useForma({ spec, validateOn: "blur" }),
|
|
637
641
|
);
|
|
638
642
|
|
|
639
|
-
// Errors exist but not
|
|
643
|
+
// Errors exist (eager validation) but visibleErrors is empty (not touched)
|
|
640
644
|
expect(result.current.isValid).toBe(false);
|
|
641
|
-
expect(
|
|
645
|
+
expect(
|
|
646
|
+
result.current.getFieldProps("name").errors.length,
|
|
647
|
+
).toBeGreaterThan(0);
|
|
648
|
+
expect(result.current.getFieldProps("name").visibleErrors).toEqual([]);
|
|
642
649
|
});
|
|
643
650
|
|
|
644
651
|
it("should show errors immediately when validateOn: change", () => {
|
|
@@ -1854,4 +1861,100 @@ describe("useForma", () => {
|
|
|
1854
1861
|
).toBe("Item Name");
|
|
1855
1862
|
});
|
|
1856
1863
|
});
|
|
1864
|
+
|
|
1865
|
+
// ============================================================================
|
|
1866
|
+
// visibleErrors
|
|
1867
|
+
// ============================================================================
|
|
1868
|
+
|
|
1869
|
+
describe("visibleErrors", () => {
|
|
1870
|
+
it("visibleErrors is empty for untouched required fields (validateOn=blur)", () => {
|
|
1871
|
+
const spec = createTestSpec({
|
|
1872
|
+
fields: {
|
|
1873
|
+
name: { type: "text", label: "Name", required: true },
|
|
1874
|
+
email: { type: "email", label: "Email", required: true },
|
|
1875
|
+
},
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
const { result } = renderHook(() =>
|
|
1879
|
+
useForma({ spec, validateOn: "blur" }),
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1882
|
+
const nameProps = result.current.getFieldProps("name");
|
|
1883
|
+
|
|
1884
|
+
// errors should contain the required error (validation runs eagerly)
|
|
1885
|
+
expect(nameProps.errors.length).toBeGreaterThan(0);
|
|
1886
|
+
// visibleErrors should be empty (field not touched)
|
|
1887
|
+
expect(nameProps.visibleErrors).toHaveLength(0);
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
it("visibleErrors populates after field is touched", () => {
|
|
1891
|
+
const spec = createTestSpec({
|
|
1892
|
+
fields: {
|
|
1893
|
+
name: { type: "text", label: "Name", required: true },
|
|
1894
|
+
},
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
const { result } = renderHook(() =>
|
|
1898
|
+
useForma({ spec, validateOn: "blur" }),
|
|
1899
|
+
);
|
|
1900
|
+
|
|
1901
|
+
// Before touch
|
|
1902
|
+
expect(result.current.getFieldProps("name").visibleErrors).toHaveLength(
|
|
1903
|
+
0,
|
|
1904
|
+
);
|
|
1905
|
+
|
|
1906
|
+
// Touch the field
|
|
1907
|
+
act(() => {
|
|
1908
|
+
result.current.setFieldTouched("name");
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
// After touch — errors should be visible
|
|
1912
|
+
const props = result.current.getFieldProps("name");
|
|
1913
|
+
expect(props.visibleErrors.length).toBeGreaterThan(0);
|
|
1914
|
+
expect(props.visibleErrors[0].message).toBeDefined();
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
it("visibleErrors populates after form submission", async () => {
|
|
1918
|
+
const spec = createTestSpec({
|
|
1919
|
+
fields: {
|
|
1920
|
+
name: { type: "text", label: "Name", required: true },
|
|
1921
|
+
},
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
const { result } = renderHook(() =>
|
|
1925
|
+
useForma({ spec, validateOn: "blur" }),
|
|
1926
|
+
);
|
|
1927
|
+
|
|
1928
|
+
// Before submit — visibleErrors empty
|
|
1929
|
+
expect(result.current.getFieldProps("name").visibleErrors).toHaveLength(
|
|
1930
|
+
0,
|
|
1931
|
+
);
|
|
1932
|
+
|
|
1933
|
+
// Submit the form (validation will fail)
|
|
1934
|
+
await act(async () => {
|
|
1935
|
+
await result.current.submitForm();
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
// After submit — errors should be visible even without touching
|
|
1939
|
+
expect(
|
|
1940
|
+
result.current.getFieldProps("name").visibleErrors.length,
|
|
1941
|
+
).toBeGreaterThan(0);
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
it("visibleErrors is always populated when validateOn=change", () => {
|
|
1945
|
+
const spec = createTestSpec({
|
|
1946
|
+
fields: {
|
|
1947
|
+
name: { type: "text", label: "Name", required: true },
|
|
1948
|
+
},
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
const { result } = renderHook(() =>
|
|
1952
|
+
useForma({ spec, validateOn: "change" }),
|
|
1953
|
+
);
|
|
1954
|
+
|
|
1955
|
+
// With validateOn="change", errors should be visible immediately
|
|
1956
|
+
const props = result.current.getFieldProps("name");
|
|
1957
|
+
expect(props.visibleErrors.length).toBeGreaterThan(0);
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1857
1960
|
});
|
package/src/events.ts
CHANGED
|
@@ -136,7 +136,10 @@ export class FormaEventEmitter {
|
|
|
136
136
|
* Fire an event synchronously. Listener errors are caught and logged
|
|
137
137
|
* to prevent one listener from breaking others.
|
|
138
138
|
*/
|
|
139
|
-
fire<K extends keyof FormaEventMap>(
|
|
139
|
+
fire<K extends keyof FormaEventMap>(
|
|
140
|
+
event: K,
|
|
141
|
+
payload: FormaEventMap[K],
|
|
142
|
+
): void {
|
|
140
143
|
const set = this.listeners.get(event);
|
|
141
144
|
if (!set || set.size === 0) return;
|
|
142
145
|
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
Forma,
|
|
7
7
|
FieldDefinition,
|
|
8
8
|
FieldError,
|
|
9
|
+
MatrixColumn,
|
|
9
10
|
SelectOption,
|
|
10
11
|
} from "@fogpipe/forma-core";
|
|
11
12
|
|
|
@@ -25,8 +26,13 @@ export interface BaseFieldProps {
|
|
|
25
26
|
required: boolean;
|
|
26
27
|
/** Whether the field is disabled */
|
|
27
28
|
disabled: boolean;
|
|
28
|
-
/** Validation errors for this field */
|
|
29
|
+
/** Validation errors for this field (always populated — use visibleErrors for display) */
|
|
29
30
|
errors: FieldError[];
|
|
31
|
+
/**
|
|
32
|
+
* Errors filtered by interaction state (touched or submitted).
|
|
33
|
+
* Use this for displaying errors in the UI to avoid showing errors on untouched fields.
|
|
34
|
+
*/
|
|
35
|
+
visibleErrors: FieldError[];
|
|
30
36
|
/** Handler for value changes */
|
|
31
37
|
onChange: (value: unknown) => void;
|
|
32
38
|
/** Handler for blur events */
|
|
@@ -311,6 +317,27 @@ export interface DisplayFieldProps extends Omit<
|
|
|
311
317
|
value?: never;
|
|
312
318
|
}
|
|
313
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Props for matrix/grid fields
|
|
322
|
+
*/
|
|
323
|
+
export interface MatrixFieldProps extends Omit<
|
|
324
|
+
BaseFieldProps,
|
|
325
|
+
"value" | "onChange"
|
|
326
|
+
> {
|
|
327
|
+
fieldType: "matrix";
|
|
328
|
+
/** Current matrix value: row ID → selected column value(s) */
|
|
329
|
+
value: Record<string, string | number | string[] | number[]> | null;
|
|
330
|
+
onChange: (
|
|
331
|
+
value: Record<string, string | number | string[] | number[]>,
|
|
332
|
+
) => void;
|
|
333
|
+
/** Row definitions with visibility state */
|
|
334
|
+
rows: Array<{ id: string; label: string; visible: boolean }>;
|
|
335
|
+
/** Column definitions (shared options for all rows) */
|
|
336
|
+
columns: MatrixColumn[];
|
|
337
|
+
/** Whether multiple selections per row are allowed */
|
|
338
|
+
multiSelect: boolean;
|
|
339
|
+
}
|
|
340
|
+
|
|
314
341
|
/**
|
|
315
342
|
* Union of all field prop types
|
|
316
343
|
*/
|
|
@@ -326,7 +353,8 @@ export type FieldProps =
|
|
|
326
353
|
| ArrayFieldProps
|
|
327
354
|
| ObjectFieldProps
|
|
328
355
|
| ComputedFieldProps
|
|
329
|
-
| DisplayFieldProps
|
|
356
|
+
| DisplayFieldProps
|
|
357
|
+
| MatrixFieldProps;
|
|
330
358
|
|
|
331
359
|
/**
|
|
332
360
|
* Map of field types to React components
|
|
@@ -350,6 +378,7 @@ export interface ComponentMap {
|
|
|
350
378
|
object?: React.ComponentType<ObjectComponentProps>;
|
|
351
379
|
computed?: React.ComponentType<ComputedComponentProps>;
|
|
352
380
|
display?: React.ComponentType<DisplayComponentProps>;
|
|
381
|
+
matrix?: React.ComponentType<MatrixComponentProps>;
|
|
353
382
|
fallback?: React.ComponentType<FieldComponentProps>;
|
|
354
383
|
}
|
|
355
384
|
|
|
@@ -358,7 +387,7 @@ export interface ComponentMap {
|
|
|
358
387
|
*/
|
|
359
388
|
export interface LayoutProps {
|
|
360
389
|
children: React.ReactNode;
|
|
361
|
-
onSubmit: () => void;
|
|
390
|
+
onSubmit: (e?: React.FormEvent) => void;
|
|
362
391
|
isSubmitting: boolean;
|
|
363
392
|
isValid: boolean;
|
|
364
393
|
}
|
|
@@ -462,6 +491,11 @@ export interface DisplayComponentProps {
|
|
|
462
491
|
spec: Forma;
|
|
463
492
|
}
|
|
464
493
|
|
|
494
|
+
export interface MatrixComponentProps {
|
|
495
|
+
field: MatrixFieldProps;
|
|
496
|
+
spec: Forma;
|
|
497
|
+
}
|
|
498
|
+
|
|
465
499
|
/**
|
|
466
500
|
* Generic field component props (for fallback/dynamic components)
|
|
467
501
|
*/
|
|
@@ -525,8 +559,13 @@ export interface GetFieldPropsResult {
|
|
|
525
559
|
showRequiredIndicator: boolean;
|
|
526
560
|
/** Whether field has been touched */
|
|
527
561
|
touched: boolean;
|
|
528
|
-
/** Validation errors for this field */
|
|
562
|
+
/** Validation errors for this field (always populated — use visibleErrors for display) */
|
|
529
563
|
errors: FieldError[];
|
|
564
|
+
/**
|
|
565
|
+
* Errors filtered by interaction state (touched or submitted).
|
|
566
|
+
* Use this for displaying errors in the UI to avoid showing errors on untouched fields.
|
|
567
|
+
*/
|
|
568
|
+
visibleErrors: FieldError[];
|
|
530
569
|
/** Handler for value changes */
|
|
531
570
|
onChange: (value: unknown) => void;
|
|
532
571
|
/** Handler for blur events */
|