UNPKG

@matthew.ngo/reform

Version:

A flexible and powerful React form management library with advanced validation, state observation, and multi-group support

557 lines (488 loc) 16.3 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ReformReturn } from '../../types'; import { FormStep, FormWizardConfig, FormWizardReturn, FormWizardState, } from './types'; import { useMemoizedCallback } from '../../common/useMemoizedCallback'; /** * Hook for creating and managing multi-step form wizards * * @template T - The type of form data * @param reform - The Reform hook return value * @param config - Configuration for the form wizard * @returns Form wizard state and methods */ export const useFormWizard = <T extends Record<string, any>>( reform: ReformReturn<T>, config: FormWizardConfig<T> ): FormWizardReturn<T> => { // Memoize config to prevent unnecessary re-renders const memoizedConfig = useMemo( () => ({ steps: config.steps, initialStepId: config.initialStepId, validateOnNext: config.validateOnNext ?? true, allowSkipSteps: config.allowSkipSteps ?? false, markCompletedOnLeave: config.markCompletedOnLeave ?? true, persistState: config.persistState ?? false, persistStateKey: config.persistStateKey ?? 'reform-wizard-state', onStepCompleted: config.onStepCompleted, onWizardCompleted: config.onWizardCompleted, }), [ // Use JSON.stringify for complex objects to compare by value JSON.stringify(config.steps), config.initialStepId, config.validateOnNext, config.allowSkipSteps, config.markCompletedOnLeave, config.persistState, config.persistStateKey, // Functions are compared by reference config.onStepCompleted, config.onWizardCompleted, ] ); const { steps, initialStepId, validateOnNext, allowSkipSteps, markCompletedOnLeave, persistState, persistStateKey, onStepCompleted, onWizardCompleted, } = memoizedConfig; // Store the reform reference to avoid unnecessary re-renders const reformRef = useRef(reform); useEffect(() => { reformRef.current = reform; }, [reform]); // Initialize state from storage if enabled const initialState = useMemo((): FormWizardState => { if (persistState) { try { const savedState = localStorage.getItem(persistStateKey); if (savedState) { const parsedState = JSON.parse(savedState) as FormWizardState; return parsedState; } } catch (error) { console.error('Error loading wizard state from storage:', error); } } return { currentStepId: initialStepId || steps[0]?.id || '', completedSteps: [], isValidating: false, isTransitioning: false, isCompleted: false, }; }, [persistState, persistStateKey, initialStepId, steps]); // State const [currentStepId, setCurrentStepId] = useState<string>( initialState.currentStepId ); const [completedSteps, setCompletedSteps] = useState<string[]>( initialState.completedSteps ); const [isValidating, setIsValidating] = useState<boolean>(false); const [isTransitioning, setIsTransitioning] = useState<boolean>(false); // Derived state const isCompleted = useMemo(() => { return steps.every(step => completedSteps.includes(step.id)); }, [steps, completedSteps]); // Persist state to storage when it changes useEffect(() => { if (persistState) { const stateToSave: FormWizardState = { currentStepId, completedSteps, isValidating, isTransitioning, isCompleted, }; localStorage.setItem(persistStateKey, JSON.stringify(stateToSave)); } }, [ currentStepId, completedSteps, isValidating, isTransitioning, isCompleted, persistState, persistStateKey, ]); // Get current step const currentStep = useMemo(() => { return steps.find(step => step.id === currentStepId) || steps[0]; }, [steps, currentStepId]); // Get groups for a specific step - memoized to prevent unnecessary re-renders const getStepGroups = useMemoizedCallback((step: FormStep<T>) => { const allGroups = reformRef.current.getGroups(); return step.groupIndices.map(index => allGroups[index]).filter(Boolean); }, []); // Validate a step - memoized to prevent unnecessary re-renders const validateStep = useMemoizedCallback( async (stepId: string): Promise<boolean> => { const step = steps.find(s => s.id === stepId); if (!step) return false; setIsValidating(true); try { const stepGroups = getStepGroups(step); // If step has custom validation, use it if (step.validate) { return step.validate(stepGroups); } // Otherwise validate all groups in the step const groupIndices = step.groupIndices; // FIXME // const validationResults = await Promise.all( // groupIndices.map(index => reformRef.current.validateGroup(index)) // ); // return validationResults.every(result => result); return await Promise.resolve(true); } finally { setIsValidating(false); } }, [steps, getStepGroups] ); // Check if a step is completed - memoized to prevent unnecessary re-renders const isStepCompleted = useMemoizedCallback( (stepId: string): boolean => { // If already marked as completed, return true if (completedSteps.includes(stepId)) return true; // Otherwise check custom completion logic const step = steps.find(s => s.id === stepId); if (!step) return false; if (step.isCompleted) { const stepGroups = getStepGroups(step); return step.isCompleted(stepGroups); } return false; }, [completedSteps, steps, getStepGroups] ); // Check if a step is enabled - memoized to prevent unnecessary re-renders const isStepEnabled = useMemoizedCallback( (stepId: string): boolean => { // If allowing skipping steps, all steps are enabled if (allowSkipSteps) return true; const step = steps.find(s => s.id === stepId); if (!step) return false; // If step has custom enabled logic, use it if (step.isEnabled) { const allGroups = reformRef.current.getGroups(); return step.isEnabled(allGroups, completedSteps); } // Otherwise, a step is enabled if it's the first step or all previous steps are completed const stepIndex = steps.findIndex(s => s.id === stepId); if (stepIndex === 0) return true; const previousSteps = steps.slice(0, stepIndex); return previousSteps.every(s => completedSteps.includes(s.id)); }, [steps, allowSkipSteps, completedSteps] ); // Mark a step as completed - memoized to prevent unnecessary re-renders const markStepCompleted = useMemoizedCallback( (stepId: string) => { if (!completedSteps.includes(stepId)) { const newCompletedSteps = [...completedSteps, stepId]; setCompletedSteps(newCompletedSteps); // Call onStepCompleted callback if (onStepCompleted) { const step = steps.find(s => s.id === stepId); if (step) { const allGroups = reformRef.current.getGroups(); onStepCompleted(stepId, allGroups); } } // Check if all steps are now completed const allCompleted = steps.every(step => newCompletedSteps.includes(step.id) ); if (allCompleted && onWizardCompleted) { const allGroups = reformRef.current.getGroups(); onWizardCompleted(allGroups); } } }, [steps, completedSteps, onStepCompleted, onWizardCompleted] ); // Mark a step as not completed - memoized to prevent unnecessary re-renders const markStepNotCompleted = useMemoizedCallback( (stepId: string) => { if (completedSteps.includes(stepId)) { setCompletedSteps(completedSteps.filter(id => id !== stepId)); } }, [completedSteps] ); // Navigation methods - memoized to prevent unnecessary re-renders const goToStep = useMemoizedCallback( async (stepId: string): Promise<boolean> => { const targetStep = steps.find(s => s.id === stepId); if (!targetStep) return false; // Check if step is enabled if (!isStepEnabled(stepId)) return false; // Set transitioning state setIsTransitioning(true); try { // Run onLeave for current step if it exists if (currentStep && currentStep.onLeave) { const direction = steps.findIndex(s => s.id === stepId) > steps.findIndex(s => s.id === currentStepId) ? 'next' : 'prev'; const allGroups = reformRef.current.getGroups(); const canLeave = await Promise.resolve( currentStep.onLeave(allGroups, direction) ); if (canLeave === false) { return false; } } // Mark current step as completed if configured to do so if (markCompletedOnLeave && currentStepId) { const isValid = await validateStep(currentStepId); if (isValid) { markStepCompleted(currentStepId); } } // Run onEnter for target step if it exists if (targetStep.onEnter) { const allGroups = reformRef.current.getGroups(); await Promise.resolve(targetStep.onEnter(allGroups)); } // Update current step setCurrentStepId(stepId); return true; } finally { setIsTransitioning(false); } }, [ steps, currentStep, currentStepId, isStepEnabled, markCompletedOnLeave, validateStep, markStepCompleted, ] ); // Navigate to next step - memoized to prevent unnecessary re-renders const next = useMemoizedCallback(async (): Promise<boolean> => { // Find current step index const currentIndex = steps.findIndex(s => s.id === currentStepId); if (currentIndex === -1 || currentIndex >= steps.length - 1) return false; // Validate current step if required if (validateOnNext) { const isValid = await validateStep(currentStepId); if (!isValid) return false; // Mark current step as completed if valid markStepCompleted(currentStepId); } // Go to next step const nextStep = steps[currentIndex + 1]; return goToStep(nextStep.id); }, [ steps, currentStepId, validateOnNext, validateStep, markStepCompleted, goToStep, ]); // Navigate to previous step - memoized to prevent unnecessary re-renders const prev = useMemoizedCallback(async (): Promise<boolean> => { // Find current step index const currentIndex = steps.findIndex(s => s.id === currentStepId); if (currentIndex <= 0) return false; // Go to previous step const prevStep = steps[currentIndex - 1]; return goToStep(prevStep.id); }, [steps, currentStepId, goToStep]); // Reset wizard - memoized to prevent unnecessary re-renders const resetWizard = useMemoizedCallback( (options?: { resetFormData?: boolean; clearCompletedSteps?: boolean }) => { const { resetFormData = false, clearCompletedSteps = true } = options || {}; // Reset to initial step setCurrentStepId(initialStepId || steps[0]?.id || ''); // Clear completed steps if requested if (clearCompletedSteps) { setCompletedSteps([]); } // Reset form data if requested if (resetFormData) { reformRef.current.formMethods.reset(); } }, [initialStepId, steps] ); // Get all steps with status information - memoized to prevent unnecessary re-renders const getSteps = useMemoizedCallback(() => { return steps.map(step => { const { isEnabled: checkEnabled, isCompleted: checkCompleted, ...restStep } = step; return { ...restStep, isActive: step.id === currentStepId, isCompleted: isStepCompleted(step.id), isEnabled: isStepEnabled(step.id), checkEnabled, checkCompleted, }; }); }, [steps, currentStepId, isStepCompleted, isStepEnabled]); // Get a specific step with status information - memoized to prevent unnecessary re-renders const getStep = useMemoizedCallback( (stepId: string) => { const step = steps.find(s => s.id === stepId); if (!step) return undefined; const { isEnabled: checkEnabled, isCompleted: checkCompleted, ...restStep } = step; return { ...restStep, isActive: step.id === currentStepId, isCompleted: isStepCompleted(step.id), isEnabled: isStepEnabled(step.id), checkEnabled, checkCompleted, }; }, [steps, currentStepId, isStepCompleted, isStepEnabled] ); // Get current step with status information - memoized to prevent unnecessary re-renders const getCurrentStep = useMemoizedCallback(() => { const step = steps.find(s => s.id === currentStepId) || steps[0]; const { isEnabled: checkEnabled, isCompleted: checkCompleted, ...restStep } = step; return { ...restStep, isActive: true, isCompleted: isStepCompleted(step.id), isEnabled: isStepEnabled(step.id), checkEnabled, checkCompleted, }; }, [steps, currentStepId, isStepCompleted, isStepEnabled]); // Add utility methods for step navigation - memoized to prevent unnecessary re-renders const canGoNext = useMemoizedCallback((): boolean => { const currentIndex = steps.findIndex(s => s.id === currentStepId); return currentIndex < steps.length - 1; }, [steps, currentStepId]); const canGoPrev = useMemoizedCallback((): boolean => { const currentIndex = steps.findIndex(s => s.id === currentStepId); return currentIndex > 0; }, [steps, currentStepId]); // Get step index - memoized to prevent unnecessary re-renders const getStepIndex = useMemoizedCallback( (stepId: string): number => { return steps.findIndex(s => s.id === stepId); }, [steps] ); // Get current step index - memoized to prevent unnecessary re-renders const getCurrentStepIndex = useMemoizedCallback((): number => { return steps.findIndex(s => s.id === currentStepId); }, [steps, currentStepId]); // Jump to a specific step by index - memoized to prevent unnecessary re-renders const goToStepByIndex = useMemoizedCallback( async (index: number): Promise<boolean> => { if (index < 0 || index >= steps.length) return false; return goToStep(steps[index].id); }, [steps, goToStep] ); // Check if a step is the current step - memoized to prevent unnecessary re-renders const isCurrentStep = useMemoizedCallback( (stepId: string): boolean => { return stepId === currentStepId; }, [currentStepId] ); // Memoize the return object to prevent unnecessary re-renders return useMemo( () => ({ // Step data steps, currentStep, currentStepId, completedSteps, isCompleted, isValidating, isTransitioning, // Step status methods isStepCompleted, isStepEnabled, isCurrentStep, // Step management markStepCompleted, markStepNotCompleted, validateStep, // Navigation methods goToStep, next, prev, canGoNext: canGoNext(), canGoPrev: canGoPrev(), resetWizard, // Step utilities getSteps, getStep, getCurrentStep, getStepGroups, getStepIndex, getCurrentStepIndex, goToStepByIndex, // Config config: memoizedConfig, }), [ steps, currentStep, currentStepId, completedSteps, isCompleted, isValidating, isTransitioning, isStepCompleted, isStepEnabled, isCurrentStep, markStepCompleted, markStepNotCompleted, validateStep, goToStep, next, prev, canGoNext, canGoPrev, resetWizard, getSteps, getStep, getCurrentStep, getStepGroups, getStepIndex, getCurrentStepIndex, goToStepByIndex, memoizedConfig, ] ); };