@fogpipe/forma-react 0.12.0-alpha.1 → 0.12.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/dist/index.d.ts +1 -1
- package/dist/index.js +521 -351
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FieldRenderer.tsx +82 -20
- package/src/FormRenderer.tsx +320 -181
- package/src/__tests__/FieldRenderer.test.tsx +136 -20
- package/src/__tests__/FormRenderer.test.tsx +264 -85
- package/src/useForma.ts +382 -249
package/src/FormRenderer.tsx
CHANGED
|
@@ -5,12 +5,36 @@
|
|
|
5
5
|
* Supports single-page and multi-page (wizard) forms.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, {
|
|
9
|
-
|
|
8
|
+
import React, {
|
|
9
|
+
forwardRef,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useRef,
|
|
12
|
+
useMemo,
|
|
13
|
+
useCallback,
|
|
14
|
+
} from "react";
|
|
15
|
+
import type {
|
|
16
|
+
Forma,
|
|
17
|
+
FieldDefinition,
|
|
18
|
+
ValidationResult,
|
|
19
|
+
JSONSchemaProperty,
|
|
20
|
+
SelectOption,
|
|
21
|
+
} from "@fogpipe/forma-core";
|
|
10
22
|
import { isAdornableField, isSelectionField } from "@fogpipe/forma-core";
|
|
11
23
|
import { useForma } from "./useForma.js";
|
|
12
24
|
import { FormaContext } from "./context.js";
|
|
13
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
ComponentMap,
|
|
27
|
+
LayoutProps,
|
|
28
|
+
FieldWrapperProps,
|
|
29
|
+
PageWrapperProps,
|
|
30
|
+
BaseFieldProps,
|
|
31
|
+
TextFieldProps,
|
|
32
|
+
NumberFieldProps,
|
|
33
|
+
SelectFieldProps,
|
|
34
|
+
ArrayFieldProps,
|
|
35
|
+
ArrayHelpers,
|
|
36
|
+
DisplayFieldProps,
|
|
37
|
+
} from "./types.js";
|
|
14
38
|
|
|
15
39
|
/**
|
|
16
40
|
* Props for FormRenderer component
|
|
@@ -23,7 +47,10 @@ export interface FormRendererProps {
|
|
|
23
47
|
/** Submit handler */
|
|
24
48
|
onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
25
49
|
/** Change handler */
|
|
26
|
-
onChange?: (
|
|
50
|
+
onChange?: (
|
|
51
|
+
data: Record<string, unknown>,
|
|
52
|
+
computed?: Record<string, unknown>,
|
|
53
|
+
) => void;
|
|
27
54
|
/** Component map for rendering fields */
|
|
28
55
|
components: ComponentMap;
|
|
29
56
|
/** Custom layout component */
|
|
@@ -75,11 +102,20 @@ function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
|
|
|
75
102
|
/**
|
|
76
103
|
* Default field wrapper component with accessibility support
|
|
77
104
|
*/
|
|
78
|
-
function DefaultFieldWrapper({
|
|
105
|
+
function DefaultFieldWrapper({
|
|
106
|
+
fieldPath,
|
|
107
|
+
field,
|
|
108
|
+
children,
|
|
109
|
+
errors,
|
|
110
|
+
showRequiredIndicator,
|
|
111
|
+
visible,
|
|
112
|
+
}: FieldWrapperProps) {
|
|
79
113
|
if (!visible) return null;
|
|
80
114
|
|
|
81
115
|
const errorId = `${fieldPath}-error`;
|
|
82
|
-
const descriptionId = field.description
|
|
116
|
+
const descriptionId = field.description
|
|
117
|
+
? `${fieldPath}-description`
|
|
118
|
+
: undefined;
|
|
83
119
|
const hasErrors = errors.length > 0;
|
|
84
120
|
|
|
85
121
|
return (
|
|
@@ -87,8 +123,14 @@ function DefaultFieldWrapper({ fieldPath, field, children, errors, showRequiredI
|
|
|
87
123
|
{field.label && (
|
|
88
124
|
<label htmlFor={fieldPath}>
|
|
89
125
|
{field.label}
|
|
90
|
-
{showRequiredIndicator &&
|
|
91
|
-
|
|
126
|
+
{showRequiredIndicator && (
|
|
127
|
+
<span className="required" aria-hidden="true">
|
|
128
|
+
*
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
{showRequiredIndicator && (
|
|
132
|
+
<span className="sr-only"> (required)</span>
|
|
133
|
+
)}
|
|
92
134
|
</label>
|
|
93
135
|
)}
|
|
94
136
|
{children}
|
|
@@ -118,7 +160,11 @@ function DefaultFieldWrapper({ fieldPath, field, children, errors, showRequiredI
|
|
|
118
160
|
/**
|
|
119
161
|
* Default page wrapper component
|
|
120
162
|
*/
|
|
121
|
-
function DefaultPageWrapper({
|
|
163
|
+
function DefaultPageWrapper({
|
|
164
|
+
title,
|
|
165
|
+
description,
|
|
166
|
+
children,
|
|
167
|
+
}: PageWrapperProps) {
|
|
122
168
|
return (
|
|
123
169
|
<div className="page-wrapper">
|
|
124
170
|
<h2>{title}</h2>
|
|
@@ -131,13 +177,23 @@ function DefaultPageWrapper({ title, description, children }: PageWrapperProps)
|
|
|
131
177
|
/**
|
|
132
178
|
* Extract numeric constraints from JSON Schema property
|
|
133
179
|
*/
|
|
134
|
-
function getNumberConstraints(schema?: JSONSchemaProperty): {
|
|
180
|
+
function getNumberConstraints(schema?: JSONSchemaProperty): {
|
|
181
|
+
min?: number;
|
|
182
|
+
max?: number;
|
|
183
|
+
step?: number;
|
|
184
|
+
} {
|
|
135
185
|
if (!schema) return {};
|
|
136
186
|
if (schema.type !== "number" && schema.type !== "integer") return {};
|
|
137
187
|
|
|
138
188
|
// Extract min/max from schema
|
|
139
|
-
const min =
|
|
140
|
-
|
|
189
|
+
const min =
|
|
190
|
+
"minimum" in schema && typeof schema.minimum === "number"
|
|
191
|
+
? schema.minimum
|
|
192
|
+
: undefined;
|
|
193
|
+
const max =
|
|
194
|
+
"maximum" in schema && typeof schema.maximum === "number"
|
|
195
|
+
? schema.maximum
|
|
196
|
+
: undefined;
|
|
141
197
|
|
|
142
198
|
// Use multipleOf for step if defined, otherwise default to 1 for integers
|
|
143
199
|
let step: number | undefined;
|
|
@@ -153,7 +209,9 @@ function getNumberConstraints(schema?: JSONSchemaProperty): { min?: number; max?
|
|
|
153
209
|
/**
|
|
154
210
|
* Create a default item for an array field based on item field definitions
|
|
155
211
|
*/
|
|
156
|
-
function createDefaultItem(
|
|
212
|
+
function createDefaultItem(
|
|
213
|
+
itemFields: Record<string, FieldDefinition>,
|
|
214
|
+
): Record<string, unknown> {
|
|
157
215
|
const item: Record<string, unknown> = {};
|
|
158
216
|
for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
|
|
159
217
|
if (fieldDef.type === "boolean") {
|
|
@@ -222,9 +280,27 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
222
280
|
isValid: forma.isValid,
|
|
223
281
|
isDirty: forma.isDirty,
|
|
224
282
|
}),
|
|
225
|
-
[forma, focusField, focusFirstError]
|
|
283
|
+
[forma, focusField, focusFirstError],
|
|
226
284
|
);
|
|
227
285
|
|
|
286
|
+
// Destructure only the values renderField needs from forma.
|
|
287
|
+
// This prevents renderField from recreating when unrelated state changes
|
|
288
|
+
// (isSubmitting, isDirty, wizard page navigation, etc.)
|
|
289
|
+
const {
|
|
290
|
+
data: formaData,
|
|
291
|
+
computed: formaComputed,
|
|
292
|
+
visibility: formaVisibility,
|
|
293
|
+
required: formaRequired,
|
|
294
|
+
enabled: formaEnabled,
|
|
295
|
+
readonly: formaReadonly,
|
|
296
|
+
optionsVisibility: formaOptionsVisibility,
|
|
297
|
+
touched: formaTouched,
|
|
298
|
+
errors: formaErrors,
|
|
299
|
+
setFieldValue,
|
|
300
|
+
setFieldTouched,
|
|
301
|
+
getArrayHelpers,
|
|
302
|
+
} = forma;
|
|
303
|
+
|
|
228
304
|
// Determine which fields to render based on pages or fieldOrder
|
|
229
305
|
const fieldsToRender = useMemo(() => {
|
|
230
306
|
if (spec.pages && spec.pages.length > 0 && forma.wizard) {
|
|
@@ -241,185 +317,248 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
241
317
|
}, [spec.pages, spec.fieldOrder, forma.wizard]);
|
|
242
318
|
|
|
243
319
|
// Render a single field (memoized)
|
|
244
|
-
const renderField = useCallback(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
320
|
+
const renderField = useCallback(
|
|
321
|
+
(fieldPath: string) => {
|
|
322
|
+
const fieldDef = spec.fields[fieldPath];
|
|
323
|
+
if (!fieldDef) return null;
|
|
324
|
+
|
|
325
|
+
const isVisible = formaVisibility[fieldPath] !== false;
|
|
326
|
+
if (!isVisible) {
|
|
327
|
+
return <div key={fieldPath} data-field-path={fieldPath} hidden />;
|
|
328
|
+
}
|
|
250
329
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
330
|
+
// Get field type (type is required on all field definitions)
|
|
331
|
+
const fieldType = fieldDef.type;
|
|
332
|
+
const componentKey = fieldType as keyof ComponentMap;
|
|
333
|
+
const Component = components[componentKey] || components.fallback;
|
|
255
334
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
335
|
+
if (!Component) {
|
|
336
|
+
console.warn(`No component found for field type: ${fieldType}`);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
260
339
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// Build type-specific props
|
|
306
|
-
let fieldProps: BaseFieldProps | TextFieldProps | NumberFieldProps | SelectFieldProps | ArrayFieldProps | DisplayFieldProps = baseProps;
|
|
307
|
-
|
|
308
|
-
if (fieldType === "number" || fieldType === "integer") {
|
|
309
|
-
const constraints = getNumberConstraints(schemaProperty);
|
|
310
|
-
fieldProps = {
|
|
311
|
-
...baseProps,
|
|
312
|
-
fieldType,
|
|
313
|
-
value: baseProps.value as number | null,
|
|
314
|
-
onChange: baseProps.onChange as (value: number | null) => void,
|
|
315
|
-
...constraints,
|
|
316
|
-
} as NumberFieldProps;
|
|
317
|
-
} else if (fieldType === "select" || fieldType === "multiselect") {
|
|
318
|
-
const selectOptions = isSelectionField(fieldDef) ? fieldDef.options : [];
|
|
319
|
-
fieldProps = {
|
|
320
|
-
...baseProps,
|
|
321
|
-
fieldType,
|
|
322
|
-
value: baseProps.value as string | string[] | null,
|
|
323
|
-
onChange: baseProps.onChange as (value: string | string[] | null) => void,
|
|
324
|
-
options: forma.optionsVisibility[fieldPath] ?? selectOptions ?? [],
|
|
325
|
-
} as SelectFieldProps;
|
|
326
|
-
} else if (fieldType === "array" && fieldDef.type === "array" && fieldDef.itemFields) {
|
|
327
|
-
const arrayValue = Array.isArray(baseProps.value) ? baseProps.value : [];
|
|
328
|
-
const minItems = fieldDef.minItems ?? 0;
|
|
329
|
-
const maxItems = fieldDef.maxItems ?? Infinity;
|
|
330
|
-
const itemFieldDefs = fieldDef.itemFields;
|
|
331
|
-
|
|
332
|
-
// Get helpers from useForma - these are fresh on each render, avoiding stale closures
|
|
333
|
-
const baseHelpers = forma.getArrayHelpers(fieldPath);
|
|
334
|
-
|
|
335
|
-
// Wrap push to add default item creation when called without arguments
|
|
336
|
-
const pushWithDefault = (item?: unknown): void => {
|
|
337
|
-
const newItem = item ?? createDefaultItem(itemFieldDefs);
|
|
338
|
-
baseHelpers.push(newItem);
|
|
340
|
+
const errors = formaErrors.filter((e) => e.field === fieldPath);
|
|
341
|
+
const touched = formaTouched[fieldPath] ?? false;
|
|
342
|
+
const required = formaRequired[fieldPath] ?? false;
|
|
343
|
+
const disabled = formaEnabled[fieldPath] === false;
|
|
344
|
+
|
|
345
|
+
// Get schema property for additional constraints
|
|
346
|
+
const schemaProperty = spec.schema.properties[fieldPath];
|
|
347
|
+
|
|
348
|
+
// Boolean fields: hide asterisk unless they have validation rules (consent pattern)
|
|
349
|
+
// - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
|
|
350
|
+
// - Consent checkbox ("I accept terms"): has validation rule → show asterisk
|
|
351
|
+
const isBooleanField =
|
|
352
|
+
schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
|
|
353
|
+
const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
|
|
354
|
+
const showRequiredIndicator =
|
|
355
|
+
required && (!isBooleanField || hasValidationRules);
|
|
356
|
+
|
|
357
|
+
// Base field props
|
|
358
|
+
const isReadonly = formaReadonly[fieldPath] ?? false;
|
|
359
|
+
const baseProps: BaseFieldProps = {
|
|
360
|
+
name: fieldPath,
|
|
361
|
+
field: fieldDef,
|
|
362
|
+
value: formaData[fieldPath],
|
|
363
|
+
touched,
|
|
364
|
+
required,
|
|
365
|
+
disabled,
|
|
366
|
+
errors,
|
|
367
|
+
onChange: (value: unknown) => setFieldValue(fieldPath, value),
|
|
368
|
+
onBlur: () => setFieldTouched(fieldPath),
|
|
369
|
+
// Convenience properties
|
|
370
|
+
visible: true, // Always true since we already filtered for visibility
|
|
371
|
+
enabled: !disabled,
|
|
372
|
+
readonly: isReadonly,
|
|
373
|
+
label: fieldDef.label ?? fieldPath,
|
|
374
|
+
description: fieldDef.description,
|
|
375
|
+
placeholder: fieldDef.placeholder,
|
|
376
|
+
// Adorner properties (only for adornable field types)
|
|
377
|
+
...(isAdornableField(fieldDef) && {
|
|
378
|
+
prefix: fieldDef.prefix,
|
|
379
|
+
suffix: fieldDef.suffix,
|
|
380
|
+
}),
|
|
381
|
+
// Presentation variant
|
|
382
|
+
variant: fieldDef.variant,
|
|
383
|
+
variantConfig: fieldDef.variantConfig,
|
|
339
384
|
};
|
|
340
385
|
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
386
|
+
// Build type-specific props
|
|
387
|
+
let fieldProps:
|
|
388
|
+
| BaseFieldProps
|
|
389
|
+
| TextFieldProps
|
|
390
|
+
| NumberFieldProps
|
|
391
|
+
| SelectFieldProps
|
|
392
|
+
| ArrayFieldProps
|
|
393
|
+
| DisplayFieldProps = baseProps;
|
|
394
|
+
|
|
395
|
+
if (fieldType === "number" || fieldType === "integer") {
|
|
396
|
+
const constraints = getNumberConstraints(schemaProperty);
|
|
397
|
+
fieldProps = {
|
|
347
398
|
...baseProps,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
399
|
+
fieldType,
|
|
400
|
+
value: baseProps.value as number | null,
|
|
401
|
+
onChange: baseProps.onChange as (value: number | null) => void,
|
|
402
|
+
...constraints,
|
|
403
|
+
} as NumberFieldProps;
|
|
404
|
+
} else if (fieldType === "select" || fieldType === "multiselect") {
|
|
405
|
+
const selectOptions = isSelectionField(fieldDef)
|
|
406
|
+
? fieldDef.options
|
|
407
|
+
: [];
|
|
408
|
+
fieldProps = {
|
|
409
|
+
...baseProps,
|
|
410
|
+
fieldType,
|
|
411
|
+
value: baseProps.value as string | string[] | null,
|
|
412
|
+
onChange: baseProps.onChange as (
|
|
413
|
+
value: string | string[] | null,
|
|
414
|
+
) => void,
|
|
415
|
+
options: formaOptionsVisibility[fieldPath] ?? selectOptions ?? [],
|
|
416
|
+
} as SelectFieldProps;
|
|
417
|
+
} else if (
|
|
418
|
+
fieldType === "array" &&
|
|
419
|
+
fieldDef.type === "array" &&
|
|
420
|
+
fieldDef.itemFields
|
|
421
|
+
) {
|
|
422
|
+
const arrayValue = Array.isArray(baseProps.value)
|
|
423
|
+
? baseProps.value
|
|
424
|
+
: [];
|
|
425
|
+
const minItems = fieldDef.minItems ?? 0;
|
|
426
|
+
const maxItems = fieldDef.maxItems ?? Infinity;
|
|
427
|
+
const itemFieldDefs = fieldDef.itemFields;
|
|
428
|
+
|
|
429
|
+
// Get helpers from useForma - these are fresh on each render, avoiding stale closures
|
|
430
|
+
const baseHelpers = getArrayHelpers(fieldPath);
|
|
431
|
+
|
|
432
|
+
// Wrap push to add default item creation when called without arguments
|
|
433
|
+
const pushWithDefault = (item?: unknown): void => {
|
|
434
|
+
const newItem = item ?? createDefaultItem(itemFieldDefs);
|
|
435
|
+
baseHelpers.push(newItem);
|
|
351
436
|
};
|
|
352
|
-
};
|
|
353
437
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
maxItems,
|
|
376
|
-
} as ArrayFieldProps;
|
|
377
|
-
} else if (fieldType === "display" && fieldDef.type === "display") {
|
|
378
|
-
// Display fields (read-only presentation content)
|
|
379
|
-
// Resolve source value if the display field has a source property
|
|
380
|
-
const sourceValue = fieldDef.source ? forma.data[fieldDef.source] ?? forma.computed[fieldDef.source] : undefined;
|
|
381
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
382
|
-
const { onChange: _onChange, value: _value, ...displayBaseProps } = baseProps;
|
|
383
|
-
fieldProps = {
|
|
384
|
-
...displayBaseProps,
|
|
385
|
-
fieldType: "display",
|
|
386
|
-
content: fieldDef.content,
|
|
387
|
-
sourceValue,
|
|
388
|
-
format: fieldDef.format,
|
|
389
|
-
} as DisplayFieldProps;
|
|
390
|
-
} else {
|
|
391
|
-
// Text-based fields
|
|
392
|
-
fieldProps = {
|
|
393
|
-
...baseProps,
|
|
394
|
-
fieldType: fieldType as "text" | "email" | "password" | "url" | "textarea",
|
|
395
|
-
value: (baseProps.value as string) ?? "",
|
|
396
|
-
onChange: baseProps.onChange as (value: string) => void,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
438
|
+
// Extend getItemFieldProps to include additional metadata (itemIndex, fieldName, options)
|
|
439
|
+
const getItemFieldPropsExtended = (
|
|
440
|
+
index: number,
|
|
441
|
+
fieldName: string,
|
|
442
|
+
) => {
|
|
443
|
+
const baseProps = baseHelpers.getItemFieldProps(index, fieldName);
|
|
444
|
+
const itemFieldDef = itemFieldDefs[fieldName];
|
|
445
|
+
const itemPath = `${fieldPath}[${index}].${fieldName}`;
|
|
446
|
+
return {
|
|
447
|
+
...baseProps,
|
|
448
|
+
itemIndex: index,
|
|
449
|
+
fieldName,
|
|
450
|
+
options:
|
|
451
|
+
(formaOptionsVisibility[itemPath] as
|
|
452
|
+
| SelectOption[]
|
|
453
|
+
| undefined) ??
|
|
454
|
+
(itemFieldDef && isSelectionField(itemFieldDef)
|
|
455
|
+
? itemFieldDef.options
|
|
456
|
+
: undefined),
|
|
457
|
+
};
|
|
458
|
+
};
|
|
399
459
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
460
|
+
const helpers: ArrayHelpers = {
|
|
461
|
+
items: arrayValue,
|
|
462
|
+
push: pushWithDefault,
|
|
463
|
+
insert: baseHelpers.insert,
|
|
464
|
+
remove: baseHelpers.remove,
|
|
465
|
+
move: baseHelpers.move,
|
|
466
|
+
swap: baseHelpers.swap,
|
|
467
|
+
getItemFieldProps: getItemFieldPropsExtended,
|
|
468
|
+
minItems,
|
|
469
|
+
maxItems,
|
|
470
|
+
canAdd: arrayValue.length < maxItems,
|
|
471
|
+
canRemove: arrayValue.length > minItems,
|
|
472
|
+
};
|
|
473
|
+
fieldProps = {
|
|
474
|
+
...baseProps,
|
|
475
|
+
fieldType: "array",
|
|
476
|
+
value: arrayValue,
|
|
477
|
+
onChange: baseProps.onChange as (value: unknown[]) => void,
|
|
478
|
+
helpers,
|
|
479
|
+
itemFields: itemFieldDefs,
|
|
480
|
+
minItems,
|
|
481
|
+
maxItems,
|
|
482
|
+
} as ArrayFieldProps;
|
|
483
|
+
} else if (fieldType === "display" && fieldDef.type === "display") {
|
|
484
|
+
// Display fields (read-only presentation content)
|
|
485
|
+
// Resolve source value if the display field has a source property
|
|
486
|
+
const sourceValue = fieldDef.source
|
|
487
|
+
? (formaData[fieldDef.source] ?? formaComputed[fieldDef.source])
|
|
488
|
+
: undefined;
|
|
489
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
490
|
+
const {
|
|
491
|
+
onChange: _onChange,
|
|
492
|
+
value: _value,
|
|
493
|
+
...displayBaseProps
|
|
494
|
+
} = baseProps;
|
|
495
|
+
fieldProps = {
|
|
496
|
+
...displayBaseProps,
|
|
497
|
+
fieldType: "display",
|
|
498
|
+
content: fieldDef.content,
|
|
499
|
+
sourceValue,
|
|
500
|
+
format: fieldDef.format,
|
|
501
|
+
} as DisplayFieldProps;
|
|
502
|
+
} else {
|
|
503
|
+
// Text-based fields
|
|
504
|
+
fieldProps = {
|
|
505
|
+
...baseProps,
|
|
506
|
+
fieldType: fieldType as
|
|
507
|
+
| "text"
|
|
508
|
+
| "email"
|
|
509
|
+
| "password"
|
|
510
|
+
| "url"
|
|
511
|
+
| "textarea",
|
|
512
|
+
value: (baseProps.value as string) ?? "",
|
|
513
|
+
onChange: baseProps.onChange as (value: string) => void,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Wrap props in { field, spec } structure for components
|
|
518
|
+
const componentProps = { field: fieldProps, spec };
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div key={fieldPath} data-field-path={fieldPath}>
|
|
522
|
+
<FieldWrapper
|
|
523
|
+
fieldPath={fieldPath}
|
|
524
|
+
field={fieldDef}
|
|
525
|
+
errors={errors}
|
|
526
|
+
touched={touched}
|
|
527
|
+
required={required}
|
|
528
|
+
showRequiredIndicator={showRequiredIndicator}
|
|
529
|
+
visible={isVisible}
|
|
530
|
+
>
|
|
531
|
+
{React.createElement(
|
|
532
|
+
Component as React.ComponentType<typeof componentProps>,
|
|
533
|
+
componentProps,
|
|
534
|
+
)}
|
|
535
|
+
</FieldWrapper>
|
|
536
|
+
</div>
|
|
537
|
+
);
|
|
538
|
+
},
|
|
539
|
+
[
|
|
540
|
+
spec,
|
|
541
|
+
components,
|
|
542
|
+
FieldWrapper,
|
|
543
|
+
formaData,
|
|
544
|
+
formaComputed,
|
|
545
|
+
formaVisibility,
|
|
546
|
+
formaRequired,
|
|
547
|
+
formaEnabled,
|
|
548
|
+
formaReadonly,
|
|
549
|
+
formaOptionsVisibility,
|
|
550
|
+
formaTouched,
|
|
551
|
+
formaErrors,
|
|
552
|
+
setFieldValue,
|
|
553
|
+
setFieldTouched,
|
|
554
|
+
getArrayHelpers,
|
|
555
|
+
],
|
|
556
|
+
);
|
|
418
557
|
|
|
419
558
|
// Render fields (memoized)
|
|
420
559
|
const renderedFields = useMemo(
|
|
421
560
|
() => fieldsToRender.map(renderField),
|
|
422
|
-
[fieldsToRender, renderField]
|
|
561
|
+
[fieldsToRender, renderField],
|
|
423
562
|
);
|
|
424
563
|
|
|
425
564
|
// Render with page wrapper if using pages
|
|
@@ -454,5 +593,5 @@ export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
|
454
593
|
</Layout>
|
|
455
594
|
</FormaContext.Provider>
|
|
456
595
|
);
|
|
457
|
-
}
|
|
596
|
+
},
|
|
458
597
|
);
|