annotate core/misc/form.es6.js @ 19:fa3358dc1485 tip

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