views/mdc/assets/js/components/events/errors.js in voom-presenters-0.2.0 vs views/mdc/assets/js/components/events/errors.js in voom-presenters-2.0.0

- old
+ new

@@ -1,142 +1,225 @@ +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(event) { - this.event = event; + constructor(root, target) { + this.root = root; + this.target = target; } clearErrors() { - var errorMessages = document.querySelectorAll('.v-error-message'); + const errorMessages = this.root.querySelectorAll('.v-error-message'); - for (var i = 0; i < errorMessages.length; i++) { + for (let i = 0; i < errorMessages.length; i++) { errorMessages[i].remove(); } } - // Rails errors - // { - // "name": [ - // "Requires name" - // ] - // } + /** + * 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 {}; + } - // Validation errors - // { :email => ["must be filled"] } + // Normalize case D into case C-1: + if (typeof errors === 'string') { + errors = {error: errors}; + } - // Custom errors - // { :email => "must be filled" } + return mapObject(errors, ([k, v]) => { + let result = null; - // Exceptions - // {exception: 'Something bad happened' } + // Case C, a single key-value pair: + if (typeof v === 'string') { + // Normalize case C into case A/B-1: + v = [v]; + } - stringsToArrays(value) { - if (Array.isArray(value) || value.constructor === Object) { - return value; - } - return new Array(value); + 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]; + }); } - normalizeErrors(errors) { - if (errors && errors.constructor === Object) { - return Object.keys(errors).reduce((previous, key) => { - previous[key] = this.stringsToArrays(errors[key]); - return previous; - }, {}); + /** + * 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 []; } - 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(); } - // [http_status, content_type, resultText] displayErrors(result) { - var httpStatus = result.statusCode; - var contentType = result.contentType; - var resultText = result.content; + const {statusCode, contentType, content} = result; - var responseErrors = null; + let responseErrors = null; - if (contentType && contentType.indexOf("application/json") !== -1) { - responseErrors = JSON.parse(resultText); - } else if (contentType && contentType.indexOf("v/errors") !== -1) { - responseErrors = resultText; + 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 (var response of responseErrors) { - var pageErrors = Object.values(this.normalizeErrors(response)).reduce(function (previous, value) { - if (Array.isArray(value) && value.length > 0) { - previous.push(value.join('<br/>')); - } - return previous; - }, []); - var fieldErrors = this.normalizeErrors(response.errors); - for (var field in fieldErrors) { - if (!this.displayInputError(field, fieldErrors[field])) { - // Collect errors that can't be displayed at the field level - pageErrors.push(fieldErrors[field]); + 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]]); + } + } } } - this.prependErrors(pageErrors); } - } else if (httpStatus === 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:" + httpStatus]); } + 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(divId, messages) { - var currentEl = document.getElementById(divId); - if (currentEl && currentEl.mdcComponent) { - currentEl.mdcComponent.helperTextContent = messages.join(', '); - var helperText = document.getElementById(divId + '-input-helper-text'); - helperText.classList.add('mdc-text-field--invalid', - 'mdc-text-field-helper-text--validation-msg', - 'mdc-text-field-helper-text--persistent'); - currentEl.mdcComponent.valid = false; - return true; + displayInputError(id, message) { + const currentEl = this.root.getElementById(id) || this.root.getElementsByName(id)[0]; + + if (!currentEl) { + return false; } - 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 + // Used to display an error message without their being an input field to + // attach the error to prependErrors(messages) { - var errorsDiv = this.findNearestErrorDiv(); - // create a new div element - var newDiv = document.createElement("div"); - newDiv.className = 'v-error-message'; - // and give it some content + const errorsDiv = this.findNearestErrorDiv(); - for (var message of messages) { - var newContent = document.createTextNode(message); - newDiv.appendChild(newContent); - let br = document.createElement('br'); - // add the text node to the newly created div - newDiv.appendChild(br); + 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) { - errorsDiv.parentElement.insertBefore(newDiv, errorsDiv); - return true; - } else { - console.error("Unable to display Errors! ", messages); + if (errorsDiv.clientTop < 10) { + errorsDiv.scrollIntoView(); } - return false; + + errorsDiv.insertAdjacentElement('beforebegin', newDiv); + + return true; } findNearestErrorDiv() { - let errorsDiv = null; - const currentDiv = this.event.target; - if(currentDiv) { - errorsDiv = currentDiv.closest('.v-errors') - }else{ - errorsDiv = document.querySelector('.v-errors'); + if (this.target) { + return this.target.closest('.v-errors'); } - return errorsDiv; + + return this.root.querySelector('.v-errors'); } }