@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.
- package/README.md +82 -0
- 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 +7 -559
- package/dist/index.js +28 -1292
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- 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/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
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/// <reference types="@testing-library/jest-dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Tests for default field components
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
7
|
+
import userEvent from "@testing-library/user-event";
|
|
8
|
+
import { describe, it, expect, vi } from "vitest";
|
|
9
|
+
import type { Forma } from "@fogpipe/forma-core";
|
|
10
|
+
import type {
|
|
11
|
+
TextFieldProps,
|
|
12
|
+
NumberFieldProps,
|
|
13
|
+
IntegerFieldProps,
|
|
14
|
+
BooleanFieldProps,
|
|
15
|
+
DateFieldProps,
|
|
16
|
+
DateTimeFieldProps,
|
|
17
|
+
SelectFieldProps,
|
|
18
|
+
MultiSelectFieldProps,
|
|
19
|
+
ComputedFieldProps,
|
|
20
|
+
DisplayFieldProps,
|
|
21
|
+
MatrixFieldProps,
|
|
22
|
+
} from "../../types.js";
|
|
23
|
+
import { TextInput } from "../../defaults/components/TextInput.js";
|
|
24
|
+
import { TextareaInput } from "../../defaults/components/TextareaInput.js";
|
|
25
|
+
import {
|
|
26
|
+
NumberInput,
|
|
27
|
+
IntegerInput,
|
|
28
|
+
} from "../../defaults/components/NumberInput.js";
|
|
29
|
+
import { BooleanInput } from "../../defaults/components/BooleanInput.js";
|
|
30
|
+
import { DateInput, DateTimeInput } from "../../defaults/components/DateInput.js";
|
|
31
|
+
import { SelectInput } from "../../defaults/components/SelectInput.js";
|
|
32
|
+
import { MultiSelectInput } from "../../defaults/components/MultiSelectInput.js";
|
|
33
|
+
import { ComputedDisplay } from "../../defaults/components/ComputedDisplay.js";
|
|
34
|
+
import { DisplayField } from "../../defaults/components/DisplayField.js";
|
|
35
|
+
import { MatrixField } from "../../defaults/components/MatrixField.js";
|
|
36
|
+
import { FallbackField } from "../../defaults/components/FallbackField.js";
|
|
37
|
+
|
|
38
|
+
const mockSpec = {
|
|
39
|
+
version: "1.0",
|
|
40
|
+
meta: { id: "test", title: "Test" },
|
|
41
|
+
schema: { type: "object", properties: {} },
|
|
42
|
+
fields: {},
|
|
43
|
+
fieldOrder: [],
|
|
44
|
+
} as unknown as Forma;
|
|
45
|
+
|
|
46
|
+
function makeBaseProps(overrides: Record<string, unknown> = {}) {
|
|
47
|
+
return {
|
|
48
|
+
name: "testField",
|
|
49
|
+
field: { type: "text", label: "Test Field" },
|
|
50
|
+
touched: false,
|
|
51
|
+
required: false,
|
|
52
|
+
disabled: false,
|
|
53
|
+
errors: [],
|
|
54
|
+
visibleErrors: [],
|
|
55
|
+
onBlur: vi.fn(),
|
|
56
|
+
visible: true,
|
|
57
|
+
enabled: true,
|
|
58
|
+
readonly: false,
|
|
59
|
+
label: "Test Field",
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// TextInput
|
|
66
|
+
// ============================================================================
|
|
67
|
+
describe("TextInput", () => {
|
|
68
|
+
function makeTextProps(
|
|
69
|
+
overrides: Record<string, unknown> = {},
|
|
70
|
+
): TextFieldProps {
|
|
71
|
+
return {
|
|
72
|
+
...makeBaseProps(),
|
|
73
|
+
fieldType: "text",
|
|
74
|
+
value: "",
|
|
75
|
+
onChange: vi.fn(),
|
|
76
|
+
...overrides,
|
|
77
|
+
} as unknown as TextFieldProps;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("renders an input with correct type for text", () => {
|
|
81
|
+
render(<TextInput field={makeTextProps()} spec={mockSpec} />);
|
|
82
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("type", "text");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders tel type for phone fieldType", () => {
|
|
86
|
+
render(
|
|
87
|
+
<TextInput
|
|
88
|
+
field={makeTextProps({ fieldType: "phone" })}
|
|
89
|
+
spec={mockSpec}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("type", "tel");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders email type for email fieldType", () => {
|
|
96
|
+
render(
|
|
97
|
+
<TextInput
|
|
98
|
+
field={makeTextProps({ fieldType: "email" })}
|
|
99
|
+
spec={mockSpec}
|
|
100
|
+
/>,
|
|
101
|
+
);
|
|
102
|
+
const input = document.querySelector('input[type="email"]');
|
|
103
|
+
expect(input).toBeTruthy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("fires onChange with string value", () => {
|
|
107
|
+
const onChange = vi.fn();
|
|
108
|
+
render(
|
|
109
|
+
<TextInput field={makeTextProps({ onChange })} spec={mockSpec} />,
|
|
110
|
+
);
|
|
111
|
+
fireEvent.change(screen.getByRole("textbox"), {
|
|
112
|
+
target: { value: "hello" },
|
|
113
|
+
});
|
|
114
|
+
expect(onChange).toHaveBeenCalledWith("hello");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("fires onBlur", () => {
|
|
118
|
+
const onBlur = vi.fn();
|
|
119
|
+
render(
|
|
120
|
+
<TextInput field={makeTextProps({ onBlur })} spec={mockSpec} />,
|
|
121
|
+
);
|
|
122
|
+
fireEvent.blur(screen.getByRole("textbox"));
|
|
123
|
+
expect(onBlur).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("renders disabled state", () => {
|
|
127
|
+
render(
|
|
128
|
+
<TextInput
|
|
129
|
+
field={makeTextProps({ disabled: true })}
|
|
130
|
+
spec={mockSpec}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
expect(screen.getByRole("textbox")).toBeDisabled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("renders readonly state", () => {
|
|
137
|
+
render(
|
|
138
|
+
<TextInput
|
|
139
|
+
field={makeTextProps({ readonly: true })}
|
|
140
|
+
spec={mockSpec}
|
|
141
|
+
/>,
|
|
142
|
+
);
|
|
143
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("readonly");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("sets aria-invalid when there are visible errors", () => {
|
|
147
|
+
render(
|
|
148
|
+
<TextInput
|
|
149
|
+
field={makeTextProps({
|
|
150
|
+
visibleErrors: [{ field: "testField", message: "Required" }],
|
|
151
|
+
})}
|
|
152
|
+
spec={mockSpec}
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
expect(screen.getByRole("textbox")).toHaveAttribute(
|
|
156
|
+
"aria-invalid",
|
|
157
|
+
"true",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sets aria-required when required", () => {
|
|
162
|
+
render(
|
|
163
|
+
<TextInput
|
|
164
|
+
field={makeTextProps({ required: true })}
|
|
165
|
+
spec={mockSpec}
|
|
166
|
+
/>,
|
|
167
|
+
);
|
|
168
|
+
expect(screen.getByRole("textbox")).toHaveAttribute(
|
|
169
|
+
"aria-required",
|
|
170
|
+
"true",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("renders adorners when prefix and suffix provided", () => {
|
|
175
|
+
render(
|
|
176
|
+
<TextInput
|
|
177
|
+
field={makeTextProps({ prefix: "$", suffix: "USD" })}
|
|
178
|
+
spec={mockSpec}
|
|
179
|
+
/>,
|
|
180
|
+
);
|
|
181
|
+
expect(screen.getByText("$")).toBeTruthy();
|
|
182
|
+
expect(screen.getByText("USD")).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// TextareaInput
|
|
188
|
+
// ============================================================================
|
|
189
|
+
describe("TextareaInput", () => {
|
|
190
|
+
function makeTextareaProps(
|
|
191
|
+
overrides: Record<string, unknown> = {},
|
|
192
|
+
): TextFieldProps {
|
|
193
|
+
return {
|
|
194
|
+
...makeBaseProps(),
|
|
195
|
+
fieldType: "textarea",
|
|
196
|
+
value: "",
|
|
197
|
+
onChange: vi.fn(),
|
|
198
|
+
...overrides,
|
|
199
|
+
} as unknown as TextFieldProps;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("renders a textarea element", () => {
|
|
203
|
+
render(
|
|
204
|
+
<TextareaInput field={makeTextareaProps()} spec={mockSpec} />,
|
|
205
|
+
);
|
|
206
|
+
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
207
|
+
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("fires onChange with string value", () => {
|
|
211
|
+
const onChange = vi.fn();
|
|
212
|
+
render(
|
|
213
|
+
<TextareaInput
|
|
214
|
+
field={makeTextareaProps({ onChange })}
|
|
215
|
+
spec={mockSpec}
|
|
216
|
+
/>,
|
|
217
|
+
);
|
|
218
|
+
fireEvent.change(screen.getByRole("textbox"), {
|
|
219
|
+
target: { value: "hello" },
|
|
220
|
+
});
|
|
221
|
+
expect(onChange).toHaveBeenCalledWith("hello");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ============================================================================
|
|
226
|
+
// NumberInput
|
|
227
|
+
// ============================================================================
|
|
228
|
+
describe("NumberInput", () => {
|
|
229
|
+
function makeNumberProps(
|
|
230
|
+
overrides: Record<string, unknown> = {},
|
|
231
|
+
): NumberFieldProps {
|
|
232
|
+
return {
|
|
233
|
+
...makeBaseProps(),
|
|
234
|
+
fieldType: "number",
|
|
235
|
+
value: null,
|
|
236
|
+
onChange: vi.fn(),
|
|
237
|
+
...overrides,
|
|
238
|
+
} as unknown as NumberFieldProps;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
it("renders a number input", () => {
|
|
242
|
+
render(<NumberInput field={makeNumberProps()} spec={mockSpec} />);
|
|
243
|
+
expect(screen.getByRole("spinbutton")).toHaveAttribute("type", "number");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("converts empty string to null", async () => {
|
|
247
|
+
const onChange = vi.fn();
|
|
248
|
+
render(
|
|
249
|
+
<NumberInput
|
|
250
|
+
field={makeNumberProps({ value: 5, onChange })}
|
|
251
|
+
spec={mockSpec}
|
|
252
|
+
/>,
|
|
253
|
+
);
|
|
254
|
+
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
|
255
|
+
// Use userEvent for reliable number input interaction
|
|
256
|
+
await userEvent.clear(input);
|
|
257
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("parses valid number with parseFloat", async () => {
|
|
261
|
+
const onChange = vi.fn();
|
|
262
|
+
render(
|
|
263
|
+
<NumberInput field={makeNumberProps({ onChange })} spec={mockSpec} />,
|
|
264
|
+
);
|
|
265
|
+
const input = screen.getByRole("spinbutton");
|
|
266
|
+
// Type a single digit — jsdom handles single digits on number inputs
|
|
267
|
+
await userEvent.type(input, "5");
|
|
268
|
+
expect(onChange).toHaveBeenCalledWith(5);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("displays value correctly for number", () => {
|
|
272
|
+
render(
|
|
273
|
+
<NumberInput
|
|
274
|
+
field={makeNumberProps({ value: 3.14 })}
|
|
275
|
+
spec={mockSpec}
|
|
276
|
+
/>,
|
|
277
|
+
);
|
|
278
|
+
expect(screen.getByRole("spinbutton")).toHaveValue(3.14);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("passes min/max/step to HTML", () => {
|
|
282
|
+
render(
|
|
283
|
+
<NumberInput
|
|
284
|
+
field={makeNumberProps({ min: 0, max: 100, step: 0.5 })}
|
|
285
|
+
spec={mockSpec}
|
|
286
|
+
/>,
|
|
287
|
+
);
|
|
288
|
+
const input = screen.getByRole("spinbutton");
|
|
289
|
+
expect(input).toHaveAttribute("min", "0");
|
|
290
|
+
expect(input).toHaveAttribute("max", "100");
|
|
291
|
+
expect(input).toHaveAttribute("step", "0.5");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("renders adorners", () => {
|
|
295
|
+
render(
|
|
296
|
+
<NumberInput
|
|
297
|
+
field={makeNumberProps({ prefix: "$", suffix: "USD" })}
|
|
298
|
+
spec={mockSpec}
|
|
299
|
+
/>,
|
|
300
|
+
);
|
|
301
|
+
expect(screen.getByText("$")).toBeTruthy();
|
|
302
|
+
expect(screen.getByText("USD")).toBeTruthy();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ============================================================================
|
|
307
|
+
// IntegerInput
|
|
308
|
+
// ============================================================================
|
|
309
|
+
describe("IntegerInput", () => {
|
|
310
|
+
function makeIntegerProps(
|
|
311
|
+
overrides: Record<string, unknown> = {},
|
|
312
|
+
): IntegerFieldProps {
|
|
313
|
+
return {
|
|
314
|
+
...makeBaseProps(),
|
|
315
|
+
fieldType: "integer",
|
|
316
|
+
value: null,
|
|
317
|
+
onChange: vi.fn(),
|
|
318
|
+
...overrides,
|
|
319
|
+
} as unknown as IntegerFieldProps;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
it("parses with parseInt", () => {
|
|
323
|
+
const onChange = vi.fn();
|
|
324
|
+
render(
|
|
325
|
+
<IntegerInput
|
|
326
|
+
field={makeIntegerProps({ onChange })}
|
|
327
|
+
spec={mockSpec}
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
fireEvent.change(screen.getByRole("spinbutton"), {
|
|
331
|
+
target: { value: "42" },
|
|
332
|
+
});
|
|
333
|
+
expect(onChange).toHaveBeenCalledWith(42);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("defaults step to 1", () => {
|
|
337
|
+
render(
|
|
338
|
+
<IntegerInput field={makeIntegerProps()} spec={mockSpec} />,
|
|
339
|
+
);
|
|
340
|
+
expect(screen.getByRole("spinbutton")).toHaveAttribute("step", "1");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// BooleanInput
|
|
346
|
+
// ============================================================================
|
|
347
|
+
describe("BooleanInput", () => {
|
|
348
|
+
function makeBooleanProps(
|
|
349
|
+
overrides: Record<string, unknown> = {},
|
|
350
|
+
): BooleanFieldProps {
|
|
351
|
+
return {
|
|
352
|
+
...makeBaseProps(),
|
|
353
|
+
fieldType: "boolean",
|
|
354
|
+
value: false,
|
|
355
|
+
onChange: vi.fn(),
|
|
356
|
+
...overrides,
|
|
357
|
+
} as unknown as BooleanFieldProps;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
it("renders a checkbox", () => {
|
|
361
|
+
render(
|
|
362
|
+
<BooleanInput field={makeBooleanProps()} spec={mockSpec} />,
|
|
363
|
+
);
|
|
364
|
+
expect(screen.getByRole("checkbox")).toBeInTheDocument();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("toggles checked state", () => {
|
|
368
|
+
const onChange = vi.fn();
|
|
369
|
+
render(
|
|
370
|
+
<BooleanInput
|
|
371
|
+
field={makeBooleanProps({ onChange })}
|
|
372
|
+
spec={mockSpec}
|
|
373
|
+
/>,
|
|
374
|
+
);
|
|
375
|
+
fireEvent.click(screen.getByRole("checkbox"));
|
|
376
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("renders unchecked when value is false", () => {
|
|
380
|
+
render(
|
|
381
|
+
<BooleanInput
|
|
382
|
+
field={makeBooleanProps({ value: false })}
|
|
383
|
+
spec={mockSpec}
|
|
384
|
+
/>,
|
|
385
|
+
);
|
|
386
|
+
expect(screen.getByRole("checkbox")).not.toBeChecked();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("renders checked when value is true", () => {
|
|
390
|
+
render(
|
|
391
|
+
<BooleanInput
|
|
392
|
+
field={makeBooleanProps({ value: true })}
|
|
393
|
+
spec={mockSpec}
|
|
394
|
+
/>,
|
|
395
|
+
);
|
|
396
|
+
expect(screen.getByRole("checkbox")).toBeChecked();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("disables when disabled", () => {
|
|
400
|
+
render(
|
|
401
|
+
<BooleanInput
|
|
402
|
+
field={makeBooleanProps({ disabled: true })}
|
|
403
|
+
spec={mockSpec}
|
|
404
|
+
/>,
|
|
405
|
+
);
|
|
406
|
+
expect(screen.getByRole("checkbox")).toBeDisabled();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// DateInput
|
|
412
|
+
// ============================================================================
|
|
413
|
+
describe("DateInput", () => {
|
|
414
|
+
function makeDateProps(
|
|
415
|
+
overrides: Record<string, unknown> = {},
|
|
416
|
+
): DateFieldProps {
|
|
417
|
+
return {
|
|
418
|
+
...makeBaseProps(),
|
|
419
|
+
fieldType: "date",
|
|
420
|
+
value: null,
|
|
421
|
+
onChange: vi.fn(),
|
|
422
|
+
...overrides,
|
|
423
|
+
} as unknown as DateFieldProps;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
it("renders date input", () => {
|
|
427
|
+
const { container } = render(
|
|
428
|
+
<DateInput field={makeDateProps()} spec={mockSpec} />,
|
|
429
|
+
);
|
|
430
|
+
const input = container.querySelector('input[type="date"]');
|
|
431
|
+
expect(input).toBeTruthy();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("passes null for empty value", () => {
|
|
435
|
+
const onChange = vi.fn();
|
|
436
|
+
const { container } = render(
|
|
437
|
+
<DateInput
|
|
438
|
+
field={makeDateProps({ value: "2024-01-01", onChange })}
|
|
439
|
+
spec={mockSpec}
|
|
440
|
+
/>,
|
|
441
|
+
);
|
|
442
|
+
const input = container.querySelector('input[type="date"]')! as HTMLInputElement;
|
|
443
|
+
// Set value directly and dispatch change since jsdom date inputs are limited
|
|
444
|
+
Object.getOwnPropertyDescriptor(
|
|
445
|
+
HTMLInputElement.prototype,
|
|
446
|
+
"value",
|
|
447
|
+
)!.set!.call(input, "");
|
|
448
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
449
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("passes date string for valid value", () => {
|
|
453
|
+
const onChange = vi.fn();
|
|
454
|
+
const { container } = render(
|
|
455
|
+
<DateInput field={makeDateProps({ onChange })} spec={mockSpec} />,
|
|
456
|
+
);
|
|
457
|
+
const input = container.querySelector('input[type="date"]')! as HTMLInputElement;
|
|
458
|
+
Object.getOwnPropertyDescriptor(
|
|
459
|
+
HTMLInputElement.prototype,
|
|
460
|
+
"value",
|
|
461
|
+
)!.set!.call(input, "2024-03-15");
|
|
462
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
463
|
+
expect(onChange).toHaveBeenCalledWith("2024-03-15");
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ============================================================================
|
|
468
|
+
// DateTimeInput
|
|
469
|
+
// ============================================================================
|
|
470
|
+
describe("DateTimeInput", () => {
|
|
471
|
+
function makeDateTimeProps(
|
|
472
|
+
overrides: Record<string, unknown> = {},
|
|
473
|
+
): DateTimeFieldProps {
|
|
474
|
+
return {
|
|
475
|
+
...makeBaseProps(),
|
|
476
|
+
fieldType: "datetime",
|
|
477
|
+
value: null,
|
|
478
|
+
onChange: vi.fn(),
|
|
479
|
+
...overrides,
|
|
480
|
+
} as unknown as DateTimeFieldProps;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
it("renders datetime-local input", () => {
|
|
484
|
+
const { container } = render(
|
|
485
|
+
<DateTimeInput field={makeDateTimeProps()} spec={mockSpec} />,
|
|
486
|
+
);
|
|
487
|
+
const input = container.querySelector('input[type="datetime-local"]');
|
|
488
|
+
expect(input).toBeTruthy();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ============================================================================
|
|
493
|
+
// SelectInput
|
|
494
|
+
// ============================================================================
|
|
495
|
+
describe("SelectInput", () => {
|
|
496
|
+
const options = [
|
|
497
|
+
{ value: "a", label: "Option A" },
|
|
498
|
+
{ value: "b", label: "Option B" },
|
|
499
|
+
{ value: "c", label: "Option C" },
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
function makeSelectProps(
|
|
503
|
+
overrides: Record<string, unknown> = {},
|
|
504
|
+
): SelectFieldProps {
|
|
505
|
+
return {
|
|
506
|
+
...makeBaseProps(),
|
|
507
|
+
fieldType: "select",
|
|
508
|
+
value: null,
|
|
509
|
+
onChange: vi.fn(),
|
|
510
|
+
options,
|
|
511
|
+
...overrides,
|
|
512
|
+
} as unknown as SelectFieldProps;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
it("renders all options", () => {
|
|
516
|
+
render(<SelectInput field={makeSelectProps()} spec={mockSpec} />);
|
|
517
|
+
expect(screen.getByText("Option A")).toBeInTheDocument();
|
|
518
|
+
expect(screen.getByText("Option B")).toBeInTheDocument();
|
|
519
|
+
expect(screen.getByText("Option C")).toBeInTheDocument();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("renders placeholder option when value is null", () => {
|
|
523
|
+
render(<SelectInput field={makeSelectProps()} spec={mockSpec} />);
|
|
524
|
+
expect(screen.getByText("Select...")).toBeInTheDocument();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("calls onChange with selected value", () => {
|
|
528
|
+
const onChange = vi.fn();
|
|
529
|
+
render(
|
|
530
|
+
<SelectInput field={makeSelectProps({ onChange })} spec={mockSpec} />,
|
|
531
|
+
);
|
|
532
|
+
fireEvent.change(screen.getByRole("combobox"), {
|
|
533
|
+
target: { value: "b" },
|
|
534
|
+
});
|
|
535
|
+
expect(onChange).toHaveBeenCalledWith("b");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("calls onChange with null for empty selection", () => {
|
|
539
|
+
const onChange = vi.fn();
|
|
540
|
+
render(
|
|
541
|
+
<SelectInput
|
|
542
|
+
field={makeSelectProps({ onChange, value: "a" })}
|
|
543
|
+
spec={mockSpec}
|
|
544
|
+
/>,
|
|
545
|
+
);
|
|
546
|
+
fireEvent.change(screen.getByRole("combobox"), {
|
|
547
|
+
target: { value: "" },
|
|
548
|
+
});
|
|
549
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("disables when disabled", () => {
|
|
553
|
+
render(
|
|
554
|
+
<SelectInput
|
|
555
|
+
field={makeSelectProps({ disabled: true })}
|
|
556
|
+
spec={mockSpec}
|
|
557
|
+
/>,
|
|
558
|
+
);
|
|
559
|
+
expect(screen.getByRole("combobox")).toBeDisabled();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ============================================================================
|
|
564
|
+
// MultiSelectInput
|
|
565
|
+
// ============================================================================
|
|
566
|
+
describe("MultiSelectInput", () => {
|
|
567
|
+
const options = [
|
|
568
|
+
{ value: "a", label: "Option A" },
|
|
569
|
+
{ value: "b", label: "Option B" },
|
|
570
|
+
];
|
|
571
|
+
|
|
572
|
+
function makeMultiSelectProps(
|
|
573
|
+
overrides: Record<string, unknown> = {},
|
|
574
|
+
): MultiSelectFieldProps {
|
|
575
|
+
return {
|
|
576
|
+
...makeBaseProps(),
|
|
577
|
+
fieldType: "multiselect",
|
|
578
|
+
value: [],
|
|
579
|
+
onChange: vi.fn(),
|
|
580
|
+
options,
|
|
581
|
+
...overrides,
|
|
582
|
+
} as unknown as MultiSelectFieldProps;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
it("renders checkboxes for each option", () => {
|
|
586
|
+
render(
|
|
587
|
+
<MultiSelectInput
|
|
588
|
+
field={makeMultiSelectProps()}
|
|
589
|
+
spec={mockSpec}
|
|
590
|
+
/>,
|
|
591
|
+
);
|
|
592
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
593
|
+
expect(checkboxes).toHaveLength(2);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("toggles value in array on click", () => {
|
|
597
|
+
const onChange = vi.fn();
|
|
598
|
+
render(
|
|
599
|
+
<MultiSelectInput
|
|
600
|
+
field={makeMultiSelectProps({ onChange })}
|
|
601
|
+
spec={mockSpec}
|
|
602
|
+
/>,
|
|
603
|
+
);
|
|
604
|
+
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
|
605
|
+
expect(onChange).toHaveBeenCalledWith(["a"]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("removes value from array when unchecking", () => {
|
|
609
|
+
const onChange = vi.fn();
|
|
610
|
+
render(
|
|
611
|
+
<MultiSelectInput
|
|
612
|
+
field={makeMultiSelectProps({ value: ["a", "b"], onChange })}
|
|
613
|
+
spec={mockSpec}
|
|
614
|
+
/>,
|
|
615
|
+
);
|
|
616
|
+
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
|
617
|
+
expect(onChange).toHaveBeenCalledWith(["b"]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("renders fieldset with legend", () => {
|
|
621
|
+
const { container } = render(
|
|
622
|
+
<MultiSelectInput
|
|
623
|
+
field={makeMultiSelectProps()}
|
|
624
|
+
spec={mockSpec}
|
|
625
|
+
/>,
|
|
626
|
+
);
|
|
627
|
+
expect(container.querySelector("fieldset")).toBeTruthy();
|
|
628
|
+
expect(container.querySelector("legend")).toBeTruthy();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// ============================================================================
|
|
633
|
+
// ComputedDisplay
|
|
634
|
+
// ============================================================================
|
|
635
|
+
describe("ComputedDisplay", () => {
|
|
636
|
+
function makeComputedProps(
|
|
637
|
+
overrides: Record<string, unknown> = {},
|
|
638
|
+
): ComputedFieldProps {
|
|
639
|
+
return {
|
|
640
|
+
...makeBaseProps(),
|
|
641
|
+
fieldType: "computed",
|
|
642
|
+
value: 42,
|
|
643
|
+
expression: "data.a + data.b",
|
|
644
|
+
...overrides,
|
|
645
|
+
} as unknown as ComputedFieldProps;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
it("renders computed value", () => {
|
|
649
|
+
render(
|
|
650
|
+
<ComputedDisplay field={makeComputedProps()} spec={mockSpec} />,
|
|
651
|
+
);
|
|
652
|
+
expect(screen.getByText("42")).toBeInTheDocument();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("renders em-dash for null", () => {
|
|
656
|
+
render(
|
|
657
|
+
<ComputedDisplay
|
|
658
|
+
field={makeComputedProps({ value: null })}
|
|
659
|
+
spec={mockSpec}
|
|
660
|
+
/>,
|
|
661
|
+
);
|
|
662
|
+
expect(screen.getByText("\u2014")).toBeInTheDocument();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("renders JSON for objects", () => {
|
|
666
|
+
render(
|
|
667
|
+
<ComputedDisplay
|
|
668
|
+
field={makeComputedProps({ value: { a: 1 } })}
|
|
669
|
+
spec={mockSpec}
|
|
670
|
+
/>,
|
|
671
|
+
);
|
|
672
|
+
expect(screen.getByText('{"a":1}')).toBeInTheDocument();
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// ============================================================================
|
|
677
|
+
// DisplayField
|
|
678
|
+
// ============================================================================
|
|
679
|
+
describe("DisplayField", () => {
|
|
680
|
+
function makeDisplayProps(
|
|
681
|
+
overrides: Record<string, unknown> = {},
|
|
682
|
+
): DisplayFieldProps {
|
|
683
|
+
return {
|
|
684
|
+
...makeBaseProps(),
|
|
685
|
+
fieldType: "display",
|
|
686
|
+
content: "Hello world",
|
|
687
|
+
...overrides,
|
|
688
|
+
} as unknown as DisplayFieldProps;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
it("renders content text", () => {
|
|
692
|
+
render(
|
|
693
|
+
<DisplayField field={makeDisplayProps()} spec={mockSpec} />,
|
|
694
|
+
);
|
|
695
|
+
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("renders sourceValue when present", () => {
|
|
699
|
+
render(
|
|
700
|
+
<DisplayField
|
|
701
|
+
field={makeDisplayProps({
|
|
702
|
+
sourceValue: "Dynamic value",
|
|
703
|
+
content: "Fallback",
|
|
704
|
+
})}
|
|
705
|
+
spec={mockSpec}
|
|
706
|
+
/>,
|
|
707
|
+
);
|
|
708
|
+
expect(screen.getByText("Dynamic value")).toBeInTheDocument();
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ============================================================================
|
|
713
|
+
// MatrixField
|
|
714
|
+
// ============================================================================
|
|
715
|
+
describe("MatrixField", () => {
|
|
716
|
+
function makeMatrixProps(
|
|
717
|
+
overrides: Record<string, unknown> = {},
|
|
718
|
+
): MatrixFieldProps {
|
|
719
|
+
return {
|
|
720
|
+
...makeBaseProps(),
|
|
721
|
+
fieldType: "matrix",
|
|
722
|
+
value: null,
|
|
723
|
+
onChange: vi.fn(),
|
|
724
|
+
onBlur: vi.fn(),
|
|
725
|
+
rows: [
|
|
726
|
+
{ id: "row1", label: "Row 1", visible: true },
|
|
727
|
+
{ id: "row2", label: "Row 2", visible: true },
|
|
728
|
+
],
|
|
729
|
+
columns: [
|
|
730
|
+
{ value: "col1", label: "Col 1" },
|
|
731
|
+
{ value: "col2", label: "Col 2" },
|
|
732
|
+
],
|
|
733
|
+
multiSelect: false,
|
|
734
|
+
...overrides,
|
|
735
|
+
} as unknown as MatrixFieldProps;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
it("renders a table", () => {
|
|
739
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
740
|
+
expect(screen.getByRole("grid")).toBeInTheDocument();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("renders column headers", () => {
|
|
744
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
745
|
+
expect(screen.getByText("Col 1")).toBeInTheDocument();
|
|
746
|
+
expect(screen.getByText("Col 2")).toBeInTheDocument();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("renders row headers", () => {
|
|
750
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
751
|
+
expect(screen.getByText("Row 1")).toBeInTheDocument();
|
|
752
|
+
expect(screen.getByText("Row 2")).toBeInTheDocument();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("renders radio buttons for single select", () => {
|
|
756
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
757
|
+
const radios = screen.getAllByRole("radio");
|
|
758
|
+
expect(radios).toHaveLength(4); // 2 rows × 2 columns
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("calls onChange when radio selected", () => {
|
|
762
|
+
const onChange = vi.fn();
|
|
763
|
+
render(
|
|
764
|
+
<MatrixField
|
|
765
|
+
field={makeMatrixProps({ onChange })}
|
|
766
|
+
spec={mockSpec}
|
|
767
|
+
/>,
|
|
768
|
+
);
|
|
769
|
+
fireEvent.click(screen.getAllByRole("radio")[0]);
|
|
770
|
+
expect(onChange).toHaveBeenCalledWith({ row1: "col1" });
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("renders checkboxes for multi-select", () => {
|
|
774
|
+
render(
|
|
775
|
+
<MatrixField
|
|
776
|
+
field={makeMatrixProps({ multiSelect: true })}
|
|
777
|
+
spec={mockSpec}
|
|
778
|
+
/>,
|
|
779
|
+
);
|
|
780
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
781
|
+
expect(checkboxes).toHaveLength(4);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("filters invisible rows", () => {
|
|
785
|
+
render(
|
|
786
|
+
<MatrixField
|
|
787
|
+
field={makeMatrixProps({
|
|
788
|
+
rows: [
|
|
789
|
+
{ id: "row1", label: "Row 1", visible: true },
|
|
790
|
+
{ id: "row2", label: "Row 2", visible: false },
|
|
791
|
+
],
|
|
792
|
+
})}
|
|
793
|
+
spec={mockSpec}
|
|
794
|
+
/>,
|
|
795
|
+
);
|
|
796
|
+
expect(screen.getByText("Row 1")).toBeInTheDocument();
|
|
797
|
+
expect(screen.queryByText("Row 2")).not.toBeInTheDocument();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// ============================================================================
|
|
802
|
+
// FallbackField
|
|
803
|
+
// ============================================================================
|
|
804
|
+
describe("FallbackField", () => {
|
|
805
|
+
it("renders a text input", () => {
|
|
806
|
+
const field = {
|
|
807
|
+
...makeBaseProps(),
|
|
808
|
+
fieldType: "unknown",
|
|
809
|
+
value: "test",
|
|
810
|
+
onChange: vi.fn(),
|
|
811
|
+
field: { type: "custom-type", label: "Custom" },
|
|
812
|
+
};
|
|
813
|
+
render(
|
|
814
|
+
<FallbackField field={field as never} spec={mockSpec} />,
|
|
815
|
+
);
|
|
816
|
+
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
817
|
+
});
|
|
818
|
+
});
|