@fogpipe/forma-react 0.17.0 → 0.18.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.
Files changed (46) hide show
  1. package/README.md +111 -26
  2. package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
  3. package/dist/chunk-5K4QITFH.js +1276 -0
  4. package/dist/chunk-5K4QITFH.js.map +1 -0
  5. package/dist/defaults/index.d.ts +56 -0
  6. package/dist/defaults/index.js +895 -0
  7. package/dist/defaults/index.js.map +1 -0
  8. package/dist/defaults/styles/forma-defaults.css +696 -0
  9. package/dist/index.d.ts +13 -549
  10. package/dist/index.js +34 -1273
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/FieldRenderer.tsx +12 -4
  14. package/src/FormRenderer.tsx +26 -9
  15. package/src/__tests__/FieldRenderer.test.tsx +5 -1
  16. package/src/__tests__/FormRenderer.test.tsx +146 -0
  17. package/src/__tests__/canProceed.test.ts +243 -0
  18. package/src/__tests__/defaults/components.test.tsx +818 -0
  19. package/src/__tests__/defaults/integration.test.tsx +494 -0
  20. package/src/__tests__/defaults/layout.test.tsx +298 -0
  21. package/src/__tests__/events.test.ts +15 -5
  22. package/src/__tests__/useForma.test.ts +108 -5
  23. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  24. package/src/defaults/componentMap.ts +45 -0
  25. package/src/defaults/components/ArrayField.tsx +183 -0
  26. package/src/defaults/components/BooleanInput.tsx +32 -0
  27. package/src/defaults/components/ComputedDisplay.tsx +26 -0
  28. package/src/defaults/components/DateInput.tsx +59 -0
  29. package/src/defaults/components/DisplayField.tsx +15 -0
  30. package/src/defaults/components/FallbackField.tsx +35 -0
  31. package/src/defaults/components/MatrixField.tsx +98 -0
  32. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  33. package/src/defaults/components/NumberInput.tsx +73 -0
  34. package/src/defaults/components/ObjectField.tsx +22 -0
  35. package/src/defaults/components/SelectInput.tsx +44 -0
  36. package/src/defaults/components/TextInput.tsx +48 -0
  37. package/src/defaults/components/TextareaInput.tsx +46 -0
  38. package/src/defaults/index.ts +33 -0
  39. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  40. package/src/defaults/layout/FormLayout.tsx +34 -0
  41. package/src/defaults/layout/PageWrapper.tsx +18 -0
  42. package/src/defaults/layout/WizardLayout.tsx +130 -0
  43. package/src/defaults/styles/forma-defaults.css +696 -0
  44. package/src/events.ts +4 -1
  45. package/src/types.ts +16 -4
  46. package/src/useForma.ts +48 -34
@@ -0,0 +1,298 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ /**
3
+ * Tests for default layout components
4
+ */
5
+ import React from "react";
6
+ import { render, screen, fireEvent } from "@testing-library/react";
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import type { FieldError } from "@fogpipe/forma-core";
9
+ import type { FieldWrapperProps, PageWrapperProps } from "../../types.js";
10
+ import { FieldWrapper } from "../../defaults/layout/FieldWrapper.js";
11
+ import { FormLayout } from "../../defaults/layout/FormLayout.js";
12
+ import { PageWrapper } from "../../defaults/layout/PageWrapper.js";
13
+ import { FormaContext } from "../../context.js";
14
+ import type { UseFormaReturn } from "../../useForma.js";
15
+
16
+ function mockFormaContext(
17
+ overrides: Partial<UseFormaReturn> = {},
18
+ ): UseFormaReturn {
19
+ return {
20
+ data: {},
21
+ computed: {},
22
+ visibility: {},
23
+ required: {},
24
+ enabled: {},
25
+ readonly: {},
26
+ optionsVisibility: {},
27
+ touched: {},
28
+ errors: [],
29
+ isValid: true,
30
+ isSubmitting: false,
31
+ isSubmitted: false,
32
+ isDirty: false,
33
+ spec: {} as UseFormaReturn["spec"],
34
+ wizard: null,
35
+ setFieldValue: vi.fn(),
36
+ setFieldTouched: vi.fn(),
37
+ setValues: vi.fn(),
38
+ validateField: vi.fn(),
39
+ validateForm: vi.fn(),
40
+ submitForm: vi.fn(),
41
+ resetForm: vi.fn(),
42
+ getFieldProps: vi.fn(),
43
+ getSelectFieldProps: vi.fn(),
44
+ getArrayHelpers: vi.fn(),
45
+ on: vi.fn(),
46
+ ...overrides,
47
+ } as unknown as UseFormaReturn;
48
+ }
49
+
50
+ function renderWithContext(
51
+ ui: React.ReactElement,
52
+ contextOverrides: Partial<UseFormaReturn> = {},
53
+ ) {
54
+ const ctx = mockFormaContext(contextOverrides);
55
+ return render(
56
+ <FormaContext.Provider value={ctx}>{ui}</FormaContext.Provider>,
57
+ );
58
+ }
59
+
60
+ // ============================================================================
61
+ // FieldWrapper
62
+ // ============================================================================
63
+ describe("FieldWrapper", () => {
64
+ function makeProps(
65
+ overrides: Partial<FieldWrapperProps> = {},
66
+ ): FieldWrapperProps {
67
+ return {
68
+ fieldPath: "testField",
69
+ field: {
70
+ type: "text",
71
+ label: "Test Field",
72
+ } as FieldWrapperProps["field"],
73
+ children: <input type="text" />,
74
+ errors: [],
75
+ touched: false,
76
+ required: false,
77
+ showRequiredIndicator: false,
78
+ visible: true,
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ it("renders label with field name", () => {
84
+ renderWithContext(<FieldWrapper {...makeProps()} />);
85
+ expect(screen.getByText("Test Field")).toBeInTheDocument();
86
+ });
87
+
88
+ it("shows required indicator when showRequiredIndicator is true", () => {
89
+ renderWithContext(
90
+ <FieldWrapper {...makeProps({ showRequiredIndicator: true })} />,
91
+ );
92
+ expect(screen.getByText("*")).toBeInTheDocument();
93
+ });
94
+
95
+ it("hides required indicator when showRequiredIndicator is false", () => {
96
+ renderWithContext(
97
+ <FieldWrapper {...makeProps({ showRequiredIndicator: false })} />,
98
+ );
99
+ expect(screen.queryByText("*")).not.toBeInTheDocument();
100
+ });
101
+
102
+ it("renders description when provided", () => {
103
+ renderWithContext(
104
+ <FieldWrapper
105
+ {...makeProps({
106
+ field: {
107
+ type: "text",
108
+ label: "Test Field",
109
+ description: "Help text here",
110
+ } as FieldWrapperProps["field"],
111
+ })}
112
+ />,
113
+ );
114
+ expect(screen.getByText("Help text here")).toBeInTheDocument();
115
+ });
116
+
117
+ it("renders children", () => {
118
+ renderWithContext(
119
+ <FieldWrapper
120
+ {...makeProps({ children: <span>Child content</span> })}
121
+ />,
122
+ );
123
+ expect(screen.getByText("Child content")).toBeInTheDocument();
124
+ });
125
+
126
+ it("does NOT render errors when touched is false and not submitted", () => {
127
+ const errors: FieldError[] = [
128
+ { field: "testField", message: "Required", severity: "error" },
129
+ ];
130
+ renderWithContext(
131
+ <FieldWrapper {...makeProps({ errors, touched: false })} />,
132
+ );
133
+ expect(screen.queryByText("Required")).not.toBeInTheDocument();
134
+ });
135
+
136
+ it("renders errors when touched is true and errors exist", () => {
137
+ const errors: FieldError[] = [
138
+ { field: "testField", message: "Required", severity: "error" },
139
+ ];
140
+ renderWithContext(
141
+ <FieldWrapper {...makeProps({ errors, touched: true })} />,
142
+ );
143
+ expect(screen.getByText("Required")).toBeInTheDocument();
144
+ });
145
+
146
+ it("renders errors when isSubmitted is true even if not touched", () => {
147
+ const errors: FieldError[] = [
148
+ { field: "testField", message: "Required", severity: "error" },
149
+ ];
150
+ renderWithContext(
151
+ <FieldWrapper {...makeProps({ errors, touched: false })} />,
152
+ { isSubmitted: true },
153
+ );
154
+ expect(screen.getByText("Required")).toBeInTheDocument();
155
+ });
156
+
157
+ it("error container has role=alert", () => {
158
+ const errors: FieldError[] = [
159
+ { field: "testField", message: "Required", severity: "error" },
160
+ ];
161
+ renderWithContext(
162
+ <FieldWrapper {...makeProps({ errors, touched: true })} />,
163
+ );
164
+ expect(screen.getByRole("alert")).toBeInTheDocument();
165
+ });
166
+
167
+ it("returns null when visible is false", () => {
168
+ const { container } = renderWithContext(
169
+ <FieldWrapper {...makeProps({ visible: false })} />,
170
+ );
171
+ expect(container.firstChild).toBeNull();
172
+ });
173
+
174
+ it("applies forma-field--error class when has visible errors", () => {
175
+ const errors: FieldError[] = [
176
+ { field: "testField", message: "Required", severity: "error" },
177
+ ];
178
+ const { container } = renderWithContext(
179
+ <FieldWrapper {...makeProps({ errors, touched: true })} />,
180
+ );
181
+ expect(container.firstChild).toHaveClass("forma-field--error");
182
+ });
183
+
184
+ it("applies forma-field--required class when showRequiredIndicator", () => {
185
+ const { container } = renderWithContext(
186
+ <FieldWrapper {...makeProps({ showRequiredIndicator: true })} />,
187
+ );
188
+ expect(container.firstChild).toHaveClass("forma-field--required");
189
+ });
190
+ });
191
+
192
+ // ============================================================================
193
+ // FormLayout
194
+ // ============================================================================
195
+ describe("FormLayout", () => {
196
+ it("renders as form element", () => {
197
+ const { container } = render(
198
+ <FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
199
+ <div>Content</div>
200
+ </FormLayout>,
201
+ );
202
+ expect(container.querySelector("form")).toBeInTheDocument();
203
+ });
204
+
205
+ it("calls onSubmit on form submit", () => {
206
+ const onSubmit = vi.fn();
207
+ const { container } = render(
208
+ <FormLayout onSubmit={onSubmit} isSubmitting={false} isValid={true}>
209
+ <div>Content</div>
210
+ </FormLayout>,
211
+ );
212
+ fireEvent.submit(container.querySelector("form")!);
213
+ expect(onSubmit).toHaveBeenCalled();
214
+ });
215
+
216
+ it("renders submit button with Submit text", () => {
217
+ render(
218
+ <FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
219
+ <div>Content</div>
220
+ </FormLayout>,
221
+ );
222
+ expect(screen.getByRole("button")).toHaveTextContent("Submit");
223
+ });
224
+
225
+ it("shows Submitting... and disables button when isSubmitting", () => {
226
+ render(
227
+ <FormLayout onSubmit={vi.fn()} isSubmitting={true} isValid={true}>
228
+ <div>Content</div>
229
+ </FormLayout>,
230
+ );
231
+ const button = screen.getByRole("button");
232
+ expect(button).toHaveTextContent("Submitting...");
233
+ expect(button).toBeDisabled();
234
+ });
235
+
236
+ it("sets aria-busy on button when submitting", () => {
237
+ render(
238
+ <FormLayout onSubmit={vi.fn()} isSubmitting={true} isValid={true}>
239
+ <div>Content</div>
240
+ </FormLayout>,
241
+ );
242
+ expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
243
+ });
244
+
245
+ it("renders children inside form", () => {
246
+ render(
247
+ <FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
248
+ <div>Form content</div>
249
+ </FormLayout>,
250
+ );
251
+ expect(screen.getByText("Form content")).toBeInTheDocument();
252
+ });
253
+ });
254
+
255
+ // ============================================================================
256
+ // PageWrapper
257
+ // ============================================================================
258
+ describe("PageWrapper", () => {
259
+ function makePageProps(
260
+ overrides: Partial<PageWrapperProps> = {},
261
+ ): PageWrapperProps {
262
+ return {
263
+ title: "Page Title",
264
+ children: <div>Page content</div>,
265
+ pageIndex: 0,
266
+ totalPages: 3,
267
+ ...overrides,
268
+ };
269
+ }
270
+
271
+ it("renders title as h2", () => {
272
+ render(<PageWrapper {...makePageProps()} />);
273
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
274
+ "Page Title",
275
+ );
276
+ });
277
+
278
+ it("renders description when provided", () => {
279
+ render(
280
+ <PageWrapper
281
+ {...makePageProps({ description: "Some description" })}
282
+ />,
283
+ );
284
+ expect(screen.getByText("Some description")).toBeInTheDocument();
285
+ });
286
+
287
+ it("omits description element when not provided", () => {
288
+ const { container } = render(<PageWrapper {...makePageProps()} />);
289
+ expect(
290
+ container.querySelector(".forma-page__description"),
291
+ ).not.toBeInTheDocument();
292
+ });
293
+
294
+ it("renders children", () => {
295
+ render(<PageWrapper {...makePageProps()} />);
296
+ expect(screen.getByText("Page content")).toBeInTheDocument();
297
+ });
298
+ });
@@ -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
  });
@@ -0,0 +1,43 @@
1
+ import React, { forwardRef } from "react";
2
+ import { FormRenderer } from "../FormRenderer.js";
3
+ import type { FormRendererProps, FormRendererHandle } from "../FormRenderer.js";
4
+ import {
5
+ defaultComponentMap,
6
+ defaultFieldWrapper,
7
+ defaultLayout,
8
+ defaultWizardLayout,
9
+ defaultPageWrapper,
10
+ } from "./componentMap.js";
11
+
12
+ export interface DefaultFormRendererProps
13
+ extends Omit<FormRendererProps, "components"> {
14
+ /** Component map (defaults to defaultComponentMap if not provided) */
15
+ components?: FormRendererProps["components"];
16
+ /** Use wizard layout for multi-page forms */
17
+ wizardLayout?: boolean;
18
+ }
19
+
20
+ export const DefaultFormRenderer = forwardRef<
21
+ FormRendererHandle,
22
+ DefaultFormRendererProps
23
+ >(function DefaultFormRenderer(props, ref) {
24
+ const {
25
+ components,
26
+ wizardLayout,
27
+ layout,
28
+ fieldWrapper,
29
+ pageWrapper,
30
+ ...rest
31
+ } = props;
32
+
33
+ return (
34
+ <FormRenderer
35
+ ref={ref}
36
+ components={components ?? defaultComponentMap}
37
+ fieldWrapper={fieldWrapper ?? defaultFieldWrapper}
38
+ layout={layout ?? (wizardLayout ? defaultWizardLayout : defaultLayout)}
39
+ pageWrapper={pageWrapper ?? defaultPageWrapper}
40
+ {...rest}
41
+ />
42
+ );
43
+ });
@@ -0,0 +1,45 @@
1
+ import type { ComponentMap } from "../types.js";
2
+ import { TextInput } from "./components/TextInput.js";
3
+ import { TextareaInput } from "./components/TextareaInput.js";
4
+ import { NumberInput, IntegerInput } from "./components/NumberInput.js";
5
+ import { BooleanInput } from "./components/BooleanInput.js";
6
+ import { DateInput, DateTimeInput } from "./components/DateInput.js";
7
+ import { SelectInput } from "./components/SelectInput.js";
8
+ import { MultiSelectInput } from "./components/MultiSelectInput.js";
9
+ import { ArrayField } from "./components/ArrayField.js";
10
+ import { ObjectField } from "./components/ObjectField.js";
11
+ import { ComputedDisplay } from "./components/ComputedDisplay.js";
12
+ import { DisplayField } from "./components/DisplayField.js";
13
+ import { MatrixField } from "./components/MatrixField.js";
14
+ import { FallbackField } from "./components/FallbackField.js";
15
+ import { FieldWrapper } from "./layout/FieldWrapper.js";
16
+ import { FormLayout } from "./layout/FormLayout.js";
17
+ import { WizardLayout } from "./layout/WizardLayout.js";
18
+ import { PageWrapper } from "./layout/PageWrapper.js";
19
+
20
+ export const defaultComponentMap: ComponentMap = {
21
+ text: TextInput,
22
+ email: TextInput,
23
+ phone: TextInput,
24
+ url: TextInput,
25
+ password: TextInput,
26
+ textarea: TextareaInput,
27
+ number: NumberInput,
28
+ integer: IntegerInput,
29
+ boolean: BooleanInput,
30
+ date: DateInput,
31
+ datetime: DateTimeInput,
32
+ select: SelectInput,
33
+ multiselect: MultiSelectInput,
34
+ array: ArrayField,
35
+ object: ObjectField,
36
+ computed: ComputedDisplay,
37
+ display: DisplayField,
38
+ matrix: MatrixField,
39
+ fallback: FallbackField,
40
+ };
41
+
42
+ export const defaultFieldWrapper = FieldWrapper;
43
+ export const defaultLayout = FormLayout;
44
+ export const defaultWizardLayout = WizardLayout;
45
+ export const defaultPageWrapper = PageWrapper;