summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/directives/validation.js
blob: ece09df272c0f4eff3c5d2142e8a6d1f23ff1115 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { merge } from 'lodash';
import { s__ } from '~/locale';

/**
 * 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: s__('Please fill out this field.'),
  },
  urlTypeMismatch: {
    isInvalid: (el) => el.type === 'url' && el.validity?.typeMismatch,
    message: s__('Please enter a valid URL format, ex: http://www.example.com/home'),
  },
};

const getFeedbackForElement = (feedbackMap, el) =>
  Object.values(feedbackMap).find((f) => f.isInvalid(el))?.message || el.validationMessage;

const focusFirstInvalidInput = (e) => {
  const { target: formEl } = e;
  const invalidInput = formEl.querySelector('input:invalid');

  if (invalidInput) {
    invalidInput.focus();
  }
};

const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true);

const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
  const { form } = context;
  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) : '';

  form.state = isEveryFieldValid(form);
};

/**
 * Takes an object that allows to add or change custom feedback messages.
 *
 * 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: {
 *     check: el => el.validity.tooLong === true,
 *     message: 'Your custom feedback'
 *   }
 * })
 *
 * @example
 *   validate({
 *     valueMissing: {
 *       message: 'Your custom feedback'
 *     }
 *   })
 *
 * @param {Object<string, { message: string, isValid: ?function}>} customFeedbackMap
 * @returns {{ inserted: function, update: function }} validateDirective
 */
export default function (customFeedbackMap = {}) {
  const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
  const elDataMap = new WeakMap();

  return {
    inserted(el, binding, { context }) {
      const { arg: showGlobalValidation } = binding;
      const { form: formEl } = el;

      const validate = createValidator(context, 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, 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, reportInvalidInput: showGlobalValidation });
    },
    update(el, binding) {
      const { arg: showGlobalValidation } = binding;
      const { validate, isTouched, isBlurred } = elDataMap.get(el);
      const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);

      validate({ el, reportInvalidInput: showValidationFeedback });
    },
  };
}