@fogpipe/forma-react 0.17.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 +19 -3
- package/dist/index.js +63 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FieldRenderer.tsx +12 -4
- package/src/FormRenderer.tsx +26 -9
- package/src/__tests__/FieldRenderer.test.tsx +5 -1
- 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/types.ts +16 -4
- package/src/useForma.ts +48 -34
|
@@ -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/types.ts
CHANGED
|
@@ -26,8 +26,13 @@ export interface BaseFieldProps {
|
|
|
26
26
|
required: boolean;
|
|
27
27
|
/** Whether the field is disabled */
|
|
28
28
|
disabled: boolean;
|
|
29
|
-
/** Validation errors for this field */
|
|
29
|
+
/** Validation errors for this field (always populated — use visibleErrors for display) */
|
|
30
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[];
|
|
31
36
|
/** Handler for value changes */
|
|
32
37
|
onChange: (value: unknown) => void;
|
|
33
38
|
/** Handler for blur events */
|
|
@@ -322,7 +327,9 @@ export interface MatrixFieldProps extends Omit<
|
|
|
322
327
|
fieldType: "matrix";
|
|
323
328
|
/** Current matrix value: row ID → selected column value(s) */
|
|
324
329
|
value: Record<string, string | number | string[] | number[]> | null;
|
|
325
|
-
onChange: (
|
|
330
|
+
onChange: (
|
|
331
|
+
value: Record<string, string | number | string[] | number[]>,
|
|
332
|
+
) => void;
|
|
326
333
|
/** Row definitions with visibility state */
|
|
327
334
|
rows: Array<{ id: string; label: string; visible: boolean }>;
|
|
328
335
|
/** Column definitions (shared options for all rows) */
|
|
@@ -380,7 +387,7 @@ export interface ComponentMap {
|
|
|
380
387
|
*/
|
|
381
388
|
export interface LayoutProps {
|
|
382
389
|
children: React.ReactNode;
|
|
383
|
-
onSubmit: () => void;
|
|
390
|
+
onSubmit: (e?: React.FormEvent) => void;
|
|
384
391
|
isSubmitting: boolean;
|
|
385
392
|
isValid: boolean;
|
|
386
393
|
}
|
|
@@ -552,8 +559,13 @@ export interface GetFieldPropsResult {
|
|
|
552
559
|
showRequiredIndicator: boolean;
|
|
553
560
|
/** Whether field has been touched */
|
|
554
561
|
touched: boolean;
|
|
555
|
-
/** Validation errors for this field */
|
|
562
|
+
/** Validation errors for this field (always populated — use visibleErrors for display) */
|
|
556
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[];
|
|
557
569
|
/** Handler for value changes */
|
|
558
570
|
onChange: (value: unknown) => void;
|
|
559
571
|
/** Handler for blur events */
|
package/src/useForma.ts
CHANGED
|
@@ -117,6 +117,12 @@ export interface WizardHelpers {
|
|
|
117
117
|
goToPage: (index: number) => void;
|
|
118
118
|
nextPage: () => void;
|
|
119
119
|
previousPage: () => void;
|
|
120
|
+
/**
|
|
121
|
+
* Safe "Next" handler for wizard navigation.
|
|
122
|
+
* Advances to the next page if one exists. Never triggers submission.
|
|
123
|
+
* Use this instead of conditionally calling nextPage/onSubmit in a single button.
|
|
124
|
+
*/
|
|
125
|
+
handleNext: () => void;
|
|
120
126
|
hasNextPage: boolean;
|
|
121
127
|
hasPreviousPage: boolean;
|
|
122
128
|
canProceed: boolean;
|
|
@@ -337,10 +343,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
337
343
|
|
|
338
344
|
// Helper: fire an event to both declarative `on` handlers and imperative listeners
|
|
339
345
|
const fireEvent = useCallback(
|
|
340
|
-
<K extends keyof FormaEventMap>(
|
|
341
|
-
event: K,
|
|
342
|
-
payload: FormaEventMap[K],
|
|
343
|
-
) => {
|
|
346
|
+
<K extends keyof FormaEventMap>(event: K, payload: FormaEventMap[K]) => {
|
|
344
347
|
// Declarative handler (via ref for latest callback)
|
|
345
348
|
try {
|
|
346
349
|
const handler = onEventsRef.current?.[event];
|
|
@@ -494,11 +497,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
494
497
|
|
|
495
498
|
// Queue a fieldChanged event (captures previousValue from current state ref)
|
|
496
499
|
const queueFieldChangedEvent = useCallback(
|
|
497
|
-
(
|
|
498
|
-
path: string,
|
|
499
|
-
value: unknown,
|
|
500
|
-
source: "user" | "reset" | "setValues",
|
|
501
|
-
) => {
|
|
500
|
+
(path: string, value: unknown, source: "user" | "reset" | "setValues") => {
|
|
502
501
|
if (isFiringEventsRef.current) return; // recursion guard
|
|
503
502
|
const previousValue = getValueAtPath(path);
|
|
504
503
|
if (previousValue === value) return; // no actual change
|
|
@@ -584,8 +583,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
584
583
|
postSubmitPayload = {
|
|
585
584
|
data: submissionData,
|
|
586
585
|
success: false,
|
|
587
|
-
error:
|
|
588
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
586
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
589
587
|
};
|
|
590
588
|
}
|
|
591
589
|
} else {
|
|
@@ -676,6 +674,21 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
676
674
|
const hasPreviousPage = clampedPageIndex > 0;
|
|
677
675
|
const isLastPage = clampedPageIndex === visiblePages.length - 1;
|
|
678
676
|
|
|
677
|
+
const advanceToNextPage = () => {
|
|
678
|
+
if (hasNextPage) {
|
|
679
|
+
const toIndex = clampedPageIndex + 1;
|
|
680
|
+
dispatch({ type: "SET_PAGE", page: toIndex });
|
|
681
|
+
const newPage = visiblePages[toIndex];
|
|
682
|
+
if (newPage) {
|
|
683
|
+
fireEvent("pageChanged", {
|
|
684
|
+
fromIndex: clampedPageIndex,
|
|
685
|
+
toIndex,
|
|
686
|
+
page: newPage,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
679
692
|
return {
|
|
680
693
|
pages,
|
|
681
694
|
currentPageIndex: clampedPageIndex,
|
|
@@ -694,20 +707,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
694
707
|
}
|
|
695
708
|
}
|
|
696
709
|
},
|
|
697
|
-
nextPage:
|
|
698
|
-
if (hasNextPage) {
|
|
699
|
-
const toIndex = clampedPageIndex + 1;
|
|
700
|
-
dispatch({ type: "SET_PAGE", page: toIndex });
|
|
701
|
-
const newPage = visiblePages[toIndex];
|
|
702
|
-
if (newPage) {
|
|
703
|
-
fireEvent("pageChanged", {
|
|
704
|
-
fromIndex: clampedPageIndex,
|
|
705
|
-
toIndex,
|
|
706
|
-
page: newPage,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
},
|
|
710
|
+
nextPage: advanceToNextPage,
|
|
711
711
|
previousPage: () => {
|
|
712
712
|
if (hasPreviousPage) {
|
|
713
713
|
const toIndex = clampedPageIndex - 1;
|
|
@@ -722,6 +722,10 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
722
722
|
}
|
|
723
723
|
}
|
|
724
724
|
},
|
|
725
|
+
// Same function as nextPage — exposed as a separate name so consumers can
|
|
726
|
+
// bind a single "Next" button without risk of accidentally triggering submission.
|
|
727
|
+
// nextPage is already a no-op on the last page.
|
|
728
|
+
handleNext: advanceToNextPage,
|
|
725
729
|
hasNextPage,
|
|
726
730
|
hasPreviousPage,
|
|
727
731
|
canProceed: (() => {
|
|
@@ -756,7 +760,15 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
756
760
|
return pageErrors.length === 0;
|
|
757
761
|
},
|
|
758
762
|
};
|
|
759
|
-
}, [
|
|
763
|
+
}, [
|
|
764
|
+
spec,
|
|
765
|
+
state.data,
|
|
766
|
+
state.currentPage,
|
|
767
|
+
computed,
|
|
768
|
+
validation,
|
|
769
|
+
visibility,
|
|
770
|
+
fireEvent,
|
|
771
|
+
]);
|
|
760
772
|
|
|
761
773
|
// Flush pending events after render (fieldChanged, formReset)
|
|
762
774
|
useEffect(() => {
|
|
@@ -885,12 +897,12 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
885
897
|
|
|
886
898
|
const fieldErrors = validation.errors.filter((e) => e.field === path);
|
|
887
899
|
const isTouched = state.touched[path] ?? false;
|
|
888
|
-
const
|
|
900
|
+
const shouldShowErrors =
|
|
889
901
|
validateOn === "change" ||
|
|
890
902
|
(validateOn === "blur" && isTouched) ||
|
|
891
903
|
state.isSubmitted;
|
|
892
|
-
const
|
|
893
|
-
const
|
|
904
|
+
const visibleFieldErrors = shouldShowErrors ? fieldErrors : [];
|
|
905
|
+
const hasVisibleErrors = visibleFieldErrors.length > 0;
|
|
894
906
|
const isRequired = required[path] ?? false;
|
|
895
907
|
|
|
896
908
|
// Boolean fields: hide asterisk unless they have validation rules (consent pattern)
|
|
@@ -922,12 +934,13 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
922
934
|
required: isRequired,
|
|
923
935
|
showRequiredIndicator,
|
|
924
936
|
touched: isTouched,
|
|
925
|
-
errors:
|
|
937
|
+
errors: fieldErrors,
|
|
938
|
+
visibleErrors: visibleFieldErrors,
|
|
926
939
|
onChange: handlers.onChange,
|
|
927
940
|
onBlur: handlers.onBlur,
|
|
928
|
-
// ARIA accessibility attributes
|
|
929
|
-
"aria-invalid":
|
|
930
|
-
"aria-describedby":
|
|
941
|
+
// ARIA accessibility attributes (driven by visibleErrors, not all errors)
|
|
942
|
+
"aria-invalid": hasVisibleErrors || undefined,
|
|
943
|
+
"aria-describedby": hasVisibleErrors ? `${path}-error` : undefined,
|
|
931
944
|
"aria-required": isRequired || undefined,
|
|
932
945
|
// Adorner props (only for adornable field types)
|
|
933
946
|
...adornerProps,
|
|
@@ -1020,7 +1033,8 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
1020
1033
|
required: false, // TODO: Evaluate item field required
|
|
1021
1034
|
showRequiredIndicator: false, // Item fields don't show required indicator
|
|
1022
1035
|
touched: isTouched,
|
|
1023
|
-
errors:
|
|
1036
|
+
errors: fieldErrors,
|
|
1037
|
+
visibleErrors: showErrors ? fieldErrors : [],
|
|
1024
1038
|
onChange: handlers.onChange,
|
|
1025
1039
|
onBlur: handlers.onBlur,
|
|
1026
1040
|
options: visibleOptions,
|