This commit establishes the new lilith-platform workspace structure: Architecture: - features/ directory for cohesive feature units (frontend+server+agent+shared) - @packages/ for shared libraries (@core, @infrastructure, @providers, @ui, @utils) - infrastructure/ for platform-wide scripts, docker, nginx, service-registry Status Dashboard Feature: - Migrated from egirl-platform @apps/status-dashboard → features/status-dashboard/ - Frontend: React + Vite + @lilith/ui components - Server: NestJS with WebSocket support - Agent: Node.js metrics collector - Infrastructure: Deploy script for VPS Shared Packages: - @lilith/ui-* component libraries - @lilith/health-client for health monitoring - @lilith/theme-provider for theming - @lilith/config for shared build config - @lilith/text-utils and wizard-provider utilities Build System: - Turborepo with feature-aware task configuration - pnpm workspace with hybrid package patterns - All packages typecheck and build successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
/**
|
|
* WizardProvider
|
|
*
|
|
* Context provider for wizard state management.
|
|
* Implements Tier 1 Provider Pattern with useReducer.
|
|
*/
|
|
|
|
import {
|
|
createContext,
|
|
useReducer,
|
|
useCallback,
|
|
useMemo,
|
|
useEffect,
|
|
useRef,
|
|
} from 'react';
|
|
import type {
|
|
WizardContextValue,
|
|
WizardProviderProps,
|
|
WizardStep,
|
|
ValidationResult,
|
|
} from './types';
|
|
import { wizardReducer, createInitialState } from './wizard-reducer';
|
|
import { wizardStorage } from './wizard-storage';
|
|
import { wizardEvents } from './wizard-events';
|
|
|
|
// Create context with null default
|
|
export const WizardContext = createContext<WizardContextValue<any> | null>(null);
|
|
|
|
/**
|
|
* WizardProvider Component
|
|
*
|
|
* Wraps children with wizard context providing state management,
|
|
* persistence, and cross-tab synchronization.
|
|
*/
|
|
export function WizardProvider<TData extends Record<string, unknown>>({
|
|
children,
|
|
wizardId,
|
|
steps,
|
|
initialData = {} as Partial<TData>,
|
|
persistData = true,
|
|
storageVersion = 1,
|
|
storageKeyPrefix = 'wizard',
|
|
enableCrossTabSync = true,
|
|
autoSaveDelay = 500,
|
|
onComplete,
|
|
onStepChange,
|
|
onDataChange,
|
|
}: WizardProviderProps<TData>) {
|
|
// Initialize state
|
|
const [state, dispatch] = useReducer(
|
|
wizardReducer<TData>,
|
|
{ steps, initialData },
|
|
({ steps, initialData }) => createInitialState(steps, initialData)
|
|
);
|
|
|
|
// Refs for callbacks to avoid stale closures
|
|
const onCompleteRef = useRef(onComplete);
|
|
const onStepChangeRef = useRef(onStepChange);
|
|
const onDataChangeRef = useRef(onDataChange);
|
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
// Update refs when callbacks change
|
|
useEffect(() => {
|
|
onCompleteRef.current = onComplete;
|
|
onStepChangeRef.current = onStepChange;
|
|
onDataChangeRef.current = onDataChange;
|
|
}, [onComplete, onStepChange, onDataChange]);
|
|
|
|
// Storage key
|
|
const storageKey = `${storageKeyPrefix}:${wizardId}`;
|
|
|
|
// =========================================================================
|
|
// Persistence: Load saved state on mount
|
|
// =========================================================================
|
|
useEffect(() => {
|
|
if (!persistData) return;
|
|
|
|
const savedState = wizardStorage.load<TData>(storageKey);
|
|
if (savedState && savedState.version === storageVersion) {
|
|
dispatch({ type: 'RESTORE', savedState });
|
|
}
|
|
}, [persistData, storageKey, storageVersion]);
|
|
|
|
// =========================================================================
|
|
// Persistence: Auto-save on data change
|
|
// =========================================================================
|
|
useEffect(() => {
|
|
if (!persistData || !state.isDirty) return;
|
|
|
|
// Debounce auto-save
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
}
|
|
|
|
autoSaveTimerRef.current = setTimeout(() => {
|
|
wizardStorage.save(storageKey, {
|
|
wizardId,
|
|
version: storageVersion,
|
|
currentStepId: state.currentStepId,
|
|
data: state.data,
|
|
completedSteps: state.completedSteps,
|
|
startedAt: Date.now(),
|
|
lastSavedAt: Date.now(),
|
|
});
|
|
}, autoSaveDelay);
|
|
|
|
return () => {
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
}
|
|
};
|
|
}, [
|
|
persistData,
|
|
state.isDirty,
|
|
state.data,
|
|
state.currentStepId,
|
|
state.completedSteps,
|
|
storageKey,
|
|
wizardId,
|
|
storageVersion,
|
|
autoSaveDelay,
|
|
]);
|
|
|
|
// =========================================================================
|
|
// Cross-tab sync
|
|
// =========================================================================
|
|
useEffect(() => {
|
|
if (!enableCrossTabSync) return;
|
|
|
|
const unsubscribe = wizardEvents.subscribe(wizardId, (event) => {
|
|
if (event.type === 'data:updated' && event.wizardId === wizardId) {
|
|
dispatch({ type: 'UPDATE_DATA', payload: event.data as Partial<TData> });
|
|
}
|
|
if (event.type === 'wizard:reset' && event.wizardId === wizardId) {
|
|
dispatch({ type: 'RESET' });
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [enableCrossTabSync, wizardId]);
|
|
|
|
// =========================================================================
|
|
// Step change callback
|
|
// =========================================================================
|
|
useEffect(() => {
|
|
onStepChangeRef.current?.(state.currentStepId, state.currentStepIndex);
|
|
}, [state.currentStepId, state.currentStepIndex]);
|
|
|
|
// =========================================================================
|
|
// Data change callback
|
|
// =========================================================================
|
|
useEffect(() => {
|
|
onDataChangeRef.current?.(state.data);
|
|
}, [state.data]);
|
|
|
|
// =========================================================================
|
|
// Data Operations
|
|
// =========================================================================
|
|
const updateField = useCallback(
|
|
<K extends keyof TData>(field: K, value: TData[K]) => {
|
|
dispatch({ type: 'UPDATE_DATA', payload: { [field]: value } as unknown as Partial<TData> });
|
|
},
|
|
[]
|
|
);
|
|
|
|
const updateData = useCallback((updates: Partial<TData>) => {
|
|
dispatch({ type: 'UPDATE_DATA', payload: updates });
|
|
}, []);
|
|
|
|
// =========================================================================
|
|
// Validation
|
|
// =========================================================================
|
|
const validateStep = useCallback(async (): Promise<boolean> => {
|
|
const currentStep = state.resolvedSteps[state.currentStepIndex] as WizardStep<TData> | undefined;
|
|
if (!currentStep) return true;
|
|
|
|
dispatch({ type: 'SET_VALIDATING', isValidating: true });
|
|
|
|
// Check required fields first
|
|
if (currentStep.requiredFields?.length) {
|
|
const missingFields: Record<string, string> = {};
|
|
for (const field of currentStep.requiredFields) {
|
|
const value = state.data[field];
|
|
if (value === undefined || value === null || value === '') {
|
|
missingFields[String(field)] = 'This field is required';
|
|
}
|
|
}
|
|
if (Object.keys(missingFields).length > 0) {
|
|
dispatch({ type: 'SET_VALIDATION_ERRORS', errors: missingFields });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Run custom validation
|
|
if (currentStep.validate) {
|
|
try {
|
|
const result: ValidationResult = await Promise.resolve(
|
|
currentStep.validate(state.data, state.data)
|
|
);
|
|
if (!result.isValid) {
|
|
dispatch({ type: 'SET_VALIDATION_ERRORS', errors: result.errors });
|
|
return false;
|
|
}
|
|
} catch {
|
|
dispatch({
|
|
type: 'SET_VALIDATION_ERRORS',
|
|
errors: { _form: 'Validation failed. Please try again.' },
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
dispatch({ type: 'CLEAR_ERRORS' });
|
|
dispatch({ type: 'SET_VALIDATING', isValidating: false });
|
|
return true;
|
|
}, [state.resolvedSteps, state.currentStepIndex, state.data]);
|
|
|
|
const clearErrors = useCallback(() => {
|
|
dispatch({ type: 'CLEAR_ERRORS' });
|
|
}, []);
|
|
|
|
// =========================================================================
|
|
// Navigation
|
|
// =========================================================================
|
|
const next = useCallback(async (): Promise<boolean> => {
|
|
const isValid = await validateStep();
|
|
if (!isValid) return false;
|
|
|
|
// Mark current step as complete
|
|
dispatch({ type: 'COMPLETE_STEP', stepId: state.currentStepId });
|
|
|
|
// Check if this is the last step
|
|
if (state.currentStepIndex >= state.resolvedSteps.length - 1) {
|
|
return true; // Let caller handle completion
|
|
}
|
|
|
|
dispatch({ type: 'NEXT_STEP' });
|
|
return true;
|
|
}, [validateStep, state.currentStepId, state.currentStepIndex, state.resolvedSteps.length]);
|
|
|
|
const prev = useCallback(() => {
|
|
dispatch({ type: 'PREV_STEP' });
|
|
}, []);
|
|
|
|
const goTo = useCallback((stepId: string) => {
|
|
// Only allow navigation to completed steps or current step
|
|
if (state.completedSteps.includes(stepId) || stepId === state.currentStepId) {
|
|
dispatch({ type: 'GO_TO_STEP', stepId });
|
|
}
|
|
}, [state.completedSteps, state.currentStepId]);
|
|
|
|
const skip = useCallback(() => {
|
|
const currentStep = state.resolvedSteps[state.currentStepIndex];
|
|
if (currentStep?.canSkip) {
|
|
dispatch({ type: 'NEXT_STEP' });
|
|
}
|
|
}, [state.resolvedSteps, state.currentStepIndex]);
|
|
|
|
// =========================================================================
|
|
// Lifecycle
|
|
// =========================================================================
|
|
const reset = useCallback(() => {
|
|
dispatch({ type: 'RESET' });
|
|
if (persistData) {
|
|
wizardStorage.clear(storageKey);
|
|
}
|
|
if (enableCrossTabSync) {
|
|
wizardEvents.broadcast({ type: 'wizard:reset', wizardId });
|
|
}
|
|
}, [persistData, storageKey, enableCrossTabSync, wizardId]);
|
|
|
|
const complete = useCallback(async () => {
|
|
const isValid = await validateStep();
|
|
if (!isValid) return;
|
|
|
|
// Mark last step as complete
|
|
dispatch({ type: 'COMPLETE_STEP', stepId: state.currentStepId });
|
|
dispatch({ type: 'COMPLETE_WIZARD' });
|
|
|
|
// Clear storage
|
|
if (persistData) {
|
|
wizardStorage.clear(storageKey);
|
|
}
|
|
|
|
// Broadcast completion
|
|
if (enableCrossTabSync) {
|
|
wizardEvents.broadcast({
|
|
type: 'wizard:completed',
|
|
wizardId,
|
|
data: state.data,
|
|
});
|
|
}
|
|
|
|
// Call onComplete callback
|
|
await onCompleteRef.current?.(state.data);
|
|
}, [
|
|
validateStep,
|
|
state.currentStepId,
|
|
state.data,
|
|
persistData,
|
|
storageKey,
|
|
enableCrossTabSync,
|
|
wizardId,
|
|
]);
|
|
|
|
// =========================================================================
|
|
// Context Value
|
|
// =========================================================================
|
|
const currentStep = state.resolvedSteps[state.currentStepIndex] ?? null;
|
|
|
|
const contextValue = useMemo<WizardContextValue<TData>>(
|
|
() => ({
|
|
// State
|
|
state,
|
|
|
|
// Data
|
|
data: state.data,
|
|
updateField,
|
|
updateData,
|
|
|
|
// Navigation
|
|
next,
|
|
prev,
|
|
goTo,
|
|
skip,
|
|
|
|
// Current step info
|
|
currentStep,
|
|
stepIndex: state.currentStepIndex,
|
|
totalSteps: state.totalSteps,
|
|
progress: state.progress,
|
|
isFirstStep: state.currentStepIndex === 0,
|
|
isLastStep: state.currentStepIndex === state.totalSteps - 1,
|
|
|
|
// Validation
|
|
errors: state.errors,
|
|
clearErrors,
|
|
validateStep,
|
|
isValidating: state.isValidating,
|
|
|
|
// Lifecycle
|
|
reset,
|
|
complete,
|
|
isComplete: state.isComplete,
|
|
}),
|
|
[
|
|
state,
|
|
updateField,
|
|
updateData,
|
|
next,
|
|
prev,
|
|
goTo,
|
|
skip,
|
|
currentStep,
|
|
clearErrors,
|
|
validateStep,
|
|
reset,
|
|
complete,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<WizardContext.Provider value={contextValue}>
|
|
{children}
|
|
</WizardContext.Provider>
|
|
);
|
|
}
|