summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/directives/validation.js
blob: fc0ff78e7b4a5b9e6a2fc574163ee063c00dc663 (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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import { __ } 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: __('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 = (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.
 * 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<string, { message: string, isValid: ?function}>} customFeedbackMap
 * @returns {{ inserted: function, update: function }} validateDirective
 */
export default function initValidation(customFeedbackMap = {}) {
  const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap };
  const elDataMap = new WeakMap();

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

      validate({ el, 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,
  };
};