@fogpipe/forma-react 0.11.2 → 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 +45 -3
- package/dist/index.js +553 -323
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/FieldRenderer.tsx +107 -20
- package/src/FormRenderer.tsx +321 -157
- package/src/__tests__/FieldRenderer.test.tsx +136 -20
- package/src/__tests__/FormRenderer.test.tsx +264 -85
- package/src/index.ts +2 -0
- package/src/types.ts +44 -1
- package/src/useForma.ts +392 -235
package/src/useForma.ts
CHANGED
|
@@ -5,13 +5,31 @@
|
|
|
5
5
|
* This is a placeholder - the full implementation will be migrated from formidable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
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";
|
|
22
|
+
import { isAdornableField } from "@fogpipe/forma-core";
|
|
23
|
+
import type {
|
|
24
|
+
GetFieldPropsResult,
|
|
25
|
+
GetSelectFieldPropsResult,
|
|
26
|
+
GetArrayHelpersResult,
|
|
27
|
+
} from "./types.js";
|
|
11
28
|
import {
|
|
12
29
|
getVisibility,
|
|
13
30
|
getRequired,
|
|
14
31
|
getEnabled,
|
|
32
|
+
getReadonly,
|
|
15
33
|
validate,
|
|
16
34
|
calculate,
|
|
17
35
|
getPageVisibility,
|
|
@@ -30,7 +48,10 @@ export interface UseFormaOptions {
|
|
|
30
48
|
/** Submit handler */
|
|
31
49
|
onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
32
50
|
/** Change handler */
|
|
33
|
-
onChange?: (
|
|
51
|
+
onChange?: (
|
|
52
|
+
data: Record<string, unknown>,
|
|
53
|
+
computed?: Record<string, unknown>,
|
|
54
|
+
) => void;
|
|
34
55
|
/** When to validate: on change, blur, or submit only */
|
|
35
56
|
validateOn?: "change" | "blur" | "submit";
|
|
36
57
|
/** Additional reference data to merge with spec.referenceData */
|
|
@@ -110,6 +131,8 @@ export interface UseFormaReturn {
|
|
|
110
131
|
required: Record<string, boolean>;
|
|
111
132
|
/** Field enabled state map */
|
|
112
133
|
enabled: Record<string, boolean>;
|
|
134
|
+
/** Field readonly state map */
|
|
135
|
+
readonly: Record<string, boolean>;
|
|
113
136
|
/** Visible options for select/multiselect fields, keyed by field path */
|
|
114
137
|
optionsVisibility: OptionsVisibilityResult;
|
|
115
138
|
/** Field touched state map */
|
|
@@ -218,7 +241,15 @@ function getDefaultBooleanValues(spec: Forma): Record<string, boolean> {
|
|
|
218
241
|
* Main Forma hook
|
|
219
242
|
*/
|
|
220
243
|
export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
221
|
-
const {
|
|
244
|
+
const {
|
|
245
|
+
spec: inputSpec,
|
|
246
|
+
initialData = {},
|
|
247
|
+
onSubmit,
|
|
248
|
+
onChange,
|
|
249
|
+
validateOn = "blur",
|
|
250
|
+
referenceData,
|
|
251
|
+
validationDebounceMs = 0,
|
|
252
|
+
} = options;
|
|
222
253
|
|
|
223
254
|
// Merge referenceData from options with spec.referenceData
|
|
224
255
|
const spec = useMemo((): Forma => {
|
|
@@ -251,41 +282,48 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
251
282
|
// Calculate computed values
|
|
252
283
|
const computed = useMemo(
|
|
253
284
|
() => calculate(state.data, spec),
|
|
254
|
-
[state.data, spec]
|
|
285
|
+
[state.data, spec],
|
|
255
286
|
);
|
|
256
287
|
|
|
257
288
|
// Calculate visibility
|
|
258
289
|
const visibility = useMemo(
|
|
259
290
|
() => getVisibility(state.data, spec, { computed }),
|
|
260
|
-
[state.data, spec, computed]
|
|
291
|
+
[state.data, spec, computed],
|
|
261
292
|
);
|
|
262
293
|
|
|
263
294
|
// Calculate required state
|
|
264
295
|
const required = useMemo(
|
|
265
296
|
() => getRequired(state.data, spec, { computed }),
|
|
266
|
-
[state.data, spec, computed]
|
|
297
|
+
[state.data, spec, computed],
|
|
267
298
|
);
|
|
268
299
|
|
|
269
300
|
// Calculate enabled state
|
|
270
301
|
const enabled = useMemo(
|
|
271
302
|
() => getEnabled(state.data, spec, { computed }),
|
|
272
|
-
[state.data, spec, computed]
|
|
303
|
+
[state.data, spec, computed],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Calculate readonly state
|
|
307
|
+
const readonly = useMemo(
|
|
308
|
+
() => getReadonly(state.data, spec, { computed }),
|
|
309
|
+
[state.data, spec, computed],
|
|
273
310
|
);
|
|
274
311
|
|
|
275
312
|
// Calculate visible options for all select/multiselect fields (memoized)
|
|
276
313
|
const optionsVisibility = useMemo(
|
|
277
314
|
() => getOptionsVisibility(state.data, spec, { computed }),
|
|
278
|
-
[state.data, spec, computed]
|
|
315
|
+
[state.data, spec, computed],
|
|
279
316
|
);
|
|
280
317
|
|
|
281
318
|
// Validate form - compute immediate result
|
|
282
319
|
const immediateValidation = useMemo(
|
|
283
320
|
() => validate(state.data, spec, { computed, onlyVisible: true }),
|
|
284
|
-
[state.data, spec, computed]
|
|
321
|
+
[state.data, spec, computed],
|
|
285
322
|
);
|
|
286
323
|
|
|
287
324
|
// Debounced validation state (only used when validationDebounceMs > 0)
|
|
288
|
-
const [debouncedValidation, setDebouncedValidation] =
|
|
325
|
+
const [debouncedValidation, setDebouncedValidation] =
|
|
326
|
+
useState<ValidationResult>(immediateValidation);
|
|
289
327
|
|
|
290
328
|
// Apply debouncing if configured
|
|
291
329
|
useEffect(() => {
|
|
@@ -304,7 +342,8 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
304
342
|
}, [immediateValidation, validationDebounceMs]);
|
|
305
343
|
|
|
306
344
|
// Use debounced validation for display, but immediate for submit
|
|
307
|
-
const validation =
|
|
345
|
+
const validation =
|
|
346
|
+
validationDebounceMs > 0 ? debouncedValidation : immediateValidation;
|
|
308
347
|
|
|
309
348
|
// isDirty is tracked via reducer state for O(1) performance
|
|
310
349
|
|
|
@@ -318,42 +357,52 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
318
357
|
}, [state.data, computed, onChange]);
|
|
319
358
|
|
|
320
359
|
// Helper function to set value at nested path
|
|
321
|
-
const setNestedValue = useCallback(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}
|
|
330
370
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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>;
|
|
347
393
|
}
|
|
348
|
-
current = current[part] as Record<string, unknown>;
|
|
349
|
-
}
|
|
350
394
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
395
|
+
current[pathParts[pathParts.length - 1]] = val;
|
|
396
|
+
return result;
|
|
397
|
+
};
|
|
354
398
|
|
|
355
|
-
|
|
356
|
-
|
|
399
|
+
dispatch({
|
|
400
|
+
type: "SET_VALUES",
|
|
401
|
+
values: buildNestedObject(state.data, parts, value),
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
[state.data],
|
|
405
|
+
);
|
|
357
406
|
|
|
358
407
|
// Actions
|
|
359
408
|
const setFieldValue = useCallback(
|
|
@@ -363,7 +412,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
363
412
|
dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
|
|
364
413
|
}
|
|
365
414
|
},
|
|
366
|
-
[validateOn, setNestedValue]
|
|
415
|
+
[validateOn, setNestedValue],
|
|
367
416
|
);
|
|
368
417
|
|
|
369
418
|
const setFieldTouched = useCallback((path: string, touched = true) => {
|
|
@@ -378,7 +427,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
378
427
|
(path: string): FieldError[] => {
|
|
379
428
|
return validation.errors.filter((e) => e.field === path);
|
|
380
429
|
},
|
|
381
|
-
[validation]
|
|
430
|
+
[validation],
|
|
382
431
|
);
|
|
383
432
|
|
|
384
433
|
const validateForm = useCallback((): ValidationResult => {
|
|
@@ -422,7 +471,10 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
422
471
|
|
|
423
472
|
// Clamp currentPage to valid range (handles case where current page becomes hidden)
|
|
424
473
|
const maxPageIndex = Math.max(0, visiblePages.length - 1);
|
|
425
|
-
const clampedPageIndex = Math.min(
|
|
474
|
+
const clampedPageIndex = Math.min(
|
|
475
|
+
Math.max(0, state.currentPage),
|
|
476
|
+
maxPageIndex,
|
|
477
|
+
);
|
|
426
478
|
|
|
427
479
|
// Auto-correct page index if it's out of bounds
|
|
428
480
|
if (clampedPageIndex !== state.currentPage && visiblePages.length > 0) {
|
|
@@ -460,12 +512,13 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
460
512
|
// Get errors only for visible fields on the current page
|
|
461
513
|
const pageErrors = validation.errors.filter((e) => {
|
|
462
514
|
// Check if field is on current page (including array items like "items[0].name")
|
|
463
|
-
const isOnCurrentPage =
|
|
464
|
-
currentPage.fields.
|
|
515
|
+
const isOnCurrentPage =
|
|
516
|
+
currentPage.fields.includes(e.field) ||
|
|
517
|
+
currentPage.fields.some((f) => e.field.startsWith(`${f}[`));
|
|
465
518
|
// Only count errors for visible fields
|
|
466
519
|
const isVisible = visibility[e.field] !== false;
|
|
467
520
|
// Only count actual errors, not warnings
|
|
468
|
-
const isError = e.severity ===
|
|
521
|
+
const isError = e.severity === "error";
|
|
469
522
|
return isOnCurrentPage && isVisible && isError;
|
|
470
523
|
});
|
|
471
524
|
return pageErrors.length === 0;
|
|
@@ -481,7 +534,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
481
534
|
validateCurrentPage: () => {
|
|
482
535
|
if (!currentPage) return true;
|
|
483
536
|
const pageErrors = validation.errors.filter((e) =>
|
|
484
|
-
currentPage.fields.includes(e.field)
|
|
537
|
+
currentPage.fields.includes(e.field),
|
|
485
538
|
);
|
|
486
539
|
return pageErrors.length === 0;
|
|
487
540
|
},
|
|
@@ -492,7 +545,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
492
545
|
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
493
546
|
const getValueAtPath = useCallback((path: string): unknown => {
|
|
494
547
|
// Handle array index notation: "items[0].name" -> ["items", "0", "name"]
|
|
495
|
-
const parts = path.replace(/\[(\d+)\]/g,
|
|
548
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
496
549
|
let value: unknown = stateDataRef.current;
|
|
497
550
|
for (const part of parts) {
|
|
498
551
|
if (value === null || value === undefined) return undefined;
|
|
@@ -505,7 +558,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
505
558
|
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
506
559
|
const setValueAtPath = useCallback((path: string, value: unknown): void => {
|
|
507
560
|
// For nested paths, we need to build the nested structure
|
|
508
|
-
const parts = path.replace(/\[(\d+)\]/g,
|
|
561
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
509
562
|
if (parts.length === 1) {
|
|
510
563
|
dispatch({ type: "SET_FIELD_VALUE", field: path, value });
|
|
511
564
|
return;
|
|
@@ -535,7 +588,9 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
535
588
|
}, []); // No dependencies - uses ref for current state
|
|
536
589
|
|
|
537
590
|
// Memoized onChange/onBlur handlers for fields
|
|
538
|
-
const fieldHandlers = useRef<
|
|
591
|
+
const fieldHandlers = useRef<
|
|
592
|
+
Map<string, { onChange: (value: unknown) => void; onBlur: () => void }>
|
|
593
|
+
>(new Map());
|
|
539
594
|
|
|
540
595
|
// Clean up stale field handlers when spec changes to prevent memory leaks
|
|
541
596
|
useEffect(() => {
|
|
@@ -543,7 +598,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
543
598
|
// Also include array item field patterns
|
|
544
599
|
for (const fieldId of spec.fieldOrder) {
|
|
545
600
|
const fieldDef = spec.fields[fieldId];
|
|
546
|
-
if (fieldDef?.itemFields) {
|
|
601
|
+
if (fieldDef?.type === "array" && fieldDef.itemFields) {
|
|
547
602
|
for (const key of fieldHandlers.current.keys()) {
|
|
548
603
|
if (key.startsWith(`${fieldId}[`)) {
|
|
549
604
|
validFields.add(key);
|
|
@@ -553,207 +608,309 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
553
608
|
}
|
|
554
609
|
// Remove handlers for fields that no longer exist
|
|
555
610
|
for (const key of fieldHandlers.current.keys()) {
|
|
556
|
-
const baseField = key.split(
|
|
611
|
+
const baseField = key.split("[")[0];
|
|
557
612
|
if (!validFields.has(key) && !validFields.has(baseField)) {
|
|
558
613
|
fieldHandlers.current.delete(key);
|
|
559
614
|
}
|
|
560
615
|
}
|
|
561
616
|
}, [spec]);
|
|
562
617
|
|
|
563
|
-
const getFieldHandlers = useCallback(
|
|
564
|
-
|
|
565
|
-
fieldHandlers.current.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
);
|
|
572
630
|
|
|
573
631
|
// Get field props for any field
|
|
574
|
-
const getFieldProps = useCallback(
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
if (schemaProperty
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
else if (
|
|
592
|
-
|
|
593
|
-
|
|
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
|
+
}
|
|
594
656
|
}
|
|
595
657
|
}
|
|
596
|
-
}
|
|
597
658
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
+
: {};
|
|
612
684
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
+
);
|
|
634
726
|
|
|
635
727
|
// Get select field props - uses pre-computed optionsVisibility map
|
|
636
|
-
const getSelectFieldProps = useCallback(
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
// Look up pre-computed visible options from memoized map
|
|
640
|
-
const visibleOptions = optionsVisibility[path] ?? [];
|
|
641
|
-
|
|
642
|
-
return {
|
|
643
|
-
...baseProps,
|
|
644
|
-
options: visibleOptions as SelectOption[],
|
|
645
|
-
};
|
|
646
|
-
}, [getFieldProps, optionsVisibility]);
|
|
647
|
-
|
|
648
|
-
// Get array helpers
|
|
649
|
-
const getArrayHelpers = useCallback((path: string): GetArrayHelpersResult => {
|
|
650
|
-
const fieldDef = spec.fields[path];
|
|
651
|
-
const currentValue = (getValueAtPath(path) as unknown[]) ?? [];
|
|
652
|
-
const minItems = fieldDef?.minItems ?? 0;
|
|
653
|
-
const maxItems = fieldDef?.maxItems ?? Infinity;
|
|
654
|
-
|
|
655
|
-
const canAdd = currentValue.length < maxItems;
|
|
656
|
-
const canRemove = currentValue.length > minItems;
|
|
657
|
-
|
|
658
|
-
const getItemFieldProps = (index: number, fieldName: string): GetFieldPropsResult => {
|
|
659
|
-
const itemPath = `${path}[${index}].${fieldName}`;
|
|
660
|
-
const itemFieldDef = fieldDef?.itemFields?.[fieldName];
|
|
661
|
-
const handlers = getFieldHandlers(itemPath);
|
|
662
|
-
|
|
663
|
-
// Get item value
|
|
664
|
-
const item = (currentValue[index] as Record<string, unknown>) ?? {};
|
|
665
|
-
const itemValue = item[fieldName];
|
|
666
|
-
|
|
667
|
-
const fieldErrors = validation.errors.filter((e) => e.field === itemPath);
|
|
668
|
-
const isTouched = state.touched[itemPath] ?? false;
|
|
669
|
-
const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
|
|
728
|
+
const getSelectFieldProps = useCallback(
|
|
729
|
+
(path: string): GetSelectFieldPropsResult => {
|
|
730
|
+
const baseProps = getFieldProps(path);
|
|
670
731
|
|
|
671
732
|
// Look up pre-computed visible options from memoized map
|
|
672
|
-
const visibleOptions = optionsVisibility[
|
|
733
|
+
const visibleOptions = optionsVisibility[path] ?? [];
|
|
673
734
|
|
|
674
735
|
return {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
type: itemFieldDef?.type || "text",
|
|
678
|
-
label: itemFieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
|
|
679
|
-
description: itemFieldDef?.description,
|
|
680
|
-
placeholder: itemFieldDef?.placeholder,
|
|
681
|
-
visible: true,
|
|
682
|
-
enabled: enabled[path] !== false,
|
|
683
|
-
required: false, // TODO: Evaluate item field required
|
|
684
|
-
showRequiredIndicator: false, // Item fields don't show required indicator
|
|
685
|
-
touched: isTouched,
|
|
686
|
-
errors: showErrors ? fieldErrors : [],
|
|
687
|
-
onChange: handlers.onChange,
|
|
688
|
-
onBlur: handlers.onBlur,
|
|
689
|
-
options: visibleOptions,
|
|
736
|
+
...baseProps,
|
|
737
|
+
options: visibleOptions as SelectOption[],
|
|
690
738
|
};
|
|
691
|
-
}
|
|
739
|
+
},
|
|
740
|
+
[getFieldProps, optionsVisibility],
|
|
741
|
+
);
|
|
692
742
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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) => {
|
|
702
818
|
const newArray = [...currentValue];
|
|
703
|
-
newArray.splice(
|
|
819
|
+
const [item] = newArray.splice(from, 1);
|
|
820
|
+
newArray.splice(to, 0, item);
|
|
704
821
|
setValueAtPath(path, newArray);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
move: (from: number, to: number) => {
|
|
708
|
-
const newArray = [...currentValue];
|
|
709
|
-
const [item] = newArray.splice(from, 1);
|
|
710
|
-
newArray.splice(to, 0, item);
|
|
711
|
-
setValueAtPath(path, newArray);
|
|
712
|
-
},
|
|
713
|
-
swap: (indexA: number, indexB: number) => {
|
|
714
|
-
const newArray = [...currentValue];
|
|
715
|
-
[newArray[indexA], newArray[indexB]] = [newArray[indexB], newArray[indexA]];
|
|
716
|
-
setValueAtPath(path, newArray);
|
|
717
|
-
},
|
|
718
|
-
insert: (index: number, item: unknown) => {
|
|
719
|
-
if (canAdd) {
|
|
822
|
+
},
|
|
823
|
+
swap: (indexA: number, indexB: number) => {
|
|
720
824
|
const newArray = [...currentValue];
|
|
721
|
-
newArray
|
|
825
|
+
[newArray[indexA], newArray[indexB]] = [
|
|
826
|
+
newArray[indexB],
|
|
827
|
+
newArray[indexA],
|
|
828
|
+
];
|
|
722
829
|
setValueAtPath(path, newArray);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
);
|
|
759
916
|
}
|