@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
package/src/useForma.ts
ADDED
|
@@ -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
|
+
}
|