@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.
- package/README.md +111 -26
- package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
- package/dist/chunk-5K4QITFH.js +1276 -0
- package/dist/chunk-5K4QITFH.js.map +1 -0
- package/dist/defaults/index.d.ts +56 -0
- package/dist/defaults/index.js +895 -0
- package/dist/defaults/index.js.map +1 -0
- package/dist/defaults/styles/forma-defaults.css +696 -0
- package/dist/index.d.ts +13 -549
- package/dist/index.js +34 -1273
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- 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__/defaults/components.test.tsx +818 -0
- package/src/__tests__/defaults/integration.test.tsx +494 -0
- package/src/__tests__/defaults/layout.test.tsx +298 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/defaults/DefaultFormRenderer.tsx +43 -0
- package/src/defaults/componentMap.ts +45 -0
- package/src/defaults/components/ArrayField.tsx +183 -0
- package/src/defaults/components/BooleanInput.tsx +32 -0
- package/src/defaults/components/ComputedDisplay.tsx +26 -0
- package/src/defaults/components/DateInput.tsx +59 -0
- package/src/defaults/components/DisplayField.tsx +15 -0
- package/src/defaults/components/FallbackField.tsx +35 -0
- package/src/defaults/components/MatrixField.tsx +98 -0
- package/src/defaults/components/MultiSelectInput.tsx +51 -0
- package/src/defaults/components/NumberInput.tsx +73 -0
- package/src/defaults/components/ObjectField.tsx +22 -0
- package/src/defaults/components/SelectInput.tsx +44 -0
- package/src/defaults/components/TextInput.tsx +48 -0
- package/src/defaults/components/TextareaInput.tsx +46 -0
- package/src/defaults/index.ts +33 -0
- package/src/defaults/layout/FieldWrapper.tsx +83 -0
- package/src/defaults/layout/FormLayout.tsx +34 -0
- package/src/defaults/layout/PageWrapper.tsx +18 -0
- package/src/defaults/layout/WizardLayout.tsx +130 -0
- package/src/defaults/styles/forma-defaults.css +696 -0
- package/src/events.ts +4 -1
- package/src/types.ts +16 -4
- 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", () => {
|
|
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
|
});
|
|
@@ -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;
|