Home Reference Source

src/jedi-validate.js

import deepmerge from './lib/deepmerge';
import { getData, getInputData, getValueByName } from './lib/get-data';
import { convertData } from './lib/convert-data';
import Dictionary from './i18n/jedi-validate-i18n';
import { getFormOptions, getInputRules } from './lib/get-options';
import { validateData, validateField } from './lib/validate-data';
import { ajax } from './lib/ajax';
import { initErrorMessages, markField } from './lib/utils';
import defaultMethods from './lib/methods';
import defaultOptions from './options';

/**
 * JediValidate - validation
 */
export default class JediValidate {
    /**
     * Object with fields
     * @private
     * @type {Object.<string, Element>}
     */
    fields = {};

    /**
     * Object with inputs nodes
     * @private
     * @type {Object.<string, HTMLInputElement|HTMLSelectElement|Array>}
     */
    inputs = {};

    /**
     * Object with message nodes
     * @private
     * @type {Object.<string, Element>}
     */
    messages = {};

    /**
     * Object with error message
     * @private
     * @type {Object.<string, Object.<string, string>>}
     */
    errorMessages = {};

    /**
     * Object with error message
     * @private
     * @type {object} - data object
     */
    data = {};

    /**
     * Validate methods
     * @private
     * @type {Object.<string, {func: Function, message: string}>}
     */
    methods = { ...defaultMethods };
    /* eslint-disable */
    /**
     * Validator options
     * @private
     * @type {{ajax: {url: string, enctype: string, sendType: string, method: string}, rules: {}, messages: {}, containers: {parent: string, message: string, baseMessage: string}, states: {error: string, valid: string, pristine: string, dirty: string}, formStatePrefix: string, callbacks: {success: function, error: function}, clean: boolean, redirect: boolean, language: string, translations: {}}}
     */
    options = {};

    /* eslint-enable */
    /**
     * Validator rules
     * @private
     * @type {object}
     */
    rules = {};

    /**
     * Translation dictionary
     * @private
     * @type {Dictionary}
     */
    dictionary = null;

    /**
     * Elements
     * @private
     * @type {object}
     */
    nodes = null;

    /**
     * Root element
     * @private
     * @type {Element}
     */
    root = null;

    /**
     * JediValidate
     * @param {HTMLElement} root - element which wraps form element
     * @param {object} options - object with options
     */
    constructor(root, options = {}) {
        this.root = root;

        const baseMessageClass =
            (options.containers && options.containers.baseMessage) || defaultOptions.containers.baseMessage;

        this.nodes = {
            form: this.root.querySelector('form'),
            inputs: this.root.querySelectorAll('form [name]'),
            baseMessage: this.root.querySelector(`.${baseMessageClass}`),
        };

        const formOptions = getFormOptions(this.nodes.form);

        this.options = deepmerge(this.options, defaultOptions);
        this.options = deepmerge(this.options, formOptions);
        this.options = deepmerge(this.options, options);

        this.rules = { ...this.options.rules };

        this.dictionary = new Dictionary(this.options.translations);

        this.ready();

        this.errorMessages = initErrorMessages(this.rules, this.options.messages, this.methods);
    }

    /**
     * Ready
     * @private
     */
    ready() {
        this.nodes.form.setAttribute('novalidate', 'novalidate');

        this.nodes.form.addEventListener('submit', this.handleSubmit);

        Array.from(this.nodes.inputs).forEach(input => {
            // fixme "name" and "name in data" not the same
            // name === "phone[]",
            // data: { phone: [] } - name === "phone"
            const name = input.name; // eslint-disable-line prefer-destructuring

            if (this.inputs[name]) {
                if (Array.isArray(this.inputs[name])) {
                    this.inputs[name].push(input);
                } else {
                    const groupInput = [this.inputs[name], input];
                    groupInput.name = name;
                    this.inputs[name] = groupInput;
                }
            } else {
                this.inputs[name] = input;

                let field = input.parentNode;

                do {
                    if (field.classList.contains(this.options.containers.parent)) {
                        this.fields[name] = field;
                        break;
                    }

                    field = field.parentNode;
                } while (field && field.classList);

                if (!this.fields[name]) {
                    console.warn(`Input ${name} has no parent field`);
                    delete this.inputs[name];
                    return;
                }

                this.fields[name].classList.add(this.options.states.pristine);

                const messageElement = this.fields[name].querySelector(`.${this.options.containers.message}`);

                if (messageElement) {
                    this.messages[name] = messageElement;
                } else {
                    this.messages[name] = document.createElement('div');
                    this.messages[name].classList.add(this.options.containers.message);
                    this.fields[name].appendChild(this.messages[name]);
                }

                this.rules[name] = this.rules[name] || {};
                const inputRules = getInputRules(input);
                this.rules[name] = deepmerge(inputRules, this.rules[name]);

                Object.keys(this.rules[name]).forEach(rule => {
                    if (this.rules[name][rule]) {
                        this.fields[name].classList.add(rule);
                    }
                });
            }

            input.addEventListener('change', this.handleInputChange.bind(this, name));
            input.addEventListener('input', this.handleInputInput.bind(this, name));
        });
    }

    /**
     * Handle input change
     * @private
     * @param {string} name
     */
    handleInputChange(name) {
        this.fields[name].classList.remove(this.options.states.dirty);

        const inputData = getInputData(this.inputs[name]);
        const value = getValueByName(name, inputData);

        // fixme don't work with 2 inputs phone[]
        this.data = {
            ...this.data,
            ...inputData,
        };

        const errors = validateField(
            this.rules[name],
            this.methods,
            value,
            name,
            this.errorMessages,
            this.data,
            this.translate,
        );

        markField(this.fields[name], this.messages[name], this.options.states, errors);
    }

    /**
     * Handle input
     * @private
     * @param {string} name
     */
    handleInputInput(name) {
        this.fields[name].classList.remove(this.options.states.pristine);
        this.fields[name].classList.add(this.options.states.dirty);
    }

    /**
     * Handle form submit
     * @private
     * @param {Event} event
     */
    handleSubmit = event => {
        this.data = getData(this.inputs);

        const errors = validateData(this.rules, this.methods, this.data, this.errorMessages, this.translate);

        const fieldNames = Object.keys(errors).filter(name => this.fields[name]);

        if (fieldNames.length !== 0) {
            fieldNames.forEach(name =>
                markField(this.fields[name], this.messages[name], this.options.states, errors[name]),
            );
        }

        const errorFieldNames = fieldNames.filter(name => errors[name]);

        if (errorFieldNames.length !== 0) {
            try {
                this.options.callbacks.error({ errors });
            } catch (e) {
                if (process.env.NODE_ENV === 'development') {
                    console.error(e);
                }
            }

            event.preventDefault();
            return;
        }

        if (this.options.ajax && this.options.ajax.url) {
            event.preventDefault();
        } else {
            try {
                this.options.callbacks.success({ event });
            } catch (e) {
                if (process.env.NODE_ENV === 'development') {
                    console.error(e);
                }
            }

            return;
        }

        const convertedData = convertData(this.data, this.options.ajax.sendType);
        this.send({
            ...this.options.ajax,
            data: convertedData,
        });
    };

    /**
     * Translate
     * @private
     * @param {string} text - text to translate
     */
    translate = text => this.dictionary.translate(text, this.options.language);

    /**
     * Send form
     * @private
     * @param {object} options - object with options for sending
     * @param {string} options.url
     * @param {string} options.enctype
     * @param {string} options.sendType
     * @param {string} options.method
     * @param {string|FormData} options.data
     */
    send(options) {
        ajax(options, this.translate)
            .then(response => {
                if (response.validationErrors) {
                    try {
                        this.options.callbacks.error({
                            errors: response.validationErrors,
                        });
                    } catch (e) {
                        if (process.env.NODE_ENV === 'development') {
                            console.error(e);
                        }
                    }

                    if (response.validationErrors.base) {
                        this.nodes.baseMessage.innerHTML = response.validationErrors.base.join(', ');
                        this.root.classList.add(this.options.formStatePrefix + this.options.states.error);
                        this.root.classList.remove(this.options.formStatePrefix + this.options.states.valid);
                        delete response.validationErrors.base;
                    } else {
                        this.nodes.baseMessage.innerHTML = '';
                    }

                    Object.keys(response.validationErrors).forEach(name =>
                        markField(
                            this.fields[name],
                            this.messages[name],
                            this.options.states,
                            response.validationErrors[name],
                        ),
                    );
                } else {
                    try {
                        this.options.callbacks.success({ response });
                    } catch (e) {
                        if (process.env.NODE_ENV === 'development') {
                            console.error(e);
                        }
                    }

                    if (this.options.redirect && response.redirect) {
                        window.location.href = response.redirect;
                        return;
                    }

                    if (this.options.clean) {
                        this.nodes.form.reset();
                    }
                }
            })
            .catch(({ method, url, status, statusText }) => {
                console.warn(`${method} ${url} ${status} (${statusText})`);

                this.nodes.baseMessage.innerHTML = this.translate('Can not send form!');
                this.root.classList.add(this.options.formStatePrefix + this.options.states.error);
                this.root.classList.remove(this.options.formStatePrefix + this.options.states.valid);
            });
    }

    /**
     * Collect data
     * @public
     * @param {string|Array.<string>} params - field
     * @returns {Object}
     */
    collect(params = '') {
        if (!params) {
            this.data = getData(this.inputs);

            return this.data;
        }

        if (Array.isArray(params)) {
            return params.reduce((collected, name) => {
                const inputData = getInputData(this.inputs[name]);

                this.data = {
                    ...this.data,
                    ...inputData,
                };

                return {
                    ...collected,
                    ...inputData,
                };
            }, {});
        }

        const inputData = getInputData(this.inputs[params]);

        // fixme don't work with 2 inputs phone[]
        this.data = {
            ...this.data,
            ...inputData,
        };

        return inputData;
    }

    /**
     * Add rule to validator
     * @public
     * @param {string} rule - rule name
     * @param {Function} func - function
     * @param {string} message - error message
     */
    addMethod(rule, func, message) {
        this.methods[rule] = {
            func,
            message,
        };

        this.errorMessages = initErrorMessages(this.rules, this.options.messages, this.methods);
    }

    /**
     * Add localization to JediValidate
     * @public
     * @param {string} sourceText - text on english
     * @param {string} translatedText - text on needed language
     * @param {string} language - language
     */
    addToDictionary(sourceText, translatedText, language) {
        this.dictionary.addTranslation(sourceText, translatedText, language);
    }
}