@fogpipe/forma-react 0.17.1 → 0.19.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-B7qwG4to.d.ts +566 -0
- package/dist/chunk-CFX3T5WK.js +1298 -0
- package/dist/chunk-CFX3T5WK.js.map +1 -0
- package/dist/defaults/index.d.ts +56 -0
- package/dist/defaults/index.js +899 -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 +53 -1293
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/FieldRenderer.tsx +33 -1
- package/src/FormRenderer.tsx +35 -1
- package/src/__tests__/defaults/components.test.tsx +1074 -0
- package/src/__tests__/defaults/integration.test.tsx +626 -0
- package/src/__tests__/defaults/layout.test.tsx +298 -0
- package/src/__tests__/test-utils.tsx +4 -2
- 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 +27 -0
- package/src/defaults/components/DateInput.tsx +59 -0
- package/src/defaults/components/DisplayField.tsx +22 -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/types.ts +7 -0
|
@@ -0,0 +1,1074 @@
|
|
|
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
|
+
it("formats value with currency format", () => {
|
|
676
|
+
render(
|
|
677
|
+
<ComputedDisplay
|
|
678
|
+
field={makeComputedProps({ value: 1234.56, format: "currency" })}
|
|
679
|
+
spec={mockSpec}
|
|
680
|
+
/>,
|
|
681
|
+
);
|
|
682
|
+
expect(screen.getByText("$1,234.56")).toBeInTheDocument();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("formats value with percent format", () => {
|
|
686
|
+
render(
|
|
687
|
+
<ComputedDisplay
|
|
688
|
+
field={makeComputedProps({ value: 0.5, format: "percent" })}
|
|
689
|
+
spec={mockSpec}
|
|
690
|
+
/>,
|
|
691
|
+
);
|
|
692
|
+
expect(screen.getByText("50%")).toBeInTheDocument();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("formats value with decimal format", () => {
|
|
696
|
+
render(
|
|
697
|
+
<ComputedDisplay
|
|
698
|
+
field={makeComputedProps({ value: 99.999, format: "decimal(2)" })}
|
|
699
|
+
spec={mockSpec}
|
|
700
|
+
/>,
|
|
701
|
+
);
|
|
702
|
+
expect(screen.getByText("100.00")).toBeInTheDocument();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("still shows em-dash for null even with format", () => {
|
|
706
|
+
render(
|
|
707
|
+
<ComputedDisplay
|
|
708
|
+
field={makeComputedProps({ value: null, format: "currency" })}
|
|
709
|
+
spec={mockSpec}
|
|
710
|
+
/>,
|
|
711
|
+
);
|
|
712
|
+
expect(screen.getByText("\u2014")).toBeInTheDocument();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("still shows em-dash for undefined even with format", () => {
|
|
716
|
+
render(
|
|
717
|
+
<ComputedDisplay
|
|
718
|
+
field={makeComputedProps({ value: undefined, format: "currency" })}
|
|
719
|
+
spec={mockSpec}
|
|
720
|
+
/>,
|
|
721
|
+
);
|
|
722
|
+
expect(screen.getByText("\u2014")).toBeInTheDocument();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("renders plain string without format", () => {
|
|
726
|
+
render(
|
|
727
|
+
<ComputedDisplay
|
|
728
|
+
field={makeComputedProps({ value: 42 })}
|
|
729
|
+
spec={mockSpec}
|
|
730
|
+
/>,
|
|
731
|
+
);
|
|
732
|
+
expect(screen.getByText("42")).toBeInTheDocument();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("formats zero with currency (does not show em-dash)", () => {
|
|
736
|
+
render(
|
|
737
|
+
<ComputedDisplay
|
|
738
|
+
field={makeComputedProps({ value: 0, format: "currency" })}
|
|
739
|
+
spec={mockSpec}
|
|
740
|
+
/>,
|
|
741
|
+
);
|
|
742
|
+
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("formats boolean without format as string", () => {
|
|
746
|
+
render(
|
|
747
|
+
<ComputedDisplay
|
|
748
|
+
field={makeComputedProps({ value: false })}
|
|
749
|
+
spec={mockSpec}
|
|
750
|
+
/>,
|
|
751
|
+
);
|
|
752
|
+
expect(screen.getByText("false")).toBeInTheDocument();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("formats currency with locale/currency from formatOptions", () => {
|
|
756
|
+
render(
|
|
757
|
+
<ComputedDisplay
|
|
758
|
+
field={makeComputedProps({
|
|
759
|
+
value: 25444.40,
|
|
760
|
+
format: "currency",
|
|
761
|
+
formatOptions: { locale: "sv-SE", currency: "SEK" },
|
|
762
|
+
})}
|
|
763
|
+
spec={mockSpec}
|
|
764
|
+
/>,
|
|
765
|
+
);
|
|
766
|
+
const text = screen.getByText((content) =>
|
|
767
|
+
content.includes("25") && content.includes("444") && content.includes("kr"),
|
|
768
|
+
);
|
|
769
|
+
expect(text).toBeInTheDocument();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("formats currency with EUR locale from formatOptions", () => {
|
|
773
|
+
render(
|
|
774
|
+
<ComputedDisplay
|
|
775
|
+
field={makeComputedProps({
|
|
776
|
+
value: 1000,
|
|
777
|
+
format: "currency",
|
|
778
|
+
formatOptions: { locale: "de-DE", currency: "EUR" },
|
|
779
|
+
})}
|
|
780
|
+
spec={mockSpec}
|
|
781
|
+
/>,
|
|
782
|
+
);
|
|
783
|
+
const text = screen.getByText((content) =>
|
|
784
|
+
content.includes("1.000") && content.includes("€"),
|
|
785
|
+
);
|
|
786
|
+
expect(text).toBeInTheDocument();
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ============================================================================
|
|
791
|
+
// DisplayField
|
|
792
|
+
// ============================================================================
|
|
793
|
+
describe("DisplayField", () => {
|
|
794
|
+
function makeDisplayProps(
|
|
795
|
+
overrides: Record<string, unknown> = {},
|
|
796
|
+
): DisplayFieldProps {
|
|
797
|
+
return {
|
|
798
|
+
...makeBaseProps(),
|
|
799
|
+
fieldType: "display",
|
|
800
|
+
content: "Hello world",
|
|
801
|
+
...overrides,
|
|
802
|
+
} as unknown as DisplayFieldProps;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
it("renders content text", () => {
|
|
806
|
+
render(
|
|
807
|
+
<DisplayField field={makeDisplayProps()} spec={mockSpec} />,
|
|
808
|
+
);
|
|
809
|
+
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("renders sourceValue when present", () => {
|
|
813
|
+
render(
|
|
814
|
+
<DisplayField
|
|
815
|
+
field={makeDisplayProps({
|
|
816
|
+
sourceValue: "Dynamic value",
|
|
817
|
+
content: "Fallback",
|
|
818
|
+
})}
|
|
819
|
+
spec={mockSpec}
|
|
820
|
+
/>,
|
|
821
|
+
);
|
|
822
|
+
expect(screen.getByText("Dynamic value")).toBeInTheDocument();
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("formats sourceValue with currency format", () => {
|
|
826
|
+
render(
|
|
827
|
+
<DisplayField
|
|
828
|
+
field={makeDisplayProps({
|
|
829
|
+
sourceValue: 1234.56,
|
|
830
|
+
format: "currency",
|
|
831
|
+
content: "Fallback",
|
|
832
|
+
})}
|
|
833
|
+
spec={mockSpec}
|
|
834
|
+
/>,
|
|
835
|
+
);
|
|
836
|
+
expect(screen.getByText("$1,234.56")).toBeInTheDocument();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("formats sourceValue with percent format", () => {
|
|
840
|
+
render(
|
|
841
|
+
<DisplayField
|
|
842
|
+
field={makeDisplayProps({
|
|
843
|
+
sourceValue: 0.75,
|
|
844
|
+
format: "percent",
|
|
845
|
+
content: "Fallback",
|
|
846
|
+
})}
|
|
847
|
+
spec={mockSpec}
|
|
848
|
+
/>,
|
|
849
|
+
);
|
|
850
|
+
expect(screen.getByText("75%")).toBeInTheDocument();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("formats sourceValue with decimal format", () => {
|
|
854
|
+
render(
|
|
855
|
+
<DisplayField
|
|
856
|
+
field={makeDisplayProps({
|
|
857
|
+
sourceValue: 123.456,
|
|
858
|
+
format: "decimal(1)",
|
|
859
|
+
content: "Fallback",
|
|
860
|
+
})}
|
|
861
|
+
spec={mockSpec}
|
|
862
|
+
/>,
|
|
863
|
+
);
|
|
864
|
+
expect(screen.getByText("123.5")).toBeInTheDocument();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("shows em-dash for null sourceValue with format", () => {
|
|
868
|
+
render(
|
|
869
|
+
<DisplayField
|
|
870
|
+
field={makeDisplayProps({
|
|
871
|
+
sourceValue: null,
|
|
872
|
+
format: "currency",
|
|
873
|
+
content: "Fallback",
|
|
874
|
+
})}
|
|
875
|
+
spec={mockSpec}
|
|
876
|
+
/>,
|
|
877
|
+
);
|
|
878
|
+
expect(screen.getByText("\u2014")).toBeInTheDocument();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("shows em-dash for undefined sourceValue with format", () => {
|
|
882
|
+
render(
|
|
883
|
+
<DisplayField
|
|
884
|
+
field={makeDisplayProps({
|
|
885
|
+
sourceValue: undefined,
|
|
886
|
+
format: "currency",
|
|
887
|
+
})}
|
|
888
|
+
spec={mockSpec}
|
|
889
|
+
/>,
|
|
890
|
+
);
|
|
891
|
+
// sourceValue is undefined → falls through to content
|
|
892
|
+
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("falls back to string for non-numeric sourceValue with currency format", () => {
|
|
896
|
+
render(
|
|
897
|
+
<DisplayField
|
|
898
|
+
field={makeDisplayProps({
|
|
899
|
+
sourceValue: "not a number",
|
|
900
|
+
format: "currency",
|
|
901
|
+
})}
|
|
902
|
+
spec={mockSpec}
|
|
903
|
+
/>,
|
|
904
|
+
);
|
|
905
|
+
expect(screen.getByText("not a number")).toBeInTheDocument();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("formats sourceValue without format as plain string", () => {
|
|
909
|
+
render(
|
|
910
|
+
<DisplayField
|
|
911
|
+
field={makeDisplayProps({
|
|
912
|
+
sourceValue: 42,
|
|
913
|
+
})}
|
|
914
|
+
spec={mockSpec}
|
|
915
|
+
/>,
|
|
916
|
+
);
|
|
917
|
+
expect(screen.getByText("42")).toBeInTheDocument();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it("formats zero sourceValue with currency (does not skip rendering)", () => {
|
|
921
|
+
render(
|
|
922
|
+
<DisplayField
|
|
923
|
+
field={makeDisplayProps({
|
|
924
|
+
sourceValue: 0,
|
|
925
|
+
format: "currency",
|
|
926
|
+
})}
|
|
927
|
+
spec={mockSpec}
|
|
928
|
+
/>,
|
|
929
|
+
);
|
|
930
|
+
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it("formats currency with locale/currency from formatOptions", () => {
|
|
934
|
+
render(
|
|
935
|
+
<DisplayField
|
|
936
|
+
field={makeDisplayProps({
|
|
937
|
+
sourceValue: 209550,
|
|
938
|
+
format: "currency",
|
|
939
|
+
formatOptions: { locale: "sv-SE", currency: "SEK" },
|
|
940
|
+
})}
|
|
941
|
+
spec={mockSpec}
|
|
942
|
+
/>,
|
|
943
|
+
);
|
|
944
|
+
const text = screen.getByText((content) =>
|
|
945
|
+
content.includes("209") && content.includes("550") && content.includes("kr"),
|
|
946
|
+
);
|
|
947
|
+
expect(text).toBeInTheDocument();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("formats currency with EUR locale", () => {
|
|
951
|
+
render(
|
|
952
|
+
<DisplayField
|
|
953
|
+
field={makeDisplayProps({
|
|
954
|
+
sourceValue: 1234.56,
|
|
955
|
+
format: "currency",
|
|
956
|
+
formatOptions: { locale: "de-DE", currency: "EUR" },
|
|
957
|
+
})}
|
|
958
|
+
spec={mockSpec}
|
|
959
|
+
/>,
|
|
960
|
+
);
|
|
961
|
+
const text = screen.getByText((content) =>
|
|
962
|
+
content.includes("1.234,56") || (content.includes("1234") && content.includes("€")),
|
|
963
|
+
);
|
|
964
|
+
expect(text).toBeInTheDocument();
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// ============================================================================
|
|
969
|
+
// MatrixField
|
|
970
|
+
// ============================================================================
|
|
971
|
+
describe("MatrixField", () => {
|
|
972
|
+
function makeMatrixProps(
|
|
973
|
+
overrides: Record<string, unknown> = {},
|
|
974
|
+
): MatrixFieldProps {
|
|
975
|
+
return {
|
|
976
|
+
...makeBaseProps(),
|
|
977
|
+
fieldType: "matrix",
|
|
978
|
+
value: null,
|
|
979
|
+
onChange: vi.fn(),
|
|
980
|
+
onBlur: vi.fn(),
|
|
981
|
+
rows: [
|
|
982
|
+
{ id: "row1", label: "Row 1", visible: true },
|
|
983
|
+
{ id: "row2", label: "Row 2", visible: true },
|
|
984
|
+
],
|
|
985
|
+
columns: [
|
|
986
|
+
{ value: "col1", label: "Col 1" },
|
|
987
|
+
{ value: "col2", label: "Col 2" },
|
|
988
|
+
],
|
|
989
|
+
multiSelect: false,
|
|
990
|
+
...overrides,
|
|
991
|
+
} as unknown as MatrixFieldProps;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
it("renders a table", () => {
|
|
995
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
996
|
+
expect(screen.getByRole("grid")).toBeInTheDocument();
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("renders column headers", () => {
|
|
1000
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
1001
|
+
expect(screen.getByText("Col 1")).toBeInTheDocument();
|
|
1002
|
+
expect(screen.getByText("Col 2")).toBeInTheDocument();
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("renders row headers", () => {
|
|
1006
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
1007
|
+
expect(screen.getByText("Row 1")).toBeInTheDocument();
|
|
1008
|
+
expect(screen.getByText("Row 2")).toBeInTheDocument();
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("renders radio buttons for single select", () => {
|
|
1012
|
+
render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
|
|
1013
|
+
const radios = screen.getAllByRole("radio");
|
|
1014
|
+
expect(radios).toHaveLength(4); // 2 rows × 2 columns
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("calls onChange when radio selected", () => {
|
|
1018
|
+
const onChange = vi.fn();
|
|
1019
|
+
render(
|
|
1020
|
+
<MatrixField
|
|
1021
|
+
field={makeMatrixProps({ onChange })}
|
|
1022
|
+
spec={mockSpec}
|
|
1023
|
+
/>,
|
|
1024
|
+
);
|
|
1025
|
+
fireEvent.click(screen.getAllByRole("radio")[0]);
|
|
1026
|
+
expect(onChange).toHaveBeenCalledWith({ row1: "col1" });
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
it("renders checkboxes for multi-select", () => {
|
|
1030
|
+
render(
|
|
1031
|
+
<MatrixField
|
|
1032
|
+
field={makeMatrixProps({ multiSelect: true })}
|
|
1033
|
+
spec={mockSpec}
|
|
1034
|
+
/>,
|
|
1035
|
+
);
|
|
1036
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
1037
|
+
expect(checkboxes).toHaveLength(4);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it("filters invisible rows", () => {
|
|
1041
|
+
render(
|
|
1042
|
+
<MatrixField
|
|
1043
|
+
field={makeMatrixProps({
|
|
1044
|
+
rows: [
|
|
1045
|
+
{ id: "row1", label: "Row 1", visible: true },
|
|
1046
|
+
{ id: "row2", label: "Row 2", visible: false },
|
|
1047
|
+
],
|
|
1048
|
+
})}
|
|
1049
|
+
spec={mockSpec}
|
|
1050
|
+
/>,
|
|
1051
|
+
);
|
|
1052
|
+
expect(screen.getByText("Row 1")).toBeInTheDocument();
|
|
1053
|
+
expect(screen.queryByText("Row 2")).not.toBeInTheDocument();
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// ============================================================================
|
|
1058
|
+
// FallbackField
|
|
1059
|
+
// ============================================================================
|
|
1060
|
+
describe("FallbackField", () => {
|
|
1061
|
+
it("renders a text input", () => {
|
|
1062
|
+
const field = {
|
|
1063
|
+
...makeBaseProps(),
|
|
1064
|
+
fieldType: "unknown",
|
|
1065
|
+
value: "test",
|
|
1066
|
+
onChange: vi.fn(),
|
|
1067
|
+
field: { type: "custom-type", label: "Custom" },
|
|
1068
|
+
};
|
|
1069
|
+
render(
|
|
1070
|
+
<FallbackField field={field as never} spec={mockSpec} />,
|
|
1071
|
+
);
|
|
1072
|
+
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
1073
|
+
});
|
|
1074
|
+
});
|