@fogpipe/forma-react 0.6.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 +277 -0
- package/dist/index.d.ts +668 -0
- package/dist/index.js +1039 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/ErrorBoundary.tsx +115 -0
- package/src/FieldRenderer.tsx +258 -0
- package/src/FormRenderer.tsx +470 -0
- package/src/__tests__/FormRenderer.test.tsx +803 -0
- package/src/__tests__/test-utils.tsx +297 -0
- package/src/__tests__/useForma.test.ts +1103 -0
- package/src/context.ts +23 -0
- package/src/index.ts +91 -0
- package/src/types.ts +482 -0
- package/src/useForma.ts +681 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/// <reference types="@testing-library/jest-dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Tests for FormRenderer component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi } from "vitest";
|
|
7
|
+
import { render, screen, waitFor, act } from "@testing-library/react";
|
|
8
|
+
import { userEvent } from "@testing-library/user-event";
|
|
9
|
+
import { useRef } from "react";
|
|
10
|
+
import { FormRenderer } from "../FormRenderer.js";
|
|
11
|
+
import { useFormaContext } from "../context.js";
|
|
12
|
+
import { createTestSpec, createTestComponentMap } from "./test-utils.js";
|
|
13
|
+
import type { FormRendererHandle } from "../FormRenderer.js";
|
|
14
|
+
import type { LayoutProps, PageWrapperProps, ComponentMap } from "../types.js";
|
|
15
|
+
import type { UseFormaReturn } from "../useForma.js";
|
|
16
|
+
|
|
17
|
+
describe("FormRenderer", () => {
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Basic Rendering
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
describe("basic rendering", () => {
|
|
23
|
+
it("should render fields from spec", () => {
|
|
24
|
+
const spec = createTestSpec({
|
|
25
|
+
fields: {
|
|
26
|
+
name: { type: "text", label: "Name" },
|
|
27
|
+
email: { type: "email", label: "Email" },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
render(
|
|
32
|
+
<FormRenderer
|
|
33
|
+
spec={spec}
|
|
34
|
+
components={createTestComponentMap()}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(screen.getByTestId("field-name")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByTestId("field-email")).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should render fields in fieldOrder", () => {
|
|
43
|
+
const spec = createTestSpec({
|
|
44
|
+
fields: {
|
|
45
|
+
a: { type: "text", label: "A" },
|
|
46
|
+
b: { type: "text", label: "B" },
|
|
47
|
+
c: { type: "text", label: "C" },
|
|
48
|
+
},
|
|
49
|
+
fieldOrder: ["c", "a", "b"],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<FormRenderer
|
|
54
|
+
spec={spec}
|
|
55
|
+
components={createTestComponentMap()}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const fields = container.querySelectorAll("[data-testid^='field-']");
|
|
60
|
+
expect(fields[0]).toHaveAttribute("data-testid", "field-c");
|
|
61
|
+
expect(fields[1]).toHaveAttribute("data-testid", "field-a");
|
|
62
|
+
expect(fields[2]).toHaveAttribute("data-testid", "field-b");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should render with initial data", () => {
|
|
66
|
+
const spec = createTestSpec({
|
|
67
|
+
fields: {
|
|
68
|
+
name: { type: "text", label: "Name" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<FormRenderer
|
|
74
|
+
spec={spec}
|
|
75
|
+
initialData={{ name: "John Doe" }}
|
|
76
|
+
components={createTestComponentMap()}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const input = screen.getByRole("textbox");
|
|
81
|
+
expect(input).toHaveValue("John Doe");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should use custom layout component", () => {
|
|
85
|
+
const spec = createTestSpec({
|
|
86
|
+
fields: { name: { type: "text" } },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const CustomLayout = ({ children }: LayoutProps) => (
|
|
90
|
+
<div data-testid="custom-layout">{children}</div>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
render(
|
|
94
|
+
<FormRenderer
|
|
95
|
+
spec={spec}
|
|
96
|
+
components={createTestComponentMap()}
|
|
97
|
+
layout={CustomLayout}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(screen.getByTestId("custom-layout")).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Field Type Rendering
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
describe("field type rendering", () => {
|
|
110
|
+
it("should render text field", () => {
|
|
111
|
+
const spec = createTestSpec({
|
|
112
|
+
fields: { name: { type: "text" } },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
render(
|
|
116
|
+
<FormRenderer
|
|
117
|
+
spec={spec}
|
|
118
|
+
components={createTestComponentMap()}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should render number field", () => {
|
|
126
|
+
const spec = createTestSpec({
|
|
127
|
+
fields: { age: { type: "number" } },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
render(
|
|
131
|
+
<FormRenderer
|
|
132
|
+
spec={spec}
|
|
133
|
+
components={createTestComponentMap()}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(screen.getByRole("spinbutton")).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should render boolean field", () => {
|
|
141
|
+
const spec = createTestSpec({
|
|
142
|
+
fields: { agree: { type: "boolean" } },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
render(
|
|
146
|
+
<FormRenderer
|
|
147
|
+
spec={spec}
|
|
148
|
+
components={createTestComponentMap()}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(screen.getByRole("checkbox")).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should render select field with options", () => {
|
|
156
|
+
const spec = createTestSpec({
|
|
157
|
+
fields: {
|
|
158
|
+
country: {
|
|
159
|
+
type: "select",
|
|
160
|
+
options: [
|
|
161
|
+
{ value: "us", label: "United States" },
|
|
162
|
+
{ value: "ca", label: "Canada" },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
render(
|
|
169
|
+
<FormRenderer
|
|
170
|
+
spec={spec}
|
|
171
|
+
components={createTestComponentMap()}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
|
176
|
+
expect(screen.getByText("United States")).toBeInTheDocument();
|
|
177
|
+
expect(screen.getByText("Canada")).toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should render array field", () => {
|
|
181
|
+
const spec = createTestSpec({
|
|
182
|
+
fields: {
|
|
183
|
+
items: {
|
|
184
|
+
type: "array",
|
|
185
|
+
label: "Items",
|
|
186
|
+
itemFields: { name: { type: "text" } },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
render(
|
|
192
|
+
<FormRenderer
|
|
193
|
+
spec={spec}
|
|
194
|
+
initialData={{ items: [{ name: "Item 1" }] }}
|
|
195
|
+
components={createTestComponentMap()}
|
|
196
|
+
/>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(screen.getByTestId("field-items")).toBeInTheDocument();
|
|
200
|
+
expect(screen.getByTestId("add-items")).toBeInTheDocument();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// User Interactions
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
describe("user interactions", () => {
|
|
209
|
+
it("should update value on text input", async () => {
|
|
210
|
+
const user = userEvent.setup();
|
|
211
|
+
const onChange = vi.fn();
|
|
212
|
+
const spec = createTestSpec({
|
|
213
|
+
fields: { name: { type: "text" } },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
render(
|
|
217
|
+
<FormRenderer
|
|
218
|
+
spec={spec}
|
|
219
|
+
components={createTestComponentMap()}
|
|
220
|
+
onChange={onChange}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const input = screen.getByRole("textbox");
|
|
225
|
+
await user.type(input, "Hello");
|
|
226
|
+
|
|
227
|
+
expect(input).toHaveValue("Hello");
|
|
228
|
+
expect(onChange).toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should update value on checkbox toggle", async () => {
|
|
232
|
+
const user = userEvent.setup();
|
|
233
|
+
const spec = createTestSpec({
|
|
234
|
+
fields: { agree: { type: "boolean" } },
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
render(
|
|
238
|
+
<FormRenderer
|
|
239
|
+
spec={spec}
|
|
240
|
+
initialData={{ agree: false }}
|
|
241
|
+
components={createTestComponentMap()}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const checkbox = screen.getByRole("checkbox");
|
|
246
|
+
expect(checkbox).not.toBeChecked();
|
|
247
|
+
|
|
248
|
+
await user.click(checkbox);
|
|
249
|
+
expect(checkbox).toBeChecked();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("should update value on select change", async () => {
|
|
253
|
+
const user = userEvent.setup();
|
|
254
|
+
const spec = createTestSpec({
|
|
255
|
+
fields: {
|
|
256
|
+
country: {
|
|
257
|
+
type: "select",
|
|
258
|
+
options: [
|
|
259
|
+
{ value: "us", label: "United States" },
|
|
260
|
+
{ value: "ca", label: "Canada" },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
render(
|
|
267
|
+
<FormRenderer
|
|
268
|
+
spec={spec}
|
|
269
|
+
components={createTestComponentMap()}
|
|
270
|
+
/>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const select = screen.getByRole("combobox");
|
|
274
|
+
await user.selectOptions(select, "ca");
|
|
275
|
+
|
|
276
|
+
expect(select).toHaveValue("ca");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Form Submission
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
describe("form submission", () => {
|
|
285
|
+
it("should call onSubmit with form data", async () => {
|
|
286
|
+
const user = userEvent.setup();
|
|
287
|
+
const onSubmit = vi.fn();
|
|
288
|
+
const spec = createTestSpec({
|
|
289
|
+
fields: { name: { type: "text" } },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
render(
|
|
293
|
+
<FormRenderer
|
|
294
|
+
spec={spec}
|
|
295
|
+
initialData={{ name: "John" }}
|
|
296
|
+
onSubmit={onSubmit}
|
|
297
|
+
components={createTestComponentMap()}
|
|
298
|
+
/>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const submitButton = screen.getByRole("button", { name: /submit/i });
|
|
302
|
+
await user.click(submitButton);
|
|
303
|
+
|
|
304
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should not submit when form is invalid", async () => {
|
|
308
|
+
const user = userEvent.setup();
|
|
309
|
+
const onSubmit = vi.fn();
|
|
310
|
+
const spec = createTestSpec({
|
|
311
|
+
fields: { name: { type: "text", required: true } },
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
render(
|
|
315
|
+
<FormRenderer
|
|
316
|
+
spec={spec}
|
|
317
|
+
onSubmit={onSubmit}
|
|
318
|
+
components={createTestComponentMap()}
|
|
319
|
+
/>
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const submitButton = screen.getByRole("button", { name: /submit/i });
|
|
323
|
+
await user.click(submitButton);
|
|
324
|
+
|
|
325
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should show validation errors after submit attempt", async () => {
|
|
329
|
+
const user = userEvent.setup();
|
|
330
|
+
const spec = createTestSpec({
|
|
331
|
+
fields: { name: { type: "text", required: true } },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
render(
|
|
335
|
+
<FormRenderer
|
|
336
|
+
spec={spec}
|
|
337
|
+
components={createTestComponentMap()}
|
|
338
|
+
/>
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const submitButton = screen.getByRole("button", { name: /submit/i });
|
|
342
|
+
await user.click(submitButton);
|
|
343
|
+
|
|
344
|
+
// Error should now be visible
|
|
345
|
+
await waitFor(() => {
|
|
346
|
+
expect(screen.getByTestId("error-name")).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("should disable submit button while submitting", async () => {
|
|
351
|
+
const user = userEvent.setup();
|
|
352
|
+
const onSubmit = vi.fn(
|
|
353
|
+
(): Promise<void> => new Promise((resolve) => setTimeout(resolve, 100))
|
|
354
|
+
);
|
|
355
|
+
const spec = createTestSpec({
|
|
356
|
+
fields: { name: { type: "text" } },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
render(
|
|
360
|
+
<FormRenderer
|
|
361
|
+
spec={spec}
|
|
362
|
+
initialData={{ name: "John" }}
|
|
363
|
+
onSubmit={onSubmit}
|
|
364
|
+
components={createTestComponentMap()}
|
|
365
|
+
/>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const submitButton = screen.getByRole("button", { name: /submit/i });
|
|
369
|
+
await user.click(submitButton);
|
|
370
|
+
|
|
371
|
+
expect(submitButton).toBeDisabled();
|
|
372
|
+
expect(submitButton).toHaveTextContent("Submitting...");
|
|
373
|
+
|
|
374
|
+
await waitFor(() => {
|
|
375
|
+
expect(submitButton).not.toBeDisabled();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Imperative Handle (ref)
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
describe("imperative handle (ref)", () => {
|
|
385
|
+
it("should expose submitForm via ref", async () => {
|
|
386
|
+
const onSubmit = vi.fn();
|
|
387
|
+
const spec = createTestSpec({
|
|
388
|
+
fields: { name: { type: "text" } },
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
let formRef: React.RefObject<FormRendererHandle | null>;
|
|
392
|
+
|
|
393
|
+
function TestComponent() {
|
|
394
|
+
formRef = useRef<FormRendererHandle>(null);
|
|
395
|
+
return (
|
|
396
|
+
<FormRenderer
|
|
397
|
+
ref={formRef}
|
|
398
|
+
spec={spec}
|
|
399
|
+
initialData={{ name: "Test" }}
|
|
400
|
+
onSubmit={onSubmit}
|
|
401
|
+
components={createTestComponentMap()}
|
|
402
|
+
/>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
render(<TestComponent />);
|
|
407
|
+
|
|
408
|
+
await act(async () => {
|
|
409
|
+
await formRef!.current?.submitForm();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "Test" });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("should expose resetForm via ref", async () => {
|
|
416
|
+
const spec = createTestSpec({
|
|
417
|
+
fields: { name: { type: "text" } },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
let formRef: React.RefObject<FormRendererHandle | null>;
|
|
421
|
+
|
|
422
|
+
function TestComponent() {
|
|
423
|
+
formRef = useRef<FormRendererHandle>(null);
|
|
424
|
+
return (
|
|
425
|
+
<FormRenderer
|
|
426
|
+
ref={formRef}
|
|
427
|
+
spec={spec}
|
|
428
|
+
initialData={{ name: "Original" }}
|
|
429
|
+
components={createTestComponentMap()}
|
|
430
|
+
/>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const { rerender } = render(<TestComponent />);
|
|
435
|
+
const user = userEvent.setup();
|
|
436
|
+
|
|
437
|
+
const input = screen.getByRole("textbox");
|
|
438
|
+
await user.clear(input);
|
|
439
|
+
await user.type(input, "Changed");
|
|
440
|
+
expect(input).toHaveValue("Changed");
|
|
441
|
+
|
|
442
|
+
act(() => {
|
|
443
|
+
formRef!.current?.resetForm();
|
|
444
|
+
});
|
|
445
|
+
rerender(<TestComponent />);
|
|
446
|
+
|
|
447
|
+
await waitFor(() => {
|
|
448
|
+
expect(screen.getByRole("textbox")).toHaveValue("Original");
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("should expose validateForm via ref", () => {
|
|
453
|
+
const spec = createTestSpec({
|
|
454
|
+
fields: { name: { type: "text", required: true } },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
let formRef: React.RefObject<FormRendererHandle | null>;
|
|
458
|
+
|
|
459
|
+
function TestComponent() {
|
|
460
|
+
formRef = useRef<FormRendererHandle>(null);
|
|
461
|
+
return (
|
|
462
|
+
<FormRenderer
|
|
463
|
+
ref={formRef}
|
|
464
|
+
spec={spec}
|
|
465
|
+
components={createTestComponentMap()}
|
|
466
|
+
/>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
render(<TestComponent />);
|
|
471
|
+
|
|
472
|
+
const result = formRef!.current?.validateForm();
|
|
473
|
+
|
|
474
|
+
expect(result?.valid).toBe(false);
|
|
475
|
+
expect(result?.errors.length).toBeGreaterThan(0);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("should expose getValues via ref", () => {
|
|
479
|
+
const spec = createTestSpec({
|
|
480
|
+
fields: { name: { type: "text" } },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
let formRef: React.RefObject<FormRendererHandle | null>;
|
|
484
|
+
|
|
485
|
+
function TestComponent() {
|
|
486
|
+
formRef = useRef<FormRendererHandle>(null);
|
|
487
|
+
return (
|
|
488
|
+
<FormRenderer
|
|
489
|
+
ref={formRef}
|
|
490
|
+
spec={spec}
|
|
491
|
+
initialData={{ name: "Test Value" }}
|
|
492
|
+
components={createTestComponentMap()}
|
|
493
|
+
/>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
render(<TestComponent />);
|
|
498
|
+
|
|
499
|
+
const values = formRef!.current?.getValues();
|
|
500
|
+
|
|
501
|
+
expect(values).toEqual({ name: "Test Value" });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("should expose setValues via ref", async () => {
|
|
505
|
+
const spec = createTestSpec({
|
|
506
|
+
fields: { name: { type: "text" } },
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
let formRef: React.RefObject<FormRendererHandle | null>;
|
|
510
|
+
|
|
511
|
+
function TestComponent() {
|
|
512
|
+
formRef = useRef<FormRendererHandle>(null);
|
|
513
|
+
return (
|
|
514
|
+
<FormRenderer
|
|
515
|
+
ref={formRef}
|
|
516
|
+
spec={spec}
|
|
517
|
+
components={createTestComponentMap()}
|
|
518
|
+
/>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { rerender } = render(<TestComponent />);
|
|
523
|
+
|
|
524
|
+
act(() => {
|
|
525
|
+
formRef!.current?.setValues({ name: "New Value" });
|
|
526
|
+
});
|
|
527
|
+
rerender(<TestComponent />);
|
|
528
|
+
|
|
529
|
+
await waitFor(() => {
|
|
530
|
+
expect(screen.getByRole("textbox")).toHaveValue("New Value");
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("should expose isValid and isDirty via ref", async () => {
|
|
535
|
+
const spec = createTestSpec({
|
|
536
|
+
fields: { name: { type: "text", required: true } },
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
let formRef: React.RefObject<FormRendererHandle | null>;
|
|
540
|
+
|
|
541
|
+
function TestComponent() {
|
|
542
|
+
formRef = useRef<FormRendererHandle>(null);
|
|
543
|
+
return (
|
|
544
|
+
<FormRenderer
|
|
545
|
+
ref={formRef}
|
|
546
|
+
spec={spec}
|
|
547
|
+
components={createTestComponentMap()}
|
|
548
|
+
/>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const { rerender } = render(<TestComponent />);
|
|
553
|
+
|
|
554
|
+
expect(formRef!.current?.isValid).toBe(false);
|
|
555
|
+
expect(formRef!.current?.isDirty).toBe(false);
|
|
556
|
+
|
|
557
|
+
act(() => {
|
|
558
|
+
formRef!.current?.setValues({ name: "Value" });
|
|
559
|
+
});
|
|
560
|
+
rerender(<TestComponent />);
|
|
561
|
+
|
|
562
|
+
await waitFor(() => {
|
|
563
|
+
expect(formRef!.current?.isValid).toBe(true);
|
|
564
|
+
expect(formRef!.current?.isDirty).toBe(true);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// ============================================================================
|
|
570
|
+
// Context
|
|
571
|
+
// ============================================================================
|
|
572
|
+
|
|
573
|
+
describe("context", () => {
|
|
574
|
+
it("should provide form state via FormaContext", () => {
|
|
575
|
+
const spec = createTestSpec({
|
|
576
|
+
fields: { name: { type: "text" } },
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
let contextValue: UseFormaReturn | null = null;
|
|
580
|
+
|
|
581
|
+
function ContextConsumer() {
|
|
582
|
+
contextValue = useFormaContext();
|
|
583
|
+
return <div data-testid="consumer">Consumer</div>;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Custom component that uses context
|
|
587
|
+
const components: ComponentMap = {
|
|
588
|
+
...createTestComponentMap(),
|
|
589
|
+
text: () => <ContextConsumer />,
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
render(
|
|
593
|
+
<FormRenderer
|
|
594
|
+
spec={spec}
|
|
595
|
+
initialData={{ name: "Test" }}
|
|
596
|
+
components={components}
|
|
597
|
+
/>
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
expect(screen.getByTestId("consumer")).toBeInTheDocument();
|
|
601
|
+
expect(contextValue).not.toBeNull();
|
|
602
|
+
expect(contextValue!.data).toEqual({ name: "Test" });
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("should throw error when useFormaContext used outside provider", () => {
|
|
606
|
+
function BadComponent() {
|
|
607
|
+
useFormaContext();
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
expect(() => render(<BadComponent />)).toThrow(
|
|
612
|
+
/useFormaContext must be used within/
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// ============================================================================
|
|
618
|
+
// Wizard / Multi-page Forms
|
|
619
|
+
// ============================================================================
|
|
620
|
+
|
|
621
|
+
describe("wizard / multi-page forms", () => {
|
|
622
|
+
it("should render fields for current page only", () => {
|
|
623
|
+
const spec = createTestSpec({
|
|
624
|
+
fields: {
|
|
625
|
+
name: { type: "text", label: "Name" },
|
|
626
|
+
email: { type: "email", label: "Email" },
|
|
627
|
+
},
|
|
628
|
+
pages: [
|
|
629
|
+
{ id: "page1", title: "Step 1", fields: ["name"] },
|
|
630
|
+
{ id: "page2", title: "Step 2", fields: ["email"] },
|
|
631
|
+
],
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
render(
|
|
635
|
+
<FormRenderer
|
|
636
|
+
spec={spec}
|
|
637
|
+
components={createTestComponentMap()}
|
|
638
|
+
/>
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// First page should be visible
|
|
642
|
+
expect(screen.getByTestId("field-name")).toBeInTheDocument();
|
|
643
|
+
// Second page should not be visible
|
|
644
|
+
expect(screen.queryByTestId("field-email")).not.toBeInTheDocument();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("should use custom page wrapper", () => {
|
|
648
|
+
const spec = createTestSpec({
|
|
649
|
+
fields: { name: { type: "text" } },
|
|
650
|
+
pages: [{ id: "page1", title: "Step 1", fields: ["name"] }],
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Note: forma-oss passes title/description directly, not a page object
|
|
654
|
+
const CustomPageWrapper = ({ title, children }: PageWrapperProps) => (
|
|
655
|
+
<div data-testid="custom-page" data-page-title={title}>
|
|
656
|
+
{children}
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
render(
|
|
661
|
+
<FormRenderer
|
|
662
|
+
spec={spec}
|
|
663
|
+
components={createTestComponentMap()}
|
|
664
|
+
pageWrapper={CustomPageWrapper}
|
|
665
|
+
/>
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const pageWrapper = screen.getByTestId("custom-page");
|
|
669
|
+
expect(pageWrapper).toBeInTheDocument();
|
|
670
|
+
expect(pageWrapper).toHaveAttribute("data-page-title", "Step 1");
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// ============================================================================
|
|
675
|
+
// Visibility
|
|
676
|
+
// ============================================================================
|
|
677
|
+
|
|
678
|
+
describe("visibility", () => {
|
|
679
|
+
it("should hide fields when visibility is false", () => {
|
|
680
|
+
const spec = createTestSpec({
|
|
681
|
+
fields: {
|
|
682
|
+
showDetails: { type: "boolean", label: "Show Details" },
|
|
683
|
+
details: {
|
|
684
|
+
type: "text",
|
|
685
|
+
label: "Details",
|
|
686
|
+
visibleWhen: "showDetails = true",
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
render(
|
|
692
|
+
<FormRenderer
|
|
693
|
+
spec={spec}
|
|
694
|
+
initialData={{ showDetails: false }}
|
|
695
|
+
components={createTestComponentMap()}
|
|
696
|
+
/>
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
// Details field should not be rendered when hidden
|
|
700
|
+
expect(screen.queryByTestId("field-details")).not.toBeInTheDocument();
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("should show fields when visibility becomes true", async () => {
|
|
704
|
+
const user = userEvent.setup();
|
|
705
|
+
const spec = createTestSpec({
|
|
706
|
+
fields: {
|
|
707
|
+
showDetails: { type: "boolean", label: "Show Details" },
|
|
708
|
+
details: {
|
|
709
|
+
type: "text",
|
|
710
|
+
label: "Details",
|
|
711
|
+
visibleWhen: "showDetails = true",
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
render(
|
|
717
|
+
<FormRenderer
|
|
718
|
+
spec={spec}
|
|
719
|
+
initialData={{ showDetails: false }}
|
|
720
|
+
components={createTestComponentMap()}
|
|
721
|
+
/>
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
// Initially hidden
|
|
725
|
+
expect(screen.queryByTestId("field-details")).not.toBeInTheDocument();
|
|
726
|
+
|
|
727
|
+
// Click checkbox to show
|
|
728
|
+
const checkbox = screen.getByRole("checkbox");
|
|
729
|
+
await user.click(checkbox);
|
|
730
|
+
|
|
731
|
+
// Now visible
|
|
732
|
+
await waitFor(() => {
|
|
733
|
+
expect(screen.getByTestId("field-details")).toBeInTheDocument();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// ============================================================================
|
|
739
|
+
// Array Field Interactions
|
|
740
|
+
// ============================================================================
|
|
741
|
+
|
|
742
|
+
describe("array field interactions", () => {
|
|
743
|
+
it("should add item when add button clicked", async () => {
|
|
744
|
+
const user = userEvent.setup();
|
|
745
|
+
const spec = createTestSpec({
|
|
746
|
+
fields: {
|
|
747
|
+
items: {
|
|
748
|
+
type: "array",
|
|
749
|
+
label: "Items",
|
|
750
|
+
itemFields: { name: { type: "text" } },
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
render(
|
|
756
|
+
<FormRenderer
|
|
757
|
+
spec={spec}
|
|
758
|
+
initialData={{ items: [] }}
|
|
759
|
+
components={createTestComponentMap()}
|
|
760
|
+
/>
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const addButton = screen.getByTestId("add-items");
|
|
764
|
+
await user.click(addButton);
|
|
765
|
+
|
|
766
|
+
await waitFor(() => {
|
|
767
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("should remove item when remove button clicked", async () => {
|
|
772
|
+
const user = userEvent.setup();
|
|
773
|
+
const spec = createTestSpec({
|
|
774
|
+
fields: {
|
|
775
|
+
items: {
|
|
776
|
+
type: "array",
|
|
777
|
+
label: "Items",
|
|
778
|
+
itemFields: { name: { type: "text" } },
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
render(
|
|
784
|
+
<FormRenderer
|
|
785
|
+
spec={spec}
|
|
786
|
+
initialData={{ items: [{ name: "Item 1" }, { name: "Item 2" }] }}
|
|
787
|
+
components={createTestComponentMap()}
|
|
788
|
+
/>
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
792
|
+
expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
|
|
793
|
+
|
|
794
|
+
const removeButton = screen.getByTestId("remove-items-0");
|
|
795
|
+
await user.click(removeButton);
|
|
796
|
+
|
|
797
|
+
await waitFor(() => {
|
|
798
|
+
// After removing first item, we should have only 1 item (at index 0)
|
|
799
|
+
expect(screen.queryByTestId("array-item-items-1")).not.toBeInTheDocument();
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
});
|