@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/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 { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
9
- import type { Forma, FieldError, ValidationResult, SelectOption } from "@fogpipe/forma-core";
10
- import type { GetFieldPropsResult, GetSelectFieldPropsResult, GetArrayHelpersResult } from "./types.js";
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?: (data: Record<string, unknown>, computed?: Record<string, unknown>) => void;
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 { spec: inputSpec, initialData = {}, onSubmit, onChange, validateOn = "blur", referenceData, validationDebounceMs = 0 } = options;
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] = useState<ValidationResult>(immediateValidation);
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 = validationDebounceMs > 0 ? debouncedValidation : immediateValidation;
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((path: string, value: unknown): void => {
322
- // Handle array index notation: "items[0].name" -> nested structure
323
- const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
324
-
325
- if (parts.length === 1) {
326
- // Simple path - just set directly
327
- dispatch({ type: "SET_FIELD_VALUE", field: path, value });
328
- return;
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
- // Build nested object for complex paths
332
- const buildNestedObject = (data: Record<string, unknown>, pathParts: string[], val: unknown): Record<string, unknown> => {
333
- const result = { ...data };
334
- let current: Record<string, unknown> = result;
335
-
336
- for (let i = 0; i < pathParts.length - 1; i++) {
337
- const part = pathParts[i];
338
- const nextPart = pathParts[i + 1];
339
- const isNextArrayIndex = /^\d+$/.test(nextPart);
340
-
341
- if (current[part] === undefined) {
342
- current[part] = isNextArrayIndex ? [] : {};
343
- } else if (Array.isArray(current[part])) {
344
- current[part] = [...(current[part] as unknown[])];
345
- } else {
346
- current[part] = { ...(current[part] as Record<string, unknown>) };
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
- current[pathParts[pathParts.length - 1]] = val;
352
- return result;
353
- };
395
+ current[pathParts[pathParts.length - 1]] = val;
396
+ return result;
397
+ };
354
398
 
355
- dispatch({ type: "SET_VALUES", values: buildNestedObject(state.data, parts, value) });
356
- }, [state.data]);
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(Math.max(0, state.currentPage), maxPageIndex);
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 = currentPage.fields.includes(e.field) ||
464
- currentPage.fields.some(f => e.field.startsWith(`${f}[`));
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 === 'error';
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, '.$1').split('.');
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, '.$1').split('.');
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<Map<string, { onChange: (value: unknown) => void; onBlur: () => void }>>(new Map());
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('[')[0];
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((path: string) => {
564
- if (!fieldHandlers.current.has(path)) {
565
- fieldHandlers.current.set(path, {
566
- onChange: (value: unknown) => setValueAtPath(path, value),
567
- onBlur: () => setFieldTouched(path),
568
- });
569
- }
570
- return fieldHandlers.current.get(path)!;
571
- }, [setValueAtPath, setFieldTouched]);
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((path: string): GetFieldPropsResult => {
575
- const fieldDef = spec.fields[path];
576
- const handlers = getFieldHandlers(path);
577
-
578
- // Determine field type from definition or infer from schema
579
- let fieldType = fieldDef?.type || "text";
580
- if (!fieldType || fieldType === "computed") {
581
- const schemaProperty = spec.schema.properties[path];
582
- if (schemaProperty) {
583
- if (schemaProperty.type === "number") fieldType = "number";
584
- else if (schemaProperty.type === "integer") fieldType = "integer";
585
- else if (schemaProperty.type === "boolean") fieldType = "boolean";
586
- else if (schemaProperty.type === "array") fieldType = "array";
587
- else if (schemaProperty.type === "object") fieldType = "object";
588
- else if ("enum" in schemaProperty && schemaProperty.enum) fieldType = "select";
589
- else if ("format" in schemaProperty) {
590
- if (schemaProperty.format === "date") fieldType = "date";
591
- else if (schemaProperty.format === "date-time") fieldType = "datetime";
592
- else if (schemaProperty.format === "email") fieldType = "email";
593
- else if (schemaProperty.format === "uri") fieldType = "url";
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
- const fieldErrors = validation.errors.filter((e) => e.field === path);
599
- const isTouched = state.touched[path] ?? false;
600
- const showErrors = validateOn === "change" || (validateOn === "blur" && isTouched) || state.isSubmitted;
601
- const displayedErrors = showErrors ? fieldErrors : [];
602
- const hasErrors = displayedErrors.length > 0;
603
- const isRequired = required[path] ?? false;
604
-
605
- // Boolean fields: hide asterisk unless they have validation rules (consent pattern)
606
- // - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
607
- // - Consent checkbox ("I accept terms"): has validation rule → show asterisk
608
- const schemaProperty = spec.schema.properties[path];
609
- const isBooleanField = schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
610
- const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
611
- const showRequiredIndicator = isRequired && (!isBooleanField || hasValidationRules);
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
- return {
614
- name: path,
615
- value: getValueAtPath(path),
616
- type: fieldType,
617
- label: fieldDef?.label || path.charAt(0).toUpperCase() + path.slice(1),
618
- description: fieldDef?.description,
619
- placeholder: fieldDef?.placeholder,
620
- visible: visibility[path] !== false,
621
- enabled: enabled[path] !== false,
622
- required: isRequired,
623
- showRequiredIndicator,
624
- touched: isTouched,
625
- errors: displayedErrors,
626
- onChange: handlers.onChange,
627
- onBlur: handlers.onBlur,
628
- // ARIA accessibility attributes
629
- "aria-invalid": hasErrors || undefined,
630
- "aria-describedby": hasErrors ? `${path}-error` : undefined,
631
- "aria-required": isRequired || undefined,
632
- };
633
- }, [spec, state.touched, state.isSubmitted, visibility, enabled, required, validation.errors, validateOn, getValueAtPath, getFieldHandlers]);
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((path: string): GetSelectFieldPropsResult => {
637
- const baseProps = getFieldProps(path);
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[itemPath] as SelectOption[] | undefined;
733
+ const visibleOptions = optionsVisibility[path] ?? [];
673
734
 
674
735
  return {
675
- name: itemPath,
676
- value: itemValue,
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
- return {
694
- items: currentValue,
695
- push: (item: unknown) => {
696
- if (canAdd) {
697
- setValueAtPath(path, [...currentValue, item]);
698
- }
699
- },
700
- remove: (index: number) => {
701
- if (canRemove) {
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(index, 1);
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.splice(index, 0, item);
825
+ [newArray[indexA], newArray[indexB]] = [
826
+ newArray[indexB],
827
+ newArray[indexA],
828
+ ];
722
829
  setValueAtPath(path, newArray);
723
- }
724
- },
725
- getItemFieldProps,
726
- minItems,
727
- maxItems,
728
- canAdd,
729
- canRemove,
730
- };
731
- }, [spec.fields, getValueAtPath, setValueAtPath, getFieldHandlers, enabled, state.touched, state.isSubmitted, validation.errors, validateOn, optionsVisibility]);
732
-
733
- return {
734
- data: state.data,
735
- computed,
736
- visibility,
737
- required,
738
- enabled,
739
- optionsVisibility,
740
- touched: state.touched,
741
- errors: validation.errors,
742
- isValid: validation.valid,
743
- isSubmitting: state.isSubmitting,
744
- isSubmitted: state.isSubmitted,
745
- isDirty: state.isDirty,
746
- spec,
747
- wizard,
748
- setFieldValue,
749
- setFieldTouched,
750
- setValues,
751
- validateField,
752
- validateForm,
753
- submitForm,
754
- resetForm,
755
- getFieldProps,
756
- getSelectFieldProps,
757
- getArrayHelpers,
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
  }