@fogpipe/forma-react 0.17.1 → 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 (36) hide show
  1. package/README.md +82 -0
  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 +7 -559
  10. package/dist/index.js +28 -1292
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/__tests__/defaults/components.test.tsx +818 -0
  14. package/src/__tests__/defaults/integration.test.tsx +494 -0
  15. package/src/__tests__/defaults/layout.test.tsx +298 -0
  16. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  17. package/src/defaults/componentMap.ts +45 -0
  18. package/src/defaults/components/ArrayField.tsx +183 -0
  19. package/src/defaults/components/BooleanInput.tsx +32 -0
  20. package/src/defaults/components/ComputedDisplay.tsx +26 -0
  21. package/src/defaults/components/DateInput.tsx +59 -0
  22. package/src/defaults/components/DisplayField.tsx +15 -0
  23. package/src/defaults/components/FallbackField.tsx +35 -0
  24. package/src/defaults/components/MatrixField.tsx +98 -0
  25. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  26. package/src/defaults/components/NumberInput.tsx +73 -0
  27. package/src/defaults/components/ObjectField.tsx +22 -0
  28. package/src/defaults/components/SelectInput.tsx +44 -0
  29. package/src/defaults/components/TextInput.tsx +48 -0
  30. package/src/defaults/components/TextareaInput.tsx +46 -0
  31. package/src/defaults/index.ts +33 -0
  32. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  33. package/src/defaults/layout/FormLayout.tsx +34 -0
  34. package/src/defaults/layout/PageWrapper.tsx +18 -0
  35. package/src/defaults/layout/WizardLayout.tsx +130 -0
  36. package/src/defaults/styles/forma-defaults.css +696 -0
@@ -0,0 +1,494 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ /**
3
+ * Integration tests for DefaultFormRenderer
4
+ */
5
+ import React from "react";
6
+ import { render, screen, waitFor } from "@testing-library/react";
7
+ import userEvent from "@testing-library/user-event";
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import { DefaultFormRenderer } from "../../defaults/DefaultFormRenderer.js";
10
+ import {
11
+ defaultComponentMap,
12
+ defaultFieldWrapper,
13
+ defaultLayout,
14
+ } from "../../defaults/componentMap.js";
15
+ import { createTestSpec } from "../test-utils.js";
16
+ import type { FormRendererHandle } from "../../FormRenderer.js";
17
+
18
+ // ============================================================================
19
+ // DefaultFormRenderer
20
+ // ============================================================================
21
+ describe("DefaultFormRenderer", () => {
22
+ it("renders a form with just spec and onSubmit (minimal props)", () => {
23
+ const spec = createTestSpec({
24
+ fields: {
25
+ name: { type: "text", label: "Name" },
26
+ },
27
+ });
28
+ render(<DefaultFormRenderer spec={spec} onSubmit={vi.fn()} />);
29
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
30
+ });
31
+
32
+ it("uses defaultComponentMap when components not provided", () => {
33
+ const spec = createTestSpec({
34
+ fields: {
35
+ name: { type: "text", label: "Name" },
36
+ },
37
+ });
38
+ render(<DefaultFormRenderer spec={spec} onSubmit={vi.fn()} />);
39
+ // The default TextInput renders a native input
40
+ const input = screen.getByLabelText("Name");
41
+ expect(input.tagName).toBe("INPUT");
42
+ });
43
+
44
+ it("uses provided components when explicitly passed (override)", () => {
45
+ const CustomText = ({ field }: { field: { label: string } }) => (
46
+ <div data-testid="custom">Custom: {field.label}</div>
47
+ );
48
+ const spec = createTestSpec({
49
+ fields: {
50
+ name: { type: "text", label: "Name" },
51
+ },
52
+ });
53
+ render(
54
+ <DefaultFormRenderer
55
+ spec={spec}
56
+ onSubmit={vi.fn()}
57
+ components={{ ...defaultComponentMap, text: CustomText as never }}
58
+ />,
59
+ );
60
+ expect(screen.getByTestId("custom")).toHaveTextContent("Custom: Name");
61
+ });
62
+
63
+ it("forwards ref correctly", async () => {
64
+ const ref = React.createRef<FormRendererHandle>();
65
+ const spec = createTestSpec({
66
+ fields: {
67
+ name: { type: "text", label: "Name" },
68
+ },
69
+ });
70
+ render(<DefaultFormRenderer ref={ref} spec={spec} onSubmit={vi.fn()} />);
71
+ expect(ref.current).toBeTruthy();
72
+ expect(typeof ref.current!.submitForm).toBe("function");
73
+ expect(typeof ref.current!.getValues).toBe("function");
74
+ });
75
+ });
76
+
77
+ // ============================================================================
78
+ // componentMap exports
79
+ // ============================================================================
80
+ describe("defaultComponentMap", () => {
81
+ it("has entries for all 18 field types + fallback", () => {
82
+ const expectedKeys = [
83
+ "text",
84
+ "email",
85
+ "phone",
86
+ "url",
87
+ "password",
88
+ "textarea",
89
+ "number",
90
+ "integer",
91
+ "boolean",
92
+ "date",
93
+ "datetime",
94
+ "select",
95
+ "multiselect",
96
+ "array",
97
+ "object",
98
+ "computed",
99
+ "display",
100
+ "matrix",
101
+ "fallback",
102
+ ];
103
+ for (const key of expectedKeys) {
104
+ expect(defaultComponentMap[key as keyof typeof defaultComponentMap]).toBeDefined();
105
+ }
106
+ });
107
+
108
+ it("all entries are valid React components", () => {
109
+ for (const [, component] of Object.entries(defaultComponentMap)) {
110
+ expect(typeof component).toBe("function");
111
+ }
112
+ });
113
+ });
114
+
115
+ describe("layout exports", () => {
116
+ it("defaultFieldWrapper is defined", () => {
117
+ expect(defaultFieldWrapper).toBeDefined();
118
+ expect(typeof defaultFieldWrapper).toBe("function");
119
+ });
120
+
121
+ it("defaultLayout is defined", () => {
122
+ expect(defaultLayout).toBeDefined();
123
+ expect(typeof defaultLayout).toBe("function");
124
+ });
125
+ });
126
+
127
+ // ============================================================================
128
+ // Simple Form Integration
129
+ // ============================================================================
130
+ describe("Simple form integration", () => {
131
+ it("fills in text, number, boolean, select fields and submits", async () => {
132
+ const onSubmit = vi.fn();
133
+ const user = userEvent.setup();
134
+ const spec = createTestSpec({
135
+ fields: {
136
+ name: { type: "text", label: "Name" },
137
+ age: { type: "number", label: "Age" },
138
+ agree: { type: "boolean", label: "I agree" },
139
+ color: {
140
+ type: "select",
141
+ label: "Color",
142
+ options: [
143
+ { value: "red", label: "Red" },
144
+ { value: "blue", label: "Blue" },
145
+ ],
146
+ },
147
+ },
148
+ });
149
+
150
+ render(<DefaultFormRenderer spec={spec} onSubmit={onSubmit} />);
151
+
152
+ // Fill in text field
153
+ await user.type(screen.getByLabelText("Name"), "John");
154
+
155
+ // Fill in number field
156
+ await user.type(screen.getByLabelText("Age"), "30");
157
+
158
+ // Check boolean
159
+ await user.click(screen.getByLabelText("I agree"));
160
+
161
+ // Select an option
162
+ await user.selectOptions(screen.getByLabelText("Color"), "blue");
163
+
164
+ // Submit
165
+ await user.click(screen.getByRole("button", { name: "Submit" }));
166
+
167
+ await waitFor(() => {
168
+ expect(onSubmit).toHaveBeenCalled();
169
+ });
170
+
171
+ const data = onSubmit.mock.calls[0][0];
172
+ expect(data.name).toBe("John");
173
+ expect(data.age).toBe(30);
174
+ expect(data.agree).toBe(true);
175
+ expect(data.color).toBe("blue");
176
+ });
177
+ });
178
+
179
+ // ============================================================================
180
+ // Validation Integration
181
+ // ============================================================================
182
+ describe("Validation integration", () => {
183
+ it("shows validation errors after submission", async () => {
184
+ const onSubmit = vi.fn();
185
+ const user = userEvent.setup();
186
+ const spec = createTestSpec({
187
+ fields: {
188
+ name: { type: "text", label: "Name", required: true },
189
+ },
190
+ });
191
+
192
+ render(<DefaultFormRenderer spec={spec} onSubmit={onSubmit} />);
193
+
194
+ // Submit without filling in required field
195
+ await user.click(screen.getByRole("button", { name: "Submit" }));
196
+
197
+ await waitFor(() => {
198
+ // After submit, isSubmitted is true so FieldWrapper shows errors
199
+ const alerts = screen.queryAllByRole("alert");
200
+ expect(alerts.length).toBeGreaterThan(0);
201
+ });
202
+ });
203
+ });
204
+
205
+ // ============================================================================
206
+ // Touched-gating Integration
207
+ // ============================================================================
208
+ describe("Touched-gating integration", () => {
209
+ it("does NOT show errors on initial render", () => {
210
+ const spec = createTestSpec({
211
+ fields: {
212
+ name: { type: "text", label: "Name", required: true },
213
+ },
214
+ });
215
+
216
+ render(<DefaultFormRenderer spec={spec} onSubmit={vi.fn()} />);
217
+
218
+ // No errors visible initially
219
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
220
+ });
221
+
222
+ it("shows error after blur on empty required field", async () => {
223
+ const user = userEvent.setup();
224
+ const spec = createTestSpec({
225
+ fields: {
226
+ name: { type: "text", label: "Name", required: true },
227
+ },
228
+ });
229
+
230
+ render(
231
+ <DefaultFormRenderer spec={spec} onSubmit={vi.fn()} validateOn="blur" />,
232
+ );
233
+
234
+ // Tab through the field (focus + blur)
235
+ const input = screen.getByRole("textbox");
236
+ await user.click(input);
237
+ await user.tab();
238
+
239
+ await waitFor(() => {
240
+ expect(screen.getByRole("alert")).toBeInTheDocument();
241
+ });
242
+ });
243
+ });
244
+
245
+ // ============================================================================
246
+ // Component Override Integration
247
+ // ============================================================================
248
+ describe("Component override integration", () => {
249
+ it("renders custom component for overridden field type, defaults for others", () => {
250
+ const CustomSelect = () => (
251
+ <div data-testid="custom-select">Custom Select</div>
252
+ );
253
+ const spec = createTestSpec({
254
+ fields: {
255
+ name: { type: "text", label: "Name" },
256
+ color: {
257
+ type: "select",
258
+ label: "Color",
259
+ options: [{ value: "red", label: "Red" }],
260
+ },
261
+ },
262
+ });
263
+
264
+ render(
265
+ <DefaultFormRenderer
266
+ spec={spec}
267
+ onSubmit={vi.fn()}
268
+ components={{ ...defaultComponentMap, select: CustomSelect as never }}
269
+ />,
270
+ );
271
+
272
+ // Custom select rendered
273
+ expect(screen.getByTestId("custom-select")).toBeInTheDocument();
274
+ // Default text input still works
275
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
276
+ });
277
+ });
278
+
279
+ // ============================================================================
280
+ // Wizard Form Integration
281
+ // ============================================================================
282
+ describe("Wizard form integration", () => {
283
+ function createWizardSpec() {
284
+ return createTestSpec({
285
+ fields: {
286
+ name: { type: "text", label: "Name" },
287
+ email: { type: "text", label: "Email" },
288
+ city: { type: "text", label: "City" },
289
+ },
290
+ pages: [
291
+ { id: "page1", title: "Personal", fields: ["name"] },
292
+ { id: "page2", title: "Contact", fields: ["email"] },
293
+ { id: "page3", title: "Address", fields: ["city"] },
294
+ ],
295
+ });
296
+ }
297
+
298
+ it("renders page 1 fields initially", () => {
299
+ render(
300
+ <DefaultFormRenderer
301
+ spec={createWizardSpec()}
302
+ onSubmit={vi.fn()}
303
+ wizardLayout
304
+ />,
305
+ );
306
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
307
+ expect(screen.queryByLabelText("Email")).not.toBeInTheDocument();
308
+ });
309
+
310
+ it("navigates Next to page 2 and Previous back", async () => {
311
+ const user = userEvent.setup();
312
+ render(
313
+ <DefaultFormRenderer
314
+ spec={createWizardSpec()}
315
+ onSubmit={vi.fn()}
316
+ wizardLayout
317
+ />,
318
+ );
319
+
320
+ // Page 1 — click Next
321
+ await user.click(screen.getByRole("button", { name: "Next" }));
322
+ await waitFor(() => {
323
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
324
+ });
325
+ expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
326
+
327
+ // Page 2 — click Previous
328
+ await user.click(screen.getByRole("button", { name: "Previous" }));
329
+ await waitFor(() => {
330
+ expect(screen.getByLabelText("Name")).toBeInTheDocument();
331
+ });
332
+ });
333
+
334
+ it("shows Submit button only on last page", async () => {
335
+ const user = userEvent.setup();
336
+ render(
337
+ <DefaultFormRenderer
338
+ spec={createWizardSpec()}
339
+ onSubmit={vi.fn()}
340
+ wizardLayout
341
+ />,
342
+ );
343
+
344
+ // Page 1 — no Submit, has Next
345
+ expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
346
+ expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
347
+
348
+ // Navigate to last page
349
+ await user.click(screen.getByRole("button", { name: "Next" }));
350
+ await waitFor(() => screen.getByLabelText("Email"));
351
+ await user.click(screen.getByRole("button", { name: "Next" }));
352
+
353
+ // Last page — has Submit, no Next
354
+ await waitFor(() => {
355
+ expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
356
+ });
357
+ expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument();
358
+ });
359
+
360
+ it("renders step indicator with correct count", () => {
361
+ const { container } = render(
362
+ <DefaultFormRenderer
363
+ spec={createWizardSpec()}
364
+ onSubmit={vi.fn()}
365
+ wizardLayout
366
+ />,
367
+ );
368
+ // Step indicator labels (inside .forma-step__label spans)
369
+ const stepLabels = container.querySelectorAll(".forma-step__label");
370
+ expect(stepLabels).toHaveLength(3);
371
+ expect(stepLabels[0].textContent).toBe("Personal");
372
+ expect(stepLabels[1].textContent).toBe("Contact");
373
+ expect(stepLabels[2].textContent).toBe("Address");
374
+ });
375
+ });
376
+
377
+ // ============================================================================
378
+ // Wizard Validation Integration
379
+ // ============================================================================
380
+ describe("Wizard validation integration", () => {
381
+ it("does NOT advance when required field is empty", async () => {
382
+ const user = userEvent.setup();
383
+ const spec = createTestSpec({
384
+ fields: {
385
+ name: { type: "text", label: "Name", required: true },
386
+ email: { type: "text", label: "Email" },
387
+ },
388
+ pages: [
389
+ { id: "page1", title: "Step 1", fields: ["name"] },
390
+ { id: "page2", title: "Step 2", fields: ["email"] },
391
+ ],
392
+ });
393
+
394
+ const { container } = render(
395
+ <DefaultFormRenderer spec={spec} onSubmit={vi.fn()} wizardLayout />,
396
+ );
397
+
398
+ // Verify we're on page 1
399
+ const page1Title = container.querySelector(".forma-page__title");
400
+ expect(page1Title?.textContent).toBe("Step 1");
401
+
402
+ // Click Next without filling in required name
403
+ await user.click(screen.getByRole("button", { name: "Next" }));
404
+
405
+ // Should still be on page 1
406
+ await waitFor(() => {
407
+ const currentTitle = container.querySelector(".forma-page__title");
408
+ expect(currentTitle?.textContent).toBe("Step 1");
409
+ });
410
+ });
411
+ });
412
+
413
+ // ============================================================================
414
+ // Array Field Integration
415
+ // ============================================================================
416
+ describe("Array field integration", () => {
417
+ it("adds items, fills sub-fields, removes an item, and submits", async () => {
418
+ const onSubmit = vi.fn();
419
+ const user = userEvent.setup();
420
+ const spec = createTestSpec({
421
+ fields: {
422
+ items: {
423
+ type: "array",
424
+ label: "Items",
425
+ itemFields: {
426
+ title: { type: "text", label: "Title" },
427
+ },
428
+ itemFieldOrder: ["title"],
429
+ items: { type: "object", properties: { title: { type: "string" } } },
430
+ },
431
+ },
432
+ });
433
+
434
+ render(<DefaultFormRenderer spec={spec} onSubmit={onSubmit} />);
435
+
436
+ // Initially empty
437
+ expect(screen.getByText("No items")).toBeInTheDocument();
438
+
439
+ // Add two items
440
+ await user.click(screen.getByRole("button", { name: "+ Add Item" }));
441
+ await user.click(screen.getByRole("button", { name: "+ Add Item" }));
442
+
443
+ // No items message gone
444
+ expect(screen.queryByText("No items")).not.toBeInTheDocument();
445
+
446
+ // Two title inputs should exist
447
+ const inputs = screen.getAllByRole("textbox");
448
+ expect(inputs).toHaveLength(2);
449
+
450
+ // Fill them in
451
+ await user.type(inputs[0], "First");
452
+ await user.type(inputs[1], "Second");
453
+
454
+ // Remove the first item
455
+ const removeButtons = screen.getAllByRole("button", { name: "Remove" });
456
+ await user.click(removeButtons[0]);
457
+
458
+ // Only one input left
459
+ await waitFor(() => {
460
+ expect(screen.getAllByRole("textbox")).toHaveLength(1);
461
+ });
462
+
463
+ // Submit
464
+ await user.click(screen.getByRole("button", { name: "Submit" }));
465
+ await waitFor(() => {
466
+ expect(onSubmit).toHaveBeenCalled();
467
+ });
468
+
469
+ const data = onSubmit.mock.calls[0][0];
470
+ expect(data.items).toHaveLength(1);
471
+ expect(data.items[0].title).toBe("Second");
472
+ });
473
+ });
474
+
475
+ // ============================================================================
476
+ // Disabled/Readonly Integration
477
+ // ============================================================================
478
+ describe("Disabled field integration", () => {
479
+ it("renders disabled fields as not editable", () => {
480
+ const spec = createTestSpec({
481
+ fields: {
482
+ name: {
483
+ type: "text",
484
+ label: "Name",
485
+ enabledWhen: "false",
486
+ },
487
+ },
488
+ });
489
+
490
+ render(<DefaultFormRenderer spec={spec} onSubmit={vi.fn()} />);
491
+
492
+ expect(screen.getByLabelText("Name")).toBeDisabled();
493
+ });
494
+ });