import { __ } from '~/locale'; import { getInstanceFromDirective } from '~/lib/utils/vue3compat/get_instance_from_directive'; /** * Validation messages will take priority based on the property order. * * For example: * { valueMissing: {...}, urlTypeMismatch: {...} } * * `valueMissing` will be displayed the user has entered a value * after that, if the input is not a valid URL then `urlTypeMismatch` will show */ const defaultFeedbackMap = { valueMissing: { isInvalid: (el) => el.validity?.valueMissing, message: __('Please fill out this field.'), }, urlTypeMismatch: { isInvalid: (el) => el.type === 'url' && el.validity?.typeMismatch, message: __('Please enter a valid URL format, ex: http://www.example.com/home'), }, }; const getFeedbackForElement = (feedbackMap, el) => { const field = Object.values(feedbackMap).find((f) => f.isInvalid(el)); let elMessage = null; if (field) { elMessage = el.getAttribute('validation-message'); } return field?.message || elMessage || el.validationMessage; }; const focusFirstInvalidInput = (e) => { const { target: formEl } = e; const invalidInput = formEl.querySelector('input:invalid'); if (invalidInput) { invalidInput.focus(); } }; const getInputElement = (el) => { return el.querySelector('input') || el; }; const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true); const createValidator = (feedbackMap) => ({ el, form, reportInvalidInput = false }) => { const { name } = el; if (!name) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn( '[gitlab] the validation directive requires the given input to have "name" attribute', ); } return; } const formField = form.fields[name]; const isValid = el.checkValidity(); // This makes sure we always report valid fields - this can be useful for cases where the consuming // component's logic depends on certain fields being in a valid state. // Invalid input, on the other hand, should only be reported once we want to display feedback to the user. // (eg.: After a field has been touched and moved away from, a submit-button has been clicked, ...) formField.state = reportInvalidInput ? isValid : isValid || null; formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : ''; // eslint-disable-next-line no-param-reassign form.state = isEveryFieldValid(form); }; /** * Takes an object that allows to add or change custom feedback messages. * See possibilities here: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState * * The passed in object will be merged with the built-in feedback * so it is possible to override a built-in message. * * @example * validate({ * tooLong: { * isInvalid: el => el.validity.tooLong === true, * message: 'Your custom feedback' * } * }) * * @example * validate({ * valueMissing: { * message: 'Your custom feedback' * } * }) * * @param {Object} customFeedbackMap * @returns {{ inserted: function, update: function }} validateDirective */ export default function initValidation(customFeedbackMap = {}) { const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap }; const elDataMap = new WeakMap(); return { inserted(element, binding, vnode) { const { arg: showGlobalValidation } = binding; const el = getInputElement(element); const { form: formEl } = el; const instance = getInstanceFromDirective({ binding, vnode }); const validate = createValidator(feedbackMap); const elData = { validate, isTouched: false, isBlurred: false }; elDataMap.set(el, elData); el.addEventListener('input', function markAsTouched() { elData.isTouched = true; // once the element has been marked as touched we can stop listening on the 'input' event el.removeEventListener('input', markAsTouched); }); el.addEventListener('blur', function markAsBlurred({ target }) { if (elData.isTouched) { elData.isBlurred = true; validate({ el: target, form: instance.form, reportInvalidInput: true }); // this event handler can be removed, since the live-feedback in `update` takes over el.removeEventListener('blur', markAsBlurred); } }); if (formEl) { formEl.addEventListener('submit', focusFirstInvalidInput); } validate({ el, form: instance.form, reportInvalidInput: showGlobalValidation }); }, update(element, binding, vnode) { const el = getInputElement(element); const { arg: showGlobalValidation } = binding; const { validate, isTouched, isBlurred } = elDataMap.get(el); const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred); const instance = getInstanceFromDirective({ binding, vnode }); validate({ el, form: instance.form, reportInvalidInput: showValidationFeedback }); }, }; } /** * This is a helper that initialize the form fields structure to be used in initForm * @param {*} fieldValues * @returns formObject */ const initFormField = ({ value, required = true, skipValidation = false }) => ({ value, required, state: skipValidation ? true : null, feedback: null, }); /** * This is a helper that initialize the form structure that is compliant to be used with the validation directive * * @example * const form initForm = initForm({ * fields: { * name: { * value: 'lorem' * }, * description: { * value: 'ipsum', * required: false, * skipValidation: true * } * } * }) * * @example * const form initForm = initForm({ * state: true, // to override * foo: { // something custom * bar: 'lorem' * }, * fields: {...} * }) * * @param {*} formObject * @returns form */ export const initForm = ({ fields = {}, ...rest } = {}) => { const initFields = Object.fromEntries( Object.entries(fields).map(([fieldName, fieldValues]) => { return [fieldName, initFormField(fieldValues)]; }), ); return { state: false, showValidation: false, ...rest, fields: initFields, }; };