@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,681 @@
1
+ /**
2
+ * useForma Hook
3
+ *
4
+ * Main hook for managing Forma form state.
5
+ * This is a placeholder - the full implementation will be migrated from formidable.
6
+ */
7
+
8
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
9
+ import type { Forma, FieldError, ValidationResult } from "@fogpipe/forma-core";
10
+ import type { GetFieldPropsResult, GetSelectFieldPropsResult, GetArrayHelpersResult } from "./types.js";
11
+ import {
12
+ getVisibility,
13
+ getRequired,
14
+ getEnabled,
15
+ validate,
16
+ calculate,
17
+ getPageVisibility,
18
+ } from "@fogpipe/forma-core";
19
+
20
+ /**
21
+ * Options for useForma hook
22
+ */
23
+ export interface UseFormaOptions {
24
+ /** The Forma specification */
25
+ spec: Forma;
26
+ /** Initial form data */
27
+ initialData?: Record<string, unknown>;
28
+ /** Submit handler */
29
+ onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
30
+ /** Change handler */
31
+ onChange?: (data: Record<string, unknown>, computed?: Record<string, unknown>) => void;
32
+ /** When to validate: on change, blur, or submit only */
33
+ validateOn?: "change" | "blur" | "submit";
34
+ /** Additional reference data to merge with spec.referenceData */
35
+ referenceData?: Record<string, unknown>;
36
+ /**
37
+ * Debounce validation by this many milliseconds.
38
+ * Useful for large forms to improve performance.
39
+ * Set to 0 (default) for immediate validation.
40
+ */
41
+ validationDebounceMs?: number;
42
+ }
43
+
44
+ /**
45
+ * Form state
46
+ */
47
+ interface FormState {
48
+ data: Record<string, unknown>;
49
+ touched: Record<string, boolean>;
50
+ isSubmitting: boolean;
51
+ isSubmitted: boolean;
52
+ isDirty: boolean;
53
+ currentPage: number;
54
+ }
55
+
56
+ /**
57
+ * State actions
58
+ */
59
+ type FormAction =
60
+ | { type: "SET_FIELD_VALUE"; field: string; value: unknown }
61
+ | { type: "SET_FIELD_TOUCHED"; field: string; touched: boolean }
62
+ | { type: "SET_VALUES"; values: Record<string, unknown> }
63
+ | { type: "SET_SUBMITTING"; isSubmitting: boolean }
64
+ | { type: "SET_SUBMITTED"; isSubmitted: boolean }
65
+ | { type: "SET_PAGE"; page: number }
66
+ | { type: "RESET"; initialData: Record<string, unknown> };
67
+
68
+ /**
69
+ * Page state for multi-page forms
70
+ */
71
+ export interface PageState {
72
+ id: string;
73
+ title: string;
74
+ description?: string;
75
+ visible: boolean;
76
+ fields: string[];
77
+ }
78
+
79
+ /**
80
+ * Wizard navigation helpers
81
+ */
82
+ export interface WizardHelpers {
83
+ pages: PageState[];
84
+ currentPageIndex: number;
85
+ currentPage: PageState | null;
86
+ goToPage: (index: number) => void;
87
+ nextPage: () => void;
88
+ previousPage: () => void;
89
+ hasNextPage: boolean;
90
+ hasPreviousPage: boolean;
91
+ canProceed: boolean;
92
+ isLastPage: boolean;
93
+ touchCurrentPageFields: () => void;
94
+ validateCurrentPage: () => boolean;
95
+ }
96
+
97
+ /**
98
+ * Return type of useForma hook
99
+ */
100
+ export interface UseFormaReturn {
101
+ /** Current form data */
102
+ data: Record<string, unknown>;
103
+ /** Computed field values */
104
+ computed: Record<string, unknown>;
105
+ /** Field visibility map */
106
+ visibility: Record<string, boolean>;
107
+ /** Field required state map */
108
+ required: Record<string, boolean>;
109
+ /** Field enabled state map */
110
+ enabled: Record<string, boolean>;
111
+ /** Field touched state map */
112
+ touched: Record<string, boolean>;
113
+ /** Validation errors */
114
+ errors: FieldError[];
115
+ /** Whether form is valid */
116
+ isValid: boolean;
117
+ /** Whether form is submitting */
118
+ isSubmitting: boolean;
119
+ /** Whether form has been submitted */
120
+ isSubmitted: boolean;
121
+ /** Whether any field has been modified */
122
+ isDirty: boolean;
123
+ /** The Forma spec */
124
+ spec: Forma;
125
+ /** Wizard helpers (if multi-page) */
126
+ wizard: WizardHelpers | null;
127
+
128
+ /** Set a field value */
129
+ setFieldValue: (path: string, value: unknown) => void;
130
+ /** Set a field as touched */
131
+ setFieldTouched: (path: string, touched?: boolean) => void;
132
+ /** Set multiple values */
133
+ setValues: (values: Record<string, unknown>) => void;
134
+ /** Validate a single field */
135
+ validateField: (path: string) => FieldError[];
136
+ /** Validate entire form */
137
+ validateForm: () => ValidationResult;
138
+ /** Submit the form */
139
+ submitForm: () => Promise<void>;
140
+ /** Reset the form */
141
+ resetForm: () => void;
142
+
143
+ // Helper methods for getting field props
144
+ /** Get props for any field */
145
+ getFieldProps: (path: string) => GetFieldPropsResult;
146
+ /** Get props for select field (includes options) */
147
+ getSelectFieldProps: (path: string) => GetSelectFieldPropsResult;
148
+ /** Get array helpers for array field */
149
+ getArrayHelpers: (path: string) => GetArrayHelpersResult;
150
+ }
151
+
152
+ /**
153
+ * State reducer
154
+ */
155
+ function formReducer(state: FormState, action: FormAction): FormState {
156
+ switch (action.type) {
157
+ case "SET_FIELD_VALUE":
158
+ return {
159
+ ...state,
160
+ data: { ...state.data, [action.field]: action.value },
161
+ isDirty: true,
162
+ isSubmitted: false, // Clear on data change
163
+ };
164
+ case "SET_FIELD_TOUCHED":
165
+ return {
166
+ ...state,
167
+ touched: { ...state.touched, [action.field]: action.touched },
168
+ };
169
+ case "SET_VALUES":
170
+ return {
171
+ ...state,
172
+ data: { ...state.data, ...action.values },
173
+ isDirty: true,
174
+ isSubmitted: false, // Clear on data change
175
+ };
176
+ case "SET_SUBMITTING":
177
+ return { ...state, isSubmitting: action.isSubmitting };
178
+ case "SET_SUBMITTED":
179
+ return { ...state, isSubmitted: action.isSubmitted };
180
+ case "SET_PAGE":
181
+ return { ...state, currentPage: action.page };
182
+ case "RESET":
183
+ return {
184
+ data: action.initialData,
185
+ touched: {},
186
+ isSubmitting: false,
187
+ isSubmitted: false,
188
+ isDirty: false,
189
+ currentPage: 0,
190
+ };
191
+ default:
192
+ return state;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Main Forma hook
198
+ */
199
+ export function useForma(options: UseFormaOptions): UseFormaReturn {
200
+ const { spec: inputSpec, initialData = {}, onSubmit, onChange, validateOn = "blur", referenceData, validationDebounceMs = 0 } = options;
201
+
202
+ // Merge referenceData from options with spec.referenceData
203
+ const spec = useMemo((): Forma => {
204
+ if (!referenceData) return inputSpec;
205
+ return {
206
+ ...inputSpec,
207
+ referenceData: {
208
+ ...inputSpec.referenceData,
209
+ ...referenceData,
210
+ },
211
+ };
212
+ }, [inputSpec, referenceData]);
213
+
214
+ const [state, dispatch] = useReducer(formReducer, {
215
+ data: initialData,
216
+ touched: {},
217
+ isSubmitting: false,
218
+ isSubmitted: false,
219
+ isDirty: false,
220
+ currentPage: 0,
221
+ });
222
+
223
+ // Track if we've initialized (to avoid calling onChange on first render)
224
+ const hasInitialized = useRef(false);
225
+
226
+ // Calculate computed values
227
+ const computed = useMemo(
228
+ () => calculate(state.data, spec),
229
+ [state.data, spec]
230
+ );
231
+
232
+ // Calculate visibility
233
+ const visibility = useMemo(
234
+ () => getVisibility(state.data, spec, { computed }),
235
+ [state.data, spec, computed]
236
+ );
237
+
238
+ // Calculate required state
239
+ const required = useMemo(
240
+ () => getRequired(state.data, spec, { computed }),
241
+ [state.data, spec, computed]
242
+ );
243
+
244
+ // Calculate enabled state
245
+ const enabled = useMemo(
246
+ () => getEnabled(state.data, spec, { computed }),
247
+ [state.data, spec, computed]
248
+ );
249
+
250
+ // Validate form - compute immediate result
251
+ const immediateValidation = useMemo(
252
+ () => validate(state.data, spec, { computed, onlyVisible: true }),
253
+ [state.data, spec, computed]
254
+ );
255
+
256
+ // Debounced validation state (only used when validationDebounceMs > 0)
257
+ const [debouncedValidation, setDebouncedValidation] = useState<ValidationResult>(immediateValidation);
258
+
259
+ // Apply debouncing if configured
260
+ useEffect(() => {
261
+ if (validationDebounceMs <= 0) {
262
+ // No debouncing - use immediate validation
263
+ setDebouncedValidation(immediateValidation);
264
+ return;
265
+ }
266
+
267
+ // Debounce validation updates
268
+ const timeoutId = setTimeout(() => {
269
+ setDebouncedValidation(immediateValidation);
270
+ }, validationDebounceMs);
271
+
272
+ return () => clearTimeout(timeoutId);
273
+ }, [immediateValidation, validationDebounceMs]);
274
+
275
+ // Use debounced validation for display, but immediate for submit
276
+ const validation = validationDebounceMs > 0 ? debouncedValidation : immediateValidation;
277
+
278
+ // isDirty is tracked via reducer state for O(1) performance
279
+
280
+ // Call onChange when data changes (not on initial render)
281
+ useEffect(() => {
282
+ if (hasInitialized.current) {
283
+ onChange?.(state.data, computed);
284
+ } else {
285
+ hasInitialized.current = true;
286
+ }
287
+ }, [state.data, computed, onChange]);
288
+
289
+ // Helper function to set value at nested path
290
+ const setNestedValue = useCallback((path: string, value: unknown): void => {
291
+ // Handle array index notation: "items[0].name" -> nested structure
292
+ const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
293
+
294
+ if (parts.length === 1) {
295
+ // Simple path - just set directly
296
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
297
+ return;
298
+ }
299
+
300
+ // Build nested object for complex paths
301
+ const buildNestedObject = (data: Record<string, unknown>, pathParts: string[], val: unknown): Record<string, unknown> => {
302
+ const result = { ...data };
303
+ let current: Record<string, unknown> = result;
304
+
305
+ for (let i = 0; i < pathParts.length - 1; i++) {
306
+ const part = pathParts[i];
307
+ const nextPart = pathParts[i + 1];
308
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
309
+
310
+ if (current[part] === undefined) {
311
+ current[part] = isNextArrayIndex ? [] : {};
312
+ } else if (Array.isArray(current[part])) {
313
+ current[part] = [...(current[part] as unknown[])];
314
+ } else {
315
+ current[part] = { ...(current[part] as Record<string, unknown>) };
316
+ }
317
+ current = current[part] as Record<string, unknown>;
318
+ }
319
+
320
+ current[pathParts[pathParts.length - 1]] = val;
321
+ return result;
322
+ };
323
+
324
+ dispatch({ type: "SET_VALUES", values: buildNestedObject(state.data, parts, value) });
325
+ }, [state.data]);
326
+
327
+ // Actions
328
+ const setFieldValue = useCallback(
329
+ (path: string, value: unknown) => {
330
+ setNestedValue(path, value);
331
+ if (validateOn === "change") {
332
+ dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
333
+ }
334
+ },
335
+ [validateOn, setNestedValue]
336
+ );
337
+
338
+ const setFieldTouched = useCallback((path: string, touched = true) => {
339
+ dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
340
+ }, []);
341
+
342
+ const setValues = useCallback((values: Record<string, unknown>) => {
343
+ dispatch({ type: "SET_VALUES", values });
344
+ }, []);
345
+
346
+ const validateField = useCallback(
347
+ (path: string): FieldError[] => {
348
+ return validation.errors.filter((e) => e.field === path);
349
+ },
350
+ [validation]
351
+ );
352
+
353
+ const validateForm = useCallback((): ValidationResult => {
354
+ return validation;
355
+ }, [validation]);
356
+
357
+ const submitForm = useCallback(async () => {
358
+ dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
359
+ try {
360
+ // Always use immediate validation on submit to ensure accurate result
361
+ if (immediateValidation.valid && onSubmit) {
362
+ await onSubmit(state.data);
363
+ }
364
+ dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
365
+ } finally {
366
+ dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
367
+ }
368
+ }, [immediateValidation, onSubmit, state.data]);
369
+
370
+ const resetForm = useCallback(() => {
371
+ dispatch({ type: "RESET", initialData });
372
+ }, [initialData]);
373
+
374
+ // Wizard helpers
375
+ const wizard = useMemo((): WizardHelpers | null => {
376
+ if (!spec.pages || spec.pages.length === 0) return null;
377
+
378
+ const pageVisibility = getPageVisibility(state.data, spec, { computed });
379
+
380
+ // Include all pages with their visibility status
381
+ const pages: PageState[] = spec.pages.map((p) => ({
382
+ id: p.id,
383
+ title: p.title,
384
+ description: p.description,
385
+ visible: pageVisibility[p.id] !== false,
386
+ fields: p.fields,
387
+ }));
388
+
389
+ // For navigation, only count visible pages
390
+ const visiblePages = pages.filter((p) => p.visible);
391
+ const currentPage = visiblePages[state.currentPage] || null;
392
+ const hasNextPage = state.currentPage < visiblePages.length - 1;
393
+ const hasPreviousPage = state.currentPage > 0;
394
+ const isLastPage = state.currentPage === visiblePages.length - 1;
395
+
396
+ return {
397
+ pages,
398
+ currentPageIndex: state.currentPage,
399
+ currentPage,
400
+ goToPage: (index: number) => dispatch({ type: "SET_PAGE", page: index }),
401
+ nextPage: () => {
402
+ if (hasNextPage) {
403
+ dispatch({ type: "SET_PAGE", page: state.currentPage + 1 });
404
+ }
405
+ },
406
+ previousPage: () => {
407
+ if (hasPreviousPage) {
408
+ dispatch({ type: "SET_PAGE", page: state.currentPage - 1 });
409
+ }
410
+ },
411
+ hasNextPage,
412
+ hasPreviousPage,
413
+ canProceed: true, // TODO: Validate current page
414
+ isLastPage,
415
+ touchCurrentPageFields: () => {
416
+ if (currentPage) {
417
+ currentPage.fields.forEach((field) => {
418
+ dispatch({ type: "SET_FIELD_TOUCHED", field, touched: true });
419
+ });
420
+ }
421
+ },
422
+ validateCurrentPage: () => {
423
+ if (!currentPage) return true;
424
+ const pageErrors = validation.errors.filter((e) =>
425
+ currentPage.fields.includes(e.field)
426
+ );
427
+ return pageErrors.length === 0;
428
+ },
429
+ };
430
+ }, [spec, state.data, state.currentPage, computed, validation]);
431
+
432
+ // Helper to get value at nested path
433
+ const getValueAtPath = useCallback((path: string): unknown => {
434
+ // Handle array index notation: "items[0].name" -> ["items", "0", "name"]
435
+ const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
436
+ let value: unknown = state.data;
437
+ for (const part of parts) {
438
+ if (value === null || value === undefined) return undefined;
439
+ value = (value as Record<string, unknown>)[part];
440
+ }
441
+ return value;
442
+ }, [state.data]);
443
+
444
+ // Helper to set value at nested path
445
+ const setValueAtPath = useCallback((path: string, value: unknown): void => {
446
+ // For nested paths, we need to build the nested structure
447
+ const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
448
+ if (parts.length === 1) {
449
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
450
+ return;
451
+ }
452
+
453
+ // Build nested object
454
+ const newData = { ...state.data };
455
+ let current: Record<string, unknown> = newData;
456
+
457
+ for (let i = 0; i < parts.length - 1; i++) {
458
+ const part = parts[i];
459
+ const nextPart = parts[i + 1];
460
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
461
+
462
+ if (current[part] === undefined) {
463
+ current[part] = isNextArrayIndex ? [] : {};
464
+ } else if (Array.isArray(current[part])) {
465
+ current[part] = [...(current[part] as unknown[])];
466
+ } else {
467
+ current[part] = { ...(current[part] as Record<string, unknown>) };
468
+ }
469
+ current = current[part] as Record<string, unknown>;
470
+ }
471
+
472
+ current[parts[parts.length - 1]] = value;
473
+ dispatch({ type: "SET_VALUES", values: newData });
474
+ }, [state.data]);
475
+
476
+ // Memoized onChange/onBlur handlers for fields
477
+ const fieldHandlers = useRef<Map<string, { onChange: (value: unknown) => void; onBlur: () => void }>>(new Map());
478
+
479
+ // Clean up stale field handlers when spec changes to prevent memory leaks
480
+ useEffect(() => {
481
+ const validFields = new Set(spec.fieldOrder);
482
+ // Also include array item field patterns
483
+ for (const fieldId of spec.fieldOrder) {
484
+ const fieldDef = spec.fields[fieldId];
485
+ if (fieldDef?.itemFields) {
486
+ for (const key of fieldHandlers.current.keys()) {
487
+ if (key.startsWith(`${fieldId}[`)) {
488
+ validFields.add(key);
489
+ }
490
+ }
491
+ }
492
+ }
493
+ // Remove handlers for fields that no longer exist
494
+ for (const key of fieldHandlers.current.keys()) {
495
+ const baseField = key.split('[')[0];
496
+ if (!validFields.has(key) && !validFields.has(baseField)) {
497
+ fieldHandlers.current.delete(key);
498
+ }
499
+ }
500
+ }, [spec]);
501
+
502
+ const getFieldHandlers = useCallback((path: string) => {
503
+ if (!fieldHandlers.current.has(path)) {
504
+ fieldHandlers.current.set(path, {
505
+ onChange: (value: unknown) => setValueAtPath(path, value),
506
+ onBlur: () => setFieldTouched(path),
507
+ });
508
+ }
509
+ return fieldHandlers.current.get(path)!;
510
+ }, [setValueAtPath, setFieldTouched]);
511
+
512
+ // Get field props for any field
513
+ const getFieldProps = useCallback((path: string): GetFieldPropsResult => {
514
+ const fieldDef = spec.fields[path];
515
+ const handlers = getFieldHandlers(path);
516
+
517
+ // Determine field type from definition or infer from schema
518
+ let fieldType = fieldDef?.type || "text";
519
+ if (!fieldType || fieldType === "computed") {
520
+ const schemaProperty = spec.schema.properties[path];
521
+ if (schemaProperty) {
522
+ if (schemaProperty.type === "number") fieldType = "number";
523
+ else if (schemaProperty.type === "integer") fieldType = "integer";
524
+ else if (schemaProperty.type === "boolean") fieldType = "boolean";
525
+ else if (schemaProperty.type === "array") fieldType = "array";
526
+ else if (schemaProperty.type === "object") fieldType = "object";
527
+ else if ("enum" in schemaProperty && schemaProperty.enum) fieldType = "select";
528
+ else if ("format" in schemaProperty) {
529
+ if (schemaProperty.format === "date") fieldType = "date";
530
+ else if (schemaProperty.format === "date-time") fieldType = "datetime";
531
+ else if (schemaProperty.format === "email") fieldType = "email";
532
+ else if (schemaProperty.format === "uri") fieldType = "url";
533
+ }
534
+ }
535
+ }
536
+
537
+ const fieldErrors = validation.errors.filter((e) => e.field === path);
538
+ const isTouched = state.touched[path] ?? false;
539
+ const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
540
+ const displayedErrors = showErrors ? fieldErrors : [];
541
+ const hasErrors = displayedErrors.length > 0;
542
+ const isRequired = required[path] ?? false;
543
+
544
+ return {
545
+ name: path,
546
+ value: getValueAtPath(path),
547
+ type: fieldType,
548
+ label: fieldDef?.label || path.charAt(0).toUpperCase() + path.slice(1),
549
+ description: fieldDef?.description,
550
+ placeholder: fieldDef?.placeholder,
551
+ visible: visibility[path] !== false,
552
+ enabled: enabled[path] !== false,
553
+ required: isRequired,
554
+ touched: isTouched,
555
+ errors: displayedErrors,
556
+ onChange: handlers.onChange,
557
+ onBlur: handlers.onBlur,
558
+ // ARIA accessibility attributes
559
+ "aria-invalid": hasErrors || undefined,
560
+ "aria-describedby": hasErrors ? `${path}-error` : undefined,
561
+ "aria-required": isRequired || undefined,
562
+ };
563
+ }, [spec, state.touched, state.isSubmitted, visibility, enabled, required, validation.errors, validateOn, getValueAtPath, getFieldHandlers]);
564
+
565
+ // Get select field props
566
+ const getSelectFieldProps = useCallback((path: string): GetSelectFieldPropsResult => {
567
+ const baseProps = getFieldProps(path);
568
+ const fieldDef = spec.fields[path];
569
+
570
+ return {
571
+ ...baseProps,
572
+ options: fieldDef?.options ?? [],
573
+ };
574
+ }, [getFieldProps, spec.fields]);
575
+
576
+ // Get array helpers
577
+ const getArrayHelpers = useCallback((path: string): GetArrayHelpersResult => {
578
+ const fieldDef = spec.fields[path];
579
+ const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
580
+ const minItems = fieldDef?.minItems ?? 0;
581
+ const maxItems = fieldDef?.maxItems ?? Infinity;
582
+
583
+ const canAdd = currentValue.length < maxItems;
584
+ const canRemove = currentValue.length > minItems;
585
+
586
+ const getItemFieldProps = (index: number, fieldName: string): GetFieldPropsResult => {
587
+ const itemPath = `${path}[${index}].${fieldName}`;
588
+ const itemFieldDef = fieldDef?.itemFields?.[fieldName];
589
+ const handlers = getFieldHandlers(itemPath);
590
+
591
+ // Get item value
592
+ const item = currentValue[index] as Record<string, unknown> | undefined;
593
+ const itemValue = item?.[fieldName];
594
+
595
+ const fieldErrors = validation.errors.filter((e) => e.field === itemPath);
596
+ const isTouched = state.touched[itemPath] ?? false;
597
+ const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
598
+
599
+ return {
600
+ name: itemPath,
601
+ value: itemValue,
602
+ type: itemFieldDef?.type || "text",
603
+ label: itemFieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
604
+ description: itemFieldDef?.description,
605
+ placeholder: itemFieldDef?.placeholder,
606
+ visible: true,
607
+ enabled: enabled[path] !== false,
608
+ required: false, // TODO: Evaluate item field required
609
+ touched: isTouched,
610
+ errors: showErrors ? fieldErrors : [],
611
+ onChange: handlers.onChange,
612
+ onBlur: handlers.onBlur,
613
+ };
614
+ };
615
+
616
+ return {
617
+ items: currentValue,
618
+ push: (item: unknown) => {
619
+ if (canAdd) {
620
+ setValueAtPath(path, [...currentValue, item]);
621
+ }
622
+ },
623
+ remove: (index: number) => {
624
+ if (canRemove) {
625
+ const newArray = [...currentValue];
626
+ newArray.splice(index, 1);
627
+ setValueAtPath(path, newArray);
628
+ }
629
+ },
630
+ move: (from: number, to: number) => {
631
+ const newArray = [...currentValue];
632
+ const [item] = newArray.splice(from, 1);
633
+ newArray.splice(to, 0, item);
634
+ setValueAtPath(path, newArray);
635
+ },
636
+ swap: (indexA: number, indexB: number) => {
637
+ const newArray = [...currentValue];
638
+ [newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]];
639
+ setValueAtPath(path, newArray);
640
+ },
641
+ insert: (index: number, item: unknown) => {
642
+ if (canAdd) {
643
+ const newArray = [...currentValue];
644
+ newArray.splice(index, 0, item);
645
+ setValueAtPath(path, newArray);
646
+ }
647
+ },
648
+ getItemFieldProps,
649
+ minItems,
650
+ maxItems,
651
+ canAdd,
652
+ canRemove,
653
+ };
654
+ }, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, state.touched, state.isSubmitted, validation.errors, validateOn]);
655
+
656
+ return {
657
+ data: state.data,
658
+ computed,
659
+ visibility,
660
+ required,
661
+ enabled,
662
+ touched: state.touched,
663
+ errors: validation.errors,
664
+ isValid: validation.valid,
665
+ isSubmitting: state.isSubmitting,
666
+ isSubmitted: state.isSubmitted,
667
+ isDirty: state.isDirty,
668
+ spec,
669
+ wizard,
670
+ setFieldValue,
671
+ setFieldTouched,
672
+ setValues,
673
+ validateField,
674
+ validateForm,
675
+ submitForm,
676
+ resetForm,
677
+ getFieldProps,
678
+ getSelectFieldProps,
679
+ getArrayHelpers,
680
+ };
681
+ }