@fogpipe/forma-react 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +277 -0
- package/dist/index.d.ts +668 -0
- package/dist/index.js +1039 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/ErrorBoundary.tsx +115 -0
- package/src/FieldRenderer.tsx +258 -0
- package/src/FormRenderer.tsx +470 -0
- package/src/__tests__/FormRenderer.test.tsx +803 -0
- package/src/__tests__/test-utils.tsx +297 -0
- package/src/__tests__/useForma.test.ts +1103 -0
- package/src/context.ts +23 -0
- package/src/index.ts +91 -0
- package/src/types.ts +482 -0
- package/src/useForma.ts +681 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities for @fogpipe/forma-react
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { render, type RenderOptions } from "@testing-library/react";
|
|
6
|
+
import type { Forma, FieldDefinition, PageDefinition } from "@fogpipe/forma-core";
|
|
7
|
+
import type {
|
|
8
|
+
ComponentMap,
|
|
9
|
+
TextComponentProps,
|
|
10
|
+
NumberComponentProps,
|
|
11
|
+
BooleanComponentProps,
|
|
12
|
+
SelectComponentProps,
|
|
13
|
+
MultiSelectComponentProps,
|
|
14
|
+
ArrayComponentProps,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a minimal Forma for testing
|
|
19
|
+
*/
|
|
20
|
+
export function createTestSpec(
|
|
21
|
+
options: {
|
|
22
|
+
fields?: Record<string, { type: string; [key: string]: unknown }>;
|
|
23
|
+
fieldOrder?: string[];
|
|
24
|
+
computed?: Record<string, { expression: string }>;
|
|
25
|
+
pages?: PageDefinition[];
|
|
26
|
+
referenceData?: Record<string, unknown>;
|
|
27
|
+
} = {}
|
|
28
|
+
): Forma {
|
|
29
|
+
const { fields = {}, fieldOrder, computed, pages, referenceData } = options;
|
|
30
|
+
|
|
31
|
+
// Build schema from fields
|
|
32
|
+
const schemaProperties: Record<string, unknown> = {};
|
|
33
|
+
const schemaRequired: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
36
|
+
const { type, required, options: fieldOptions, items, ...rest } = field as Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
let schemaType = type;
|
|
39
|
+
if (type === "text" || type === "email" || type === "url" || type === "textarea" || type === "password") {
|
|
40
|
+
schemaType = "string";
|
|
41
|
+
}
|
|
42
|
+
if (type === "select") {
|
|
43
|
+
schemaType = "string";
|
|
44
|
+
if (fieldOptions) {
|
|
45
|
+
schemaProperties[name] = {
|
|
46
|
+
type: "string",
|
|
47
|
+
enum: (fieldOptions as Array<{ value: string }>).map((o) => o.value),
|
|
48
|
+
...rest,
|
|
49
|
+
};
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (type === "multiselect") {
|
|
54
|
+
schemaProperties[name] = {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: { type: "string" },
|
|
57
|
+
...rest,
|
|
58
|
+
};
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (type === "boolean") {
|
|
62
|
+
schemaProperties[name] = { type: "boolean", ...rest };
|
|
63
|
+
if (required) schemaRequired.push(name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (type === "array") {
|
|
67
|
+
schemaProperties[name] = {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: items || { type: "object", properties: {} },
|
|
70
|
+
...rest,
|
|
71
|
+
};
|
|
72
|
+
if (required) schemaRequired.push(name);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
schemaProperties[name] = { type: schemaType || "string", ...rest };
|
|
77
|
+
if (required) schemaRequired.push(name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build field definitions
|
|
81
|
+
const fieldDefs: Record<string, FieldDefinition> = {};
|
|
82
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
83
|
+
// Extract type and required (required is used in schema building above)
|
|
84
|
+
const { type, required: __, ...rest } = field as Record<string, unknown>;
|
|
85
|
+
void __; // Mark as intentionally unused
|
|
86
|
+
fieldDefs[name] = {
|
|
87
|
+
type: type as FieldDefinition["type"],
|
|
88
|
+
label: (rest.label as string) || name.charAt(0).toUpperCase() + name.slice(1),
|
|
89
|
+
...rest,
|
|
90
|
+
} as FieldDefinition;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
version: "1.0",
|
|
95
|
+
meta: {
|
|
96
|
+
id: "test-form",
|
|
97
|
+
title: "Test Form",
|
|
98
|
+
},
|
|
99
|
+
schema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: schemaProperties as Forma["schema"]["properties"],
|
|
102
|
+
required: schemaRequired.length > 0 ? schemaRequired : undefined,
|
|
103
|
+
},
|
|
104
|
+
fields: fieldDefs,
|
|
105
|
+
fieldOrder: fieldOrder || Object.keys(fields),
|
|
106
|
+
computed,
|
|
107
|
+
pages,
|
|
108
|
+
referenceData,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a basic component map for testing
|
|
114
|
+
*
|
|
115
|
+
* Components receive wrapper props { field, spec } from FormRenderer
|
|
116
|
+
*/
|
|
117
|
+
export function createTestComponentMap(): ComponentMap {
|
|
118
|
+
// Text-based fields (text, email, password, url, textarea)
|
|
119
|
+
const TextComponent = ({ field: props }: TextComponentProps) => {
|
|
120
|
+
const { name, field, value, errors, onChange, onBlur, disabled } = props;
|
|
121
|
+
return (
|
|
122
|
+
<div data-testid={`field-${name}`}>
|
|
123
|
+
<label htmlFor={name}>{field.label}</label>
|
|
124
|
+
<input
|
|
125
|
+
id={name}
|
|
126
|
+
type="text"
|
|
127
|
+
value={value || ""}
|
|
128
|
+
onChange={(e) => onChange(e.target.value)}
|
|
129
|
+
onBlur={onBlur}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
/>
|
|
132
|
+
{errors.length > 0 && (
|
|
133
|
+
<span data-testid={`error-${name}`}>{errors[0].message}</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Number fields
|
|
140
|
+
const NumberComponent = ({ field: props }: NumberComponentProps) => {
|
|
141
|
+
const { name, field, value, errors, onChange, onBlur, disabled } = props;
|
|
142
|
+
return (
|
|
143
|
+
<div data-testid={`field-${name}`}>
|
|
144
|
+
<label htmlFor={name}>{field.label}</label>
|
|
145
|
+
<input
|
|
146
|
+
id={name}
|
|
147
|
+
type="number"
|
|
148
|
+
value={value !== null ? String(value) : ""}
|
|
149
|
+
onChange={(e) => {
|
|
150
|
+
const val = e.target.value;
|
|
151
|
+
onChange(val === "" ? null : Number(val));
|
|
152
|
+
}}
|
|
153
|
+
onBlur={onBlur}
|
|
154
|
+
disabled={disabled}
|
|
155
|
+
/>
|
|
156
|
+
{errors.length > 0 && (
|
|
157
|
+
<span data-testid={`error-${name}`}>{errors[0].message}</span>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Boolean/checkbox fields
|
|
164
|
+
const BooleanComponent = ({ field: props }: BooleanComponentProps) => {
|
|
165
|
+
const { name, field, value, onChange, onBlur, disabled } = props;
|
|
166
|
+
return (
|
|
167
|
+
<div data-testid={`field-${name}`}>
|
|
168
|
+
<label>
|
|
169
|
+
<input
|
|
170
|
+
type="checkbox"
|
|
171
|
+
checked={Boolean(value)}
|
|
172
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
173
|
+
onBlur={onBlur}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
/>
|
|
176
|
+
{field.label}
|
|
177
|
+
</label>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Select fields (single selection)
|
|
183
|
+
const SelectComponent = ({ field: props }: SelectComponentProps) => {
|
|
184
|
+
const { name, field, value, options, onChange, onBlur, disabled } = props;
|
|
185
|
+
return (
|
|
186
|
+
<div data-testid={`field-${name}`}>
|
|
187
|
+
<label htmlFor={name}>{field.label}</label>
|
|
188
|
+
<select
|
|
189
|
+
id={name}
|
|
190
|
+
value={String(value ?? "")}
|
|
191
|
+
onChange={(e) => onChange(e.target.value || null)}
|
|
192
|
+
onBlur={onBlur}
|
|
193
|
+
disabled={disabled}
|
|
194
|
+
>
|
|
195
|
+
<option value="">Select...</option>
|
|
196
|
+
{options.map((opt) => (
|
|
197
|
+
<option key={String(opt.value)} value={String(opt.value)}>
|
|
198
|
+
{opt.label}
|
|
199
|
+
</option>
|
|
200
|
+
))}
|
|
201
|
+
</select>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Multiselect fields
|
|
207
|
+
const MultiSelectComponent = ({ field: props }: MultiSelectComponentProps) => {
|
|
208
|
+
const { name, field, value, options, onChange, onBlur, disabled } = props;
|
|
209
|
+
const displayValue = (value || []).join(",");
|
|
210
|
+
return (
|
|
211
|
+
<div data-testid={`field-${name}`}>
|
|
212
|
+
<label htmlFor={name}>{field.label}</label>
|
|
213
|
+
<select
|
|
214
|
+
id={name}
|
|
215
|
+
value={displayValue}
|
|
216
|
+
onChange={(e) => {
|
|
217
|
+
const val = e.target.value;
|
|
218
|
+
onChange(val ? val.split(",") : []);
|
|
219
|
+
}}
|
|
220
|
+
onBlur={onBlur}
|
|
221
|
+
disabled={disabled}
|
|
222
|
+
>
|
|
223
|
+
<option value="">Select...</option>
|
|
224
|
+
{options.map((opt) => (
|
|
225
|
+
<option key={String(opt.value)} value={String(opt.value)}>
|
|
226
|
+
{opt.label}
|
|
227
|
+
</option>
|
|
228
|
+
))}
|
|
229
|
+
</select>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Array fields
|
|
235
|
+
const ArrayComponent = ({ field: props }: ArrayComponentProps) => {
|
|
236
|
+
const { name, field, value, helpers } = props;
|
|
237
|
+
const items = (value || []) as unknown[];
|
|
238
|
+
return (
|
|
239
|
+
<div data-testid={`field-${name}`}>
|
|
240
|
+
<label>{field.label}</label>
|
|
241
|
+
<div data-testid={`array-items-${name}`}>
|
|
242
|
+
{items.map((_, index) => (
|
|
243
|
+
<div key={index} data-testid={`array-item-${name}-${index}`}>
|
|
244
|
+
Item {index}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => helpers.remove(index)}
|
|
248
|
+
data-testid={`remove-${name}-${index}`}
|
|
249
|
+
>
|
|
250
|
+
Remove
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => helpers.push({})}
|
|
258
|
+
data-testid={`add-${name}`}
|
|
259
|
+
>
|
|
260
|
+
Add
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
text: TextComponent,
|
|
268
|
+
email: TextComponent,
|
|
269
|
+
password: TextComponent,
|
|
270
|
+
url: TextComponent,
|
|
271
|
+
textarea: TextComponent,
|
|
272
|
+
number: NumberComponent,
|
|
273
|
+
integer: NumberComponent,
|
|
274
|
+
boolean: BooleanComponent,
|
|
275
|
+
date: TextComponent as ComponentMap["date"],
|
|
276
|
+
datetime: TextComponent as ComponentMap["datetime"],
|
|
277
|
+
select: SelectComponent,
|
|
278
|
+
multiselect: MultiSelectComponent,
|
|
279
|
+
array: ArrayComponent,
|
|
280
|
+
object: TextComponent as ComponentMap["object"],
|
|
281
|
+
computed: TextComponent as ComponentMap["computed"],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Custom render function with common providers
|
|
287
|
+
*/
|
|
288
|
+
export function renderWithProviders(
|
|
289
|
+
ui: React.ReactElement,
|
|
290
|
+
options?: Omit<RenderOptions, "wrapper">
|
|
291
|
+
) {
|
|
292
|
+
return render(ui, { ...options });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Re-export everything from testing-library
|
|
296
|
+
export * from "@testing-library/react";
|
|
297
|
+
export { default as userEvent } from "@testing-library/user-event";
|