@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.
@@ -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", () => { calls.push(1); });
41
- emitter.on("fieldChanged", () => { calls.push(2); });
42
- emitter.on("fieldChanged", () => { calls.push(3); });
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: () => { events.push("fieldChanged"); },
586
- formReset: () => { events.push("formReset"); },
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
- // Initially no errors shown (not touched)
616
- expect(result.current.getFieldProps("name").errors).toEqual([]);
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, errors should show
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 shown
643
+ // Errors exist (eager validation) but visibleErrors is empty (not touched)
640
644
  expect(result.current.isValid).toBe(false);
641
- expect(result.current.getFieldProps("name").errors).toEqual([]);
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>(event: K, payload: FormaEventMap[K]): void {
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
@@ -86,6 +86,8 @@ export type {
86
86
  ComputedComponentProps,
87
87
  DisplayFieldProps,
88
88
  DisplayComponentProps,
89
+ MatrixFieldProps,
90
+ MatrixComponentProps,
89
91
  ComponentMap,
90
92
 
91
93
  // Form state
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 */