06a53b2c759d7af48cd30ce4a673c6ea269c4c35
[yaffs-website] / web / core / misc / form.es6.js
1 /**
2  * @file
3  * Form features.
4  */
5
6 /**
7  * Triggers when a value in the form changed.
8  *
9  * The event triggers when content is typed or pasted in a text field, before
10  * the change event triggers.
11  *
12  * @event formUpdated
13  */
14
15 /**
16  * Triggers when a click on a page fragment link or hash change is detected.
17  *
18  * The event triggers when the fragment in the URL changes (a hash change) and
19  * when a link containing a fragment identifier is clicked. In case the hash
20  * changes due to a click this event will only be triggered once.
21  *
22  * @event formFragmentLinkClickOrHashChange
23  */
24
25 (function($, Drupal, debounce) {
26   /**
27    * Retrieves the summary for the first element.
28    *
29    * @return {string}
30    *   The text of the summary.
31    */
32   $.fn.drupalGetSummary = function() {
33     const callback = this.data('summaryCallback');
34     return this[0] && callback ? $.trim(callback(this[0])) : '';
35   };
36
37   /**
38    * Sets the summary for all matched elements.
39    *
40    * @param {function} callback
41    *   Either a function that will be called each time the summary is
42    *   retrieved or a string (which is returned each time).
43    *
44    * @return {jQuery}
45    *   jQuery collection of the current element.
46    *
47    * @fires event:summaryUpdated
48    *
49    * @listens event:formUpdated
50    */
51   $.fn.drupalSetSummary = function(callback) {
52     const self = this;
53
54     // To facilitate things, the callback should always be a function. If it's
55     // not, we wrap it into an anonymous function which just returns the value.
56     if (typeof callback !== 'function') {
57       const val = callback;
58       callback = function() {
59         return val;
60       };
61     }
62
63     return (
64       this.data('summaryCallback', callback)
65         // To prevent duplicate events, the handlers are first removed and then
66         // (re-)added.
67         .off('formUpdated.summary')
68         .on('formUpdated.summary', () => {
69           self.trigger('summaryUpdated');
70         })
71         // The actual summaryUpdated handler doesn't fire when the callback is
72         // changed, so we have to do this manually.
73         .trigger('summaryUpdated')
74     );
75   };
76
77   /**
78    * Prevents consecutive form submissions of identical form values.
79    *
80    * Repetitive form submissions that would submit the identical form values
81    * are prevented, unless the form values are different to the previously
82    * submitted values.
83    *
84    * This is a simplified re-implementation of a user-agent behavior that
85    * should be natively supported by major web browsers, but at this time, only
86    * Firefox has a built-in protection.
87    *
88    * A form value-based approach ensures that the constraint is triggered for
89    * consecutive, identical form submissions only. Compared to that, a form
90    * button-based approach would (1) rely on [visible] buttons to exist where
91    * technically not required and (2) require more complex state management if
92    * there are multiple buttons in a form.
93    *
94    * This implementation is based on form-level submit events only and relies
95    * on jQuery's serialize() method to determine submitted form values. As such,
96    * the following limitations exist:
97    *
98    * - Event handlers on form buttons that preventDefault() do not receive a
99    *   double-submit protection. That is deemed to be fine, since such button
100    *   events typically trigger reversible client-side or server-side
101    *   operations that are local to the context of a form only.
102    * - Changed values in advanced form controls, such as file inputs, are not
103    *   part of the form values being compared between consecutive form submits
104    *   (due to limitations of jQuery.serialize()). That is deemed to be
105    *   acceptable, because if the user forgot to attach a file, then the size of
106    *   HTTP payload will most likely be small enough to be fully passed to the
107    *   server endpoint within (milli)seconds. If a user mistakenly attached a
108    *   wrong file and is technically versed enough to cancel the form submission
109    *   (and HTTP payload) in order to attach a different file, then that
110    *   edge-case is not supported here.
111    *
112    * Lastly, all forms submitted via HTTP GET are idempotent by definition of
113    * HTTP standards, so excluded in this implementation.
114    *
115    * @type {Drupal~behavior}
116    */
117   Drupal.behaviors.formSingleSubmit = {
118     attach() {
119       function onFormSubmit(e) {
120         const $form = $(e.currentTarget);
121         const formValues = $form.serialize();
122         const previousValues = $form.attr('data-drupal-form-submit-last');
123         if (previousValues === formValues) {
124           e.preventDefault();
125         } else {
126           $form.attr('data-drupal-form-submit-last', formValues);
127         }
128       }
129
130       $('body')
131         .once('form-single-submit')
132         .on('submit.singleSubmit', 'form:not([method~="GET"])', onFormSubmit);
133     },
134   };
135
136   /**
137    * Sends a 'formUpdated' event each time a form element is modified.
138    *
139    * @param {HTMLElement} element
140    *   The element to trigger a form updated event on.
141    *
142    * @fires event:formUpdated
143    */
144   function triggerFormUpdated(element) {
145     $(element).trigger('formUpdated');
146   }
147
148   /**
149    * Collects the IDs of all form fields in the given form.
150    *
151    * @param {HTMLFormElement} form
152    *   The form element to search.
153    *
154    * @return {Array}
155    *   Array of IDs for form fields.
156    */
157   function fieldsList(form) {
158     const $fieldList = $(form)
159       .find('[name]')
160       .map(
161         // We use id to avoid name duplicates on radio fields and filter out
162         // elements with a name but no id.
163         (index, element) => element.getAttribute('id'),
164       );
165     // Return a true array.
166     return $.makeArray($fieldList);
167   }
168
169   /**
170    * Triggers the 'formUpdated' event on form elements when they are modified.
171    *
172    * @type {Drupal~behavior}
173    *
174    * @prop {Drupal~behaviorAttach} attach
175    *   Attaches formUpdated behaviors.
176    * @prop {Drupal~behaviorDetach} detach
177    *   Detaches formUpdated behaviors.
178    *
179    * @fires event:formUpdated
180    */
181   Drupal.behaviors.formUpdated = {
182     attach(context) {
183       const $context = $(context);
184       const contextIsForm = $context.is('form');
185       const $forms = (contextIsForm ? $context : $context.find('form')).once(
186         'form-updated',
187       );
188       let formFields;
189
190       if ($forms.length) {
191         // Initialize form behaviors, use $.makeArray to be able to use native
192         // forEach array method and have the callback parameters in the right
193         // order.
194         $.makeArray($forms).forEach(form => {
195           const events = 'change.formUpdated input.formUpdated ';
196           const eventHandler = debounce(event => {
197             triggerFormUpdated(event.target);
198           }, 300);
199           formFields = fieldsList(form).join(',');
200
201           form.setAttribute('data-drupal-form-fields', formFields);
202           $(form).on(events, eventHandler);
203         });
204       }
205       // On ajax requests context is the form element.
206       if (contextIsForm) {
207         formFields = fieldsList(context).join(',');
208         // @todo replace with form.getAttribute() when #1979468 is in.
209         const currentFields = $(context).attr('data-drupal-form-fields');
210         // If there has been a change in the fields or their order, trigger
211         // formUpdated.
212         if (formFields !== currentFields) {
213           triggerFormUpdated(context);
214         }
215       }
216     },
217     detach(context, settings, trigger) {
218       const $context = $(context);
219       const contextIsForm = $context.is('form');
220       if (trigger === 'unload') {
221         const $forms = (contextIsForm
222           ? $context
223           : $context.find('form')
224         ).removeOnce('form-updated');
225         if ($forms.length) {
226           $.makeArray($forms).forEach(form => {
227             form.removeAttribute('data-drupal-form-fields');
228             $(form).off('.formUpdated');
229           });
230         }
231       }
232     },
233   };
234
235   /**
236    * Prepopulate form fields with information from the visitor browser.
237    *
238    * @type {Drupal~behavior}
239    *
240    * @prop {Drupal~behaviorAttach} attach
241    *   Attaches the behavior for filling user info from browser.
242    */
243   Drupal.behaviors.fillUserInfoFromBrowser = {
244     attach(context, settings) {
245       const userInfo = ['name', 'mail', 'homepage'];
246       const $forms = $('[data-user-info-from-browser]').once(
247         'user-info-from-browser',
248       );
249       if ($forms.length) {
250         userInfo.forEach(info => {
251           const $element = $forms.find(`[name=${info}]`);
252           const browserData = localStorage.getItem(`Drupal.visitor.${info}`);
253           const emptyOrDefault =
254             $element.val() === '' ||
255             $element.attr('data-drupal-default-value') === $element.val();
256           if ($element.length && emptyOrDefault && browserData) {
257             $element.val(browserData);
258           }
259         });
260       }
261       $forms.on('submit', () => {
262         userInfo.forEach(info => {
263           const $element = $forms.find(`[name=${info}]`);
264           if ($element.length) {
265             localStorage.setItem(`Drupal.visitor.${info}`, $element.val());
266           }
267         });
268       });
269     },
270   };
271
272   /**
273    * Sends a fragment interaction event on a hash change or fragment link click.
274    *
275    * @param {jQuery.Event} e
276    *   The event triggered.
277    *
278    * @fires event:formFragmentLinkClickOrHashChange
279    */
280   const handleFragmentLinkClickOrHashChange = e => {
281     let url;
282     if (e.type === 'click') {
283       url = e.currentTarget.location
284         ? e.currentTarget.location
285         : e.currentTarget;
286     } else {
287       url = window.location;
288     }
289     const hash = url.hash.substr(1);
290     if (hash) {
291       const $target = $(`#${hash}`);
292       $('body').trigger('formFragmentLinkClickOrHashChange', [$target]);
293
294       /**
295        * Clicking a fragment link or a hash change should focus the target
296        * element, but event timing issues in multiple browsers require a timeout.
297        */
298       setTimeout(() => $target.trigger('focus'), 300);
299     }
300   };
301
302   const debouncedHandleFragmentLinkClickOrHashChange = debounce(
303     handleFragmentLinkClickOrHashChange,
304     300,
305     true,
306   );
307
308   // Binds a listener to handle URL fragment changes.
309   $(window).on(
310     'hashchange.form-fragment',
311     debouncedHandleFragmentLinkClickOrHashChange,
312   );
313
314   /**
315    * Binds a listener to handle clicks on fragment links and absolute URL links
316    * containing a fragment, this is needed next to the hash change listener
317    * because clicking such links doesn't trigger a hash change when the fragment
318    * is already in the URL.
319    */
320   $(document).on(
321     'click.form-fragment',
322     'a[href*="#"]',
323     debouncedHandleFragmentLinkClickOrHashChange,
324   );
325 })(jQuery, Drupal, Drupal.debounce);