@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.
@@ -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";