UNPKG

@de100/form-echo

Version:

A form state management for fields validations and errors

726 lines (653 loc) 20.9 kB
import FormStoreField from './FormStoreField'; import { type FormEvent } from 'react'; import { type ValidationEvents, type CreateFormStoreProps, type FormStoreShape, type GetFromFormStoreShape, // type HandlePreSubmit, type HandleSubmitCB, type GetValidationValuesFromSchema, } from '../types'; import { errorFormatter, isZodValidator } from './zod'; export * from './zod'; export * from './zustand'; // export * from './helpers'; export { /** * @description field value helpers */ default as fvh, } from './helpers/fieldValue'; type SetStateInternal<T> = ( partial: T | Partial<T> | ((state: T) => T | Partial<T>), // replace?: boolean | undefined, ) => void; function createFormStoreMetadata<FieldsValues, ValidationsHandlers>( params: CreateFormStoreProps<FieldsValues, ValidationsHandlers>, baseId: string, ) { type FormStore = FormStoreShape<FieldsValues, ValidationsHandlers>; if (!params.initialValues || typeof params.initialValues !== 'object') throw new Error(''); const metadata = { baseId, formId: `${baseId}-form`, fieldsNames: {}, fieldsNamesMap: {}, // validatedFieldsNames: [], validatedFieldsNamesMap: {}, // // manualValidatedFields: [], manualValidatedFieldsMap: [], // // referencedValidatedFields: [], referencedValidatedFieldsMap: [], } as unknown as FormStore['metadata']; metadata.fieldsNames = Object.keys( params.initialValues, ) as typeof metadata.fieldsNames; for (const fieldName of metadata.fieldsNames) { metadata.fieldsNamesMap[fieldName] = true; } for (const key in params.validationsHandlers) { metadata.validatedFieldsNames.push(key); metadata.validatedFieldsNamesMap[key] = true; if (key in metadata.fieldsNamesMap) { metadata.referencedValidatedFields.push( key as unknown as (typeof metadata)['referencedValidatedFields'][number], ); metadata.referencedValidatedFieldsMap[ key as unknown as (typeof metadata)['referencedValidatedFields'][number] ] = true; continue; } metadata.manualValidatedFields.push( key as unknown as (typeof metadata)['manualValidatedFields'][number], ); (metadata.manualValidatedFieldsMap as Record<string, true>)[ key // as unknown as (typeof metadata)['manualValidatedFieldsMap'][number] ] = true; } return metadata; } function createFormStoreValidations<FieldsValues, ValidationsHandlers>( params: CreateFormStoreProps<FieldsValues, ValidationsHandlers>, metadata: FormStoreShape<FieldsValues, ValidationsHandlers>['metadata'], ) { type FormStore = FormStoreShape<FieldsValues, ValidationsHandlers>; let fieldValidationEvents: NonNullable<typeof params.validationEvents> = { submit: true, blur: true, }; let isFieldHavingPassedValidations = false; let fieldValidationEventKey: ValidationEvents; const validations: FormStore['validations'] = {} as FormStore['validations']; for (const fieldName of metadata.validatedFieldsNames) { const fieldValidationsHandler = params.validationsHandlers?.[ fieldName as keyof GetFromFormStoreShape<FormStore> & keyof GetFromFormStoreShape<FormStore, 'validationHandlers'> ]; validations[fieldName] = { handler: !fieldValidationsHandler ? undefined : isZodValidator(fieldValidationsHandler) ? (value: unknown) => fieldValidationsHandler.parse(value) : fieldValidationsHandler, currentDirtyEventsCounter: 0, failedAttempts: 0, passedAttempts: 0, events: { // mount: { }, blur: { failedAttempts: 0, passedAttempts: 0, isActive: params.validationEvents?.blur ?? true, isDirty: false, error: null, }, change: { failedAttempts: 0, passedAttempts: 0, isActive: params.validationEvents?.change ?? false, isDirty: false, error: null, }, submit: { failedAttempts: 0, passedAttempts: 0, isActive: params.validationEvents?.submit ?? true, isDirty: false, error: null, }, }, isDirty: false, metadata: { name: fieldName }, } as NonNullable<FormStore['validations'][keyof FormStore['validations']]>; if (params.validationEvents) { isFieldHavingPassedValidations = true; fieldValidationEvents = { ...fieldValidationEvents, ...params.validationEvents, }; } if (isFieldHavingPassedValidations) { for (fieldValidationEventKey in fieldValidationEvents) { validations[fieldName].events[fieldValidationEventKey].isActive = !!typeof fieldValidationEvents[fieldValidationEventKey]; } } } return validations; } function createFormStoreFields<FieldsValues, ValidationsHandlers>( params: CreateFormStoreProps<FieldsValues, ValidationsHandlers>, baseId: string, metadata: FormStoreShape<FieldsValues, ValidationsHandlers>['metadata'], ) { type FormStore = FormStoreShape<FieldsValues, ValidationsHandlers>; const fields = {} as FormStore['fields']; for (const fieldName of metadata.fieldsNames) { fields[fieldName] = new FormStoreField({ value: params.initialValues[fieldName], valueFromFieldToStore: params.valuesFromFieldsToStore?.[fieldName] ? params.valuesFromFieldsToStore[fieldName] : undefined, valueFromStoreToField: params.valuesFromStoreToFields?.[fieldName] ? params.valuesFromStoreToFields[fieldName] : undefined, id: `${baseId}field-${String(fieldName)}`, metadata: { name: fieldName, initialValue: params.initialValues[fieldName], }, } as (typeof fields)[typeof fieldName]); } return fields; } function _setFieldError<FieldsValues, ValidationsHandlers>(params: { name: keyof ValidationsHandlers; message: string | null; validationEvent: ValidationEvents; }) { return function ( currentState: FormStoreShape<FieldsValues, ValidationsHandlers>, ): FormStoreShape<FieldsValues, ValidationsHandlers> { if ( !currentState.validations[params.name].events[params.validationEvent] .isActive ) return currentState; let currentDirtyFieldsCounter = currentState.currentDirtyFieldsCounter; const validation = { ...currentState.validations[params.name], }; if (params.message) { validation.failedAttempts++; validation.events[params.validationEvent].failedAttempts++; if (!validation.isDirty) { validation.currentDirtyEventsCounter++; if (validation.currentDirtyEventsCounter > 0) { currentDirtyFieldsCounter++; } } validation.events[params.validationEvent].error = { message: params.message, }; validation.error = { message: params.message }; validation.events[params.validationEvent].isDirty = true; validation.isDirty = true; } else { validation.passedAttempts++; validation.events[params.validationEvent].passedAttempts++; if (validation.isDirty) { validation.currentDirtyEventsCounter--; if (validation.currentDirtyEventsCounter === 0) { currentDirtyFieldsCounter--; } } validation.events[params.validationEvent].error = null; validation.error = null; validation.events[params.validationEvent].isDirty = false; validation.isDirty = false; } currentState.currentDirtyFieldsCounter = currentDirtyFieldsCounter; currentState.isDirty = currentDirtyFieldsCounter > 0; currentState.validations = { ...currentState.validations, [params.name]: validation, }; return currentState; }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any type TFunction = (...args: any[]) => any; type AnyValueExceptFunctions = // eslint-disable-next-line @typescript-eslint/ban-types Exclude<{} | null | undefined, TFunction>; function _setFieldValue< FieldsValues, ValidationsHandlers, Name extends keyof FieldsValues, >( name: Name, valueOrUpdater: | AnyValueExceptFunctions | ((value: FieldsValues[Name]) => FieldsValues[Name]), ) { return function ( currentState: FormStoreShape<FieldsValues, ValidationsHandlers>, ): FormStoreShape<FieldsValues, ValidationsHandlers> { const field = currentState.fields[name]; field.value = ( typeof valueOrUpdater === 'function' ? valueOrUpdater(field.value) : valueOrUpdater ) as FieldsValues[typeof name]; return { ...currentState, fields: { ...currentState.fields, [name]: field, }, }; }; } const itemsToResetDefaults = { fields: true, validations: true, submit: false, focus: true, }; export function createFormStoreBuilder<FieldsValues, ValidationsHandlers>( params: CreateFormStoreProps<FieldsValues, ValidationsHandlers>, ) { type FormStore = FormStoreShape<FieldsValues, ValidationsHandlers>; const baseId = params.baseId ? `${params.baseId}-` : ''; const metadata = createFormStoreMetadata(params, baseId); const fields = createFormStoreFields(params, baseId, metadata); const validations = createFormStoreValidations(params, metadata); return ( set: SetStateInternal<FormStore>, get: () => FormStore, ): FormStore => { return { baseId, metadata, validations, fields, id: `${baseId}form`, isDirty: false, submit: { counter: 0, passedAttempts: 0, failedAttempts: 0, errorMessage: null, isActive: false, }, focus: { isActive: false, field: null }, currentDirtyFieldsCounter: 0, getFieldValues() { const currentState = get(); const fieldsValues = {} as FieldsValues; let fieldName: string; for (fieldName in currentState.fields) { fieldsValues[fieldName as keyof FieldsValues] = currentState.fields[fieldName as keyof FieldsValues].value; } return fieldsValues; }, setSubmitState(valueOrUpdater) { set(function (currentState) { return { // ...currentState, submit: { ...currentState.submit, ...(typeof valueOrUpdater === 'function' ? valueOrUpdater(currentState.submit) : valueOrUpdater), }, }; }); }, setFocusState(fieldName, validationName, isActive) { set(function (currentState) { let _currentState = currentState; if ( !isActive && _currentState.validations[validationName].events.blur.isActive ) { try { _currentState.validations[validationName].handler( validationName && fieldName !== validationName ? _currentState.getFieldValues() : _currentState.fields[fieldName].value, 'blur', ); _currentState = _setFieldError<FieldsValues, ValidationsHandlers>( { name: validationName, message: null, validationEvent: 'blur', }, )(_currentState); } catch (error) { const message = _currentState.errorFormatter(error, 'blur'); _currentState = _setFieldError<FieldsValues, ValidationsHandlers>( { name: validationName, message, validationEvent: 'blur', }, )(_currentState); } if ( _currentState.focus.isActive && _currentState.focus.field.name !== fieldName ) return _currentState; } return { ..._currentState, focus: isActive ? { isActive: true, field: { name: fieldName, id: _currentState.fields[fieldName].id, }, } : { isActive: false, field: null }, }; }); }, resetFormStore: function (itemsToReset = itemsToResetDefaults) { return set(function (currentState) { const fields = currentState.fields; const validations = currentState.validations; let isDirty = currentState.isDirty; let submit = currentState.submit; let focus = currentState.focus; if (itemsToReset.fields) { let fieldName: keyof typeof fields; for (fieldName in fields) { fields[fieldName].value = fields[fieldName].metadata.initialValue; } } if (itemsToReset.validations) { for (const key in validations) { validations[key].failedAttempts = 0; validations[key].passedAttempts = 0; validations[key].isDirty = false; validations[key].error = null; let eventKey: keyof (typeof validations)[typeof key]['events']; for (eventKey in validations[key].events) { // validations[key].events[eventKey]. validations[key].events[eventKey].failedAttempts = 0; validations[key].events[eventKey].passedAttempts = 0; validations[key].events[eventKey].isDirty = false; validations[key].events[eventKey].error = null; } } isDirty = false; } if (itemsToReset.submit) { submit = { counter: 0, passedAttempts: 0, failedAttempts: 0, errorMessage: null, isActive: false, }; } if (itemsToReset.focus) { focus = { isActive: false, field: null, }; } return { // ...currentState, fields, validations, isDirty, submit, focus, }; }); }, setFieldValue(name, value) { return set(_setFieldValue(name, value)); }, setFieldError(params) { set(_setFieldError(params)); }, errorFormatter: params.errorFormatter ?? errorFormatter, handleInputChange(name, valueOrUpdater, validationName) { let currentState = get(); const field = currentState.fields[name]; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const _value = typeof valueOrUpdater === 'function' ? valueOrUpdater(field.value) : valueOrUpdater; const value = field.valueFromFieldToStore ? field.valueFromFieldToStore(_value) : (_value as FieldsValues[typeof name]); const _validationName = ( validationName ? validationName : // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore currentState.metadata.referencedValidatedFieldsMap[name] ? name : undefined ) as typeof validationName; const setFieldValue = _setFieldValue< FieldsValues, ValidationsHandlers, typeof name >; const setFieldError = _setFieldError<FieldsValues, ValidationsHandlers>; if ( _validationName && currentState.validations[_validationName].events['change'].isActive ) { try { currentState = setFieldValue( name, currentState.validations[_validationName].handler( validationName && // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore validationName !== name ? currentState.getFieldValues() : value, 'change', ), )(currentState); currentState = setFieldError({ name: _validationName, message: null, validationEvent: 'change', })(currentState); } catch (error) { currentState = setFieldError({ name: _validationName, message: currentState.errorFormatter(error, 'change'), validationEvent: 'change', })(currentState); currentState = setFieldValue(name, value)(currentState); } } else { currentState = setFieldValue(name, value)(currentState); } set(currentState); }, getFieldEventsListeners(name, validationName) { const currentState = get(); const _validationName = validationName ?? name; return { onChange: (event: { target: { value: string } }) => { currentState.handleInputChange(name, event.target.value); }, onFocus: () => { currentState.setFocusState( name, _validationName as keyof ValidationsHandlers, true, ); }, onBlur: () => { currentState.setFocusState( name, _validationName as keyof ValidationsHandlers, false, ); }, }; }, handleSubmit(cb: HandleSubmitCB<FieldsValues, ValidationsHandlers>) { return async function ( event: FormEvent<HTMLFormElement>, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ): Promise<unknown> | unknown { event.preventDefault(); const currentState = get(); currentState.setSubmitState({ isActive: true }); const metadata = currentState.metadata; const fields = currentState.fields; const validations = currentState.validations; const values: Record<string, unknown> = {}; const validatedValues: Record<string, unknown> = {}; const errors: Record< string, { name: string | number | symbol; message: string | null; validationEvent: ValidationEvents; } > = {}; let hasError = false; let fieldName: keyof typeof fields & string; for (fieldName in fields) { values[fieldName] = fields[fieldName].value; try { const validationSchema = fieldName in metadata.referencedValidatedFieldsMap && validations[fieldName as unknown as keyof typeof validations] .handler; if ( typeof validationSchema !== 'function' || !validations[fieldName as unknown as keyof typeof validations] .events.submit.isActive ) { continue; } validatedValues[fieldName] = validationSchema( fields[fieldName].value, 'submit', ); errors[fieldName] = { name: fieldName, message: null, validationEvent: 'submit', }; } catch (error) { errors[fieldName] = { name: fieldName, message: currentState.errorFormatter(error, 'submit'), validationEvent: 'submit', }; } } let manualFieldName: keyof (typeof metadata)['manualValidatedFieldsMap']; for (manualFieldName of metadata.manualValidatedFields) { try { const validationSchema = currentState.validations[manualFieldName].handler; if (typeof validationSchema !== 'function') { continue; } validatedValues[manualFieldName as string] = validationSchema( values as FieldsValues, 'submit', ); errors[manualFieldName as string] = { name: manualFieldName, message: null, validationEvent: 'submit', }; } catch (error) { errors[manualFieldName as string] = { name: manualFieldName, message: currentState.errorFormatter(error, 'submit'), validationEvent: 'submit', }; } } type NecessaryEvil = { values: FieldsValues; validatedValues: GetValidationValuesFromSchema<ValidationsHandlers>; error: Parameters< typeof _setFieldError<FieldsValues, ValidationsHandlers> // ['utils']['setFieldError'] >[0]; errors: { [Key in keyof ValidationsHandlers]: { name: Key; message: string | null; validationEvent: ValidationEvents; }; }; }; let _currentState: FormStoreShape<FieldsValues, ValidationsHandlers> = get(); let errorKey: keyof typeof errors & string; for (errorKey in errors) { const errorObj = errors[errorKey]; _currentState = _setFieldError<FieldsValues, ValidationsHandlers>( errors[errorKey] as unknown as NecessaryEvil['error'], )(_currentState); if (typeof errorObj.message !== 'string') continue; hasError = true; } if (!hasError) { try { await cb({ event, values: values as NecessaryEvil['values'], validatedValues: validatedValues as NecessaryEvil['validatedValues'], hasError, errors: errors as NecessaryEvil['errors'], }); currentState.setSubmitState((prev) => ({ isActive: false, counter: prev.counter + 1, passedAttempts: prev.counter + 1, errorMessage: null, })); } catch (error) { currentState.setSubmitState((prev) => ({ isActive: false, counter: prev.counter + 1, failedAttempts: prev.counter + 1, errorMessage: currentState.errorFormatter(error, 'submit'), })); } } else { set(_currentState); currentState.setSubmitState((prev) => ({ isActive: false, counter: prev.counter + 1, failedAttempts: prev.counter + 1, errorMessage: null, })); } }; }, }; }; }