packages/forms/src/FormValidator.js

Summary

Maintainability
A
3 hrs
Test Coverage
import Rx from 'rxjs';
import log from 'loglevel';
import { isFunction } from 'lodash/fp';

export const FormFieldStatuses = {
    VALID: 'VALID',
    INVALID: 'INVALID',
    VALIDATING: 'VALIDATING',
};

function identity(val) {
    return val;
}

function getAllPromiseValues(promises) {
    return Promise.all(promises.map(promise => promise.then(identity, identity)));
}

function validatorRunner(fieldName, fieldValue, formSource) {
    return function runValidator(validator) {
        let result;

        try {
            result = validator(fieldValue, fieldName, formSource);
        } catch (e) {
            log.debug(`Validator for '${fieldName}' ignored because the validator threw an error.`);
            log.debug(`${validator}`);
            log.debug(e.message);
            return Promise.resolve(true);
        }

        if (result === false) {
            return Promise.reject(validator.message);
        }

        return Promise.resolve(result);
    };
}

function awaitAsyncValidators(accumulator, validatorPromise, index, validators) {
    if (validatorPromise) {
        accumulator.push(validatorPromise);
    }

    if (validators.length === 0 || validators.length === (index + 1)) {
        return getAllPromiseValues(accumulator);
    }
    return accumulator;
}

function grabErrorMessages(validationStatuses) {
    return validationStatuses.filter(s => s !== true);
}

function getFieldStatus(statusMessages = []) {
    return {
        status: statusMessages.length === 0 ? FormFieldStatuses.VALID : FormFieldStatuses.INVALID,
        messages: statusMessages,
    };
}

export default function createFormValidator(fieldConfigs = [], scheduler) {
    const validatorQueue = new Rx.Subject();
    const statusSubject = new Rx.ReplaySubject(1);
    const initialStatuses = fieldConfigs
        .filter(fieldConfig => Array.isArray(fieldConfig.validators) && fieldConfig.validators.length > 0)
        .map(fc => [fc.name, { status: FormFieldStatuses.VALID, messages: [] }]);
    const formFieldStatuses = new Map(initialStatuses);

    const validatorQueues = new Map(initialStatuses.map(([name]) => [name, new Rx.Subject()]));
    Array.from(validatorQueues.values())
        .forEach((validatorObservable) => {
            validatorObservable
                .debounceTime(300, scheduler)
                .map(({ fieldName, fieldValue, formSource }) => {
                    const fieldConfig = fieldConfigs
                        .filter(fc => fc.name === fieldName)
                        .shift();

                    validatorQueue.next(Promise.resolve({ fieldName, fieldStatus: { status: FormFieldStatuses.VALIDATING, messages: [] } }));

                    const validatorToRun = fieldConfig.validators
                        .filter((validator) => {
                            if (!isFunction(validator)) {
                                log.warn(`Warning: One of the validators for '${fieldName}' is not a function.`);
                                return false;
                            }
                            return isFunction(validator);
                        })
                        .map(validatorRunner(fieldName, fieldValue, formSource));

                    if (!validatorToRun.length) {
                        return Promise.resolve({
                            fieldName,
                            fieldStatus: getFieldStatus(),
                        });
                    }

                    return validatorToRun
                        .reduce(awaitAsyncValidators, [])
                        .then(grabErrorMessages)
                        .then(errorMessages => ({
                            fieldName,
                            fieldStatus: getFieldStatus(errorMessages),
                        }))
                        .catch(log.error);
                })
                .concatAll()
                .subscribe(({ fieldName, fieldStatus }) => {
                    formFieldStatuses.set(fieldName, fieldStatus);
                    statusSubject.next(formFieldStatuses);
                });
        });

    validatorQueue
        .concatAll()
        .subscribe((fieldValidatorStatus) => {
            const { fieldName, fieldStatus } = fieldValidatorStatus;
            formFieldStatuses.set(fieldName, fieldStatus);
            statusSubject.next(formFieldStatuses);
        });

    const formValidator = {
        status: statusSubject
            .debounceTime(100),

        setStatus(status) {
            statusSubject.next(status);
        },

        /**
         * Start a validation run for a specific field with a provided value. This runs sync and async validators
         * and reports the status back using the `formValidator.status` observable.
         *
         * @param {String} fieldName Name of the field to run the validator for.
         * @param {String} fieldValue Value of the field to run the validator for.
         * @returns {boolean} Returns true when a validator run has started, otherwise false.
         *
         * @example
         * ```js
         *   formValidator.runFor('name', 'Mark');
         * ```
         */
        runFor(fieldName, fieldValue, formSource) {
            if (validatorQueues.has(fieldName)) {
                validatorQueues.get(fieldName).next({ fieldName, fieldValue, formSource });
                return true;
            }
            return false;
        },

        /**
         * Returns the current status for the passed field.
         *
         * @param {String} fieldName Name of the field. Generally this is the `name` property on the `fieldConfig`
         * @returns {Object} Status object with a `status` and a `messages` property.
         *
         * @example
         * ```js
         *   formValidator.getStatusFor('password')
         *   // {
         *   //   status: FormFieldStatuses.VALID,
         *   //   messages: []
         *   // }
         * ```
         */
        getStatusFor(fieldName) {
            if (formFieldStatuses.has(fieldName)) {
                return formFieldStatuses.get(fieldName);
            }

            return {
                status: FormFieldStatuses.VALID,
                messages: [],
            };
        },
    };

    return formValidator;
}