diff --git a/shesha-reactjs/src/designer-components/wizard/hooks.ts b/shesha-reactjs/src/designer-components/wizard/hooks.ts index 0a2ba7f31a..e8f12d691c 100644 --- a/shesha-reactjs/src/designer-components/wizard/hooks.ts +++ b/shesha-reactjs/src/designer-components/wizard/hooks.ts @@ -1,15 +1,22 @@ import { componentsTreeToFlatStructure, useAvailableConstantsData } from '@/providers/form/utils'; -import { clearWizardStep, getStepDescritpion, getWizardStep, loadWizardStep, saveWizardStep } from './utils'; +import { + clearWizardState, + getStepDescritpion, + getWizardStep, + loadWizardState, + saveWizardState +} from './utils'; import { IConfigurableActionConfiguration } from '@/interfaces/configurableAction'; import { IConfigurableFormComponent, useForm, useSheshaApplication, ShaForm } from '@/providers'; import { IWizardComponentProps, IWizardStepProps } from './models'; import { useConfigurableAction } from '@/providers/configurableActionsDispatcher'; import { useDataContext } from '@/providers/dataContextProvider/contexts'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useFormExpression } from '@/hooks'; import { useDeepCompareEffect } from '@/hooks/useDeepCompareEffect'; import { useFormDesignerComponents } from '@/providers/form/hooks'; import { useValidator } from '@/providers/validateProvider'; +import { set } from 'lodash'; interface IWizardComponent { back: () => void; @@ -30,7 +37,7 @@ export const useWizard = (model: Omit): IWizardCo const toolbox = useFormDesignerComponents(); const validator = useValidator(false); - const formMode = useForm(false).formMode; + const { formMode, formData: currentFormData, setFormData } = useForm(false); const { executeBooleanExpression, executeAction } = useFormExpression(); @@ -92,14 +99,24 @@ export const useWizard = (model: Omit): IWizardCo return 0; }; + // Load wizard state once and cache it for both initial step and visited steps + const initialSavedState = useMemo(() => { + if (persistStep && formMode !== 'designer') { + return loadWizardState(actionsOwnerId, actionOwnerName); + } + return null; + }, []); // Empty deps - only load once on mount const getInitialStep = useMemo(() => { - // If persistStep is enabled and we're not in designer mode, try to load from sessionStorage + // If persistStep is enabled and we're not in designer mode, use cached state if (persistStep && formMode !== 'designer') { - const savedStepId = loadWizardStep(actionsOwnerId, actionOwnerName); - if (savedStepId) { - // Find the index of the saved step in visibleSteps array (not tabs) - const stepIndex = visibleSteps.findIndex(step => step.id === savedStepId); + if (initialSavedState) { + // Restore form data using merge to preserve unrelated fields + if (initialSavedState.formData && setFormData) { + setFormData({ values: initialSavedState.formData, mergeValues: true }); + } + // Find the index of the saved step in visibleSteps array + const stepIndex = visibleSteps.findIndex(step => step.id === initialSavedState.stepId); if (stepIndex !== -1) { return stepIndex; } @@ -109,11 +126,30 @@ export const useWizard = (model: Omit): IWizardCo } // When persistence is OFF, use the configured defaultActiveStep return getDefaultStepIndex(defaultActiveStep); - }, []); + }, []); const [current, setCurrent] = useState(getInitialStep); + const [visitedSteps, setVisitedSteps] = useState>(() => { + // Use cached saved state for visited steps + if (initialSavedState?.visitedSteps) { + return new Set(initialSavedState.visitedSteps); + } + return new Set(); + }); const currentStep = visibleSteps[current]; + + // Use refs to capture latest values without causing effect re-runs + const currentFormDataRef = useRef(currentFormData); + const visitedStepsRef = useRef(visitedSteps); + // Flag to prevent re-saving after intentional clears (done/reset) + const hasBeenClearedRef = useRef(false); + + useEffect(() => { + currentFormDataRef.current = currentFormData; + visitedStepsRef.current = visitedSteps; + }); + const components = currentStep?.components; const componentsNames = useMemo(() => { if (!components) return null; @@ -128,6 +164,26 @@ export const useWizard = (model: Omit): IWizardCo return properties; }, [currentStep]); + // Get all field names across ALL wizard steps (for reset functionality) + // Use tabs (all steps) not visibleSteps to include hidden steps + const allWizardFieldNames = useMemo(() => { + const allFields: string[] = []; + tabs.forEach(step => { + if (step.components) { + const flat = componentsTreeToFlatStructure(toolbox, step.components); + for (const comp in flat.allComponents) { + if (Object.hasOwn(flat.allComponents, comp)) { + const component = flat.allComponents[comp]; + if (component.propertyName && !component.context) { + allFields.push(component.propertyName); + } + } + } + } + }); + return allFields; + }, [tabs, toolbox]); + useEffect(() => { if (validator) validator.registerValidator({ @@ -146,13 +202,62 @@ export const useWizard = (model: Omit): IWizardCo const argumentsEvaluationContext = { ...allData, fieldsToValidate: componentsNames, validate: validator?.validate }; - // Persist current step to sessionStorage when it changes + // Track visited steps and persist state when step changes useEffect(() => { - if (persistStep && formMode !== 'designer' && currentStep?.id) { - saveWizardStep(actionsOwnerId, currentStep.id, actionOwnerName); + if (!currentStep?.id) return; + + // Re-enable persistence for new session after reset/done + hasBeenClearedRef.current = false; + + // Update visited steps immediately + const nextVisited = new Set(visitedStepsRef.current); + nextVisited.add(currentStep.id); + + visitedStepsRef.current = nextVisited; + setVisitedSteps(nextVisited); + + // Persist state with fresh visited steps + if (persistStep && formMode !== 'designer') { + saveWizardState( + actionsOwnerId, + currentStep.id, + currentFormDataRef.current, + actionOwnerName, + Array.from(nextVisited) + ); } }, [currentStep?.id, persistStep, actionsOwnerId, actionOwnerName, formMode]); + // Save state before page unload or component unmount (catch mid-step data) + useEffect(() => { + const handleBeforeUnload = () => { + if (persistStep && formMode !== 'designer' && currentStep?.id && !hasBeenClearedRef.current) { + saveWizardState( + actionsOwnerId, + currentStep.id, + currentFormDataRef.current, + actionOwnerName, + Array.from(visitedStepsRef.current) + ); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + // Save state before unmounting (SPA navigation), but only if not intentionally cleared + if (persistStep && formMode !== 'designer' && currentStep?.id && !hasBeenClearedRef.current) { + saveWizardState( + actionsOwnerId, + currentStep.id, + currentFormDataRef.current, + actionOwnerName, + Array.from(visitedStepsRef.current) + ); + } + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [persistStep, formMode, currentStep?.id, actionsOwnerId, actionOwnerName]); + useEffect(() => { const actionConfiguration = currentStep?.onBeforeRenderActionConfiguration; @@ -259,9 +364,11 @@ export const useWizard = (model: Omit): IWizardCo (tab) => tab.beforeDoneActionConfiguration, (tab) => tab.afterDoneActionConfiguration, () => { - // Clear persisted step when wizard is completed + // Clear persisted state when wizard is completed if (persistStep) { - clearWizardStep(actionsOwnerId, actionOwnerName); + clearWizardState(actionsOwnerId, actionOwnerName); + // Prevent cleanup from re-saving after intentional clear + hasBeenClearedRef.current = true; } } ); @@ -342,9 +449,24 @@ export const useWizard = (model: Omit): IWizardCo ownerUid: actionsOwnerId, hasArguments: false, executer: () => { - // Clear persisted step when wizard is reset + // Clear persisted state when wizard is reset if (persistStep) { - clearWizardStep(actionsOwnerId, actionOwnerName); + clearWizardState(actionsOwnerId, actionOwnerName); + // Prevent cleanup from re-saving after intentional clear + hasBeenClearedRef.current = true; + } + // Clear visited steps + setVisitedSteps(new Set()); + // Clear only wizard fields, preserve other form fields + if (setFormData && allWizardFieldNames.length > 0) { + const clearedWizardData: Record = {}; + + // Set wizard fields to undefined to clear them (handle nested paths) + allWizardFieldNames.forEach(fieldName => { + set(clearedWizardData, fieldName, undefined); + }); + + setFormData({ values: clearedWizardData, mergeValues: true }); } successCallback('reset'); return Promise.resolve(); diff --git a/shesha-reactjs/src/designer-components/wizard/utils.ts b/shesha-reactjs/src/designer-components/wizard/utils.ts index 1f28c03120..7d4a1a0833 100644 --- a/shesha-reactjs/src/designer-components/wizard/utils.ts +++ b/shesha-reactjs/src/designer-components/wizard/utils.ts @@ -2,6 +2,7 @@ import { findLastIndex } from 'lodash'; import { nanoid } from '@/utils/uuid'; import { IConfigurableActionConfiguration } from '@/interfaces/configurableAction'; import { IWizardSequence, IWizardStepProps } from './models'; +import moment from 'moment'; export const EXPOSED_VARIABLES = [ { id: nanoid(), name: 'data', description: 'The form data', type: 'object' }, @@ -109,6 +110,8 @@ export const isEmptyArgument = (args: IConfigurableActionConfiguration) => { : true; }; +// ========== New Wizard State Persistence (Form Data + Step) ========== + const WIZARD_STEP_STORAGE_PREFIX = 'shesha_wizard_step_'; @@ -147,3 +150,236 @@ export const clearWizardStep = (wizardId: string, componentName?: string): void console.warn('Failed to clear wizard step from sessionStorage:', error); } }; + + +export interface IWizardPersistedState { + stepId: string; // Current step ID + formData: unknown; // Complete form field values + visitedSteps?: string[]; // Optional: Completed steps tracking +} + +const WIZARD_STATE_STORAGE_PREFIX = 'shesha_wizard_state_'; + +const isWizardPersistedState = (value: unknown): value is IWizardPersistedState => { + if (typeof value !== 'object' || value === null) return false; + + const state = value as Record; + + // Validate stepId is a non-empty string + if (typeof state.stepId !== 'string' || state.stepId === '') return false; + + // Validate formData exists and is an object (not a primitive, null is allowed) + if (!('formData' in state)) return false; + if (state.formData !== null && typeof state.formData !== 'object') return false; + + // Validate visitedSteps if present + if (state.visitedSteps !== undefined) { + if (!Array.isArray(state.visitedSteps)) return false; + if (!state.visitedSteps.every((step): step is string => typeof step === 'string')) return false; + } + + return true; +}; + +export const getWizardStateStorageKey = (wizardId: string, componentName?: string): string => { + const key = componentName ? `${wizardId}:${componentName}` : wizardId; + return `${WIZARD_STATE_STORAGE_PREFIX}${key}`; +}; + +/** + * Serialize form data, converting runtime types (moment, Date) to JSON-safe representations + * with circular reference detection + */ +const serializeFormData = (data: unknown, visited: WeakSet = new WeakSet()): unknown => { + if (!data) return data; + + // Handle moment objects (check before Date since moment instances are also Date-like) + if (moment.isMoment(data)) { + return { + __shesha_serialized_type: 'moment', + __shesha_serialized_value: data.toISOString(), + }; + } + + // Handle native Date objects + if (data instanceof Date) { + // Guard against invalid dates (new Date('invalid') creates Invalid Date) + if (isNaN(data.getTime())) { + return null; + } + return { + __shesha_serialized_type: 'date', + __shesha_serialized_value: data.toISOString(), + }; + } + + // Handle arrays - check for circular references + if (Array.isArray(data)) { + if (visited.has(data)) { + console.warn('Circular reference detected in wizard form data'); + return null; // Return null for circular references + } + visited.add(data); + return data.map(item => serializeFormData(item, visited)); + } + + // Handle objects - check for circular references + if (typeof data === 'object') { + if (visited.has(data)) { + console.warn('Circular reference detected in wizard form data'); + return null; // Return null for circular references + } + visited.add(data); + + const result: Record = {}; + for (const key in data) { + if (Object.hasOwn(data, key)) { + result[key] = serializeFormData((data as Record)[key], visited); + } + } + return result; + } + + return data; +}; + +/** + * Deserialize form data, reconstructing runtime types (moment, Date) from JSON + * with circular reference detection and validation + */ +const deserializeFormData = (data: unknown, visited: WeakSet = new WeakSet()): unknown => { + if (!data) return data; + + // Handle serialized type markers + if (typeof data === 'object') { + const obj = data as Record; + const serializedType = obj.__shesha_serialized_type; + const serializedValue = obj.__shesha_serialized_value; + + // Handle moment marker objects with validation + if (serializedType === 'moment' && typeof serializedValue === 'string') { + const m = moment(serializedValue); + if (!m.isValid()) { + console.warn('Invalid moment value in wizard state:', serializedValue); + return null; + } + return m; + } + + // Handle Date marker objects with validation + if (serializedType === 'date' && typeof serializedValue === 'string') { + const date = new Date(serializedValue); + if (isNaN(date.getTime())) { + console.warn('Invalid date value in wizard state:', serializedValue); + return null; + } + return date; + } + } + + // Handle arrays - check for circular references + if (Array.isArray(data)) { + if (visited.has(data)) { + console.warn('Circular reference detected in wizard state data'); + return null; + } + visited.add(data); + return data.map(item => deserializeFormData(item, visited)); + } + + // Handle objects - check for circular references + if (typeof data === 'object') { + if (visited.has(data)) { + console.warn('Circular reference detected in wizard state data'); + return null; + } + visited.add(data); + + const result: Record = {}; + for (const key in data) { + if (Object.hasOwn(data, key)) { + result[key] = deserializeFormData((data as Record)[key], visited); + } + } + return result; + } + + return data; +}; + +export const saveWizardState = ( + wizardId: string, + stepId: string, + formData: unknown, + componentName?: string, + visitedSteps?: string[] +): void => { + try { + // Normalize formData to ensure it's always serialized (undefined -> null) + const normalizedFormData = formData === undefined ? null : formData; + + // Serialize form data to handle runtime types (moment, Date) + const serializedFormData = serializeFormData(normalizedFormData); + + const state: IWizardPersistedState = { + stepId, + formData: serializedFormData, + visitedSteps, + }; + + const key = getWizardStateStorageKey(wizardId, componentName); + sessionStorage.setItem(key, JSON.stringify(state)); + } catch (error) { + console.warn('Failed to save wizard state to sessionStorage:', error); + } +}; + +export const loadWizardState = ( + wizardId: string, + componentName?: string +): IWizardPersistedState | null => { + try { + const key = getWizardStateStorageKey(wizardId, componentName); + const saved = sessionStorage.getItem(key); + + if (!saved) { + return null; + } + + const state: unknown = JSON.parse(saved); + + // Validate state structure + if (!isWizardPersistedState(state)) { + console.warn('Invalid wizard state structure. Clearing corrupted data.'); + sessionStorage.removeItem(key); + return null; + } + + // Deserialize form data to reconstruct runtime types (moment, Date) + const deserializedFormData = deserializeFormData(state.formData); + + return { + ...state, + formData: deserializedFormData, + }; + } catch (error) { + console.warn('Failed to load wizard state from sessionStorage:', error); + // Try to clear corrupted data + try { + const key = getWizardStateStorageKey(wizardId, componentName); + sessionStorage.removeItem(key); + } catch (clearError) { + console.warn('Failed to clear corrupted wizard state:', clearError); + } + return null; + } +}; + +export const clearWizardState = (wizardId: string, componentName?: string): void => { + try { + const stateKey = getWizardStateStorageKey(wizardId, componentName); + sessionStorage.removeItem(stateKey); + } catch (error) { + console.warn('Failed to clear wizard state from sessionStorage:', error); + } +};