rx/presenters

View on GitHub
views/mdc/assets/js/components/events/errors.js

Summary

Maintainability
C
7 hrs
Test Coverage
import 'core-js/features/array/flat';

/**
 * mapObject transforms an object's key-value pairs via the provided function.
 * @param {Object} object
 * @param {Function} fn A mapping function suitable for use with Array.map
 * @return {Object}
 */
function mapObject(object, fn) {
    return Object.entries(object)
        .map(fn)
        .reduce((obj, [k, v]) => Object.assign(obj, {[[k]]: v}), {});
}

/*
    Attempt to interpret and serialize the following cases for display:

    A: Rails errors:
        1. { "name": ["Requires name"] }

    B: Validation errors:
        1. { :email => ["must be filled"] }
        2. { :fees => 0 => { :fee => ["must be filled", "must be greater than zero"] } }

    C: Custom errors and client-side exceptions:
        1. { :email => "must be filled" }
        2. { exception: 'Something bad happened' }

    D: Logical errors:
        1. "undefined method `map' for nil:NilClass"
 */

export class VErrors {
    constructor(root, target) {
        this.root = root;
        this.target = target;
    }

    clearErrors() {
        const errorMessages = this.root.querySelectorAll('.v-error-message');

        for (let i = 0; i < errorMessages.length; i++) {
            errorMessages[i].remove();
        }
    }

    /**
     * normalize attempts to convert the various error structures described
     * above into a single consistent structure by replacing error arrays
     * with joined strings.
     * @param {Object} errors
     * @return {Object}
     */
    normalize(errors) {
        if (!errors) {
            return {};
        }

        // Normalize case D into case C-1:
        if (typeof errors === 'string') {
            errors = {error: errors};
        }

        return mapObject(errors, ([k, v]) => {
            let result = null;

            // Case C, a single key-value pair:
            if (typeof v === 'string') {
                // Normalize case C into case A/B-1:
                v = [v];
            }

            if (Array.isArray(v)) {
                // Case A and B-1: an array of error messages:
                result = v.join(', ');
            } else if (v.constructor === Object) {
                // Case B-2: a nested structure:
                result = this.normalize(v);
            } else {
                throw new Error(`Cannot normalize value of type ${typeof v}`);
            }

            return [k, result];
        });
    }

    /**
     * flatten attempts to extract all human-readable error messages from an
     * arbitrary error structure, yielding a flat array of strings.
     * @param {Object} errors
     * @return {Array<String>}
     */
    flatten(errors) {
        if (!errors) {
            return [];
        }

        // Normalize case D into case C-1:
        if (typeof errors === 'string') {
            errors = {error: errors};
        }

        const object = mapObject(errors, ([k, v]) => {
            let result = null;

            if (typeof v === 'string') {
                result = v;
            }
            else if (v.constructor === Object) {
                result = this.flatten(v);
            }
            else {
                throw new Error(`Cannot flatten value of type ${typeof v}`);
            }

            return [k, result];
        });

        return Object.values(object).flat();
    }

    displayErrors(result) {
        const {statusCode, contentType, content} = result;

        let responseErrors = null;

        if (contentType && contentType.includes('application/json')) {
            responseErrors = JSON.parse(content);
        }
        else if (contentType && contentType.includes('v/errors')) {
            responseErrors = content;
        }

        if (responseErrors) {
            if (!Array.isArray(responseErrors)) {
                responseErrors = [responseErrors];
            }

            for (const response of responseErrors) {
                const normalizedResponse = this.normalize(response);
                const errors = normalizedResponse.errors ? normalizedResponse.errors : normalizedResponse;
                if (errors.constructor === String) {
                    this.prependErrors([errors]);
                }
                else {
                    for (const key in errors) {
                        if (!this.displayInputError(key, errors[key])) {
                            // If not handled at the field level, display at the page level
                            if (errors[key].length > 0) {
                                this.prependErrors([errors[key]]);
                            }
                        }
                    }
                }
            }
        }
        else if (statusCode === 0) {
            this.prependErrors(
                ['Unable to contact server. Please check that you are online and retry.']
            );
        }
        else {
            this.prependErrors(
                [`The server returned an unexpected response! Status: ${statusCode}`]
            );
        }
    }

    // Sets the helper text on the field
    // Returns true if it was able to set the error on the control
    displayInputError(id, message) {
        const currentEl = this.root.getElementById(id) || this.root.getElementsByName(id)[0];

        if (!currentEl) {
            return false;
        }

        const helperText = this.root.getElementById(`${currentEl.id}-helper-text`);
        if (!helperText) {
            return false;
        }

        helperText.innerHTML = message;
        currentEl.classList.add('mdc-text-field--invalid');
        helperText.classList.add('mdc-text-field-helper-text--validation-msg');
        helperText.classList.remove('v-hidden');

        return true;
    }

    // Creates a div before the element with the same id as the error
    // Used to display an error message without their being an input field to
    // attach the error to
    prependErrors(messages) {
        const errorsDiv = this.findNearestErrorDiv();

        if (!errorsDiv) {
            console.error('Unable to display Errors! ', messages);

            return false;
        }

        const newDiv = document.createElement('div');

        newDiv.classList.add('v-error-message');
        newDiv.insertAdjacentHTML('beforeend', messages.join('<br>'));

        // add the newly created element and its content into the DOM
        if (errorsDiv.clientTop < 10) {
            errorsDiv.scrollIntoView();
        }

        errorsDiv.insertAdjacentElement('beforebegin', newDiv);

        return true;
    }

    findNearestErrorDiv() {
        if (this.target) {
            return this.target.closest('.v-errors');
        }

        return this.root.querySelector('.v-errors');
    }
}