/* eslint max-lines: off */ import { createState, CreateState } from "@reins/state"; import { ObjectType, PathsOf, ObjectPathToValue, MaybePromise, OptionalKeys } from "@reins/types"; import { ValidatorsMapType, FormStateType, FieldErrorType, ValidateFieldOutput, FormAdapterOptionsType, FieldValidation, FormErrorTypes, } from "./form-adapter.types"; import { runFieldValidator, runNativeValidation } from "./form-adapter.utils"; import { FieldElements } from "hooks"; export class FormAdapter> { public initialStore: FormStateType; readonly store: CreateState>; public getState() { return this.store.getState(); } public setState(...args: Parameters<(typeof this.store)["setState"]>) { return this.store.setState(...args); } public getValues() { return this.getState().values; } public getErrors() { return this.getState().errors; } /** * This property stores registered fields, so we know what fields currently active on screen * @private */ private readonly _registeredFields: Record = {}; /** * This property stores registered validators on fields, so we know what fields do have * and require validation * @private */ private readonly _registeredValidators: ValidatorsMapType> = new Map(); // TODO: this might need to add isomorphic case? // What if instead of HTMLElement provide callback which supposed to scroll for desired field // to be provided by client private readonly _attachedNodes: OptionalKeys> = {}; constructor( initialValues: Values, public options?: FormAdapterOptionsType, ) { this.initialStore = { submitCount: 0, page: 1, pages: options?.pages || 1, isSubmitting: false, isValidating: false, values: initialValues, errors: {} as Record, touched: {} as Record, dirty: {} as Record, validations: {}, }; this.store = createState>(this.initialStore); } /** * Attach the field element to the form adapter * This is required to be able to scroll to the field * @param field * @param element * @returns */ public attachFieldElement(field: Name, element: FieldElements): VoidFunction { if (!this._attachedNodes[field]?.includes(element)) { this._attachedNodes[field]?.push(element); } return () => this.detachFields(field); } public detachFields(field: Name) { this._attachedNodes[field] = []; } public getAttachedNodes(field: Name) { return this._attachedNodes[field] || []; } public scrollToField(field: Name) { const fieldElement = this._attachedNodes[field]?.[0]; fieldElement?.scrollIntoView?.({ behavior: "smooth", block: "center" }); } public registerField(field: Name) { const registeredField = this._registeredFields[field]; if (!registeredField) { this._registeredFields[field] = 0; } this._registeredFields[field] += 1; return () => { this.unregisterField(field); }; } public unregisterField(field: Name) { const registeredFieldAmount = this._registeredFields[field]; if (!registeredFieldAmount) { throw new TypeError( "You are trying to 'unregisterField' on field which wasn't registered. " + "Please ensure that you and 'registerField' before 'unregisterField'", ); } this._registeredFields[field] = registeredFieldAmount - 1; // NOTE: If we unregister last form field then remove it from map if (!this._registeredFields[field]) delete this._registeredFields[field]; } public registerFieldValidation>( field: Name, validation: FieldValidation, Value>, ) { this._registeredValidators.set(field, validation); return () => { this.unregisterFieldValidation(field); }; } public unregisterFieldValidation(field: Name) { this._registeredValidators.delete(field); } public async validateField(field: Names): Promise { this.setState((draft) => { draft.errors[field] = { errors: {}, message: "", type: "none", valid: true }; draft.validations[field] = false; }); const fieldValidators = this._registeredValidators.get(field); if (!fieldValidators) { console.warn(`You are trying to validate '${field}' field which doesn't have any registered validation`); return { field, result: { errors: {}, message: "", type: "none", valid: true } }; } const fieldValue = this.getFieldValue(field); this.setState((draft) => { draft.validations[field] = true; }); try { const result: FieldErrorType = { errors: {}, type: "none", message: "", valid: true, }; if (fieldValidators.validate) { const validationResults = await runFieldValidator(this, fieldValue, fieldValidators.validate); result.errors.validate = validationResults; } const errors = runNativeValidation(fieldValue, fieldValidators); Object.assign(result.errors, errors); const firstError = Object.entries(result.errors).find(([, value]) => !!value); if (firstError) { const [type, error] = firstError; const errorMessage = typeof error === "boolean" ? "" : (error as string) || ""; result.valid = false; result.type = type as FormErrorTypes; result.message = Array.isArray(error) ? error[0] : errorMessage; } this.setState((draft) => { draft.errors[field] = result; draft.validations[field] = false; }); return { field, result }; } catch (rejectionError: unknown) { const error = rejectionError && typeof rejectionError === "object" && "message" in rejectionError && rejectionError?.message; const message: string = error ? String(error) : "Unhandled validator rejection"; const result: FieldErrorType = { errors: {}, message, type: "unexpected", valid: false, }; this.setState((draft) => { draft.errors[field] = result; draft.validations[field] = false; }); return { field, result }; } } public async validate(): Promise<{ isValid: boolean; firstError: ValidateFieldOutput | null; result: ValidateFieldOutput[]; values: Values; }> { this.setState((draft) => { draft.isValidating = true; }); const fieldsValidationsPromises = Array.from(this._registeredValidators.keys()).map((field) => { return this.validateField(field as Names); }); const result = await Promise.all(fieldsValidationsPromises); const firstError = result.find((item) => !item.result.valid) || null; const hasError = Boolean(firstError); this.setState((draft) => { draft.isValidating = false; }); return { isValid: !hasError, firstError, result, values: this.getValues(), }; } public async submit(onSubmit: (values: Values) => MaybePromise): Promise { if (this.getState().isSubmitting) { console.warn( `You are trying to 'submitForm()' which is still submitting. This is not allowed. Please ensure that you do not call 'submitForm()' while it's already submitting`, ); return false; } this.setState((draft) => { draft.submitCount += 1; draft.isSubmitting = true; }); const { values, isValid, firstError } = await this.validate(); if (firstError) { const field = firstError.field as Names; const fieldElement = this._attachedNodes[field]?.[0]; if (fieldElement) this.scrollToField(field); } if (!isValid) { this.setState((draft) => { draft.isSubmitting = false; }); return false; } try { await onSubmit(values); this.setState((draft) => { draft.isSubmitting = false; }); return true; } catch (error) { console.warn("An unhandled error was caught during 'submitForm()' execution"); console.warn(error); this.setState((draft) => { draft.isSubmitting = false; }); return false; } } public reinitialize(values: Values, withValues = false) { if (withValues) this.setValues(values); } public setValues(values: Values) { this.setState((draft) => { draft.values = values; }); } public reset(auto = true) { if (auto) { return this.store.setState(this.initialStore); } this.setState((draft) => { draft.submitCount = 0; draft.isSubmitting = false; draft.isValidating = false; }); Object.keys(this._registeredFields).forEach((field) => this.resetField(field as Names)); } public resetField(field: Name) { this.setState((draft) => { const paths = field.split("."); const lastItem = paths.splice(-1, 1)[0]; const reduced = paths.reduce( (acc, curr) => ({ initialValues: acc.initialValues[curr], values: acc.values[curr], }), { initialValues: this.initialStore.values, values: draft.values }, ); reduced.values[lastItem as keyof Values] = reduced.initialValues[lastItem]; draft.errors[field] = { errors: {}, message: "", type: "none", valid: true }; draft.touched[field] = false; draft.validations[field] = false; }); } public getFieldValue(field: Name): ObjectPathToValue { try { const paths = field.split("."); return paths.reduce((acc, curr) => acc[curr], this.getValues()) as ObjectPathToValue; } catch (error) { throw new SyntaxError("Invalid field path passed to 'getFieldValue'."); } } public setFieldValue(field: Name, value: ObjectPathToValue) { try { let setDirty = false; this.setState((draft) => { const paths = field.split("."); const lastItem = paths.splice(-1, 1)[0]; const reduced = paths.reduce((acc, curr) => acc[curr], draft.values); if (reduced[lastItem] !== value) { setDirty = true; } reduced[lastItem as keyof Values] = value; }); if (setDirty) { this.setFieldDirty(field, true); } } catch (error) { throw new SyntaxError("Invalid field path passed to 'setFieldValue'."); } } public getFieldError(field: Name): FieldErrorType { return ( this.getState().errors[field] ?? { errors: {}, message: "", type: "none", valid: true, } ); } public setFieldError(field: Name, value: FieldErrorType): void { this.setState((draft) => { draft.errors[field] = value; }); } public getFieldTouched(field: Name): boolean { return this.getState().touched[field] ?? false; } public setFieldTouched(field: Name, value: boolean): void { this.setState((draft) => { draft.touched[field] = value; }); } public getFieldDirty(field: Name): boolean { return this.getState().dirty[field] ?? false; } public setFieldDirty(field: Name, value: boolean): void { this.setState((draft) => { draft.dirty[field] = value; }); } public getFieldValidation(field: Name): boolean { return this.getState().validations[field] ?? false; } public setFieldValidation(field: Name, value: boolean): void { this.setState((draft) => { draft.validations[field] = value; }); } }