Merge pull request #19927 from eileenmcnaughton/etfa
[civicrm-core.git] / js / Common.js
index e5754fcfe09bd07512ffbb12771af1bb10e2cf73..1db1f25885f7f037af559efa45f3b0a83e223579 100644 (file)
@@ -144,39 +144,7 @@ function showHideByValue(trigger_field_id, trigger_value, target_element_id, tar
   }
 }
 
-/**
- * Function to change button text and disable one it is clicked
- * @deprecated
- * @param obj object - the button clicked
- * @param formID string - the id of the form being submitted
- * @param string procText - button text after user clicks it
- * @return bool
- */
 var submitcount = 0;
-/* Changes button label on submit, and disables button after submit for newer browsers.
- Puts up alert for older browsers. */
-function submitOnce(obj, formId, procText) {
-  // if named button clicked, change text
-  if (obj.value != null) {
-    cj('input[name=' + obj.name + ']').val(procText + " ...");
-  }
-  cj(obj).closest('form').attr('data-warn-changes', 'false');
-  if (document.getElementById) { // disable submit button for newer browsers
-    cj('input[name=' + obj.name + ']').attr("disabled", true);
-    document.getElementById(formId).submit();
-    return true;
-  }
-  else { // for older browsers
-    if (submitcount == 0) {
-      submitcount++;
-      return true;
-    }
-    else {
-      alert("Your request is currently being processed ... Please wait.");
-      return false;
-    }
-  }
-}
 
 /**
  * Function to show / hide the row in optionFields
@@ -224,7 +192,7 @@ if (!CRM.vars) CRM.vars = {};
   $.propHooks.disabled = {
     set: function (el, value, name) {
       // Sync button enabled status with wrapper css
-      if ($(el).is('span.crm-button > input.crm-form-submit')) {
+      if ($(el).is('.crm-button.crm-form-submit')) {
         $(el).parent().toggleClass('crm-button-disabled', !!value);
       }
       // Sync button enabled status with dialog button
@@ -238,9 +206,13 @@ if (!CRM.vars) CRM.vars = {};
   };
 
   var scriptsLoaded = {};
-  CRM.loadScript = function(url) {
+  CRM.loadScript = function(url, appendCacheCode) {
     if (!scriptsLoaded[url]) {
-      var script = document.createElement('script');
+      var script = document.createElement('script'),
+        src = url;
+      if (appendCacheCode !== false) {
+        src += (_.includes(url, '?') ? '&r=' : '?r=') + CRM.config.resourceCacheCode;
+      }
       scriptsLoaded[url] = $.Deferred();
       script.onload = function () {
         // Give the script time to execute
@@ -256,7 +228,7 @@ if (!CRM.vars) CRM.vars = {};
         CRM.CMSjQuery = window.jQuery;
         window.jQuery = CRM.$;
       }
-      script.src = url;
+      script.src = src;
       document.getElementsByTagName("head")[0].appendChild(script);
     }
     return scriptsLoaded[url];
@@ -277,6 +249,11 @@ if (!CRM.vars) CRM.vars = {};
         opts = placeholder || placeholder === '' ? '' : '[value!=""]';
       $elect.find('option' + opts).remove();
       var newOptions = CRM.utils.renderOptions(options, val);
+      if (options.length == 0) {
+        $elect.removeClass('required');
+      } else if ($elect.hasClass('crm-field-required') && !$elect.hasClass('required')) {
+        $elect.addClass('required');
+      }
       if (typeof placeholder === 'string') {
         if ($elect.is('[multiple]')) {
           select.attr('placeholder', placeholder);
@@ -317,6 +294,17 @@ if (!CRM.vars) CRM.vars = {};
     return rendered;
   };
 
+  CRM.utils.getOptions = function(select) {
+    var options = [];
+    $('option', select).each(function() {
+      var option = {key: $(this).attr('value'), value: $(this).text()};
+      if (option.key !== '') {
+        options.push(option);
+      }
+    });
+    return options;
+  };
+
   function chainSelect() {
     var $form = $(this).closest('form'),
       $target = $('select[data-name="' + $(this).data('target') + '"]', $form),
@@ -389,7 +377,7 @@ if (!CRM.vars) CRM.vars = {};
       description = row.description || $(row.element).data('description'),
       ret = '';
     if (icon) {
-      ret += '<i class="crm-i ' + icon + '"></i> ';
+      ret += '<i class="crm-i ' + icon + '" aria-hidden="true"></i> ';
     }
     if (color) {
       ret += '<span class="crm-select-item-color" style="background-color: ' + color + '"></span> ';
@@ -397,6 +385,35 @@ if (!CRM.vars) CRM.vars = {};
     return ret + _.escape(row.text) + (description ? '<div class="crm-select2-row-description"><p>' + _.escape(description) + '</p></div>' : '');
   }
 
+  /**
+   * Helper to generate an icon with alt text.
+   *
+   * See also smarty `{icon}` and CRM_Core_Page::crmIcon() functions
+   *
+   * @param string icon
+   *   The Font Awesome icon class to use.
+   * @param string text
+   *   Alt text to display.
+   * @param mixed condition
+   *   This will only display if this is truthy.
+   *
+   * @return string
+   *   The formatted icon markup.
+   */
+  CRM.utils.formatIcon = function (icon, text, condition) {
+    if (typeof condition !== 'undefined' && !condition) {
+      return '';
+    }
+    var title = '';
+    var sr = '';
+    if (text) {
+      text = _.escape(text);
+      title = ' title="' + text + '"';
+      sr = '<span class="sr-only">' + text + '</span>';
+    }
+    return '<i class="crm-i ' + icon + '"' + title + ' aria-hidden="true"></i>' + sr;
+  };
+
   /**
    * Wrapper for select2 initialization function; supplies defaults
    * @param options object
@@ -406,6 +423,7 @@ if (!CRM.vars) CRM.vars = {};
       return $(this).each(function() {
         $(this)
           .removeClass('crm-ajax-select')
+          .off('.crmSelect2')
           .select2('destroy');
       });
     }
@@ -420,6 +438,8 @@ if (!CRM.vars) CRM.vars = {};
         };
 
       // quickform doesn't support optgroups so here's a hack :(
+      // Instead of using wrapAll or similar that repeatedly appends options to the group and redraw the page (=> very slow on large lists),
+      // build bulk HTML and insert in single shot
       var optGroups = {};
       $('option[value^=crm_optgroup]', this).each(function () {
         var groupHtml = '';
@@ -448,12 +468,19 @@ if (!CRM.vars) CRM.vars = {};
             placeholder = settings.placeholder || $el.data('placeholder') || $el.attr('placeholder') || $('option[value=""]', $el).text();
           if (m.length && placeholder === m) {
             iconClass = $el.attr('class').match(/(fa-\S*)/)[1];
-            out = '<i class="crm-i ' + iconClass + '"></i> ' + out;
+            out = '<i class="crm-i ' + iconClass + '" aria-hidden="true"></i> ' + out;
           }
           return out;
         };
       }
 
+      // Use description as title for each option
+      $el.on('select2-loaded.crmSelect2', function() {
+        $('.crm-select2-row-description', '#select2-drop').each(function() {
+          $(this).closest('.select2-result-label').attr('title', $(this).text());
+        });
+      });
+
       // Defaults for single-selects
       if ($el.is('select:not([multiple])')) {
         settings.minimumResultsForSearch = 10;
@@ -479,7 +506,7 @@ if (!CRM.vars) CRM.vars = {};
         var entity = $(this).data('api-entity') || '';
         $(this)
           .off('.crmEntity')
-          .removeClass('crm-form-entityref crm-' + entity.toLowerCase() + '-ref')
+          .removeClass('crm-form-entityref crm-' + _.kebabCase(entity) + '-ref')
           .crmSelect2('destroy');
       });
     }
@@ -488,13 +515,45 @@ if (!CRM.vars) CRM.vars = {};
     return $(this).each(function() {
       var
         $el = $(this).off('.crmEntity'),
-        entity = options.entity || $el.data('api-entity') || 'contact',
-        selectParams = {};
+        entity = options.entity || $el.data('api-entity') || 'Contact',
+        selectParams = {},
+        staticPresets = {
+          user_contact_id: {
+            id: 'user_contact_id',
+            label: ts('Select Current User'),
+            icon: 'fa-user-circle-o'
+          }
+        };
+      // Legacy: fix entity name if passed in as snake case
+      if (entity.charAt(0).toUpperCase() !== entity.charAt(0)) {
+        entity = _.capitalize(_.camelCase(entity));
+      }
       $el.data('api-entity', entity);
       $el.data('select-params', $.extend({}, $el.data('select-params') || {}, options.select));
       $el.data('api-params', $.extend(true, {}, $el.data('api-params') || {}, options.api));
       $el.data('create-links', options.create || $el.data('create-links'));
-      $el.addClass('crm-form-entityref crm-' + entity.toLowerCase() + '-ref');
+      var staticItems = options.static || $el.data('static') || [];
+      _.each(staticItems, function(option, i) {
+        if (_.isString(option)) {
+          staticItems[i] = staticPresets[option];
+        }
+      });
+
+      function staticItemMarkup() {
+        if (!staticItems.length) {
+          return '';
+        }
+        var markup = '<div class="crm-entityref-links crm-entityref-links-static">';
+        _.each(staticItems, function(link) {
+          markup += ' <a class="crm-hover-button" href="#' + link.id + '">' +
+            '<i class="crm-i ' + link.icon + '" aria-hidden="true"></i> ' +
+            _.escape(link.label) + '</a>';
+        });
+        markup += '</div>';
+        return markup;
+      }
+
+      $el.addClass('crm-form-entityref crm-' + _.kebabCase(entity) + '-ref');
       var settings = {
         // Use select2 ajax helper instead of CRM.api3 because it provides more value
         ajax: {
@@ -522,17 +581,21 @@ if (!CRM.vars) CRM.vars = {};
           var
             multiple = !!$el.data('select-params').multiple,
             val = $el.val(),
-            stored = $el.data('entity-value') || [];
+            stored = ($el.data('entity-value') || []).concat(staticItems);
           if (val === '') {
             return;
           }
+          var idsNeeded = _.difference(val.split(','), _.pluck(stored, 'id'));
+          var existing = _.remove(stored, function(item) {
+            return _.includes(val.split(','), item.id);
+          });
           // If we already have this data, just return it
-          if (!_.xor(val.split(','), _.pluck(stored, 'id')).length) {
-            callback(multiple ? stored : stored[0]);
+          if (!idsNeeded.length) {
+            callback(multiple ? existing : existing[0]);
           } else {
-            var params = $.extend({}, $el.data('api-params') || {}, {id: val});
+            var params = $.extend({}, $el.data('api-params') || {}, {id: idsNeeded.join(',')});
             CRM.api3($el.data('api-entity'), 'getlist', params).done(function(result) {
-              callback(multiple ? result.values : result.values[0]);
+              callback(multiple ? result.values.concat(existing) : result.values[0]);
               // Trigger change (store data to avoid an infinite loop of lookups)
               $el.data('entity-value', result.values).trigger('change');
             });
@@ -540,7 +603,7 @@ if (!CRM.vars) CRM.vars = {};
         }
       };
       // Create new items inline - works for tags
-      if ($el.data('create-links') && entity.toLowerCase() === 'tag') {
+      if ($el.data('create-links') && entity === 'Tag') {
         selectParams.createSearchChoice = function(term, data) {
           if (!_.findKey(data, {label: term})) {
             return {id: "0", term: term, label: term + ' (' + ts('new tag') + ')'};
@@ -573,7 +636,7 @@ if (!CRM.vars) CRM.vars = {};
       else {
         selectParams.formatInputTooShort = function() {
           var txt = $el.data('select-params').formatInputTooShort || $.fn.select2.defaults.formatInputTooShort.call(this);
-          txt += entityRefFiltersMarkup($el) + renderEntityRefCreateLinks($el);
+          txt += entityRefFiltersMarkup($el) + staticItemMarkup() + renderEntityRefCreateLinks($el);
           return txt;
         };
         selectParams.formatNoMatches = function() {
@@ -583,7 +646,6 @@ if (!CRM.vars) CRM.vars = {};
         };
         $el.on('select2-open.crmEntity', function() {
           var $el = $(this);
-          renderEntityRefFilterValue($el);
           $('#select2-drop')
             .off('.crmEntity')
             .on('click.crmEntity', 'a.crm-add-entity', function(e) {
@@ -591,11 +653,13 @@ if (!CRM.vars) CRM.vars = {};
                 formUrl = $(this).attr('href') + '&returnExtra=display_name,sort_name' + (extra ? (',' + extra) : '');
               $el.select2('close');
               CRM.loadForm(formUrl, {
-                dialog: {width: 500, height: 220}
+                dialog: {width: '50%', height: 220}
               }).on('crmFormSuccess', function(e, data) {
                 if (data.status === 'success' && data.id) {
-                  data.label = data.extra.sort_name;
-                  CRM.status(ts('%1 Created', {1: data.extra.display_name}));
+                  if (!data.crmMessages) {
+                    CRM.status(ts('%1 Created', {1: data.label || data.extra.display_name}));
+                  }
+                  data.label = data.label || data.extra.sort_name;
                   if ($el.select2('container').hasClass('select2-container-multi')) {
                     var selection = $el.select2('data');
                     selection.push(data);
@@ -607,17 +671,32 @@ if (!CRM.vars) CRM.vars = {};
               });
               return false;
             })
+            .on('click.crmEntity', '.crm-entityref-links-static a', function(e) {
+              var id = $(this).attr('href').substr(1),
+                item = _.findWhere(staticItems, {id: id});
+              $el.select2('close');
+              if ($el.select2('container').hasClass('select2-container-multi')) {
+                var selection = $el.select2('data');
+                if (!_.findWhere(selection, {id: id})) {
+                  selection.push(item);
+                  $el.select2('data', selection, true);
+                }
+              } else {
+                $el.select2('data', item, true);
+              }
+              return false;
+            })
             .on('change.crmEntity', '.crm-entityref-filter-value', function() {
               var filter = $el.data('user-filter') || {};
               filter.value = $(this).val();
               $(this).toggleClass('active', !!filter.value);
               $el.data('user-filter', filter);
-              if (filter.value) {
+              if (filter.value && $(this).is('select')) {
                 // Once a filter has been chosen, rerender create links and refocus the search box
                 $el.select2('close');
                 $el.select2('open');
               } else {
-                $('.crm-entityref-links', '#select2-drop').replaceWith(renderEntityRefCreateLinks($el));
+                $('.crm-entityref-links-create', '#select2-drop').replaceWith(renderEntityRefCreateLinks($el));
               }
             })
             .on('change.crmEntity', 'select.crm-entityref-filter-key', function() {
@@ -659,153 +738,12 @@ if (!CRM.vars) CRM.vars = {};
     return combined;
   }
 
-  function copyAttributes($source, $target, attributes) {
+  CRM.utils.copyAttributes = function ($source, $target, attributes) {
     _.each(attributes, function(name) {
       if ($source.attr(name) !== undefined) {
         $target.attr(name, $source.attr(name));
       }
     });
-  }
-
-  /**
-   * @see http://wiki.civicrm.org/confluence/display/CRMDOC/crmDatepicker
-   */
-  $.fn.crmDatepicker = function(options) {
-    return $(this).each(function() {
-      if ($(this).is('.crm-form-date-wrapper .crm-hidden-date')) {
-        // Already initialized - destroy
-        $(this)
-          .off('.crmDatepicker')
-          .css('display', '')
-          .removeClass('crm-hidden-date')
-          .siblings().remove();
-        $(this).unwrap();
-      }
-      if (options === 'destroy') {
-        return;
-      }
-      var
-        $dataField = $(this).wrap('<span class="crm-form-date-wrapper" />'),
-        settings = _.cloneDeep(options || {}),
-        $dateField = $(),
-        $timeField = $(),
-        $clearLink = $(),
-        hasDatepicker = settings.date !== false && settings.date !== 'yy',
-        type = hasDatepicker ? 'text' : 'number';
-
-      if (settings.allowClear !== undefined ? settings.allowClear : !$dataField.is('.required, [required]')) {
-        $clearLink = $('<a class="crm-hover-button crm-clear-link" title="'+ _.escape(ts('Clear')) +'"><i class="crm-i fa-times"></i></a>')
-          .insertAfter($dataField);
-      }
-      if (settings.time !== false) {
-        $timeField = $('<input>').insertAfter($dataField);
-        copyAttributes($dataField, $timeField, ['class', 'disabled']);
-        $timeField
-          .addClass('crm-form-text crm-form-time')
-          .attr('placeholder', $dataField.attr('time-placeholder') === undefined ? ts('Time') : $dataField.attr('time-placeholder'))
-          .attr('aria-label', $dataField.attr('time-placeholder') === undefined ? ts('Time') : $dataField.attr('time-placeholder'))
-          .change(updateDataField)
-          .timeEntry({
-            spinnerImage: '',
-            show24Hours: settings.time === true || settings.time === undefined ? CRM.config.timeIs24Hr : settings.time == '24'
-          });
-      }
-      if (settings.date !== false) {
-        // Render "number" field for year-only format, calendar popup for all other formats
-        $dateField = $('<input type="' + type + '">').insertAfter($dataField);
-        copyAttributes($dataField, $dateField, ['placeholder', 'style', 'class', 'disabled', 'aria-label']);
-        $dateField.addClass('crm-form-' + type);
-        if (hasDatepicker) {
-          settings.minDate = settings.minDate ? CRM.utils.makeDate(settings.minDate) : null;
-          settings.maxDate = settings.maxDate ? CRM.utils.makeDate(settings.maxDate) : null;
-          settings.dateFormat = typeof settings.date === 'string' ? settings.date : CRM.config.dateInputFormat;
-          settings.changeMonth = _.includes(settings.dateFormat, 'm');
-          settings.changeYear = _.includes(settings.dateFormat, 'y');
-          if (!settings.yearRange && settings.minDate !== null && settings.maxDate !== null) {
-            settings.yearRange = '' + CRM.utils.formatDate(settings.minDate, 'yy') + ':' + CRM.utils.formatDate(settings.maxDate, 'yy');
-          }
-          $dateField.addClass('crm-form-date').datepicker(settings);
-        } else {
-          $dateField.attr('min', settings.minDate ? CRM.utils.formatDate(settings.minDate, 'yy') : '1000');
-          $dateField.attr('max', settings.maxDate ? CRM.utils.formatDate(settings.maxDate, 'yy') : '4000');
-        }
-        $dateField.change(updateDataField);
-      }
-      // Rudimentary validation. TODO: Roll into use of jQUery validate and ui.datepicker.validation
-      function isValidDate() {
-        // FIXME: parseDate doesn't work with incomplete date formats; skip validation if no month, day or year in format
-        var lowerFormat = settings.dateFormat.toLowerCase();
-        if (lowerFormat.indexOf('y') < 0 || lowerFormat.indexOf('m') < 0 || !dateHasDay()) {
-          return true;
-        }
-        try {
-          $.datepicker.parseDate(settings.dateFormat, $dateField.val());
-          return true;
-        } catch (e) {
-          return false;
-        }
-      }
-
-      /**
-       * Does the date format contain the day.
-       *
-       * @returns {boolean}
-       */
-      function dateHasDay() {
-        var lowerFormat = settings.dateFormat.toLowerCase();
-        if (lowerFormat.indexOf('d') < 0) {
-          return false;
-        }
-        return true;
-      }
-      function updateInputFields(e, context) {
-        var val = $dataField.val(),
-          time = null;
-        if (context !== 'userInput' && context !== 'crmClear') {
-          if (hasDatepicker) {
-            $dateField.datepicker('setDate', _.includes(val, '-') ? $.datepicker.parseDate('yy-mm-dd', val) : null);
-          } else if ($dateField.length) {
-            $dateField.val(val.slice(0, 4));
-          }
-          if ($timeField.length) {
-            if (val.length === 8) {
-              time = val;
-            } else if (val.length === 19) {
-              time = val.split(' ')[1];
-            }
-            $timeField.timeEntry('setTime', time);
-          }
-        }
-        $clearLink.css('visibility', val ? 'visible' : 'hidden');
-      }
-      function updateDataField(e, context) {
-        // The crmClear event wipes all the field values anyway, so no need to respond
-        if (context !== 'crmClear') {
-          var val = '';
-          if ($dateField.val()) {
-            if (hasDatepicker && isValidDate() && dateHasDay()) {
-              val = $.datepicker.formatDate('yy-mm-dd', $dateField.datepicker('getDate'));
-              $dateField.removeClass('crm-error');
-            } else if (!hasDatepicker) {
-              val = $dateField.val() + '-01-01';
-            }
-            else if (!dateHasDay()) {
-              // This would be a Year-month date (yyyy-mm)
-              // it could be argued it should not use a datepicker....
-              val = $dateField.val() + '-01';
-            } else {
-              $dateField.addClass('crm-error');
-            }
-          }
-          if ($timeField.val()) {
-            val += (val ? ' ' : '') + $timeField.timeEntry('getTime').toTimeString().substr(0, 8);
-          }
-          $dataField.val(val).trigger('change', ['userInput']);
-        }
-      }
-      $dataField.hide().addClass('crm-hidden-date').on('change.crmDatepicker', updateInputFields);
-      updateInputFields();
-    });
   };
 
   CRM.utils.formatSelect2Result = function (row) {
@@ -822,7 +760,7 @@ if (!CRM.vars) CRM.vars = {};
       '</div>' +
       '<div class="crm-select2-row-description">';
     $.each(row.description || [], function(k, text) {
-      markup += '<p>' + _.escape(text) + '</p>';
+      markup += '<p>' + _.escape(text) + '</p> ';
     });
     markup += '</div></div></div>';
     return markup;
@@ -837,33 +775,18 @@ if (!CRM.vars) CRM.vars = {};
     var
       createLinks = $el.data('create-links'),
       params = getEntityRefApiParams($el).params,
-      markup = '<div class="crm-entityref-links">';
-    if (!createLinks || $el.data('api-entity').toLowerCase() !== 'contact') {
+      entity = $el.data('api-entity'),
+      markup = '<div class="crm-entityref-links crm-entityref-links-create">';
+    if (!createLinks || (createLinks === true && !CRM.config.entityRef.links[entity])) {
       return '';
     }
     if (createLinks === true) {
-      createLinks = params.contact_type ? _.where(CRM.config.entityRef.contactCreate, {type: params.contact_type}) : CRM.config.entityRef.contactCreate;
+      createLinks = params.contact_type ? _.where(CRM.config.entityRef.links[entity], {type: params.contact_type}) : CRM.config.entityRef.links[entity];
     }
     _.each(createLinks, function(link) {
-      var icon;
-      switch (link.type) {
-        case 'Individual':
-          icon = 'fa-user';
-          break;
-
-        case 'Organization':
-          icon = 'fa-building';
-          break;
-
-        case 'Household':
-          icon = 'fa-home';
-          break;
-      }
-      markup += ' <a class="crm-add-entity crm-hover-button" href="' + link.url + '">';
-      if (icon) {
-        markup += '<i class="crm-i ' + icon + '"></i> ';
-      }
-      markup += _.escape(link.label) + '</a>';
+      markup += ' <a class="crm-add-entity crm-hover-button" href="' + link.url + '">' +
+        '<i class="crm-i ' + (link.icon || 'fa-plus-circle') + '" aria-hidden="true"></i> ' +
+        _.escape(link.label) + '</a>';
     });
     markup += '</div>';
     return markup;
@@ -871,19 +794,20 @@ if (!CRM.vars) CRM.vars = {};
 
   function getEntityRefFilters($el) {
     var
-      entity = $el.data('api-entity').toLowerCase(),
-      filters = $.extend([], CRM.config.entityRef.filters[entity] || []),
+      entity = $el.data('api-entity'),
+      filters = CRM.config.entityRef.filters[entity] || [],
       params = $.extend({params: {}}, $el.data('api-params') || {}).params,
       result = [];
-    $.each(filters, function() {
-      var filter = $.extend({type: 'select', 'attributes': {}, entity: entity}, this);
-      if (typeof params[filter.key] === 'undefined') {
+    _.each(filters, function(filter) {
+      _.defaults(filter, {type: 'select', 'attributes': {}, entity: entity});
+      if (!params[filter.key]) {
+        // Filter out options if params don't match its condition
+        if (filter.condition && !_.isMatch(params, _.pick(filter.condition, _.keys(params)))) {
+          return;
+        }
         result.push(filter);
       }
       else if (filter.key == 'contact_type' && typeof params.contact_sub_type === 'undefined') {
-        filter.options = _.remove(filter.options, function(option) {
-          return option.key.indexOf(params.contact_type + '__') === 0;
-        });
         result.push(filter);
       }
     });
@@ -905,14 +829,14 @@ if (!CRM.vars) CRM.vars = {};
       '<select class="crm-entityref-filter-key' + (filter.key ? ' active' : '') + '">' +
       '<option value="">' + _.escape(ts('Refine search...')) + '</option>' +
       CRM.utils.renderOptions(filters, filter.key) +
-      '</select>' + entityRefFilterValueMarkup(filter, filterSpec) + '</div>';
+      '</select>' + entityRefFilterValueMarkup($el, filter, filterSpec) + '</div>';
     return markup;
   }
 
   /**
    * Provide markup for entity ref filter value field
    */
-  function entityRefFilterValueMarkup(filter, filterSpec) {
+  function entityRefFilterValueMarkup($el, filter, filterSpec) {
     var markup = '';
     if (filterSpec) {
       var attrs = '',
@@ -926,11 +850,12 @@ if (!CRM.vars) CRM.vars = {};
         attrs += ' ' + attr + '="' + val + '"';
       });
       if (filterSpec.type === 'select') {
-        markup = '<select' + attrs + '><option value="">' + _.escape(ts('- select -')) + '</option>';
+        var fieldName = _.last(filter.key.split('.')),
+          options = [{key: '', value: ts('- select -')}];
         if (filterSpec.options) {
-          markup += CRM.utils.renderOptions(filterSpec.options, filter.value);
+          options = options.concat(getEntityRefFilterOptions(fieldName, $el, filterSpec));
         }
-        markup += '</select>';
+        markup = '<select' + attrs + '>' + CRM.utils.renderOptions(options, filter.value) + '</select>';
       } else {
         markup = '<input' + attrs + '/>';
       }
@@ -949,9 +874,9 @@ if (!CRM.vars) CRM.vars = {};
       $valField = null;
     if (filterSpec) {
       $('.crm-entityref-filter-value', '#select2-drop').remove();
-      $valField = $(entityRefFilterValueMarkup(filter, filterSpec));
+      $valField = $(entityRefFilterValueMarkup($el, filter, filterSpec));
       $keyField.after($valField);
-      if (filterSpec.type === 'select' && !filterSpec.options) {
+      if (filterSpec.type === 'select') {
         loadEntityRefFilterOptions(filter, filterSpec, $valField, $el);
       }
     } else {
@@ -960,24 +885,37 @@ if (!CRM.vars) CRM.vars = {};
   }
 
   /**
-   * Fetch options for a filter via ajax api
+   * Fetch options for a filter from cache or ajax api
    */
   function loadEntityRefFilterOptions(filter, filterSpec, $valField, $el) {
-    $valField.prop('disabled', true);
     // Fieldname may be prefixed with joins - strip those out
     var fieldName = _.last(filter.key.split('.'));
+    if (filterSpec.options) {
+      CRM.utils.setOptions($valField, getEntityRefFilterOptions(fieldName, $el, filterSpec), false, filter.value);
+      return;
+    }
+    $('.crm-entityref-filters select', '#select2-drop').prop('disabled', true);
     CRM.api3(filterSpec.entity, 'getoptions', {field: fieldName, context: 'search', sequential: 1})
       .done(function(result) {
-        var entity = $el.data('api-entity').toLowerCase(),
-          globalFilterSpec = _.find(CRM.config.entityRef.filters[entity], {key: filter.key}) || {};
+        var entity = $el.data('api-entity').toLowerCase();
         // Store options globally so we don't have to look them up again
-        globalFilterSpec.options = result.values;
-        $valField.prop('disabled', false);
-        CRM.utils.setOptions($valField, result.values);
-        $valField.val(filter.value || '');
+        filterSpec.options = result.values;
+        $('.crm-entityref-filters select', '#select2-drop').prop('disabled', false);
+        CRM.utils.setOptions($valField, getEntityRefFilterOptions(fieldName, $el, filterSpec), false, filter.value);
       });
   }
 
+  function getEntityRefFilterOptions(fieldName, $el, filterSpec) {
+    var values = _.cloneDeep(filterSpec.options),
+      params = $.extend({params: {}}, $el.data('api-params') || {}).params;
+    if (fieldName === 'contact_type' && params.contact_type) {
+      values = _.remove(values, function(option) {
+        return option.key.indexOf(params.contact_type + '__') === 0;
+      });
+    }
+    return values;
+  }
+
   //CRM-15598 - Override url validator method to allow relative url's (e.g. /index.htm)
   $.validator.addMethod("url", function(value, element) {
     if (/^\//.test(value)) {
@@ -993,9 +931,11 @@ if (!CRM.vars) CRM.vars = {};
    */
   $.fn.crmValidate = function(params) {
     return $(this).each(function () {
-      var that = this,
-        settings = $.extend({}, CRM.validate._defaults, CRM.validate.params);
-      $(this).validate(settings);
+      var validator = $(this).validate();
+      var that = this;
+      validator.settings = $.extend({}, validator.settings, CRM.validate._defaults, CRM.validate.params);
+      // Call our custom validation handler.
+      $(validator.currentForm).on("invalid-form.validate", validator.settings.invalidHandler);
       // Call any post-initialization callbacks
       if (CRM.validate.functions && CRM.validate.functions.length) {
         $.each(CRM.validate.functions, function(i, func) {
@@ -1005,6 +945,41 @@ if (!CRM.vars) CRM.vars = {};
     });
   };
 
+  // Submit-once
+  var submitted = [],
+    submitButton;
+  function submitOnceForm(e) {
+    if (e.isDefaultPrevented()) {
+      return;
+    }
+    if (_.contains(submitted, e.target)) {
+      return false;
+    }
+    submitted.push(e.target);
+    // Spin submit button icon
+    if (submitButton && $(submitButton, e.target).length) {
+      // Dialog button
+      if ($(e.target).closest('.ui-dialog .crm-ajax-container')) {
+        var identifier = $(submitButton).attr('name') || $(submitButton).attr('href');
+        if (identifier) {
+          submitButton = $(e.target).closest('.ui-dialog').find('button[data-identifier="' + identifier + '"]')[0] || submitButton;
+        }
+      }
+      var $icon = $(submitButton).siblings('.crm-i').add('.crm-i, .ui-icon', submitButton);
+      $icon.data('origClass', $icon.attr('class')).removeClass().addClass('crm-i crm-submit-icon fa-spinner fa-pulse');
+    }
+  }
+
+  // If form fails validation, restore button icon and reset the submitted array
+  function submitFormInvalid(form) {
+    submitted = [];
+    $('.crm-i.crm-submit-icon').each(function() {
+      if ($(this).data('origClass')) {
+        $(this).removeClass().addClass($(this).data('origClass'));
+      }
+    });
+  }
+
   // Initialize widgets
   $(document)
     .on('crmLoad', function(e) {
@@ -1024,6 +999,7 @@ if (!CRM.vars) CRM.vars = {};
           }
         })
         .find('input.select-row:checked').parents('tr').addClass('crm-row-selected');
+      $('.crm-sortable-list', e.target).sortable();
       $('table.crm-sortable', e.target).DataTable();
       $('table.crm-ajax-table', e.target).each(function() {
         var
@@ -1043,7 +1019,7 @@ if (!CRM.vars) CRM.vars = {};
           });
         }
       });
-      if ($("input:radio[name=radio_ts]").size() == 1) {
+      if ($("input:radio[name=radio_ts]").length == 1) {
         $("input:radio[name=radio_ts]").prop("checked", true);
       }
       $('.crm-select2:not(.select2-offscreen, .select2-container)', e.target).crmSelect2();
@@ -1069,9 +1045,17 @@ if (!CRM.vars) CRM.vars = {};
           CRM.wysiwyg.create(this);
         }
       });
+      // Submit once handlers
+      $('form[data-submit-once]', e.target)
+        .submit(submitOnceForm)
+        .on('invalid-form', submitFormInvalid);
+      $('form[data-submit-once] button[type=submit]', e.target).click(function(e) {
+        submitButton = e.target;
+      });
     })
     .on('dialogopen', function(e) {
       var $el = $(e.target);
+      $('body').addClass('ui-dialog-open');
       // Modal dialogs should disable scrollbars
       if ($el.dialog('option', 'modal')) {
         $el.addClass('modal-dialog');
@@ -1088,6 +1072,9 @@ if (!CRM.vars) CRM.vars = {};
             $(this).button('option', 'icons', {primary: 'fa-expand'});
           } else {
             var menuHeight = $('#civicrm-menu').outerHeight();
+            if ($('body').hasClass('crm-menubar-below-cms-menu')) {
+              menuHeight += $('#civicrm-menu').offset().top;
+            }
             $el.data('origSize', {
               position: {my: 'center', at: 'center center+' + (menuHeight / 2), of: window},
               width: $el.dialog('option', 'width'),
@@ -1106,6 +1093,9 @@ if (!CRM.vars) CRM.vars = {};
       if ($('.ui-dialog .modal-dialog:visible').not(e.target).length < 1) {
         $('body').css({overflow: ''});
       }
+      if ($('.ui-dialog-content:visible').not(e.target).length < 1) {
+        $('body').removeClass('ui-dialog-open');
+      }
     })
     .on('submit', function(e) {
       // CRM-14353 - disable changes warn when submitting a form
@@ -1120,6 +1110,8 @@ if (!CRM.vars) CRM.vars = {};
   };
 
   $.fn.crmtooltip = function () {
+    var TOOLTIP_HIDE_DELAY = 300;
+
     $(document)
       .on('mouseover', 'a.crm-summary-link:not(.crm-processed)', function (e) {
         $(this).addClass('crm-processed crm-tooltip-active');
@@ -1134,8 +1126,13 @@ if (!CRM.vars) CRM.vars = {};
             .load(this.href);
         }
       })
-      .on('mouseout', 'a.crm-summary-link', function () {
-        $(this).removeClass('crm-processed crm-tooltip-active crm-tooltip-down');
+      .on('mouseleave', 'a.crm-summary-link', function () {
+        var tooltipLink = $(this);
+        setTimeout(function () {
+          if (tooltipLink.filter(':hover').length === 0) {
+            tooltipLink.removeClass('crm-processed crm-tooltip-active crm-tooltip-down');
+          }
+        }, TOOLTIP_HIDE_DELAY);
       })
       .on('click', 'a.crm-summary-link', false);
   };
@@ -1178,7 +1175,7 @@ if (!CRM.vars) CRM.vars = {};
     }
   };
   /**
-   * @see https://wiki.civicrm.org/confluence/display/CRMDOC/Notification+Reference
+   * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
    */
   CRM.status = function(options, deferred) {
     // For simple usage without async operations you can pass in a string. 2nd param is optional string 'error' if this is not a success msg.
@@ -1242,7 +1239,7 @@ if (!CRM.vars) CRM.vars = {};
   };
 
   /**
-   * @see https://wiki.civicrm.org/confluence/display/CRMDOC/Notification+Reference
+   * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
    */
   CRM.alert = function (text, title, type, options) {
     type = type || 'alert';
@@ -1290,7 +1287,7 @@ if (!CRM.vars) CRM.vars = {};
   };
 
   /**
-   * @see https://wiki.civicrm.org/confluence/display/CRMDOC/Notification+Reference
+   * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
    */
   CRM.confirm = function (options) {
     var dialog, url, msg, buttons = [], settings = {
@@ -1368,7 +1365,7 @@ if (!CRM.vars) CRM.vars = {};
   };
 
   /**
-   * @see https://wiki.civicrm.org/confluence/display/CRMDOC/Notification+Reference
+   * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
    */
   $.fn.crmError = function (text, title, options) {
     title = title || '';
@@ -1377,10 +1374,10 @@ if (!CRM.vars) CRM.vars = {};
 
     var extra = {
       expires: 0
-    };
+    }, label;
     if ($(this).length) {
       if (title === '') {
-        var label = $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]').not('[generated=true]');
+        label = $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]').not('[generated=true]');
         if (label.length) {
           label.addClass('crm-error');
           var $label = label.clone();
@@ -1399,8 +1396,10 @@ if (!CRM.vars) CRM.vars = {};
       setTimeout(function () {
         ele.one('change', function () {
           if (msg && msg.close) msg.close();
-          ele.removeClass('error');
-          label.removeClass('crm-error');
+          ele.removeClass('crm-error');
+          if (label) {
+            label.removeClass('crm-error');
+          }
         });
       }, 1000);
     }
@@ -1584,27 +1583,42 @@ if (!CRM.vars) CRM.vars = {};
    */
   var currencyTemplate;
   CRM.formatMoney = function(value, onlyNumber, format) {
-    var decimal, separator, sign, i, j, result;
+    var precision, decimal, separator, sign, i, j, result;
     if (value === 'init' && format) {
       currencyTemplate = format;
       return;
     }
     format = format || currencyTemplate;
-    result = /1(.?)234(.?)56/.exec(format);
-    if (result === null) {
+    if ((result = /1(.?)234(.?)56/.exec(format)) !== null) { // If value is formatted to 2 decimals
+      precision = 2;
+    }
+    else if ((result = /1(.?)234(.?)6/.exec(format)) !== null) { // If value is formatted to 1 decimal
+      precision = 1;
+    }
+    else if ((result = /1(.?)235/.exec(format)) !== null) { // If value is formatted to zero decimals
+      precision = false;
+    }
+    else {
       return 'Invalid format passed to CRM.formatMoney';
     }
     separator = result[1];
-    decimal = result[2];
+    decimal = precision ? result[2] : false;
     sign = (value < 0) ? '-' : '';
     //extracting the absolute value of the integer part of the number and converting to string
     i = parseInt(value = Math.abs(value).toFixed(2)) + '';
     j = ((j = i.length) > 3) ? j % 3 : 0;
-    result = sign + (j ? i.substr(0, j) + separator : '') + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + separator) + (2 ? decimal + Math.abs(value - i).toFixed(2).slice(2) : '');
-    if ( onlyNumber ) {
+    result = sign + (j ? i.substr(0, j) + separator : '') + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + separator) + (precision ? decimal + Math.abs(value - i).toFixed(precision).slice(2) : '');
+    if (onlyNumber) {
       return result;
     }
-    return format.replace(/1.*234.*56/, result);
+    switch (precision) {
+      case 2:
+        return format.replace(/1.*234.*56/, result);
+      case 1:
+        return format.replace(/1.*234.*6/, result);
+      case false:
+        return format.replace(/1.*235/, result);
+    }
   };
 
   CRM.angRequires = function(name) {
@@ -1652,7 +1666,7 @@ if (!CRM.vars) CRM.vars = {};
   // Determine if a user has a given permission.
   // @see CRM_Core_Resources::addPermissions
   CRM.checkPerm = function(perm) {
-    return CRM.permissions[perm];
+    return CRM.permissions && CRM.permissions[perm];
   };
 
   // Round while preserving sigfigs
@@ -1662,7 +1676,11 @@ if (!CRM.vars) CRM.vars = {};
     return Math.round(n / scale) * scale;
   };
 
-  // Create a js Date object from a unix timestamp or a yyyy-mm-dd string
+  /**
+   * Create a js Date object from a unix timestamp or a yyyy-mm-dd string
+   * @param input
+   * @returns {Date}
+   */
   CRM.utils.makeDate = function(input) {
     switch (typeof input) {
       case 'object':
@@ -1671,10 +1689,16 @@ if (!CRM.vars) CRM.vars = {};
 
       case 'string':
         // convert iso format with or without dashes
-        if (input.indexOf('-') > 0) {
-          return $.datepicker.parseDate('yy-mm-dd', input.substr(0, 10));
+        input = input.replace(/[- :]/g, '');
+        var output = $.datepicker.parseDate('yymmdd', input.substr(0, 8));
+        if (input.length === 14) {
+          output.setHours(
+            parseInt(input.substr(8, 2), 10),
+            parseInt(input.substr(10, 2), 10),
+            parseInt(input.substr(12, 2), 10)
+          );
         }
-        return $.datepicker.parseDate('yymmdd', input.substr(0, 8));
+        return output;
 
       case 'number':
         // convert unix timestamp
@@ -1683,10 +1707,39 @@ if (!CRM.vars) CRM.vars = {};
     throw 'Invalid input passed to CRM.utils.makeDate';
   };
 
-  // Format a date for output to the user
-  // Input may be a js Date object, a unix timestamp or a yyyy-mm-dd string
-  CRM.utils.formatDate = function(input, outputFormat) {
-    return input ? $.datepicker.formatDate(outputFormat || CRM.config.dateInputFormat, CRM.utils.makeDate(input)) : '';
+  /**
+   * Format a date (and optionally time) for output to the user
+   *
+   * @param {string|int|Date} input
+   *   Input may be a js Date object, a unix timestamp or a 'yyyy-mm-dd' string
+   * @param {string|null} dateFormat
+   *   A string like 'yy-mm-dd' or null to use the system default
+   * @param {int|bool} timeFormat
+   *   Leave empty to omit time from the output (default)
+   *   Or pass 12, 24, or true to use the system default for 12/24hr format
+   * @returns {string}
+   */
+  CRM.utils.formatDate = function(input, dateFormat, timeFormat) {
+    if (!input) {
+      return '';
+    }
+    var date = CRM.utils.makeDate(input),
+      output = $.datepicker.formatDate(dateFormat || CRM.config.dateInputFormat, date);
+    if (timeFormat) {
+      var hour = date.getHours(),
+        min = date.getMinutes(),
+        suf = '';
+      if (timeFormat === 12 || (timeFormat === true && !CRM.config.timeIs24Hr)) {
+        suf = ' ' + (hour < 12 ? ts('AM') : ts('PM'));
+        if (hour === 0 || hour > 12) {
+          hour = Math.abs(hour - 12);
+        }
+      } else if (hour < 10) {
+        hour = '0' + hour;
+      }
+      output += ' ' + hour + ':' + (min < 10 ? '0' : '') + min + suf;
+    }
+    return output;
   };
 
   // Used to set appropriate text color for a given background
@@ -1699,4 +1752,99 @@ if (!CRM.vars) CRM.vars = {};
     return (yiq >= 128) ? 'black' : 'white';
   };
 
+  // CVE-2015-9251 - Prevent auto-execution of scripts when no explicit dataType was provided
+  $.ajaxPrefilter(function(s) {
+    if (s.crossDomain) {
+      s.contents.script = false;
+    }
+  });
+
+  // CVE-2020-11022 and CVE-2020-11023  Passing HTML from untrusted sources - even after sanitizing it - to one of jQuery's DOM manipulation methods (i.e. .html(), .append(), and others) may execute untrusted code.
+  $.htmlPrefilter = function(html) {
+    // Prior to jQuery 3.5, jQuery converted XHTML-style self-closing tags to
+    // their XML equivalent: e.g., "<div />" to "<div></div>". This is
+    // problematic for several reasons, including that it's vulnerable to XSS
+    // attacks. However, since this was jQuery's behavior for many years, many
+    // Drupal modules and jQuery plugins may be relying on it. Therefore, we
+    // preserve that behavior, but for a limited set of tags only, that we believe
+    // to not be vulnerable. This is the set of HTML tags that satisfy all of the
+    // following conditions:
+    // - In DOMPurify's list of HTML tags. If an HTML tag isn't safe enough to
+    //   appear in that list, then we don't want to mess with it here either.
+    //   @see https://github.com/cure53/DOMPurify/blob/2.0.11/dist/purify.js#L128
+    // - A normal element (not a void, template, text, or foreign element).
+    //   @see https://html.spec.whatwg.org/multipage/syntax.html#elements-2
+    // - An element that is still defined by the current HTML specification
+    //   (not a deprecated element), because we do not want to rely on how
+    //   browsers parse deprecated elements.
+    //   @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element
+    // - Not 'html', 'head', or 'body', because this pseudo-XHTML expansion is
+    //   designed for fragments, not entire documents.
+    // - Not 'colgroup', because due to an idiosyncrasy of jQuery's original
+    //   regular expression, it didn't match on colgroup, and we don't want to
+    //   introduce a behavior change for that.
+    var selfClosingTagsToReplace = [
+      'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo',
+      'blockquote', 'button', 'canvas', 'caption', 'cite', 'code', 'data',
+      'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em',
+      'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
+      'h4', 'h5', 'h6', 'header', 'hgroup', 'i', 'ins', 'kbd', 'label', 'legend',
+      'li', 'main', 'map', 'mark', 'menu', 'meter', 'nav', 'ol', 'optgroup',
+      'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt',
+      'ruby', 's', 'samp', 'section', 'select', 'small', 'source', 'span',
+      'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
+      'thead', 'time', 'tr', 'u', 'ul', 'var', 'video'
+    ];
+
+    // Define regular expressions for <TAG/> and <TAG ATTRIBUTES/>. Doing this as
+    // two expressions makes it easier to target <a/> without also targeting
+    // every tag that starts with "a".
+    var xhtmlRegExpGroup = '(' + selfClosingTagsToReplace.join('|') + ')';
+    var whitespace = '[\\x20\\t\\r\\n\\f]';
+    var rxhtmlTagWithoutSpaceOrAttributes = new RegExp('<' + xhtmlRegExpGroup + '\\/>', 'gi');
+    var rxhtmlTagWithSpaceAndMaybeAttributes = new RegExp('<' + xhtmlRegExpGroup + '(' + whitespace + '[^>]*)\\/>', 'gi');
+
+    // jQuery 3.5 also fixed a vulnerability for when </select> appears within
+    // an <option> or <optgroup>, but it did that in local code that we can't
+    // backport directly. Instead, we filter such cases out. To do so, we need to
+    // determine when jQuery would otherwise invoke the vulnerable code, which it
+    // uses this regular expression to determine. The regular expression changed
+    // for version 3.0.0 and changed again for 3.4.0.
+    // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4958
+    // @see https://github.com/jquery/jquery/blob/3.0.0/dist/jquery.js#L4584
+    // @see https://github.com/jquery/jquery/blob/3.4.0/dist/jquery.js#L4712
+    var rtagName = /<([\w:]+)/;
+
+    // The regular expression that jQuery uses to determine which self-closing
+    // tags to expand to open and close tags. This is vulnerable, because it
+    // matches all tag names except the few excluded ones. We only use this
+    // expression for determining vulnerability. The expression changed for
+    // version 3, but we only need to check for vulnerability in versions 1 and 2,
+    // so we use the expression from those versions.
+    // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4957
+    var rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi;
+
+    // This is how jQuery determines the first tag in the HTML.
+    // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5521
+    var tag = ( rtagName.exec( html ) || [ "", "" ] )[ 1 ].toLowerCase();
+
+    // It is not valid HTML for <option> or <optgroup> to have <select> as
+    // either a descendant or sibling, and attempts to inject one can cause
+    // XSS on jQuery versions before 3.5. Since this is invalid HTML and a
+    // possible XSS attack, reject the entire string.
+    // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11023
+    if ((tag === 'option' || tag === 'optgroup') && html.match(/<\/?select/i)) {
+      html = '';
+    }
+
+    // Retain jQuery's prior to 3.5 conversion of pseudo-XHTML, but for only
+    // the tags in the `selfClosingTagsToReplace` list defined above.
+    // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5518
+    // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11022
+    html = html.replace(rxhtmlTagWithoutSpaceOrAttributes, "<$1></$1>");
+    html = html.replace(rxhtmlTagWithSpaceAndMaybeAttributes, "<$1$2></$1>");
+
+    return html;
+  };
+
 })(jQuery, _);