import { klona } from 'klona' import type { InjectionKey, UnwrapRef, MaybeRefOrGetter } from 'vue' type WithId = T & { id: number } export interface StepForm< DATA extends Record = Record, META extends Record = Record, > { meta: META to: string validate?: (ctx: MultiStepFormContext) => Promise | boolean | void } export interface MultiStepFormConfig< DATA extends Record = Record, META extends Record = Record, > { initialState: MaybeRefOrGetter steps: StepForm[] onSubmit?: (data: DATA, ctx: MultiStepFormContext) => Promise | void onError?: ( error: any, ctx: MultiStepFormContext, ) => Promise | void } export interface MultiStepFormContext< DATA extends Record = Record, META extends Record = Record, > { steps: ComputedRef>[]> totalSteps: ComputedRef currentStepId: ComputedRef currentStep: ComputedRef>> progress: ComputedRef isLastStep: ComputedRef data: Ref> errors: Readonly }>> loading: Readonly> complete: Readonly> getStep(id?: number): WithId> | undefined getNextStep(id?: number): WithId> | null getPrevStep(id?: number): WithId> | null goToStep(step?: WithId>): Promise reset(initialState?: MaybeRefOrGetter): void setErrorMessage(message?: string): void setFieldError(field: string, message?: string): void resetFieldError(field?: string | string[]): void validateStep(step?: WithId>): Promise handleSubmit(): Promise checkPreviousSteps(): Promise } const injectionKey = Symbol('multi-step-form-context') as InjectionKey /** * This function is used to create a multi-step form context. * It allows you to create a multi-step form with rules and steps and * track the current step, the progress, the data, etc. * * @see app/pages/wizard.vue * @see app/pages/layouts/invite.vue * @see app/pages/layouts/receive.vue * @see app/pages/layouts/send.vue */ export function provideMultiStepForm< DATA extends Record = Record, META extends Record = Record, >(rules: MultiStepFormConfig): MultiStepFormContext { const data = ref(klona(toValue(rules.initialState))) const errors = ref<{ message: string fields: Record }>({ message: '', fields: {}, }) const router = useRouter() const loading = ref(false) const complete = ref(false) const steps = computed(() => rules.steps.map((step, id) => ({ ...step, id }))) const totalSteps = computed(() => steps.value.length) const progress = computed( () => ((currentStepId.value + 1) / totalSteps.value) * 100, ) const currentStepId = computed(() => steps.value.find( step => step.to === router.currentRoute.value.path, )?.id ?? 0) const currentStep = computed(() => steps.value[currentStepId.value]) const isLastStep = computed(() => currentStepId.value === totalSteps.value - 1) const context: MultiStepFormContext = { steps, totalSteps, currentStepId, currentStep, isLastStep, data, progress, errors: readonly(errors), loading: readonly(loading), complete: readonly(complete), getStep, getNextStep, getPrevStep, goToStep, reset, setErrorMessage, setFieldError, resetFieldError, validateStep, handleSubmit, checkPreviousSteps, } function getNextStep(id = currentStepId.value) { return getStep(id + 1) } function getPrevStep(id = currentStepId.value) { return getStep(id - 1) } function getStep(id = currentStepId.value) { return steps.value[id] } async function goToStep(step?: StepForm) { if (step) { await router.push(step.to) } } function setFieldError(field: string, error?: string) { errors.value.fields[field] = error } function resetFieldError(field?: string | string[]) { if (Array.isArray(field)) { field.forEach((key) => { errors.value.fields[key] = undefined }) } else if (field) { errors.value.fields[field] = undefined } else { errors.value.fields = {} } } function setErrorMessage(error: string = '') { errors.value.message = error } async function validateStep(step = currentStep.value) { if (step.validate) { try { const success = await step.validate(context) const hasErrors = Object.values(errors.value.fields).filter(Boolean).length > 0 || errors.value.message if (success === false || hasErrors) { return false } } catch (error) { if (rules.onError) { await rules.onError(error, context) } return false } } return true } function reset(initialState = rules.initialState) { data.value = klona(toValue(initialState)) as UnwrapRef loading.value = false complete.value = false errors.value = { message: '', fields: {}, } } async function checkPreviousSteps() { let current = 0 while (current < currentStepId.value) { const step = getStep(current) if (!step) break if (!await validateStep(step)) { await goToStep(step) return } current++ } } async function handleSubmit() { if (loading.value) return loading.value = true resetFieldError() if (!await validateStep()) { loading.value = false return } const nextStep = getNextStep() // go to the next step if there is one if (nextStep) { await goToStep(nextStep) loading.value = false return } // submit the form otherwise try { if (rules.onSubmit) { await rules.onSubmit(data.value as DATA, context) } complete.value = true } catch (error) { if (rules.onError) { await rules.onError(error, context) } } finally { loading.value = false } } provide(injectionKey, context as any) return context } export function useMultiStepForm< DATA extends Record = Record, META extends Record = Record, >() { const context = inject( injectionKey as InjectionKey< MultiStepFormContext >, null, ) if (!context) { throw new Error( 'MultiStepForm: no context found, did you forget to call provideMultiStepForm?', ) } return context }