@fogpipe/forma-react 0.12.0-alpha.1 → 0.12.0-alpha.2
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/useForma.ts
CHANGED
|
@@ -5,10 +5,26 @@
|
|
|
5
5
|
* This is a placeholder - the full implementation will be migrated from formidable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useReducer,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from "react";
|
|
16
|
+
import type {
|
|
17
|
+
Forma,
|
|
18
|
+
FieldError,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
SelectOption,
|
|
21
|
+
} from "@fogpipe/forma-core";
|
|
10
22
|
import { isAdornableField } from "@fogpipe/forma-core";
|
|
11
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
GetFieldPropsResult,
|
|
25
|
+
GetSelectFieldPropsResult,
|
|
26
|
+
GetArrayHelpersResult,
|
|
27
|
+
} from "./types.js";
|
|
12
28
|
import {
|
|
13
29
|
getVisibility,
|
|
14
30
|
getRequired,
|
|
@@ -32,7 +48,10 @@ export interface UseFormaOptions {
|
|
|
32
48
|
/** Submit handler */
|
|
33
49
|
onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
34
50
|
/** Change handler */
|
|
35
|
-
onChange?: (
|
|
51
|
+
onChange?: (
|
|
52
|
+
data: Record<string, unknown>,
|
|
53
|
+
computed?: Record<string, unknown>,
|
|
54
|
+
) => void;
|
|
36
55
|
/** When to validate: on change, blur, or submit only */
|
|
37
56
|
validateOn?: "change" | "blur" | "submit";
|
|
38
57
|
/** Additional reference data to merge with spec.referenceData */
|
|
@@ -222,7 +241,15 @@ function getDefaultBooleanValues(spec: Forma): Record<string, boolean> {
|
|
|
222
241
|
* Main Forma hook
|
|
223
242
|
*/
|
|
224
243
|
export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
225
|
-
const {
|
|
244
|
+
const {
|
|
245
|
+
spec: inputSpec,
|
|
246
|
+
initialData = {},
|
|
247
|
+
onSubmit,
|
|
248
|
+
onChange,
|
|
249
|
+
validateOn = "blur",
|
|
250
|
+
referenceData,
|
|
251
|
+
validationDebounceMs = 0,
|
|
252
|
+
} = options;
|
|
226
253
|
|
|
227
254
|
// Merge referenceData from options with spec.referenceData
|
|
228
255
|
const spec = useMemo((): Forma => {
|
|
@@ -255,47 +282,48 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
255
282
|
// Calculate computed values
|
|
256
283
|
const computed = useMemo(
|
|
257
284
|
() => calculate(state.data, spec),
|
|
258
|
-
[state.data, spec]
|
|
285
|
+
[state.data, spec],
|
|
259
286
|
);
|
|
260
287
|
|
|
261
288
|
// Calculate visibility
|
|
262
289
|
const visibility = useMemo(
|
|
263
290
|
() => getVisibility(state.data, spec, { computed }),
|
|
264
|
-
[state.data, spec, computed]
|
|
291
|
+
[state.data, spec, computed],
|
|
265
292
|
);
|
|
266
293
|
|
|
267
294
|
// Calculate required state
|
|
268
295
|
const required = useMemo(
|
|
269
296
|
() => getRequired(state.data, spec, { computed }),
|
|
270
|
-
[state.data, spec, computed]
|
|
297
|
+
[state.data, spec, computed],
|
|
271
298
|
);
|
|
272
299
|
|
|
273
300
|
// Calculate enabled state
|
|
274
301
|
const enabled = useMemo(
|
|
275
302
|
() => getEnabled(state.data, spec, { computed }),
|
|
276
|
-
[state.data, spec, computed]
|
|
303
|
+
[state.data, spec, computed],
|
|
277
304
|
);
|
|
278
305
|
|
|
279
306
|
// Calculate readonly state
|
|
280
307
|
const readonly = useMemo(
|
|
281
308
|
() => getReadonly(state.data, spec, { computed }),
|
|
282
|
-
[state.data, spec, computed]
|
|
309
|
+
[state.data, spec, computed],
|
|
283
310
|
);
|
|
284
311
|
|
|
285
312
|
// Calculate visible options for all select/multiselect fields (memoized)
|
|
286
313
|
const optionsVisibility = useMemo(
|
|
287
314
|
() => getOptionsVisibility(state.data, spec, { computed }),
|
|
288
|
-
[state.data, spec, computed]
|
|
315
|
+
[state.data, spec, computed],
|
|
289
316
|
);
|
|
290
317
|
|
|
291
318
|
// Validate form - compute immediate result
|
|
292
319
|
const immediateValidation = useMemo(
|
|
293
320
|
() => validate(state.data, spec, { computed, onlyVisible: true }),
|
|
294
|
-
[state.data, spec, computed]
|
|
321
|
+
[state.data, spec, computed],
|
|
295
322
|
);
|
|
296
323
|
|
|
297
324
|
// Debounced validation state (only used when validationDebounceMs > 0)
|
|
298
|
-
const [debouncedValidation, setDebouncedValidation] =
|
|
325
|
+
const [debouncedValidation, setDebouncedValidation] =
|
|
326
|
+
useState<ValidationResult>(immediateValidation);
|
|
299
327
|
|
|
300
328
|
// Apply debouncing if configured
|
|
301
329
|
useEffect(() => {
|
|
@@ -314,7 +342,8 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
314
342
|
}, [immediateValidation, validationDebounceMs]);
|
|
315
343
|
|
|
316
344
|
// Use debounced validation for display, but immediate for submit
|
|
317
|
-
const validation =
|
|
345
|
+
const validation =
|
|
346
|
+
validationDebounceMs > 0 ? debouncedValidation : immediateValidation;
|
|
318
347
|
|
|
319
348
|
// isDirty is tracked via reducer state for O(1) performance
|
|
320
349
|
|
|
@@ -328,42 +357,52 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
328
357
|
}, [state.data, computed, onChange]);
|
|
329
358
|
|
|
330
359
|
// Helper function to set value at nested path
|
|
331
|
-
const setNestedValue = useCallback(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
360
|
+
const setNestedValue = useCallback(
|
|
361
|
+
(path: string, value: unknown): void => {
|
|
362
|
+
// Handle array index notation: "items[0].name" -> nested structure
|
|
363
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
364
|
+
|
|
365
|
+
if (parts.length === 1) {
|
|
366
|
+
// Simple path - just set directly
|
|
367
|
+
dispatch({ type: "SET_FIELD_VALUE", field: path, value });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
340
370
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
371
|
+
// Build nested object for complex paths
|
|
372
|
+
const buildNestedObject = (
|
|
373
|
+
data: Record<string, unknown>,
|
|
374
|
+
pathParts: string[],
|
|
375
|
+
val: unknown,
|
|
376
|
+
): Record<string, unknown> => {
|
|
377
|
+
const result = { ...data };
|
|
378
|
+
let current: Record<string, unknown> = result;
|
|
379
|
+
|
|
380
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
381
|
+
const part = pathParts[i];
|
|
382
|
+
const nextPart = pathParts[i + 1];
|
|
383
|
+
const isNextArrayIndex = /^\d+$/.test(nextPart);
|
|
384
|
+
|
|
385
|
+
if (current[part] === undefined) {
|
|
386
|
+
current[part] = isNextArrayIndex ? [] : {};
|
|
387
|
+
} else if (Array.isArray(current[part])) {
|
|
388
|
+
current[part] = [...(current[part] as unknown[])];
|
|
389
|
+
} else {
|
|
390
|
+
current[part] = { ...(current[part] as Record<string, unknown>) };
|
|
391
|
+
}
|
|
392
|
+
current = current[part] as Record<string, unknown>;
|
|
357
393
|
}
|
|
358
|
-
current = current[part] as Record<string, unknown>;
|
|
359
|
-
}
|
|
360
394
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
395
|
+
current[pathParts[pathParts.length - 1]] = val;
|
|
396
|
+
return result;
|
|
397
|
+
};
|
|
364
398
|
|
|
365
|
-
|
|
366
|
-
|
|
399
|
+
dispatch({
|
|
400
|
+
type: "SET_VALUES",
|
|
401
|
+
values: buildNestedObject(state.data, parts, value),
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
[state.data],
|
|
405
|
+
);
|
|
367
406
|
|
|
368
407
|
// Actions
|
|
369
408
|
const setFieldValue = useCallback(
|
|
@@ -373,7 +412,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
373
412
|
dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
|
|
374
413
|
}
|
|
375
414
|
},
|
|
376
|
-
[validateOn, setNestedValue]
|
|
415
|
+
[validateOn, setNestedValue],
|
|
377
416
|
);
|
|
378
417
|
|
|
379
418
|
const setFieldTouched = useCallback((path: string, touched = true) => {
|
|
@@ -388,7 +427,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
388
427
|
(path: string): FieldError[] => {
|
|
389
428
|
return validation.errors.filter((e) => e.field === path);
|
|
390
429
|
},
|
|
391
|
-
[validation]
|
|
430
|
+
[validation],
|
|
392
431
|
);
|
|
393
432
|
|
|
394
433
|
const validateForm = useCallback((): ValidationResult => {
|
|
@@ -432,7 +471,10 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
432
471
|
|
|
433
472
|
// Clamp currentPage to valid range (handles case where current page becomes hidden)
|
|
434
473
|
const maxPageIndex = Math.max(0, visiblePages.length - 1);
|
|
435
|
-
const clampedPageIndex = Math.min(
|
|
474
|
+
const clampedPageIndex = Math.min(
|
|
475
|
+
Math.max(0, state.currentPage),
|
|
476
|
+
maxPageIndex,
|
|
477
|
+
);
|
|
436
478
|
|
|
437
479
|
// Auto-correct page index if it's out of bounds
|
|
438
480
|
if (clampedPageIndex !== state.currentPage && visiblePages.length > 0) {
|
|
@@ -470,12 +512,13 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
470
512
|
// Get errors only for visible fields on the current page
|
|
471
513
|
const pageErrors = validation.errors.filter((e) => {
|
|
472
514
|
// Check if field is on current page (including array items like "items[0].name")
|
|
473
|
-
const isOnCurrentPage =
|
|
474
|
-
currentPage.fields.
|
|
515
|
+
const isOnCurrentPage =
|
|
516
|
+
currentPage.fields.includes(e.field) ||
|
|
517
|
+
currentPage.fields.some((f) => e.field.startsWith(`${f}[`));
|
|
475
518
|
// Only count errors for visible fields
|
|
476
519
|
const isVisible = visibility[e.field] !== false;
|
|
477
520
|
// Only count actual errors, not warnings
|
|
478
|
-
const isError = e.severity ===
|
|
521
|
+
const isError = e.severity === "error";
|
|
479
522
|
return isOnCurrentPage && isVisible && isError;
|
|
480
523
|
});
|
|
481
524
|
return pageErrors.length === 0;
|
|
@@ -491,7 +534,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
491
534
|
validateCurrentPage: () => {
|
|
492
535
|
if (!currentPage) return true;
|
|
493
536
|
const pageErrors = validation.errors.filter((e) =>
|
|
494
|
-
currentPage.fields.includes(e.field)
|
|
537
|
+
currentPage.fields.includes(e.field),
|
|
495
538
|
);
|
|
496
539
|
return pageErrors.length === 0;
|
|
497
540
|
},
|
|
@@ -502,7 +545,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
502
545
|
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
503
546
|
const getValueAtPath = useCallback((path: string): unknown => {
|
|
504
547
|
// Handle array index notation: "items[0].name" -> ["items", "0", "name"]
|
|
505
|
-
const parts = path.replace(/\[(\d+)\]/g,
|
|
548
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
506
549
|
let value: unknown = stateDataRef.current;
|
|
507
550
|
for (const part of parts) {
|
|
508
551
|
if (value === null || value === undefined) return undefined;
|
|
@@ -515,7 +558,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
515
558
|
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
516
559
|
const setValueAtPath = useCallback((path: string, value: unknown): void => {
|
|
517
560
|
// For nested paths, we need to build the nested structure
|
|
518
|
-
const parts = path.replace(/\[(\d+)\]/g,
|
|
561
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
519
562
|
if (parts.length === 1) {
|
|
520
563
|
dispatch({ type: "SET_FIELD_VALUE", field: path, value });
|
|
521
564
|
return;
|
|
@@ -545,7 +588,9 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
545
588
|
}, []); // No dependencies - uses ref for current state
|
|
546
589
|
|
|
547
590
|
// Memoized onChange/onBlur handlers for fields
|
|
548
|
-
const fieldHandlers = useRef<
|
|
591
|
+
const fieldHandlers = useRef<
|
|
592
|
+
Map<string, { onChange: (value: unknown) => void; onBlur: () => void }>
|
|
593
|
+
>(new Map());
|
|
549
594
|
|
|
550
595
|
// Clean up stale field handlers when spec changes to prevent memory leaks
|
|
551
596
|
useEffect(() => {
|
|
@@ -563,221 +608,309 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
563
608
|
}
|
|
564
609
|
// Remove handlers for fields that no longer exist
|
|
565
610
|
for (const key of fieldHandlers.current.keys()) {
|
|
566
|
-
const baseField = key.split(
|
|
611
|
+
const baseField = key.split("[")[0];
|
|
567
612
|
if (!validFields.has(key) && !validFields.has(baseField)) {
|
|
568
613
|
fieldHandlers.current.delete(key);
|
|
569
614
|
}
|
|
570
615
|
}
|
|
571
616
|
}, [spec]);
|
|
572
617
|
|
|
573
|
-
const getFieldHandlers = useCallback(
|
|
574
|
-
|
|
575
|
-
fieldHandlers.current.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
618
|
+
const getFieldHandlers = useCallback(
|
|
619
|
+
(path: string) => {
|
|
620
|
+
if (!fieldHandlers.current.has(path)) {
|
|
621
|
+
fieldHandlers.current.set(path, {
|
|
622
|
+
onChange: (value: unknown) => setValueAtPath(path, value),
|
|
623
|
+
onBlur: () => setFieldTouched(path),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return fieldHandlers.current.get(path)!;
|
|
627
|
+
},
|
|
628
|
+
[setValueAtPath, setFieldTouched],
|
|
629
|
+
);
|
|
582
630
|
|
|
583
631
|
// Get field props for any field
|
|
584
|
-
const getFieldProps = useCallback(
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
if (schemaProperty
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
else if (
|
|
602
|
-
|
|
603
|
-
|
|
632
|
+
const getFieldProps = useCallback(
|
|
633
|
+
(path: string): GetFieldPropsResult => {
|
|
634
|
+
const fieldDef = spec.fields[path];
|
|
635
|
+
const handlers = getFieldHandlers(path);
|
|
636
|
+
|
|
637
|
+
// Determine field type from definition or infer from schema
|
|
638
|
+
let fieldType = fieldDef?.type || "text";
|
|
639
|
+
if (!fieldType || fieldType === "computed") {
|
|
640
|
+
const schemaProperty = spec.schema.properties[path];
|
|
641
|
+
if (schemaProperty) {
|
|
642
|
+
if (schemaProperty.type === "number") fieldType = "number";
|
|
643
|
+
else if (schemaProperty.type === "integer") fieldType = "integer";
|
|
644
|
+
else if (schemaProperty.type === "boolean") fieldType = "boolean";
|
|
645
|
+
else if (schemaProperty.type === "array") fieldType = "array";
|
|
646
|
+
else if (schemaProperty.type === "object") fieldType = "object";
|
|
647
|
+
else if ("enum" in schemaProperty && schemaProperty.enum)
|
|
648
|
+
fieldType = "select";
|
|
649
|
+
else if ("format" in schemaProperty) {
|
|
650
|
+
if (schemaProperty.format === "date") fieldType = "date";
|
|
651
|
+
else if (schemaProperty.format === "date-time")
|
|
652
|
+
fieldType = "datetime";
|
|
653
|
+
else if (schemaProperty.format === "email") fieldType = "email";
|
|
654
|
+
else if (schemaProperty.format === "uri") fieldType = "url";
|
|
655
|
+
}
|
|
604
656
|
}
|
|
605
657
|
}
|
|
606
|
-
}
|
|
607
658
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
659
|
+
const fieldErrors = validation.errors.filter((e) => e.field === path);
|
|
660
|
+
const isTouched = state.touched[path] ?? false;
|
|
661
|
+
const showErrors =
|
|
662
|
+
validateOn === "change" ||
|
|
663
|
+
(validateOn === "blur" && isTouched) ||
|
|
664
|
+
state.isSubmitted;
|
|
665
|
+
const displayedErrors = showErrors ? fieldErrors : [];
|
|
666
|
+
const hasErrors = displayedErrors.length > 0;
|
|
667
|
+
const isRequired = required[path] ?? false;
|
|
668
|
+
|
|
669
|
+
// Boolean fields: hide asterisk unless they have validation rules (consent pattern)
|
|
670
|
+
// - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
|
|
671
|
+
// - Consent checkbox ("I accept terms"): has validation rule → show asterisk
|
|
672
|
+
const schemaProperty = spec.schema.properties[path];
|
|
673
|
+
const isBooleanField =
|
|
674
|
+
schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
|
|
675
|
+
const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
|
|
676
|
+
const showRequiredIndicator =
|
|
677
|
+
isRequired && (!isBooleanField || hasValidationRules);
|
|
678
|
+
|
|
679
|
+
// Pass through adorner props for adornable field types
|
|
680
|
+
const adornerProps =
|
|
681
|
+
fieldDef && isAdornableField(fieldDef)
|
|
682
|
+
? { prefix: fieldDef.prefix, suffix: fieldDef.suffix }
|
|
683
|
+
: {};
|
|
627
684
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
685
|
+
return {
|
|
686
|
+
name: path,
|
|
687
|
+
value: getValueAtPath(path),
|
|
688
|
+
type: fieldType,
|
|
689
|
+
label: fieldDef?.label || path.charAt(0).toUpperCase() + path.slice(1),
|
|
690
|
+
description: fieldDef?.description,
|
|
691
|
+
placeholder: fieldDef?.placeholder,
|
|
692
|
+
visible: visibility[path] !== false,
|
|
693
|
+
enabled: enabled[path] !== false,
|
|
694
|
+
readonly: readonly[path] ?? false,
|
|
695
|
+
required: isRequired,
|
|
696
|
+
showRequiredIndicator,
|
|
697
|
+
touched: isTouched,
|
|
698
|
+
errors: displayedErrors,
|
|
699
|
+
onChange: handlers.onChange,
|
|
700
|
+
onBlur: handlers.onBlur,
|
|
701
|
+
// ARIA accessibility attributes
|
|
702
|
+
"aria-invalid": hasErrors || undefined,
|
|
703
|
+
"aria-describedby": hasErrors ? `${path}-error` : undefined,
|
|
704
|
+
"aria-required": isRequired || undefined,
|
|
705
|
+
// Adorner props (only for adornable field types)
|
|
706
|
+
...adornerProps,
|
|
707
|
+
// Presentation variant
|
|
708
|
+
variant: fieldDef?.variant,
|
|
709
|
+
variantConfig: fieldDef?.variantConfig,
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
[
|
|
713
|
+
spec,
|
|
714
|
+
state.touched,
|
|
715
|
+
state.isSubmitted,
|
|
716
|
+
visibility,
|
|
717
|
+
enabled,
|
|
718
|
+
readonly,
|
|
719
|
+
required,
|
|
720
|
+
validation.errors,
|
|
721
|
+
validateOn,
|
|
722
|
+
getValueAtPath,
|
|
723
|
+
getFieldHandlers,
|
|
724
|
+
],
|
|
725
|
+
);
|
|
655
726
|
|
|
656
727
|
// Get select field props - uses pre-computed optionsVisibility map
|
|
657
|
-
const getSelectFieldProps = useCallback(
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
// Look up pre-computed visible options from memoized map
|
|
661
|
-
const visibleOptions = optionsVisibility[path] ?? [];
|
|
662
|
-
|
|
663
|
-
return {
|
|
664
|
-
...baseProps,
|
|
665
|
-
options: visibleOptions as SelectOption[],
|
|
666
|
-
};
|
|
667
|
-
}, [getFieldProps, optionsVisibility]);
|
|
668
|
-
|
|
669
|
-
// Get array helpers
|
|
670
|
-
const getArrayHelpers = useCallback((path: string): GetArrayHelpersResult => {
|
|
671
|
-
const fieldDef = spec.fields[path];
|
|
672
|
-
const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
|
|
673
|
-
const arrayDef = fieldDef?.type === "array" ? fieldDef : undefined;
|
|
674
|
-
const minItems = arrayDef?.minItems ?? 0;
|
|
675
|
-
const maxItems = arrayDef?.maxItems ?? Infinity;
|
|
676
|
-
|
|
677
|
-
const canAdd = currentValue.length < maxItems;
|
|
678
|
-
const canRemove = currentValue.length > minItems;
|
|
679
|
-
|
|
680
|
-
const getItemFieldProps = (index: number, fieldName: string): GetFieldPropsResult => {
|
|
681
|
-
const itemPath = `${path}[${index}].${fieldName}`;
|
|
682
|
-
const itemFieldDef = arrayDef?.itemFields?.[fieldName];
|
|
683
|
-
const handlers = getFieldHandlers(itemPath);
|
|
684
|
-
|
|
685
|
-
// Get item value
|
|
686
|
-
const item = (currentValue[index] as Record<string, unknown>) ?? {};
|
|
687
|
-
const itemValue = item[fieldName];
|
|
688
|
-
|
|
689
|
-
const fieldErrors = validation.errors.filter((e) => e.field === itemPath);
|
|
690
|
-
const isTouched = state.touched[itemPath] ?? false;
|
|
691
|
-
const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
|
|
728
|
+
const getSelectFieldProps = useCallback(
|
|
729
|
+
(path: string): GetSelectFieldPropsResult => {
|
|
730
|
+
const baseProps = getFieldProps(path);
|
|
692
731
|
|
|
693
732
|
// Look up pre-computed visible options from memoized map
|
|
694
|
-
const visibleOptions = optionsVisibility[
|
|
733
|
+
const visibleOptions = optionsVisibility[path] ?? [];
|
|
695
734
|
|
|
696
735
|
return {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
type: itemFieldDef?.type || "text",
|
|
700
|
-
label: itemFieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
|
|
701
|
-
description: itemFieldDef?.description,
|
|
702
|
-
placeholder: itemFieldDef?.placeholder,
|
|
703
|
-
visible: true,
|
|
704
|
-
enabled: enabled[path] !== false,
|
|
705
|
-
readonly: readonly[itemPath] ?? false,
|
|
706
|
-
required: false, // TODO: Evaluate item field required
|
|
707
|
-
showRequiredIndicator: false, // Item fields don't show required indicator
|
|
708
|
-
touched: isTouched,
|
|
709
|
-
errors: showErrors ? fieldErrors : [],
|
|
710
|
-
onChange: handlers.onChange,
|
|
711
|
-
onBlur: handlers.onBlur,
|
|
712
|
-
options: visibleOptions,
|
|
736
|
+
...baseProps,
|
|
737
|
+
options: visibleOptions as SelectOption[],
|
|
713
738
|
};
|
|
714
|
-
}
|
|
739
|
+
},
|
|
740
|
+
[getFieldProps, optionsVisibility],
|
|
741
|
+
);
|
|
715
742
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
743
|
+
// Get array helpers
|
|
744
|
+
const getArrayHelpers = useCallback(
|
|
745
|
+
(path: string): GetArrayHelpersResult => {
|
|
746
|
+
const fieldDef = spec.fields[path];
|
|
747
|
+
const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
|
|
748
|
+
const arrayDef = fieldDef?.type === "array" ? fieldDef : undefined;
|
|
749
|
+
const minItems = arrayDef?.minItems ?? 0;
|
|
750
|
+
const maxItems = arrayDef?.maxItems ?? Infinity;
|
|
751
|
+
|
|
752
|
+
const canAdd = currentValue.length < maxItems;
|
|
753
|
+
const canRemove = currentValue.length > minItems;
|
|
754
|
+
|
|
755
|
+
const getItemFieldProps = (
|
|
756
|
+
index: number,
|
|
757
|
+
fieldName: string,
|
|
758
|
+
): GetFieldPropsResult => {
|
|
759
|
+
const itemPath = `${path}[${index}].${fieldName}`;
|
|
760
|
+
const itemFieldDef = arrayDef?.itemFields?.[fieldName];
|
|
761
|
+
const handlers = getFieldHandlers(itemPath);
|
|
762
|
+
|
|
763
|
+
// Get item value
|
|
764
|
+
const item = (currentValue[index] as Record<string, unknown>) ?? {};
|
|
765
|
+
const itemValue = item[fieldName];
|
|
766
|
+
|
|
767
|
+
const fieldErrors = validation.errors.filter(
|
|
768
|
+
(e) => e.field === itemPath,
|
|
769
|
+
);
|
|
770
|
+
const isTouched = state.touched[itemPath] ?? false;
|
|
771
|
+
const showErrors =
|
|
772
|
+
validateOn === "change" ||
|
|
773
|
+
(validateOn === "blur" && isTouched) ||
|
|
774
|
+
state.isSubmitted;
|
|
775
|
+
|
|
776
|
+
// Look up pre-computed visible options from memoized map
|
|
777
|
+
const visibleOptions = optionsVisibility[itemPath] as
|
|
778
|
+
| SelectOption[]
|
|
779
|
+
| undefined;
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
name: itemPath,
|
|
783
|
+
value: itemValue,
|
|
784
|
+
type: itemFieldDef?.type || "text",
|
|
785
|
+
label:
|
|
786
|
+
itemFieldDef?.label ||
|
|
787
|
+
fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
|
|
788
|
+
description: itemFieldDef?.description,
|
|
789
|
+
placeholder: itemFieldDef?.placeholder,
|
|
790
|
+
visible: true,
|
|
791
|
+
enabled: enabled[path] !== false,
|
|
792
|
+
readonly: readonly[itemPath] ?? false,
|
|
793
|
+
required: false, // TODO: Evaluate item field required
|
|
794
|
+
showRequiredIndicator: false, // Item fields don't show required indicator
|
|
795
|
+
touched: isTouched,
|
|
796
|
+
errors: showErrors ? fieldErrors : [],
|
|
797
|
+
onChange: handlers.onChange,
|
|
798
|
+
onBlur: handlers.onBlur,
|
|
799
|
+
options: visibleOptions,
|
|
800
|
+
};
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
items: currentValue,
|
|
805
|
+
push: (item: unknown) => {
|
|
806
|
+
if (canAdd) {
|
|
807
|
+
setValueAtPath(path, [...currentValue, item]);
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
remove: (index: number) => {
|
|
811
|
+
if (canRemove) {
|
|
812
|
+
const newArray = [...currentValue];
|
|
813
|
+
newArray.splice(index, 1);
|
|
814
|
+
setValueAtPath(path, newArray);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
move: (from: number, to: number) => {
|
|
725
818
|
const newArray = [...currentValue];
|
|
726
|
-
newArray.splice(
|
|
819
|
+
const [item] = newArray.splice(from, 1);
|
|
820
|
+
newArray.splice(to, 0, item);
|
|
727
821
|
setValueAtPath(path, newArray);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
move: (from: number, to: number) => {
|
|
731
|
-
const newArray = [...currentValue];
|
|
732
|
-
const [item] = newArray.splice(from, 1);
|
|
733
|
-
newArray.splice(to, 0, item);
|
|
734
|
-
setValueAtPath(path, newArray);
|
|
735
|
-
},
|
|
736
|
-
swap: (indexA: number, indexB: number) => {
|
|
737
|
-
const newArray = [...currentValue];
|
|
738
|
-
[newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]];
|
|
739
|
-
setValueAtPath(path, newArray);
|
|
740
|
-
},
|
|
741
|
-
insert: (index: number, item: unknown) => {
|
|
742
|
-
if (canAdd) {
|
|
822
|
+
},
|
|
823
|
+
swap: (indexA: number, indexB: number) => {
|
|
743
824
|
const newArray = [...currentValue];
|
|
744
|
-
newArray
|
|
825
|
+
[newArray[indexA], newArray[indexB]] = [
|
|
826
|
+
newArray[indexB],
|
|
827
|
+
newArray[indexA],
|
|
828
|
+
];
|
|
745
829
|
setValueAtPath(path, newArray);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
830
|
+
},
|
|
831
|
+
insert: (index: number, item: unknown) => {
|
|
832
|
+
if (canAdd) {
|
|
833
|
+
const newArray = [...currentValue];
|
|
834
|
+
newArray.splice(index, 0, item);
|
|
835
|
+
setValueAtPath(path, newArray);
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
getItemFieldProps,
|
|
839
|
+
minItems,
|
|
840
|
+
maxItems,
|
|
841
|
+
canAdd,
|
|
842
|
+
canRemove,
|
|
843
|
+
};
|
|
844
|
+
},
|
|
845
|
+
[
|
|
846
|
+
spec.fields,
|
|
847
|
+
getValueAtPath,
|
|
848
|
+
setValueAtPath,
|
|
849
|
+
getFieldHandlers,
|
|
850
|
+
enabled,
|
|
851
|
+
readonly,
|
|
852
|
+
state.touched,
|
|
853
|
+
state.isSubmitted,
|
|
854
|
+
validation.errors,
|
|
855
|
+
validateOn,
|
|
856
|
+
optionsVisibility,
|
|
857
|
+
],
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
return useMemo(
|
|
861
|
+
(): UseFormaReturn => ({
|
|
862
|
+
data: state.data,
|
|
863
|
+
computed,
|
|
864
|
+
visibility,
|
|
865
|
+
required,
|
|
866
|
+
enabled,
|
|
867
|
+
readonly,
|
|
868
|
+
optionsVisibility,
|
|
869
|
+
touched: state.touched,
|
|
870
|
+
errors: validation.errors,
|
|
871
|
+
isValid: validation.valid,
|
|
872
|
+
isSubmitting: state.isSubmitting,
|
|
873
|
+
isSubmitted: state.isSubmitted,
|
|
874
|
+
isDirty: state.isDirty,
|
|
875
|
+
spec,
|
|
876
|
+
wizard,
|
|
877
|
+
setFieldValue,
|
|
878
|
+
setFieldTouched,
|
|
879
|
+
setValues,
|
|
880
|
+
validateField,
|
|
881
|
+
validateForm,
|
|
882
|
+
submitForm,
|
|
883
|
+
resetForm,
|
|
884
|
+
getFieldProps,
|
|
885
|
+
getSelectFieldProps,
|
|
886
|
+
getArrayHelpers,
|
|
887
|
+
}),
|
|
888
|
+
[
|
|
889
|
+
state.data,
|
|
890
|
+
state.touched,
|
|
891
|
+
state.isSubmitting,
|
|
892
|
+
state.isSubmitted,
|
|
893
|
+
state.isDirty,
|
|
894
|
+
computed,
|
|
895
|
+
visibility,
|
|
896
|
+
required,
|
|
897
|
+
enabled,
|
|
898
|
+
readonly,
|
|
899
|
+
optionsVisibility,
|
|
900
|
+
validation.errors,
|
|
901
|
+
validation.valid,
|
|
902
|
+
spec,
|
|
903
|
+
wizard,
|
|
904
|
+
setFieldValue,
|
|
905
|
+
setFieldTouched,
|
|
906
|
+
setValues,
|
|
907
|
+
validateField,
|
|
908
|
+
validateForm,
|
|
909
|
+
submitForm,
|
|
910
|
+
resetForm,
|
|
911
|
+
getFieldProps,
|
|
912
|
+
getSelectFieldProps,
|
|
913
|
+
getArrayHelpers,
|
|
914
|
+
],
|
|
915
|
+
);
|
|
783
916
|
}
|