@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,626 @@
|
|
|
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
|
+
|
|
495
|
+
it("display field inherits format from source computed field", () => {
|
|
496
|
+
const spec = createTestSpec({
|
|
497
|
+
fields: {
|
|
498
|
+
totalDisplay: {
|
|
499
|
+
type: "display",
|
|
500
|
+
label: "Total",
|
|
501
|
+
source: "total",
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
computed: {
|
|
505
|
+
total: {
|
|
506
|
+
expression: "42",
|
|
507
|
+
format: "currency",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Provide initial data with the computed value already resolved
|
|
513
|
+
render(
|
|
514
|
+
<DefaultFormRenderer
|
|
515
|
+
spec={spec}
|
|
516
|
+
onSubmit={vi.fn()}
|
|
517
|
+
initialData={{ total: 1234.56 }}
|
|
518
|
+
/>,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// The display field should inherit "currency" format from computed.total
|
|
522
|
+
expect(screen.getByText("$1,234.56")).toBeInTheDocument();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("display field's own format takes priority over computed source format", () => {
|
|
526
|
+
const spec = createTestSpec({
|
|
527
|
+
fields: {
|
|
528
|
+
totalDisplay: {
|
|
529
|
+
type: "display",
|
|
530
|
+
label: "Total",
|
|
531
|
+
source: "total",
|
|
532
|
+
format: "percent",
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
computed: {
|
|
536
|
+
total: {
|
|
537
|
+
expression: "42",
|
|
538
|
+
format: "currency",
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
render(
|
|
544
|
+
<DefaultFormRenderer
|
|
545
|
+
spec={spec}
|
|
546
|
+
onSubmit={vi.fn()}
|
|
547
|
+
initialData={{ total: 0.75 }}
|
|
548
|
+
/>,
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// Display field's own format "percent" should win over computed "currency"
|
|
552
|
+
expect(screen.getByText("75%")).toBeInTheDocument();
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// ============================================================================
|
|
557
|
+
// Spec-Level Locale/Currency Integration
|
|
558
|
+
// ============================================================================
|
|
559
|
+
describe("Spec-level locale/currency", () => {
|
|
560
|
+
it("display field uses spec.meta.locale and spec.meta.currency for formatting", () => {
|
|
561
|
+
const spec = createTestSpec({
|
|
562
|
+
meta: { locale: "sv-SE", currency: "SEK" },
|
|
563
|
+
fields: {
|
|
564
|
+
totalDisplay: {
|
|
565
|
+
type: "display",
|
|
566
|
+
label: "Total",
|
|
567
|
+
source: "total",
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
computed: {
|
|
571
|
+
total: {
|
|
572
|
+
expression: "42",
|
|
573
|
+
format: "currency",
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
render(
|
|
579
|
+
<DefaultFormRenderer
|
|
580
|
+
spec={spec}
|
|
581
|
+
onSubmit={vi.fn()}
|
|
582
|
+
initialData={{ total: 209550 }}
|
|
583
|
+
/>,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// Should render with Swedish locale and SEK currency, not USD
|
|
587
|
+
const text = screen.getByText((content) =>
|
|
588
|
+
content.includes("209") && content.includes("550") && content.includes("kr"),
|
|
589
|
+
);
|
|
590
|
+
expect(text).toBeInTheDocument();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("formatOptions prop overrides spec.meta locale/currency", () => {
|
|
594
|
+
const spec = createTestSpec({
|
|
595
|
+
meta: { locale: "sv-SE", currency: "SEK" },
|
|
596
|
+
fields: {
|
|
597
|
+
totalDisplay: {
|
|
598
|
+
type: "display",
|
|
599
|
+
label: "Total",
|
|
600
|
+
source: "total",
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
computed: {
|
|
604
|
+
total: {
|
|
605
|
+
expression: "42",
|
|
606
|
+
format: "currency",
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
render(
|
|
612
|
+
<DefaultFormRenderer
|
|
613
|
+
spec={spec}
|
|
614
|
+
onSubmit={vi.fn()}
|
|
615
|
+
initialData={{ total: 1000 }}
|
|
616
|
+
formatOptions={{ locale: "de-DE", currency: "EUR" }}
|
|
617
|
+
/>,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Should render with German locale and EUR, overriding spec meta
|
|
621
|
+
const text = screen.getByText((content) =>
|
|
622
|
+
content.includes("1.000") && content.includes("€"),
|
|
623
|
+
);
|
|
624
|
+
expect(text).toBeInTheDocument();
|
|
625
|
+
});
|
|
626
|
+
});
|