@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,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FormRenderer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a complete form from a Forma specification.
|
|
5
|
+
* Supports single-page and multi-page (wizard) forms.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { forwardRef, useImperativeHandle, useRef, useMemo, useCallback } from "react";
|
|
9
|
+
import type { Forma, FieldDefinition, ValidationResult, JSONSchemaProperty } from "@fogpipe/forma-core";
|
|
10
|
+
import { useForma } from "./useForma.js";
|
|
11
|
+
import { FormaContext } from "./context.js";
|
|
12
|
+
import type { ComponentMap, LayoutProps, FieldWrapperProps, PageWrapperProps, BaseFieldProps, TextFieldProps, NumberFieldProps, SelectFieldProps, ArrayFieldProps, ArrayHelpers } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props for FormRenderer component
|
|
16
|
+
*/
|
|
17
|
+
export interface FormRendererProps {
|
|
18
|
+
/** The Forma specification */
|
|
19
|
+
spec: Forma;
|
|
20
|
+
/** Initial form data */
|
|
21
|
+
initialData?: Record<string, unknown>;
|
|
22
|
+
/** Submit handler */
|
|
23
|
+
onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
24
|
+
/** Change handler */
|
|
25
|
+
onChange?: (data: Record<string, unknown>, computed?: Record<string, unknown>) => void;
|
|
26
|
+
/** Component map for rendering fields */
|
|
27
|
+
components: ComponentMap;
|
|
28
|
+
/** Custom layout component */
|
|
29
|
+
layout?: React.ComponentType<LayoutProps>;
|
|
30
|
+
/** Custom field wrapper component */
|
|
31
|
+
fieldWrapper?: React.ComponentType<FieldWrapperProps>;
|
|
32
|
+
/** Custom page wrapper component */
|
|
33
|
+
pageWrapper?: React.ComponentType<PageWrapperProps>;
|
|
34
|
+
/** When to validate */
|
|
35
|
+
validateOn?: "change" | "blur" | "submit";
|
|
36
|
+
/** Current page for controlled wizard */
|
|
37
|
+
page?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Imperative handle for FormRenderer
|
|
42
|
+
*/
|
|
43
|
+
export interface FormRendererHandle {
|
|
44
|
+
submitForm: () => Promise<void>;
|
|
45
|
+
resetForm: () => void;
|
|
46
|
+
validateForm: () => ValidationResult;
|
|
47
|
+
focusField: (path: string) => void;
|
|
48
|
+
focusFirstError: () => void;
|
|
49
|
+
getValues: () => Record<string, unknown>;
|
|
50
|
+
setValues: (values: Record<string, unknown>) => void;
|
|
51
|
+
isValid: boolean;
|
|
52
|
+
isDirty: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Default layout component
|
|
57
|
+
*/
|
|
58
|
+
function DefaultLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
|
|
59
|
+
return (
|
|
60
|
+
<form
|
|
61
|
+
onSubmit={(e) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
onSubmit();
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
<button type="submit" disabled={isSubmitting}>
|
|
68
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
69
|
+
</button>
|
|
70
|
+
</form>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Default field wrapper component with accessibility support
|
|
76
|
+
*/
|
|
77
|
+
function DefaultFieldWrapper({ fieldPath, field, children, errors, required, visible }: FieldWrapperProps) {
|
|
78
|
+
if (!visible) return null;
|
|
79
|
+
|
|
80
|
+
const errorId = `${fieldPath}-error`;
|
|
81
|
+
const descriptionId = field.description ? `${fieldPath}-description` : undefined;
|
|
82
|
+
const hasErrors = errors.length > 0;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="field-wrapper" data-field-path={fieldPath}>
|
|
86
|
+
{field.label && (
|
|
87
|
+
<label htmlFor={fieldPath}>
|
|
88
|
+
{field.label}
|
|
89
|
+
{required && <span className="required" aria-hidden="true">*</span>}
|
|
90
|
+
{required && <span className="sr-only"> (required)</span>}
|
|
91
|
+
</label>
|
|
92
|
+
)}
|
|
93
|
+
{children}
|
|
94
|
+
{hasErrors && (
|
|
95
|
+
<div
|
|
96
|
+
id={errorId}
|
|
97
|
+
className="field-errors"
|
|
98
|
+
role="alert"
|
|
99
|
+
aria-live="polite"
|
|
100
|
+
>
|
|
101
|
+
{errors.map((error, i) => (
|
|
102
|
+
<span key={i} className="error">
|
|
103
|
+
{error.message}
|
|
104
|
+
</span>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
{field.description && (
|
|
109
|
+
<p id={descriptionId} className="field-description">
|
|
110
|
+
{field.description}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Default page wrapper component
|
|
119
|
+
*/
|
|
120
|
+
function DefaultPageWrapper({ title, description, children }: PageWrapperProps) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="page-wrapper">
|
|
123
|
+
<h2>{title}</h2>
|
|
124
|
+
{description && <p>{description}</p>}
|
|
125
|
+
{children}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extract numeric constraints from JSON Schema property
|
|
132
|
+
*/
|
|
133
|
+
function getNumberConstraints(schema?: JSONSchemaProperty): { min?: number; max?: number; step?: number } {
|
|
134
|
+
if (!schema) return {};
|
|
135
|
+
if (schema.type !== "number" && schema.type !== "integer") return {};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
min: "minimum" in schema ? schema.minimum : undefined,
|
|
139
|
+
max: "maximum" in schema ? schema.maximum : undefined,
|
|
140
|
+
step: schema.type === "integer" ? 1 : undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a default item for an array field based on item field definitions
|
|
146
|
+
*/
|
|
147
|
+
function createDefaultItem(itemFields: Record<string, FieldDefinition>): Record<string, unknown> {
|
|
148
|
+
const item: Record<string, unknown> = {};
|
|
149
|
+
for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
|
|
150
|
+
if (fieldDef.type === "boolean") {
|
|
151
|
+
item[fieldName] = false;
|
|
152
|
+
} else if (fieldDef.type === "number" || fieldDef.type === "integer") {
|
|
153
|
+
item[fieldName] = null;
|
|
154
|
+
} else {
|
|
155
|
+
item[fieldName] = "";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return item;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* FormRenderer component
|
|
163
|
+
*/
|
|
164
|
+
export const FormRenderer = forwardRef<FormRendererHandle, FormRendererProps>(
|
|
165
|
+
function FormRenderer(props, ref) {
|
|
166
|
+
const {
|
|
167
|
+
spec,
|
|
168
|
+
initialData,
|
|
169
|
+
onSubmit,
|
|
170
|
+
onChange,
|
|
171
|
+
components,
|
|
172
|
+
layout: Layout = DefaultLayout,
|
|
173
|
+
fieldWrapper: FieldWrapper = DefaultFieldWrapper,
|
|
174
|
+
pageWrapper: PageWrapper = DefaultPageWrapper,
|
|
175
|
+
validateOn,
|
|
176
|
+
} = props;
|
|
177
|
+
|
|
178
|
+
const forma = useForma({
|
|
179
|
+
spec,
|
|
180
|
+
initialData,
|
|
181
|
+
onSubmit,
|
|
182
|
+
onChange,
|
|
183
|
+
validateOn,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const fieldRefs = useRef<Map<string, HTMLElement>>(new Map());
|
|
187
|
+
|
|
188
|
+
// Cache for array helper functions to prevent recreation on every render
|
|
189
|
+
const arrayHelpersCache = useRef<Map<string, {
|
|
190
|
+
push: (item?: unknown) => void;
|
|
191
|
+
insert: (index: number, item: unknown) => void;
|
|
192
|
+
remove: (index: number) => void;
|
|
193
|
+
move: (from: number, to: number) => void;
|
|
194
|
+
swap: (indexA: number, indexB: number) => void;
|
|
195
|
+
}>>(new Map());
|
|
196
|
+
|
|
197
|
+
// Focus a specific field by path
|
|
198
|
+
const focusField = useCallback((path: string) => {
|
|
199
|
+
const element = fieldRefs.current.get(path);
|
|
200
|
+
element?.focus();
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
// Focus the first field with an error
|
|
204
|
+
const focusFirstError = useCallback(() => {
|
|
205
|
+
const firstError = forma.errors[0];
|
|
206
|
+
if (firstError) {
|
|
207
|
+
focusField(firstError.field);
|
|
208
|
+
}
|
|
209
|
+
}, [forma.errors, focusField]);
|
|
210
|
+
|
|
211
|
+
// Expose imperative handle
|
|
212
|
+
useImperativeHandle(
|
|
213
|
+
ref,
|
|
214
|
+
() => ({
|
|
215
|
+
submitForm: forma.submitForm,
|
|
216
|
+
resetForm: forma.resetForm,
|
|
217
|
+
validateForm: forma.validateForm,
|
|
218
|
+
focusField,
|
|
219
|
+
focusFirstError,
|
|
220
|
+
getValues: () => forma.data,
|
|
221
|
+
setValues: forma.setValues,
|
|
222
|
+
isValid: forma.isValid,
|
|
223
|
+
isDirty: forma.isDirty,
|
|
224
|
+
}),
|
|
225
|
+
[forma, focusField, focusFirstError]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Determine which fields to render based on pages or fieldOrder
|
|
229
|
+
const fieldsToRender = useMemo(() => {
|
|
230
|
+
if (spec.pages && spec.pages.length > 0 && forma.wizard) {
|
|
231
|
+
// Wizard mode - render fields for the active page
|
|
232
|
+
const currentPage = forma.wizard.currentPage;
|
|
233
|
+
if (currentPage) {
|
|
234
|
+
return currentPage.fields;
|
|
235
|
+
}
|
|
236
|
+
// Fallback to first page
|
|
237
|
+
return spec.pages[0]?.fields ?? [];
|
|
238
|
+
}
|
|
239
|
+
// Single page mode - render all fields in order
|
|
240
|
+
return spec.fieldOrder;
|
|
241
|
+
}, [spec.pages, spec.fieldOrder, forma.wizard]);
|
|
242
|
+
|
|
243
|
+
// Render a single field (memoized)
|
|
244
|
+
const renderField = useCallback((fieldPath: string) => {
|
|
245
|
+
const fieldDef = spec.fields[fieldPath];
|
|
246
|
+
if (!fieldDef) return null;
|
|
247
|
+
|
|
248
|
+
const isVisible = forma.visibility[fieldPath] !== false;
|
|
249
|
+
if (!isVisible) return null;
|
|
250
|
+
|
|
251
|
+
// Infer field type
|
|
252
|
+
const fieldType = fieldDef.type || (fieldDef.itemFields ? "array" : "text");
|
|
253
|
+
const componentKey = fieldType as keyof ComponentMap;
|
|
254
|
+
const Component = components[componentKey] || components.fallback;
|
|
255
|
+
|
|
256
|
+
if (!Component) {
|
|
257
|
+
console.warn(`No component found for field type: ${fieldType}`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const errors = forma.errors.filter((e) => e.field === fieldPath);
|
|
262
|
+
const touched = forma.touched[fieldPath] ?? false;
|
|
263
|
+
const required = forma.required[fieldPath] ?? false;
|
|
264
|
+
const disabled = forma.enabled[fieldPath] === false;
|
|
265
|
+
|
|
266
|
+
// Get schema property for additional constraints
|
|
267
|
+
const schemaProperty = spec.schema.properties[fieldPath];
|
|
268
|
+
|
|
269
|
+
// Base field props
|
|
270
|
+
const baseProps: BaseFieldProps = {
|
|
271
|
+
name: fieldPath,
|
|
272
|
+
field: fieldDef,
|
|
273
|
+
value: forma.data[fieldPath],
|
|
274
|
+
touched,
|
|
275
|
+
required,
|
|
276
|
+
disabled,
|
|
277
|
+
errors,
|
|
278
|
+
onChange: (value: unknown) => forma.setFieldValue(fieldPath, value),
|
|
279
|
+
onBlur: () => forma.setFieldTouched(fieldPath),
|
|
280
|
+
// Convenience properties
|
|
281
|
+
visible: true, // Always true since we already filtered for visibility
|
|
282
|
+
enabled: !disabled,
|
|
283
|
+
label: fieldDef.label ?? fieldPath,
|
|
284
|
+
description: fieldDef.description,
|
|
285
|
+
placeholder: fieldDef.placeholder,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Build type-specific props
|
|
289
|
+
let fieldProps: BaseFieldProps | TextFieldProps | NumberFieldProps | SelectFieldProps | ArrayFieldProps = baseProps;
|
|
290
|
+
|
|
291
|
+
if (fieldType === "number" || fieldType === "integer") {
|
|
292
|
+
const constraints = getNumberConstraints(schemaProperty);
|
|
293
|
+
fieldProps = {
|
|
294
|
+
...baseProps,
|
|
295
|
+
fieldType,
|
|
296
|
+
value: baseProps.value as number | null,
|
|
297
|
+
onChange: baseProps.onChange as (value: number | null) => void,
|
|
298
|
+
...constraints,
|
|
299
|
+
} as NumberFieldProps;
|
|
300
|
+
} else if (fieldType === "select" || fieldType === "multiselect") {
|
|
301
|
+
fieldProps = {
|
|
302
|
+
...baseProps,
|
|
303
|
+
fieldType,
|
|
304
|
+
value: baseProps.value as string | string[] | null,
|
|
305
|
+
onChange: baseProps.onChange as (value: string | string[] | null) => void,
|
|
306
|
+
options: fieldDef.options ?? [],
|
|
307
|
+
} as SelectFieldProps;
|
|
308
|
+
} else if (fieldType === "array" && fieldDef.itemFields) {
|
|
309
|
+
const arrayValue = (baseProps.value as unknown[] | undefined) ?? [];
|
|
310
|
+
const minItems = fieldDef.minItems ?? 0;
|
|
311
|
+
const maxItems = fieldDef.maxItems ?? Infinity;
|
|
312
|
+
const itemFieldDefs = fieldDef.itemFields;
|
|
313
|
+
|
|
314
|
+
// Get or create cached helper functions for this array field
|
|
315
|
+
// These functions read current values when called, not when created
|
|
316
|
+
if (!arrayHelpersCache.current.has(fieldPath)) {
|
|
317
|
+
arrayHelpersCache.current.set(fieldPath, {
|
|
318
|
+
push: (item?: unknown) => {
|
|
319
|
+
const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
|
|
320
|
+
const newItem = item ?? createDefaultItem(itemFieldDefs);
|
|
321
|
+
forma.setFieldValue(fieldPath, [...currentArray, newItem]);
|
|
322
|
+
},
|
|
323
|
+
insert: (index: number, item: unknown) => {
|
|
324
|
+
const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
|
|
325
|
+
const newArray = [...currentArray];
|
|
326
|
+
newArray.splice(index, 0, item);
|
|
327
|
+
forma.setFieldValue(fieldPath, newArray);
|
|
328
|
+
},
|
|
329
|
+
remove: (index: number) => {
|
|
330
|
+
const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
|
|
331
|
+
const newArray = [...currentArray];
|
|
332
|
+
newArray.splice(index, 1);
|
|
333
|
+
forma.setFieldValue(fieldPath, newArray);
|
|
334
|
+
},
|
|
335
|
+
move: (from: number, to: number) => {
|
|
336
|
+
const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
|
|
337
|
+
const newArray = [...currentArray];
|
|
338
|
+
const [item] = newArray.splice(from, 1);
|
|
339
|
+
newArray.splice(to, 0, item);
|
|
340
|
+
forma.setFieldValue(fieldPath, newArray);
|
|
341
|
+
},
|
|
342
|
+
swap: (indexA: number, indexB: number) => {
|
|
343
|
+
const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
|
|
344
|
+
const newArray = [...currentArray];
|
|
345
|
+
[newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]];
|
|
346
|
+
forma.setFieldValue(fieldPath, newArray);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const cachedHelpers = arrayHelpersCache.current.get(fieldPath)!;
|
|
351
|
+
|
|
352
|
+
const helpers: ArrayHelpers = {
|
|
353
|
+
items: arrayValue,
|
|
354
|
+
push: cachedHelpers.push,
|
|
355
|
+
insert: cachedHelpers.insert,
|
|
356
|
+
remove: cachedHelpers.remove,
|
|
357
|
+
move: cachedHelpers.move,
|
|
358
|
+
swap: cachedHelpers.swap,
|
|
359
|
+
getItemFieldProps: (index: number, fieldName: string) => {
|
|
360
|
+
const itemFieldDef = itemFieldDefs[fieldName];
|
|
361
|
+
const itemPath = `${fieldPath}[${index}].${fieldName}`;
|
|
362
|
+
const itemValue = (arrayValue[index] as Record<string, unknown>)?.[fieldName];
|
|
363
|
+
return {
|
|
364
|
+
name: itemPath,
|
|
365
|
+
value: itemValue,
|
|
366
|
+
type: itemFieldDef?.type ?? "text",
|
|
367
|
+
label: itemFieldDef?.label ?? fieldName,
|
|
368
|
+
description: itemFieldDef?.description,
|
|
369
|
+
placeholder: itemFieldDef?.placeholder,
|
|
370
|
+
visible: true,
|
|
371
|
+
enabled: !disabled,
|
|
372
|
+
required: itemFieldDef?.requiredWhen === "true",
|
|
373
|
+
touched: forma.touched[itemPath] ?? false,
|
|
374
|
+
errors: forma.errors.filter((e) => e.field === itemPath),
|
|
375
|
+
onChange: (value: unknown) => {
|
|
376
|
+
const currentArray = (forma.data[fieldPath] as unknown[] | undefined) ?? [];
|
|
377
|
+
const newArray = [...currentArray];
|
|
378
|
+
const item = (newArray[index] ?? {}) as Record<string, unknown>;
|
|
379
|
+
newArray[index] = { ...item, [fieldName]: value };
|
|
380
|
+
forma.setFieldValue(fieldPath, newArray);
|
|
381
|
+
},
|
|
382
|
+
onBlur: () => forma.setFieldTouched(itemPath),
|
|
383
|
+
itemIndex: index,
|
|
384
|
+
fieldName,
|
|
385
|
+
options: itemFieldDef?.options,
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
minItems,
|
|
389
|
+
maxItems,
|
|
390
|
+
canAdd: arrayValue.length < maxItems,
|
|
391
|
+
canRemove: arrayValue.length > minItems,
|
|
392
|
+
};
|
|
393
|
+
fieldProps = {
|
|
394
|
+
...baseProps,
|
|
395
|
+
fieldType: "array",
|
|
396
|
+
value: arrayValue,
|
|
397
|
+
onChange: baseProps.onChange as (value: unknown[]) => void,
|
|
398
|
+
helpers,
|
|
399
|
+
itemFields: itemFieldDefs,
|
|
400
|
+
minItems,
|
|
401
|
+
maxItems,
|
|
402
|
+
} as ArrayFieldProps;
|
|
403
|
+
} else {
|
|
404
|
+
// Text-based fields
|
|
405
|
+
fieldProps = {
|
|
406
|
+
...baseProps,
|
|
407
|
+
fieldType: fieldType as "text" | "email" | "password" | "url" | "textarea",
|
|
408
|
+
value: (baseProps.value as string) ?? "",
|
|
409
|
+
onChange: baseProps.onChange as (value: string) => void,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Wrap props in { field, spec } structure for components
|
|
414
|
+
const componentProps = { field: fieldProps, spec };
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<FieldWrapper
|
|
418
|
+
key={fieldPath}
|
|
419
|
+
fieldPath={fieldPath}
|
|
420
|
+
field={fieldDef}
|
|
421
|
+
errors={errors}
|
|
422
|
+
touched={touched}
|
|
423
|
+
required={required}
|
|
424
|
+
visible={isVisible}
|
|
425
|
+
>
|
|
426
|
+
{React.createElement(Component as React.ComponentType<typeof componentProps>, componentProps)}
|
|
427
|
+
</FieldWrapper>
|
|
428
|
+
);
|
|
429
|
+
}, [spec, forma, components, FieldWrapper]);
|
|
430
|
+
|
|
431
|
+
// Render fields (memoized)
|
|
432
|
+
const renderedFields = useMemo(
|
|
433
|
+
() => fieldsToRender.map(renderField),
|
|
434
|
+
[fieldsToRender, renderField]
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Render with page wrapper if using pages
|
|
438
|
+
const content = useMemo(() => {
|
|
439
|
+
if (spec.pages && spec.pages.length > 0 && forma.wizard) {
|
|
440
|
+
const currentPage = forma.wizard.currentPage;
|
|
441
|
+
if (!currentPage) return null;
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<PageWrapper
|
|
445
|
+
title={currentPage.title}
|
|
446
|
+
description={currentPage.description}
|
|
447
|
+
pageIndex={forma.wizard.currentPageIndex}
|
|
448
|
+
totalPages={forma.wizard.pages.length}
|
|
449
|
+
>
|
|
450
|
+
{renderedFields}
|
|
451
|
+
</PageWrapper>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return <>{renderedFields}</>;
|
|
456
|
+
}, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
|
|
457
|
+
|
|
458
|
+
return (
|
|
459
|
+
<FormaContext.Provider value={forma}>
|
|
460
|
+
<Layout
|
|
461
|
+
onSubmit={forma.submitForm}
|
|
462
|
+
isSubmitting={forma.isSubmitting}
|
|
463
|
+
isValid={forma.isValid}
|
|
464
|
+
>
|
|
465
|
+
{content}
|
|
466
|
+
</Layout>
|
|
467
|
+
</FormaContext.Provider>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
);
|