Merge pull request #19956 from civicrm/5.36
[civicrm-core.git] / js / Common.js
1 // https://civicrm.org/licensing
2 /* global CRM:true */
3 var CRM = CRM || {};
4 var cj = CRM.$ = jQuery;
5 CRM._ = _;
6
7 /**
8 * Short-named function for string translation, defined in global scope so it's available everywhere.
9 *
10 * @param text string for translating
11 * @param params object key:value of additional parameters
12 *
13 * @return string
14 */
15 function ts(text, params) {
16 "use strict";
17 var d = (params && params.domain) ? ('strings::' + params.domain) : null;
18 if (d && CRM[d] && CRM[d][text]) {
19 text = CRM[d][text];
20 }
21 else if (CRM.strings[text]) {
22 text = CRM.strings[text];
23 }
24 if (typeof(params) === 'object') {
25 for (var i in params) {
26 if (typeof(params[i]) === 'string' || typeof(params[i]) === 'number') {
27 // sprintf emulation: escape % characters in the replacements to avoid conflicts
28 text = text.replace(new RegExp('%' + i, 'g'), String(params[i]).replace(/%/g, '%-crmescaped-'));
29 }
30 }
31 return text.replace(/%-crmescaped-/g, '%');
32 }
33 return text;
34 }
35
36 // Legacy code - ignore warnings
37 /* jshint ignore:start */
38
39 /**
40 * This function is called by default at the bottom of template files which have forms that have
41 * conditionally displayed/hidden sections and elements. The PHP is responsible for generating
42 * a list of 'blocks to show' and 'blocks to hide' and the template passes these parameters to
43 * this function.
44 *
45 * @deprecated
46 * @param showBlocks Array of element Id's to be displayed
47 * @param hideBlocks Array of element Id's to be hidden
48 * @param elementType Value to set display style to for showBlocks (e.g. 'block' or 'table-row' or ...)
49 */
50 function on_load_init_blocks(showBlocks, hideBlocks, elementType) {
51 if (elementType == null) {
52 elementType = 'block';
53 }
54
55 var myElement, i;
56
57 /* This loop is used to display the blocks whose IDs are present within the showBlocks array */
58 for (i = 0; i < showBlocks.length; i++) {
59 myElement = document.getElementById(showBlocks[i]);
60 /* getElementById returns null if element id doesn't exist in the document */
61 if (myElement != null) {
62 myElement.style.display = elementType;
63 }
64 else {
65 alert('showBlocks array item not in .tpl = ' + showBlocks[i]);
66 }
67 }
68
69 /* This loop is used to hide the blocks whose IDs are present within the hideBlocks array */
70 for (i = 0; i < hideBlocks.length; i++) {
71 myElement = document.getElementById(hideBlocks[i]);
72 /* getElementById returns null if element id doesn't exist in the document */
73 if (myElement != null) {
74 myElement.style.display = 'none';
75 }
76 else {
77 alert('showBlocks array item not in .tpl = ' + hideBlocks[i]);
78 }
79 }
80 }
81
82 /**
83 * This function is called when we need to show or hide a related form element (target_element)
84 * based on the value (trigger_value) of another form field (trigger_field).
85 *
86 * @deprecated
87 * @param trigger_field_id HTML id of field whose onchange is the trigger
88 * @param trigger_value List of integers - option value(s) which trigger show-element action for target_field
89 * @param target_element_id HTML id of element to be shown or hidden
90 * @param target_element_type Type of element to be shown or hidden ('block' or 'table-row')
91 * @param field_type Type of element radio/select
92 * @param invert Boolean - if true, we HIDE target on value match; if false, we SHOW target on value match
93 */
94 function showHideByValue(trigger_field_id, trigger_value, target_element_id, target_element_type, field_type, invert) {
95 var target, j;
96
97 if (field_type == 'select') {
98 var trigger = trigger_value.split("|");
99 var selectedOptionValue = cj('#' + trigger_field_id).val();
100
101 target = target_element_id.split("|");
102 for (j = 0; j < target.length; j++) {
103 if (invert) {
104 cj('#' + target[j]).show();
105 }
106 else {
107 cj('#' + target[j]).hide();
108 }
109 for (var i = 0; i < trigger.length; i++) {
110 if (selectedOptionValue == trigger[i]) {
111 if (invert) {
112 cj('#' + target[j]).hide();
113 }
114 else {
115 cj('#' + target[j]).show();
116 }
117 }
118 }
119 }
120
121 }
122 else {
123 if (field_type == 'radio') {
124 target = target_element_id.split("|");
125 for (j = 0; j < target.length; j++) {
126 if (cj('[name="' + trigger_field_id + '"]:first').is(':checked')) {
127 if (invert) {
128 cj('#' + target[j]).hide();
129 }
130 else {
131 cj('#' + target[j]).show();
132 }
133 }
134 else {
135 if (invert) {
136 cj('#' + target[j]).show();
137 }
138 else {
139 cj('#' + target[j]).hide();
140 }
141 }
142 }
143 }
144 }
145 }
146
147 var submitcount = 0;
148
149 /**
150 * Function to show / hide the row in optionFields
151 * @deprecated
152 * @param index string, element whose innerHTML is to hide else will show the hidden row.
153 */
154 function showHideRow(index) {
155 if (index) {
156 cj('tr#optionField_' + index).hide();
157 if (cj('table#optionField tr:hidden:first').length) {
158 cj('div#optionFieldLink').show();
159 }
160 }
161 else {
162 cj('table#optionField tr:hidden:first').show();
163 if (!cj('table#optionField tr:hidden:last').length) {
164 cj('div#optionFieldLink').hide();
165 }
166 }
167 return false;
168 }
169
170 /* jshint ignore:end */
171
172 if (!CRM.utils) CRM.utils = {};
173 if (!CRM.strings) CRM.strings = {};
174 if (!CRM.vars) CRM.vars = {};
175
176 (function ($, _, undefined) {
177 "use strict";
178 /* jshint validthis: true */
179
180 // Theme classes for unattached elements
181 $.fn.select2.defaults.dropdownCssClass = $.ui.dialog.prototype.options.dialogClass = 'crm-container';
182
183 // https://github.com/ivaynberg/select2/pull/2090
184 $.fn.select2.defaults.width = 'resolve';
185
186 // Workaround for https://github.com/ivaynberg/select2/issues/1246
187 $.ui.dialog.prototype._allowInteraction = function(e) {
188 return !!$(e.target).closest('.ui-dialog, .ui-datepicker, .select2-drop, .cke_dialog, #civicrm-menu').length;
189 };
190
191 // Implements jQuery hook.prop
192 $.propHooks.disabled = {
193 set: function (el, value, name) {
194 // Sync button enabled status with wrapper css
195 if ($(el).is('.crm-button.crm-form-submit')) {
196 $(el).parent().toggleClass('crm-button-disabled', !!value);
197 }
198 // Sync button enabled status with dialog button
199 if ($(el).is('.ui-dialog input.crm-form-submit')) {
200 $(el).closest('.ui-dialog').find('.ui-dialog-buttonset button[data-identifier='+ $(el).attr('name') +']').prop('disabled', value);
201 }
202 if ($(el).is('.crm-form-date-wrapper .crm-hidden-date')) {
203 $(el).siblings().prop('disabled', value);
204 }
205 }
206 };
207
208 var scriptsLoaded = {};
209 CRM.loadScript = function(url, appendCacheCode) {
210 if (!scriptsLoaded[url]) {
211 var script = document.createElement('script'),
212 src = url;
213 if (appendCacheCode !== false) {
214 src += (_.includes(url, '?') ? '&r=' : '?r=') + CRM.config.resourceCacheCode;
215 }
216 scriptsLoaded[url] = $.Deferred();
217 script.onload = function () {
218 // Give the script time to execute
219 window.setTimeout(function () {
220 if (window.jQuery === CRM.$ && CRM.CMSjQuery) {
221 window.jQuery = CRM.CMSjQuery;
222 }
223 scriptsLoaded[url].resolve();
224 }, 100);
225 };
226 // Make jQuery global available while script is loading
227 if (window.jQuery !== CRM.$) {
228 CRM.CMSjQuery = window.jQuery;
229 window.jQuery = CRM.$;
230 }
231 script.src = src;
232 document.getElementsByTagName("head")[0].appendChild(script);
233 }
234 return scriptsLoaded[url];
235 };
236
237 /**
238 * Populate a select list, overwriting the existing options except for the placeholder.
239 * @param select jquery selector - 1 or more select elements
240 * @param options array in format returned by api.getoptions
241 * @param placeholder string|bool - new placeholder or false (default) to keep the old one
242 * @param value string|array - will silently update the element with new value without triggering change
243 */
244 CRM.utils.setOptions = function(select, options, placeholder, value) {
245 $(select).each(function() {
246 var
247 $elect = $(this),
248 val = value || $elect.val() || [],
249 opts = placeholder || placeholder === '' ? '' : '[value!=""]';
250 $elect.find('option' + opts).remove();
251 var newOptions = CRM.utils.renderOptions(options, val);
252 if (options.length == 0) {
253 $elect.removeClass('required');
254 } else if ($elect.hasClass('crm-field-required') && !$elect.hasClass('required')) {
255 $elect.addClass('required');
256 }
257 if (typeof placeholder === 'string') {
258 if ($elect.is('[multiple]')) {
259 select.attr('placeholder', placeholder);
260 } else {
261 newOptions = '<option value="">' + placeholder + '</option>' + newOptions;
262 }
263 }
264 $elect.append(newOptions);
265 if (!value) {
266 $elect.trigger('crmOptionsUpdated', $.extend({}, options)).trigger('change');
267 }
268 });
269 };
270
271 /**
272 * Render an option list
273 * @param options {array}
274 * @param val {string} default value
275 * @param escapeHtml {bool}
276 * @return string
277 */
278 CRM.utils.renderOptions = function(options, val, escapeHtml) {
279 var rendered = '',
280 esc = escapeHtml === false ? _.identity : _.escape;
281 if (!$.isArray(val)) {
282 val = [val];
283 }
284 _.each(options, function(option) {
285 if (option.children) {
286 rendered += '<optgroup label="' + esc(option.value) + '">' +
287 CRM.utils.renderOptions(option.children, val) +
288 '</optgroup>';
289 } else {
290 var selected = ($.inArray('' + option.key, val) > -1) ? 'selected="selected"' : '';
291 rendered += '<option value="' + esc(option.key) + '"' + selected + '>' + esc(option.value) + '</option>';
292 }
293 });
294 return rendered;
295 };
296
297 CRM.utils.getOptions = function(select) {
298 var options = [];
299 $('option', select).each(function() {
300 var option = {key: $(this).attr('value'), value: $(this).text()};
301 if (option.key !== '') {
302 options.push(option);
303 }
304 });
305 return options;
306 };
307
308 function chainSelect() {
309 var $form = $(this).closest('form'),
310 $target = $('select[data-name="' + $(this).data('target') + '"]', $form),
311 data = $target.data(),
312 val = $(this).val();
313 $target.prop('disabled', true);
314 if ($target.is('select.crm-chain-select-control')) {
315 $('select[data-name="' + $target.data('target') + '"]', $form).prop('disabled', true).blur();
316 }
317 if (!(val && val.length)) {
318 CRM.utils.setOptions($target.blur(), [], data.emptyPrompt);
319 } else {
320 $target.addClass('loading');
321 $.getJSON(CRM.url(data.callback), {_value: val}, function(vals) {
322 $target.prop('disabled', false).removeClass('loading');
323 CRM.utils.setOptions($target, vals || [], (vals && vals.length ? data.selectPrompt : data.nonePrompt));
324 });
325 }
326 }
327
328 /**
329 * Compare Form Input values against cached initial value.
330 *
331 * @return {Boolean} true if changes have been made.
332 */
333 CRM.utils.initialValueChanged = function(el) {
334 var isDirty = false;
335 $(':input:visible, .select2-container:visible+:input:hidden', el).not('[type=submit], [type=button], .crm-action-menu, :disabled').each(function () {
336 var
337 initialValue = $(this).data('crm-initial-value'),
338 currentValue = $(this).is(':checkbox, :radio') ? $(this).prop('checked') : $(this).val();
339 // skip change of value for submit buttons
340 if (initialValue !== undefined && !_.isEqual(initialValue, currentValue)) {
341 isDirty = true;
342 }
343 });
344 return isDirty;
345 };
346
347 /**
348 * This provides defaults for ui.dialog which either need to be calculated or are different from global defaults
349 *
350 * @param settings
351 * @returns {*}
352 */
353 CRM.utils.adjustDialogDefaults = function(settings) {
354 settings = $.extend({width: '65%', height: '65%', modal: true}, settings || {});
355 // Support relative height
356 if (typeof settings.height === 'string' && settings.height.indexOf('%') > 0) {
357 settings.height = parseInt($(window).height() * (parseFloat(settings.height)/100), 10);
358 }
359 // Responsive adjustment - increase percent width on small screens
360 if (typeof settings.width === 'string' && settings.width.indexOf('%') > 0) {
361 var screenWidth = $(window).width(),
362 percentage = parseInt(settings.width.replace('%', ''), 10),
363 gap = 100-percentage;
364 if (screenWidth < 701) {
365 settings.width = '100%';
366 }
367 else if (screenWidth < 1400) {
368 settings.width = '' + parseInt(percentage+gap-((screenWidth - 700)/7*(gap)/100), 10) + '%';
369 }
370 }
371 return settings;
372 };
373
374 function formatCrmSelect2(row) {
375 var icon = row.icon || $(row.element).data('icon'),
376 color = row.color || $(row.element).data('color'),
377 description = row.description || $(row.element).data('description'),
378 ret = '';
379 if (icon) {
380 ret += '<i class="crm-i ' + icon + '" aria-hidden="true"></i> ';
381 }
382 if (color) {
383 ret += '<span class="crm-select-item-color" style="background-color: ' + color + '"></span> ';
384 }
385 return ret + _.escape(row.text) + (description ? '<div class="crm-select2-row-description"><p>' + _.escape(description) + '</p></div>' : '');
386 }
387
388 /**
389 * Helper to generate an icon with alt text.
390 *
391 * See also smarty `{icon}` and CRM_Core_Page::crmIcon() functions
392 *
393 * @param string icon
394 * The Font Awesome icon class to use.
395 * @param string text
396 * Alt text to display.
397 * @param mixed condition
398 * This will only display if this is truthy.
399 *
400 * @return string
401 * The formatted icon markup.
402 */
403 CRM.utils.formatIcon = function (icon, text, condition) {
404 if (typeof condition !== 'undefined' && !condition) {
405 return '';
406 }
407 var title = '';
408 var sr = '';
409 if (text) {
410 text = _.escape(text);
411 title = ' title="' + text + '"';
412 sr = '<span class="sr-only">' + text + '</span>';
413 }
414 return '<i class="crm-i ' + icon + '"' + title + ' aria-hidden="true"></i>' + sr;
415 };
416
417 /**
418 * Wrapper for select2 initialization function; supplies defaults
419 * @param options object
420 */
421 $.fn.crmSelect2 = function(options) {
422 if (options === 'destroy') {
423 return $(this).each(function() {
424 $(this)
425 .removeClass('crm-ajax-select')
426 .off('.crmSelect2')
427 .select2('destroy');
428 });
429 }
430 return $(this).each(function () {
431 var
432 $el = $(this),
433 iconClass,
434 settings = {
435 allowClear: !$el.hasClass('required'),
436 formatResult: formatCrmSelect2,
437 formatSelection: formatCrmSelect2
438 };
439
440 // quickform doesn't support optgroups so here's a hack :(
441 // Instead of using wrapAll or similar that repeatedly appends options to the group and redraw the page (=> very slow on large lists),
442 // build bulk HTML and insert in single shot
443 var optGroups = {};
444 $('option[value^=crm_optgroup]', this).each(function () {
445 var groupHtml = '';
446 $(this).nextUntil('option[value^=crm_optgroup]').each(function () {
447 groupHtml += this.outerHTML;
448 });
449 optGroups[$(this).text()] = groupHtml;
450 $(this).remove();
451 });
452 var replacedHtml = '';
453 for (var groupLabel in optGroups) {
454 replacedHtml += '<optgroup label="' + groupLabel + '">' + optGroups[groupLabel] + '</optgroup>';
455 }
456 if (replacedHtml) {
457 $el.html(replacedHtml);
458 }
459
460 // quickform does not support disabled option, so yet another hack to
461 // add disabled property for option values
462 $('option[value^=crm_disabled_opt]', this).attr('disabled', 'disabled');
463
464 // Placeholder icon - total hack hikacking the escapeMarkup function but select2 3.5 dosn't have any other callbacks for this :(
465 if ($el.is('[class*=fa-]')) {
466 settings.escapeMarkup = function (m) {
467 var out = _.escape(m),
468 placeholder = settings.placeholder || $el.data('placeholder') || $el.attr('placeholder') || $('option[value=""]', $el).text();
469 if (m.length && placeholder === m) {
470 iconClass = $el.attr('class').match(/(fa-\S*)/)[1];
471 out = '<i class="crm-i ' + iconClass + '" aria-hidden="true"></i> ' + out;
472 }
473 return out;
474 };
475 }
476
477 // Use description as title for each option
478 $el.on('select2-loaded.crmSelect2', function() {
479 $('.crm-select2-row-description', '#select2-drop').each(function() {
480 $(this).closest('.select2-result-label').attr('title', $(this).text());
481 });
482 });
483
484 // Defaults for single-selects
485 if ($el.is('select:not([multiple])')) {
486 settings.minimumResultsForSearch = 10;
487 if ($('option:first', this).val() === '') {
488 settings.placeholderOption = 'first';
489 }
490 }
491 $.extend(settings, $el.data('select-params') || {}, options || {});
492 if (settings.ajax) {
493 $el.addClass('crm-ajax-select');
494 }
495 $el.select2(settings);
496 });
497 };
498
499 /**
500 * @see CRM_Core_Form::addEntityRef for docs
501 * @param options object
502 */
503 $.fn.crmEntityRef = function(options) {
504 if (options === 'destroy') {
505 return $(this).each(function() {
506 var entity = $(this).data('api-entity') || '';
507 $(this)
508 .off('.crmEntity')
509 .removeClass('crm-form-entityref crm-' + _.kebabCase(entity) + '-ref')
510 .crmSelect2('destroy');
511 });
512 }
513 options = options || {};
514 options.select = options.select || {};
515 return $(this).each(function() {
516 var
517 $el = $(this).off('.crmEntity'),
518 entity = options.entity || $el.data('api-entity') || 'Contact',
519 selectParams = {},
520 staticPresets = {
521 user_contact_id: {
522 id: 'user_contact_id',
523 label: ts('Select Current User'),
524 icon: 'fa-user-circle-o'
525 }
526 };
527 // Legacy: fix entity name if passed in as snake case
528 if (entity.charAt(0).toUpperCase() !== entity.charAt(0)) {
529 entity = _.capitalize(_.camelCase(entity));
530 }
531 $el.data('api-entity', entity);
532 $el.data('select-params', $.extend({}, $el.data('select-params') || {}, options.select));
533 $el.data('api-params', $.extend(true, {}, $el.data('api-params') || {}, options.api));
534 $el.data('create-links', options.create || $el.data('create-links'));
535 var staticItems = options.static || $el.data('static') || [];
536 _.each(staticItems, function(option, i) {
537 if (_.isString(option)) {
538 staticItems[i] = staticPresets[option];
539 }
540 });
541
542 function staticItemMarkup() {
543 if (!staticItems.length) {
544 return '';
545 }
546 var markup = '<div class="crm-entityref-links crm-entityref-links-static">';
547 _.each(staticItems, function(link) {
548 markup += ' <a class="crm-hover-button" href="#' + link.id + '">' +
549 '<i class="crm-i ' + link.icon + '" aria-hidden="true"></i> ' +
550 _.escape(link.label) + '</a>';
551 });
552 markup += '</div>';
553 return markup;
554 }
555
556 $el.addClass('crm-form-entityref crm-' + _.kebabCase(entity) + '-ref');
557 var settings = {
558 // Use select2 ajax helper instead of CRM.api3 because it provides more value
559 ajax: {
560 url: CRM.url('civicrm/ajax/rest'),
561 quietMillis: 300,
562 data: function (input, page_num) {
563 var params = getEntityRefApiParams($el);
564 params.input = input;
565 params.page_num = page_num;
566 return {
567 entity: $el.data('api-entity'),
568 action: 'getlist',
569 json: JSON.stringify(params)
570 };
571 },
572 results: function(data) {
573 return {more: data.more_results, results: data.values || []};
574 }
575 },
576 minimumInputLength: 1,
577 formatResult: CRM.utils.formatSelect2Result,
578 formatSelection: formatEntityRefSelection,
579 escapeMarkup: _.identity,
580 initSelection: function($el, callback) {
581 var
582 multiple = !!$el.data('select-params').multiple,
583 val = $el.val(),
584 stored = ($el.data('entity-value') || []).concat(staticItems);
585 if (val === '') {
586 return;
587 }
588 var idsNeeded = _.difference(val.split(','), _.pluck(stored, 'id'));
589 var existing = _.remove(stored, function(item) {
590 return _.includes(val.split(','), item.id);
591 });
592 // If we already have this data, just return it
593 if (!idsNeeded.length) {
594 callback(multiple ? existing : existing[0]);
595 } else {
596 var params = $.extend({}, $el.data('api-params') || {}, {id: idsNeeded.join(',')});
597 CRM.api3($el.data('api-entity'), 'getlist', params).done(function(result) {
598 callback(multiple ? result.values.concat(existing) : result.values[0]);
599 // Trigger change (store data to avoid an infinite loop of lookups)
600 $el.data('entity-value', result.values).trigger('change');
601 });
602 }
603 }
604 };
605 // Create new items inline - works for tags
606 if ($el.data('create-links') && entity === 'Tag') {
607 selectParams.createSearchChoice = function(term, data) {
608 if (!_.findKey(data, {label: term})) {
609 return {id: "0", term: term, label: term + ' (' + ts('new tag') + ')'};
610 }
611 };
612 selectParams.tokenSeparators = [','];
613 selectParams.createSearchChoicePosition = 'bottom';
614 $el.on('select2-selecting.crmEntity', function(e) {
615 if (e.val === "0") {
616 // Create a new term
617 e.object.label = e.object.term;
618 CRM.api3(entity, 'create', $.extend({name: e.object.term}, $el.data('api-params').params || {}))
619 .done(function(created) {
620 var
621 val = $el.select2('val'),
622 data = $el.select2('data'),
623 item = {id: created.id, label: e.object.term};
624 if (val === "0") {
625 $el.select2('data', item, true);
626 }
627 else if ($.isArray(val) && $.inArray("0", val) > -1) {
628 _.remove(data, {id: "0"});
629 data.push(item);
630 $el.select2('data', data, true);
631 }
632 });
633 }
634 });
635 }
636 else {
637 selectParams.formatInputTooShort = function() {
638 var txt = $el.data('select-params').formatInputTooShort || $.fn.select2.defaults.formatInputTooShort.call(this);
639 txt += entityRefFiltersMarkup($el) + staticItemMarkup() + renderEntityRefCreateLinks($el);
640 return txt;
641 };
642 selectParams.formatNoMatches = function() {
643 var txt = $el.data('select-params').formatNoMatches || $.fn.select2.defaults.formatNoMatches;
644 txt += entityRefFiltersMarkup($el) + renderEntityRefCreateLinks($el);
645 return txt;
646 };
647 $el.on('select2-open.crmEntity', function() {
648 var $el = $(this);
649 $('#select2-drop')
650 .off('.crmEntity')
651 .on('click.crmEntity', 'a.crm-add-entity', function(e) {
652 var extra = $el.data('api-params').extra,
653 formUrl = $(this).attr('href') + '&returnExtra=display_name,sort_name' + (extra ? (',' + extra) : '');
654 $el.select2('close');
655 CRM.loadForm(formUrl, {
656 dialog: {width: '50%', height: 220}
657 }).on('crmFormSuccess', function(e, data) {
658 if (data.status === 'success' && data.id) {
659 if (!data.crmMessages) {
660 CRM.status(ts('%1 Created', {1: data.label || data.extra.display_name}));
661 }
662 data.label = data.label || data.extra.sort_name;
663 if ($el.select2('container').hasClass('select2-container-multi')) {
664 var selection = $el.select2('data');
665 selection.push(data);
666 $el.select2('data', selection, true);
667 } else {
668 $el.select2('data', data, true);
669 }
670 }
671 });
672 return false;
673 })
674 .on('click.crmEntity', '.crm-entityref-links-static a', function(e) {
675 var id = $(this).attr('href').substr(1),
676 item = _.findWhere(staticItems, {id: id});
677 $el.select2('close');
678 if ($el.select2('container').hasClass('select2-container-multi')) {
679 var selection = $el.select2('data');
680 if (!_.findWhere(selection, {id: id})) {
681 selection.push(item);
682 $el.select2('data', selection, true);
683 }
684 } else {
685 $el.select2('data', item, true);
686 }
687 return false;
688 })
689 .on('change.crmEntity', '.crm-entityref-filter-value', function() {
690 var filter = $el.data('user-filter') || {};
691 filter.value = $(this).val();
692 $(this).toggleClass('active', !!filter.value);
693 $el.data('user-filter', filter);
694 if (filter.value && $(this).is('select')) {
695 // Once a filter has been chosen, rerender create links and refocus the search box
696 $el.select2('close');
697 $el.select2('open');
698 } else {
699 $('.crm-entityref-links-create', '#select2-drop').replaceWith(renderEntityRefCreateLinks($el));
700 }
701 })
702 .on('change.crmEntity', 'select.crm-entityref-filter-key', function() {
703 var filter = {key: $(this).val()};
704 $(this).toggleClass('active', !!filter.key);
705 $el.data('user-filter', filter);
706 renderEntityRefFilterValue($el);
707 $('.crm-entityref-filter-key', '#select2-drop').focus();
708 });
709 });
710 }
711 $el.crmSelect2($.extend(settings, $el.data('select-params'), selectParams));
712 });
713 };
714
715 /**
716 * Combine api-params with user-filter
717 * @param $el
718 * @returns {*}
719 */
720 function getEntityRefApiParams($el) {
721 var
722 params = $.extend({params: {}}, $el.data('api-params') || {}),
723 // Prevent original data from being modified - $.extend and _.clone don't cut it, they pass nested objects by reference!
724 combined = _.cloneDeep(params),
725 filter = $.extend({}, $el.data('user-filter') || {});
726 if (filter.key && filter.value) {
727 // Fieldname may be prefixed with joins
728 var fieldName = _.last(filter.key.split('.'));
729 // Special case for contact type/sub-type combo
730 if (fieldName === 'contact_type' && (filter.value.indexOf('__') > 0)) {
731 combined.params[filter.key] = filter.value.split('__')[0];
732 combined.params[filter.key.replace('contact_type', 'contact_sub_type')] = filter.value.split('__')[1];
733 } else {
734 // Allow json-encoded api filters e.g. {"BETWEEN":[123,456]}
735 combined.params[filter.key] = filter.value.charAt(0) === '{' ? $.parseJSON(filter.value) : filter.value;
736 }
737 }
738 return combined;
739 }
740
741 CRM.utils.copyAttributes = function ($source, $target, attributes) {
742 _.each(attributes, function(name) {
743 if ($source.attr(name) !== undefined) {
744 $target.attr(name, $source.attr(name));
745 }
746 });
747 };
748
749 CRM.utils.formatSelect2Result = function (row) {
750 var markup = '<div class="crm-select2-row">';
751 if (row.image !== undefined) {
752 markup += '<div class="crm-select2-image"><img src="' + row.image + '"/></div>';
753 }
754 else if (row.icon_class) {
755 markup += '<div class="crm-select2-icon"><div class="crm-icon ' + row.icon_class + '-icon"></div></div>';
756 }
757 markup += '<div><div class="crm-select2-row-label '+(row.label_class || '')+'">' +
758 (row.color ? '<span class="crm-select-item-color" style="background-color: ' + row.color + '"></span> ' : '') +
759 _.escape((row.prefix !== undefined ? row.prefix + ' ' : '') + row.label + (row.suffix !== undefined ? ' ' + row.suffix : '')) +
760 '</div>' +
761 '<div class="crm-select2-row-description">';
762 $.each(row.description || [], function(k, text) {
763 markup += '<p>' + _.escape(text) + '</p> ';
764 });
765 markup += '</div></div></div>';
766 return markup;
767 };
768
769 function formatEntityRefSelection(row) {
770 return (row.color ? '<span class="crm-select-item-color" style="background-color: ' + row.color + '"></span> ' : '') +
771 _.escape((row.prefix !== undefined ? row.prefix + ' ' : '') + row.label + (row.suffix !== undefined ? ' ' + row.suffix : ''));
772 }
773
774 function renderEntityRefCreateLinks($el) {
775 var
776 createLinks = $el.data('create-links'),
777 params = getEntityRefApiParams($el).params,
778 entity = $el.data('api-entity'),
779 markup = '<div class="crm-entityref-links crm-entityref-links-create">';
780 if (!createLinks || (createLinks === true && !CRM.config.entityRef.links[entity])) {
781 return '';
782 }
783 if (createLinks === true) {
784 createLinks = params.contact_type ? _.where(CRM.config.entityRef.links[entity], {type: params.contact_type}) : CRM.config.entityRef.links[entity];
785 }
786 _.each(createLinks, function(link) {
787 markup += ' <a class="crm-add-entity crm-hover-button" href="' + link.url + '">' +
788 '<i class="crm-i ' + (link.icon || 'fa-plus-circle') + '" aria-hidden="true"></i> ' +
789 _.escape(link.label) + '</a>';
790 });
791 markup += '</div>';
792 return markup;
793 }
794
795 function getEntityRefFilters($el) {
796 var
797 entity = $el.data('api-entity'),
798 filters = CRM.config.entityRef.filters[entity] || [],
799 params = $.extend({params: {}}, $el.data('api-params') || {}).params,
800 result = [];
801 _.each(filters, function(filter) {
802 _.defaults(filter, {type: 'select', 'attributes': {}, entity: entity});
803 if (!params[filter.key]) {
804 // Filter out options if params don't match its condition
805 if (filter.condition && !_.isMatch(params, _.pick(filter.condition, _.keys(params)))) {
806 return;
807 }
808 result.push(filter);
809 }
810 else if (filter.key == 'contact_type' && typeof params.contact_sub_type === 'undefined') {
811 result.push(filter);
812 }
813 });
814 return result;
815 }
816
817 /**
818 * Provide markup for entity ref filters
819 */
820 function entityRefFiltersMarkup($el) {
821 var
822 filters = getEntityRefFilters($el),
823 filter = $el.data('user-filter') || {},
824 filterSpec = filter.key ? _.find(filters, {key: filter.key}) : null;
825 if (!filters.length) {
826 return '';
827 }
828 var markup = '<div class="crm-entityref-filters">' +
829 '<select class="crm-entityref-filter-key' + (filter.key ? ' active' : '') + '">' +
830 '<option value="">' + _.escape(ts('Refine search...')) + '</option>' +
831 CRM.utils.renderOptions(filters, filter.key) +
832 '</select>' + entityRefFilterValueMarkup($el, filter, filterSpec) + '</div>';
833 return markup;
834 }
835
836 /**
837 * Provide markup for entity ref filter value field
838 */
839 function entityRefFilterValueMarkup($el, filter, filterSpec) {
840 var markup = '';
841 if (filterSpec) {
842 var attrs = '',
843 attributes = _.cloneDeep(filterSpec.attributes);
844 if (filterSpec.type !== 'select') {
845 attributes.type = filterSpec.type;
846 attributes.value = typeof filter.value !== 'undefined' ? filter.value : '';
847 }
848 attributes.class = 'crm-entityref-filter-value' + (filter.value ? ' active' : '');
849 $.each(attributes, function (attr, val) {
850 attrs += ' ' + attr + '="' + val + '"';
851 });
852 if (filterSpec.type === 'select') {
853 var fieldName = _.last(filter.key.split('.')),
854 options = [{key: '', value: ts('- select -')}];
855 if (filterSpec.options) {
856 options = options.concat(getEntityRefFilterOptions(fieldName, $el, filterSpec));
857 }
858 markup = '<select' + attrs + '>' + CRM.utils.renderOptions(options, filter.value) + '</select>';
859 } else {
860 markup = '<input' + attrs + '/>';
861 }
862 }
863 return markup;
864 }
865
866 /**
867 * Render the entity ref filter value field
868 */
869 function renderEntityRefFilterValue($el) {
870 var
871 filter = $el.data('user-filter') || {},
872 filterSpec = filter.key ? _.find(getEntityRefFilters($el), {key: filter.key}) : null,
873 $keyField = $('.crm-entityref-filter-key', '#select2-drop'),
874 $valField = null;
875 if (filterSpec) {
876 $('.crm-entityref-filter-value', '#select2-drop').remove();
877 $valField = $(entityRefFilterValueMarkup($el, filter, filterSpec));
878 $keyField.after($valField);
879 if (filterSpec.type === 'select') {
880 loadEntityRefFilterOptions(filter, filterSpec, $valField, $el);
881 }
882 } else {
883 $('.crm-entityref-filter-value', '#select2-drop').hide().val('').change();
884 }
885 }
886
887 /**
888 * Fetch options for a filter from cache or ajax api
889 */
890 function loadEntityRefFilterOptions(filter, filterSpec, $valField, $el) {
891 // Fieldname may be prefixed with joins - strip those out
892 var fieldName = _.last(filter.key.split('.'));
893 if (filterSpec.options) {
894 CRM.utils.setOptions($valField, getEntityRefFilterOptions(fieldName, $el, filterSpec), false, filter.value);
895 return;
896 }
897 $('.crm-entityref-filters select', '#select2-drop').prop('disabled', true);
898 CRM.api3(filterSpec.entity, 'getoptions', {field: fieldName, context: 'search', sequential: 1})
899 .done(function(result) {
900 var entity = $el.data('api-entity').toLowerCase();
901 // Store options globally so we don't have to look them up again
902 filterSpec.options = result.values;
903 $('.crm-entityref-filters select', '#select2-drop').prop('disabled', false);
904 CRM.utils.setOptions($valField, getEntityRefFilterOptions(fieldName, $el, filterSpec), false, filter.value);
905 });
906 }
907
908 function getEntityRefFilterOptions(fieldName, $el, filterSpec) {
909 var values = _.cloneDeep(filterSpec.options),
910 params = $.extend({params: {}}, $el.data('api-params') || {}).params;
911 if (fieldName === 'contact_type' && params.contact_type) {
912 values = _.remove(values, function(option) {
913 return option.key.indexOf(params.contact_type + '__') === 0;
914 });
915 }
916 return values;
917 }
918
919 //CRM-15598 - Override url validator method to allow relative url's (e.g. /index.htm)
920 $.validator.addMethod("url", function(value, element) {
921 if (/^\//.test(value)) {
922 // Relative url: prepend dummy path for validation.
923 value = 'http://domain.tld' + value;
924 }
925 // From jQuery Validation Plugin v1.12.0
926 return this.optional(element) || /^(https?|s?ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i.test(value);
927 });
928
929 /**
930 * Wrapper for jQuery validate initialization function; supplies defaults
931 */
932 $.fn.crmValidate = function(params) {
933 return $(this).each(function () {
934 var validator = $(this).validate();
935 var that = this;
936 validator.settings = $.extend({}, validator.settings, CRM.validate._defaults, CRM.validate.params);
937 // Call our custom validation handler.
938 $(validator.currentForm).on("invalid-form.validate", validator.settings.invalidHandler);
939 // Call any post-initialization callbacks
940 if (CRM.validate.functions && CRM.validate.functions.length) {
941 $.each(CRM.validate.functions, function(i, func) {
942 func.call(that);
943 });
944 }
945 });
946 };
947
948 // Submit-once
949 var submitted = [],
950 submitButton;
951 function submitOnceForm(e) {
952 if (e.isDefaultPrevented()) {
953 return;
954 }
955 if (_.contains(submitted, e.target)) {
956 return false;
957 }
958 submitted.push(e.target);
959 // Spin submit button icon
960 if (submitButton && $(submitButton, e.target).length) {
961 // Dialog button
962 if ($(e.target).closest('.ui-dialog .crm-ajax-container')) {
963 var identifier = $(submitButton).attr('name') || $(submitButton).attr('href');
964 if (identifier) {
965 submitButton = $(e.target).closest('.ui-dialog').find('button[data-identifier="' + identifier + '"]')[0] || submitButton;
966 }
967 }
968 var $icon = $(submitButton).siblings('.crm-i').add('.crm-i, .ui-icon', submitButton);
969 $icon.data('origClass', $icon.attr('class')).removeClass().addClass('crm-i crm-submit-icon fa-spinner fa-pulse');
970 }
971 }
972
973 // If form fails validation, restore button icon and reset the submitted array
974 function submitFormInvalid(form) {
975 submitted = [];
976 $('.crm-i.crm-submit-icon').each(function() {
977 if ($(this).data('origClass')) {
978 $(this).removeClass().addClass($(this).data('origClass'));
979 }
980 });
981 }
982
983 // Initialize widgets
984 $(document)
985 .on('crmLoad', function(e) {
986 $('table.row-highlight', e.target)
987 .off('.rowHighlight')
988 .on('change.rowHighlight', 'input.select-row, input.select-rows', function (e, data) {
989 var filter, $table = $(this).closest('table');
990 if ($(this).hasClass('select-rows')) {
991 filter = $(this).prop('checked') ? ':not(:checked)' : ':checked';
992 $('input.select-row' + filter, $table).prop('checked', $(this).prop('checked')).trigger('change', 'master-selected');
993 }
994 else {
995 $(this).closest('tr').toggleClass('crm-row-selected', $(this).prop('checked'));
996 if (data !== 'master-selected') {
997 $('input.select-rows', $table).prop('checked', $(".select-row:not(':checked')", $table).length < 1);
998 }
999 }
1000 })
1001 .find('input.select-row:checked').parents('tr').addClass('crm-row-selected');
1002 $('.crm-sortable-list', e.target).sortable();
1003 $('table.crm-sortable', e.target).DataTable();
1004 $('table.crm-ajax-table', e.target).each(function() {
1005 var
1006 $table = $(this),
1007 script = CRM.config.resourceBase + 'js/jquery/jquery.crmAjaxTable.js',
1008 $accordion = $table.closest('.crm-accordion-wrapper.collapsed, .crm-collapsible.collapsed');
1009 // For tables hidden by collapsed accordions, wait.
1010 if ($accordion.length) {
1011 $accordion.one('crmAccordion:open', function() {
1012 CRM.loadScript(script).done(function() {
1013 $table.crmAjaxTable();
1014 });
1015 });
1016 } else {
1017 CRM.loadScript(script).done(function() {
1018 $table.crmAjaxTable();
1019 });
1020 }
1021 });
1022 if ($("input:radio[name=radio_ts]").length == 1) {
1023 $("input:radio[name=radio_ts]").prop("checked", true);
1024 }
1025 $('.crm-select2:not(.select2-offscreen, .select2-container)', e.target).crmSelect2();
1026 $('.crm-form-entityref:not(.select2-offscreen, .select2-container)', e.target).crmEntityRef();
1027 $('select.crm-chain-select-control', e.target).off('.chainSelect').on('change.chainSelect', chainSelect);
1028 $('.crm-form-text[data-crm-datepicker]', e.target).each(function() {
1029 $(this).crmDatepicker($(this).data('crmDatepicker'));
1030 });
1031 $('.crm-editable', e.target).not('thead *').each(function() {
1032 var $el = $(this);
1033 CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmEditable.js').done(function() {
1034 $el.crmEditable();
1035 });
1036 });
1037 // Cache Form Input initial values
1038 $('form[data-warn-changes] :input', e.target).each(function() {
1039 $(this).data('crm-initial-value', $(this).is(':checkbox, :radio') ? $(this).prop('checked') : $(this).val());
1040 });
1041 $('textarea.crm-form-wysiwyg', e.target).each(function() {
1042 if ($(this).hasClass("collapsed")) {
1043 CRM.wysiwyg.createCollapsed(this);
1044 } else {
1045 CRM.wysiwyg.create(this);
1046 }
1047 });
1048 // Submit once handlers
1049 $('form[data-submit-once]', e.target)
1050 .submit(submitOnceForm)
1051 .on('invalid-form', submitFormInvalid);
1052 $('form[data-submit-once] button[type=submit]', e.target).click(function(e) {
1053 submitButton = e.target;
1054 });
1055 })
1056 .on('dialogopen', function(e) {
1057 var $el = $(e.target);
1058 $('body').addClass('ui-dialog-open');
1059 // Modal dialogs should disable scrollbars
1060 if ($el.dialog('option', 'modal')) {
1061 $el.addClass('modal-dialog');
1062 $('body').css({overflow: 'hidden'});
1063 }
1064 $el.parent().find('.ui-dialog-titlebar .ui-icon-closethick').removeClass('ui-icon-closethick').addClass('fa-times');
1065 // Add resize button
1066 if ($el.parent().hasClass('crm-container') && $el.dialog('option', 'resizable')) {
1067 $el.parent().find('.ui-dialog-titlebar').append($('<button class="crm-dialog-titlebar-resize ui-dialog-titlebar-close" title="'+ _.escape(ts('Toggle fullscreen'))+'" style="right:2em;"/>').button({icons: {primary: 'fa-expand'}, text: false}));
1068 $('.crm-dialog-titlebar-resize', $el.parent()).click(function(e) {
1069 if ($el.data('origSize')) {
1070 $el.dialog('option', $el.data('origSize'));
1071 $el.data('origSize', null);
1072 $(this).button('option', 'icons', {primary: 'fa-expand'});
1073 } else {
1074 var menuHeight = $('#civicrm-menu').outerHeight();
1075 if ($('body').hasClass('crm-menubar-below-cms-menu')) {
1076 menuHeight += $('#civicrm-menu').offset().top;
1077 }
1078 $el.data('origSize', {
1079 position: {my: 'center', at: 'center center+' + (menuHeight / 2), of: window},
1080 width: $el.dialog('option', 'width'),
1081 height: $el.dialog('option', 'height')
1082 });
1083 $el.dialog('option', {width: '100%', height: ($(window).height() - menuHeight), position: {my: "top", at: "top+"+menuHeight, of: window}});
1084 $(this).button('option', 'icons', {primary: 'fa-compress'});
1085 }
1086 $el.trigger('dialogresize');
1087 e.preventDefault();
1088 });
1089 }
1090 })
1091 .on('dialogclose', function(e) {
1092 // Restore scrollbars when closing modal
1093 if ($('.ui-dialog .modal-dialog:visible').not(e.target).length < 1) {
1094 $('body').css({overflow: ''});
1095 }
1096 if ($('.ui-dialog-content:visible').not(e.target).length < 1) {
1097 $('body').removeClass('ui-dialog-open');
1098 }
1099 })
1100 .on('submit', function(e) {
1101 // CRM-14353 - disable changes warn when submitting a form
1102 $('[data-warn-changes]').attr('data-warn-changes', 'false');
1103 });
1104
1105 // CRM-14353 - Warn of unsaved changes for forms which have opted in
1106 window.onbeforeunload = function() {
1107 if (CRM.utils.initialValueChanged($('form[data-warn-changes=true]:visible'))) {
1108 return ts('You have unsaved changes.');
1109 }
1110 };
1111
1112 $.fn.crmtooltip = function () {
1113 var TOOLTIP_HIDE_DELAY = 300;
1114
1115 $(document)
1116 .on('mouseover', 'a.crm-summary-link:not(.crm-processed)', function (e) {
1117 $(this).addClass('crm-processed crm-tooltip-active');
1118 var topDistance = e.pageY - $(window).scrollTop();
1119 if (topDistance < 300 || topDistance < $(this).children('.crm-tooltip-wrapper').height()) {
1120 $(this).addClass('crm-tooltip-down');
1121 }
1122 if (!$(this).children('.crm-tooltip-wrapper').length) {
1123 $(this).append('<div class="crm-tooltip-wrapper"><div class="crm-tooltip"></div></div>');
1124 $(this).children().children('.crm-tooltip')
1125 .html('<div class="crm-loading-element"></div>')
1126 .load(this.href);
1127 }
1128 })
1129 .on('mouseleave', 'a.crm-summary-link', function () {
1130 var tooltipLink = $(this);
1131 setTimeout(function () {
1132 if (tooltipLink.filter(':hover').length === 0) {
1133 tooltipLink.removeClass('crm-processed crm-tooltip-active crm-tooltip-down');
1134 }
1135 }, TOOLTIP_HIDE_DELAY);
1136 })
1137 .on('click', 'a.crm-summary-link', false);
1138 };
1139
1140 var helpDisplay, helpPrevious;
1141 // Non-ajax example:
1142 // CRM.help('Example title', 'Here is some text to describe this example');
1143 // Ajax example (will load help id "foo" from templates/CRM/bar.tpl):
1144 // CRM.help('Example title', {id: 'foo', file: 'CRM/bar'});
1145 CRM.help = function (title, params, url) {
1146 var ajax = typeof params !== 'string';
1147 if (helpDisplay && helpDisplay.close) {
1148 // If the same link is clicked twice, just close the display
1149 if (helpDisplay.isOpen && _.isEqual(helpPrevious, params)) {
1150 helpDisplay.close();
1151 return;
1152 }
1153 helpDisplay.close();
1154 }
1155 helpPrevious = _.cloneDeep(params);
1156 helpDisplay = CRM.alert(ajax ? '...' : params, title, 'crm-help ' + (ajax ? 'crm-msg-loading' : 'info'), {expires: 0});
1157 if (ajax) {
1158 if (!url) {
1159 url = CRM.url('civicrm/ajax/inline');
1160 params.class_name = 'CRM_Core_Page_Inline_Help';
1161 params.type = 'page';
1162 }
1163 $.ajax(url, {
1164 data: params,
1165 dataType: 'html',
1166 success: function (data) {
1167 $('#crm-notification-container .crm-help .notify-content:last').html(data);
1168 $('#crm-notification-container .crm-help').removeClass('crm-msg-loading').addClass('info');
1169 },
1170 error: function () {
1171 $('#crm-notification-container .crm-help .notify-content:last').html('Unable to load help file.');
1172 $('#crm-notification-container .crm-help').removeClass('crm-msg-loading').addClass('error');
1173 }
1174 });
1175 }
1176 };
1177 /**
1178 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1179 */
1180 CRM.status = function(options, deferred) {
1181 // 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.
1182 if (typeof options === 'string') {
1183 return CRM.status({start: options, success: options, error: options})[deferred === 'error' ? 'reject' : 'resolve']();
1184 }
1185 var opts = $.extend({
1186 start: ts('Saving...'),
1187 success: ts('Saved'),
1188 error: function(data) {
1189 var msg = $.isPlainObject(data) && data.error_message;
1190 CRM.alert(msg || ts('Sorry an error occurred and your information was not saved'), ts('Error'), 'error');
1191 }
1192 }, options || {});
1193 var $msg = $('<div class="crm-status-box-outer status-start"><div class="crm-status-box-inner"><div class="crm-status-box-msg">' + _.escape(opts.start) + '</div></div></div>')
1194 .appendTo('body');
1195 $msg.css('min-width', $msg.width());
1196 function handle(status, data) {
1197 var endMsg = typeof(opts[status]) === 'function' ? opts[status](data) : opts[status];
1198 if (endMsg) {
1199 $msg.removeClass('status-start').addClass('status-' + status).find('.crm-status-box-msg').text(endMsg);
1200 window.setTimeout(function() {
1201 $msg.fadeOut('slow', function() {
1202 $msg.remove();
1203 });
1204 }, 2000);
1205 } else {
1206 $msg.remove();
1207 }
1208 }
1209 return (deferred || new $.Deferred())
1210 .done(function(data) {
1211 // If the server returns an error msg call the error handler
1212 var status = $.isPlainObject(data) && (data.is_error || data.status === 'error') ? 'error' : 'success';
1213 handle(status, data);
1214 })
1215 .fail(function(data) {
1216 handle('error', data);
1217 });
1218 };
1219
1220 // Convert an Angular promise to a jQuery promise
1221 CRM.toJqPromise = function(aPromise) {
1222 var jqDeferred = $.Deferred();
1223 aPromise.then(
1224 function(data) { jqDeferred.resolve(data); },
1225 function(data) { jqDeferred.reject(data); }
1226 // should we also handle progress events?
1227 );
1228 return jqDeferred.promise();
1229 };
1230
1231 CRM.toAPromise = function($q, jqPromise) {
1232 var aDeferred = $q.defer();
1233 jqPromise.then(
1234 function(data) { aDeferred.resolve(data); },
1235 function(data) { aDeferred.reject(data); }
1236 // should we also handle progress events?
1237 );
1238 return aDeferred.promise;
1239 };
1240
1241 /**
1242 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1243 */
1244 CRM.alert = function (text, title, type, options) {
1245 type = type || 'alert';
1246 title = title || '';
1247 options = options || {};
1248 if ($('#crm-notification-container').length) {
1249 var params = {
1250 text: text,
1251 title: title,
1252 type: type
1253 };
1254 // By default, don't expire errors and messages containing links
1255 var extra = {
1256 expires: (type == 'error' || text.indexOf('<a ') > -1) ? 0 : (text ? 10000 : 5000),
1257 unique: true
1258 };
1259 options = $.extend(extra, options);
1260 options.expires = (options.expires === false || !CRM.config.allowAlertAutodismissal) ? 0 : parseInt(options.expires, 10);
1261 if (options.unique && options.unique !== '0') {
1262 $('#crm-notification-container .ui-notify-message').each(function () {
1263 if (title === $('h1', this).html() && text === $('.notify-content', this).html()) {
1264 $('.icon.ui-notify-close', this).click();
1265 }
1266 });
1267 }
1268 return $('#crm-notification-container').notify('create', params, options);
1269 }
1270 else {
1271 if (title.length) {
1272 text = title + "\n" + text;
1273 }
1274 // strip html tags as they are not parsed in standard alerts
1275 alert($("<div/>").html(text).text());
1276 return null;
1277 }
1278 };
1279
1280 /**
1281 * Close whichever alert contains the given node
1282 *
1283 * @param node
1284 */
1285 CRM.closeAlertByChild = function (node) {
1286 $(node).closest('.ui-notify-message').find('.icon.ui-notify-close').click();
1287 };
1288
1289 /**
1290 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1291 */
1292 CRM.confirm = function (options) {
1293 var dialog, url, msg, buttons = [], settings = {
1294 title: ts('Confirm'),
1295 message: ts('Are you sure you want to continue?'),
1296 url: null,
1297 width: 'auto',
1298 height: 'auto',
1299 resizable: false,
1300 dialogClass: 'crm-container crm-confirm',
1301 close: function () {
1302 $(this).dialog('destroy').remove();
1303 },
1304 options: {
1305 no: ts('Cancel'),
1306 yes: ts('Continue')
1307 }
1308 };
1309 if (options && options.url) {
1310 settings.resizable = true;
1311 settings.height = '50%';
1312 }
1313 $.extend(settings, ($.isFunction(options) ? arguments[1] : options) || {});
1314 settings = CRM.utils.adjustDialogDefaults(settings);
1315 if (!settings.buttons && $.isPlainObject(settings.options)) {
1316 $.each(settings.options, function(op, label) {
1317 buttons.push({
1318 text: label,
1319 'data-op': op,
1320 icons: {primary: op === 'no' ? 'fa-times' : 'fa-check'},
1321 click: function() {
1322 var event = $.Event('crmConfirm:' + op);
1323 $(this).trigger(event);
1324 if (!event.isDefaultPrevented()) {
1325 dialog.dialog('close');
1326 }
1327 }
1328 });
1329 });
1330 // Order buttons so that "no" goes on the right-hand side
1331 settings.buttons = _.sortBy(buttons, 'data-op').reverse();
1332 }
1333 url = settings.url;
1334 msg = url ? '' : settings.message;
1335 delete settings.options;
1336 delete settings.message;
1337 delete settings.url;
1338 dialog = $('<div class="crm-confirm-dialog"></div>').html(msg || '').dialog(settings);
1339 if ($.isFunction(options)) {
1340 dialog.on('crmConfirm:yes', options);
1341 }
1342 if (url) {
1343 CRM.loadPage(url, {target: dialog});
1344 }
1345 else {
1346 dialog.trigger('crmLoad');
1347 }
1348 return dialog;
1349 };
1350
1351 /** provides a local copy of ts for a domain */
1352 CRM.ts = function(domain) {
1353 return function(message, options) {
1354 if (domain) {
1355 options = $.extend(options || {}, {domain: domain});
1356 }
1357 return ts(message, options);
1358 };
1359 };
1360
1361 CRM.addStrings = function(domain, strings) {
1362 var bucket = (domain == 'civicrm' ? 'strings' : 'strings::' + domain);
1363 CRM[bucket] = CRM[bucket] || {};
1364 _.extend(CRM[bucket], strings);
1365 };
1366
1367 /**
1368 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1369 */
1370 $.fn.crmError = function (text, title, options) {
1371 title = title || '';
1372 text = text || '';
1373 options = options || {};
1374
1375 var extra = {
1376 expires: 0
1377 }, label;
1378 if ($(this).length) {
1379 if (title === '') {
1380 label = $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]').not('[generated=true]');
1381 if (label.length) {
1382 label.addClass('crm-error');
1383 var $label = label.clone();
1384 if (text === '' && $('.crm-marker', $label).length > 0) {
1385 text = $('.crm-marker', $label).attr('title');
1386 }
1387 $('.crm-marker', $label).remove();
1388 title = $label.text();
1389 }
1390 }
1391 $(this).addClass('crm-error');
1392 }
1393 var msg = CRM.alert(text, title, 'error', $.extend(extra, options));
1394 if ($(this).length) {
1395 var ele = $(this);
1396 setTimeout(function () {
1397 ele.one('change', function () {
1398 if (msg && msg.close) msg.close();
1399 ele.removeClass('crm-error');
1400 if (label) {
1401 label.removeClass('crm-error');
1402 }
1403 });
1404 }, 1000);
1405 }
1406 return msg;
1407 };
1408
1409 // Display system alerts through js notifications
1410 function messagesFromMarkup() {
1411 $('div.messages:visible', this).not('.help').not('.no-popup').each(function () {
1412 var text, title = '';
1413 $(this).removeClass('status messages');
1414 var type = $(this).attr('class').split(' ')[0] || 'alert';
1415 type = type.replace('crm-', '');
1416 $('.icon', this).remove();
1417 if ($('.msg-text', this).length > 0) {
1418 text = $('.msg-text', this).html();
1419 title = $('.msg-title', this).html();
1420 }
1421 else {
1422 text = $(this).html();
1423 }
1424 var options = $(this).data('options') || {};
1425 $(this).remove();
1426 // Duplicates were already removed server-side
1427 options.unique = false;
1428 CRM.alert(text, title, type, options);
1429 });
1430 // Handle qf form errors
1431 $('form :input.error', this).one('blur', function() {
1432 $('.ui-notify-message.error a.ui-notify-close').click();
1433 $(this).removeClass('error');
1434 $(this).next('span.crm-error').remove();
1435 $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]')
1436 .removeClass('crm-error')
1437 .find('.crm-error').removeClass('crm-error');
1438 });
1439 }
1440
1441 /**
1442 * Improve blockUI when used with jQuery dialog
1443 */
1444 var originalBlock = $.fn.block,
1445 originalUnblock = $.fn.unblock;
1446
1447 $.fn.block = function(opts) {
1448 if ($(this).is('.ui-dialog-content')) {
1449 originalBlock.call($(this).parents('.ui-dialog'), opts);
1450 return $(this);
1451 }
1452 return originalBlock.call(this, opts);
1453 };
1454 $.fn.unblock = function(opts) {
1455 if ($(this).is('.ui-dialog-content')) {
1456 originalUnblock.call($(this).parents('.ui-dialog'), opts);
1457 return $(this);
1458 }
1459 return originalUnblock.call(this, opts);
1460 };
1461
1462 // Preprocess all CRM ajax calls to display messages
1463 $(document).ajaxSuccess(function(event, xhr, settings) {
1464 try {
1465 if ((!settings.dataType || settings.dataType == 'json') && xhr.responseText) {
1466 var response = $.parseJSON(xhr.responseText);
1467 if (typeof(response.crmMessages) == 'object') {
1468 $.each(response.crmMessages, function(n, msg) {
1469 CRM.alert(msg.text, msg.title, msg.type, msg.options);
1470 });
1471 }
1472 if (response.backtrace) {
1473 CRM.console('log', response.backtrace);
1474 }
1475 if (typeof response.deprecated === 'string') {
1476 CRM.console('warn', response.deprecated);
1477 }
1478 }
1479 }
1480 // Ignore errors thrown by parseJSON
1481 catch (e) {}
1482 });
1483
1484 $(function () {
1485 $.blockUI.defaults.message = null;
1486 $.blockUI.defaults.ignoreIfBlocked = true;
1487
1488 if ($('#crm-container').hasClass('crm-public')) {
1489 $.fn.select2.defaults.dropdownCssClass = $.ui.dialog.prototype.options.dialogClass = 'crm-container crm-public';
1490 }
1491
1492 // Trigger crmLoad on initial content for consistency. It will also be triggered for ajax-loaded content.
1493 $('.crm-container').trigger('crmLoad');
1494
1495 if ($('#crm-notification-container').length) {
1496 // Initialize notifications
1497 $('#crm-notification-container').notify();
1498 messagesFromMarkup.call($('#crm-container'));
1499 }
1500
1501 $('body')
1502 // bind the event for image popup
1503 .on('click', 'a.crm-image-popup', function(e) {
1504 CRM.confirm({
1505 title: ts('Preview'),
1506 resizable: true,
1507 // Prevent overlap with the menubar
1508 maxHeight: $(window).height() - 30,
1509 position: {my: 'center', at: 'center center+15', of: window},
1510 message: '<div class="crm-custom-image-popup"><img style="max-width: 100%" src="' + $(this).attr('href') + '"></div>',
1511 options: null
1512 });
1513 e.preventDefault();
1514 })
1515
1516 .on('click', function (event) {
1517 $('.btn-slide-active').removeClass('btn-slide-active').find('.panel').hide();
1518 if ($(event.target).is('.btn-slide')) {
1519 $(event.target).addClass('btn-slide-active').find('.panel').show();
1520 }
1521 })
1522
1523 // Handle clear button for form elements
1524 .on('click', 'a.crm-clear-link', function() {
1525 $(this).css({visibility: 'hidden'}).siblings('.crm-form-radio:checked').prop('checked', false).trigger('change', ['crmClear']);
1526 $(this).siblings('input:text').val('').trigger('change', ['crmClear']);
1527 return false;
1528 })
1529 .on('change keyup', 'input.crm-form-radio:checked, input[allowclear=1]', function(e, context) {
1530 if (context !== 'crmClear' && ($(this).is(':checked') || ($(this).is('[allowclear=1]') && $(this).val()))) {
1531 $(this).siblings('.crm-clear-link').css({visibility: ''});
1532 }
1533 if (context !== 'crmClear' && $(this).is('[allowclear=1]') && $(this).val() === '') {
1534 $(this).siblings('.crm-clear-link').css({visibility: 'hidden'});
1535 }
1536 })
1537
1538 // Allow normal clicking of links within accordions
1539 .on('click.crmAccordions', 'div.crm-accordion-header a, .collapsible-title a', function (e) {
1540 e.stopPropagation();
1541 })
1542 // Handle accordions
1543 .on('click.crmAccordions', '.crm-accordion-header, .crm-collapsible .collapsible-title', function (e) {
1544 var action = 'open';
1545 if ($(this).parent().hasClass('collapsed')) {
1546 $(this).next().css('display', 'none').slideDown(200);
1547 }
1548 else {
1549 $(this).next().css('display', 'block').slideUp(200);
1550 action = 'close';
1551 }
1552 $(this).parent().toggleClass('collapsed').trigger('crmAccordion:' + action);
1553 e.preventDefault();
1554 });
1555
1556 $().crmtooltip();
1557 });
1558
1559 /**
1560 * Collapse or expand an accordion
1561 * @param speed
1562 */
1563 $.fn.crmAccordionToggle = function (speed) {
1564 $(this).each(function () {
1565 var action = 'open';
1566 if ($(this).hasClass('collapsed')) {
1567 $('.crm-accordion-body', this).first().css('display', 'none').slideDown(speed);
1568 }
1569 else {
1570 $('.crm-accordion-body', this).first().css('display', 'block').slideUp(speed);
1571 action = 'close';
1572 }
1573 $(this).toggleClass('collapsed').trigger('crmAccordion:' + action);
1574 });
1575 };
1576
1577 /**
1578 * Clientside currency formatting
1579 * @param number value
1580 * @param [optional] boolean onlyNumber - if true, we return formatted amount without currency sign
1581 * @param [optional] string format - currency representation of the number 1234.56
1582 * @return string
1583 */
1584 var currencyTemplate;
1585 CRM.formatMoney = function(value, onlyNumber, format) {
1586 var precision, decimal, separator, sign, i, j, result;
1587 if (value === 'init' && format) {
1588 currencyTemplate = format;
1589 return;
1590 }
1591 format = format || currencyTemplate;
1592 if ((result = /1(.?)234(.?)56/.exec(format)) !== null) { // If value is formatted to 2 decimals
1593 precision = 2;
1594 }
1595 else if ((result = /1(.?)234(.?)6/.exec(format)) !== null) { // If value is formatted to 1 decimal
1596 precision = 1;
1597 }
1598 else if ((result = /1(.?)235/.exec(format)) !== null) { // If value is formatted to zero decimals
1599 precision = false;
1600 }
1601 else {
1602 return 'Invalid format passed to CRM.formatMoney';
1603 }
1604 separator = result[1];
1605 decimal = precision ? result[2] : false;
1606 sign = (value < 0) ? '-' : '';
1607 //extracting the absolute value of the integer part of the number and converting to string
1608 i = parseInt(value = Math.abs(value).toFixed(2)) + '';
1609 j = ((j = i.length) > 3) ? j % 3 : 0;
1610 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) : '');
1611 if (onlyNumber) {
1612 return result;
1613 }
1614 switch (precision) {
1615 case 2:
1616 return format.replace(/1.*234.*56/, result);
1617 case 1:
1618 return format.replace(/1.*234.*6/, result);
1619 case false:
1620 return format.replace(/1.*235/, result);
1621 }
1622 };
1623
1624 CRM.angRequires = function(name) {
1625 return CRM.angular.requires[name] || [];
1626 };
1627
1628 CRM.console = function(method, title, msg) {
1629 if (window.console) {
1630 method = $.isFunction(console[method]) ? method : 'log';
1631 if (msg === undefined) {
1632 return console[method](title);
1633 } else {
1634 return console[method](title, msg);
1635 }
1636 }
1637 };
1638
1639 // Sugar methods for window.localStorage, with a fallback for older browsers
1640 var cacheItems = {};
1641 CRM.cache = {
1642 get: function (name, defaultValue) {
1643 try {
1644 if (localStorage.getItem('CRM' + name) !== null) {
1645 return JSON.parse(localStorage.getItem('CRM' + name));
1646 }
1647 } catch(e) {}
1648 return cacheItems[name] === undefined ? defaultValue : cacheItems[name];
1649 },
1650 set: function (name, value) {
1651 try {
1652 localStorage.setItem('CRM' + name, JSON.stringify(value));
1653 } catch(e) {}
1654 cacheItems[name] = value;
1655 },
1656 clear: function(name) {
1657 try {
1658 localStorage.removeItem('CRM' + name);
1659 } catch(e) {}
1660 delete cacheItems[name];
1661 }
1662 };
1663
1664
1665
1666 // Determine if a user has a given permission.
1667 // @see CRM_Core_Resources::addPermissions
1668 CRM.checkPerm = function(perm) {
1669 return CRM.permissions && CRM.permissions[perm];
1670 };
1671
1672 // Round while preserving sigfigs
1673 CRM.utils.sigfig = function(n, digits) {
1674 var len = ("" + n).length;
1675 var scale = Math.pow(10.0, len-digits);
1676 return Math.round(n / scale) * scale;
1677 };
1678
1679 /**
1680 * Create a js Date object from a unix timestamp or a yyyy-mm-dd string
1681 * @param input
1682 * @returns {Date}
1683 */
1684 CRM.utils.makeDate = function(input) {
1685 switch (typeof input) {
1686 case 'object':
1687 // already a date object
1688 return input;
1689
1690 case 'string':
1691 // convert iso format with or without dashes
1692 input = input.replace(/[- :]/g, '');
1693 var output = $.datepicker.parseDate('yymmdd', input.substr(0, 8));
1694 if (input.length === 14) {
1695 output.setHours(
1696 parseInt(input.substr(8, 2), 10),
1697 parseInt(input.substr(10, 2), 10),
1698 parseInt(input.substr(12, 2), 10)
1699 );
1700 }
1701 return output;
1702
1703 case 'number':
1704 // convert unix timestamp
1705 return new Date(input * 1000);
1706 }
1707 throw 'Invalid input passed to CRM.utils.makeDate';
1708 };
1709
1710 /**
1711 * Format a date (and optionally time) for output to the user
1712 *
1713 * @param {string|int|Date} input
1714 * Input may be a js Date object, a unix timestamp or a 'yyyy-mm-dd' string
1715 * @param {string|null} dateFormat
1716 * A string like 'yy-mm-dd' or null to use the system default
1717 * @param {int|bool} timeFormat
1718 * Leave empty to omit time from the output (default)
1719 * Or pass 12, 24, or true to use the system default for 12/24hr format
1720 * @returns {string}
1721 */
1722 CRM.utils.formatDate = function(input, dateFormat, timeFormat) {
1723 if (!input) {
1724 return '';
1725 }
1726 var date = CRM.utils.makeDate(input),
1727 output = $.datepicker.formatDate(dateFormat || CRM.config.dateInputFormat, date);
1728 if (timeFormat) {
1729 var hour = date.getHours(),
1730 min = date.getMinutes(),
1731 suf = '';
1732 if (timeFormat === 12 || (timeFormat === true && !CRM.config.timeIs24Hr)) {
1733 suf = ' ' + (hour < 12 ? ts('AM') : ts('PM'));
1734 if (hour === 0 || hour > 12) {
1735 hour = Math.abs(hour - 12);
1736 }
1737 } else if (hour < 10) {
1738 hour = '0' + hour;
1739 }
1740 output += ' ' + hour + ':' + (min < 10 ? '0' : '') + min + suf;
1741 }
1742 return output;
1743 };
1744
1745 // Used to set appropriate text color for a given background
1746 CRM.utils.colorContrast = function (hexcolor) {
1747 hexcolor = hexcolor.replace(/[ #]/g, '');
1748 var r = parseInt(hexcolor.substr(0, 2), 16),
1749 g = parseInt(hexcolor.substr(2, 2), 16),
1750 b = parseInt(hexcolor.substr(4, 2), 16),
1751 yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
1752 return (yiq >= 128) ? 'black' : 'white';
1753 };
1754
1755 // CVE-2015-9251 - Prevent auto-execution of scripts when no explicit dataType was provided
1756 $.ajaxPrefilter(function(s) {
1757 if (s.crossDomain) {
1758 s.contents.script = false;
1759 }
1760 });
1761
1762 // 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.
1763 $.htmlPrefilter = function(html) {
1764 // Prior to jQuery 3.5, jQuery converted XHTML-style self-closing tags to
1765 // their XML equivalent: e.g., "<div />" to "<div></div>". This is
1766 // problematic for several reasons, including that it's vulnerable to XSS
1767 // attacks. However, since this was jQuery's behavior for many years, many
1768 // Drupal modules and jQuery plugins may be relying on it. Therefore, we
1769 // preserve that behavior, but for a limited set of tags only, that we believe
1770 // to not be vulnerable. This is the set of HTML tags that satisfy all of the
1771 // following conditions:
1772 // - In DOMPurify's list of HTML tags. If an HTML tag isn't safe enough to
1773 // appear in that list, then we don't want to mess with it here either.
1774 // @see https://github.com/cure53/DOMPurify/blob/2.0.11/dist/purify.js#L128
1775 // - A normal element (not a void, template, text, or foreign element).
1776 // @see https://html.spec.whatwg.org/multipage/syntax.html#elements-2
1777 // - An element that is still defined by the current HTML specification
1778 // (not a deprecated element), because we do not want to rely on how
1779 // browsers parse deprecated elements.
1780 // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element
1781 // - Not 'html', 'head', or 'body', because this pseudo-XHTML expansion is
1782 // designed for fragments, not entire documents.
1783 // - Not 'colgroup', because due to an idiosyncrasy of jQuery's original
1784 // regular expression, it didn't match on colgroup, and we don't want to
1785 // introduce a behavior change for that.
1786 var selfClosingTagsToReplace = [
1787 'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo',
1788 'blockquote', 'button', 'canvas', 'caption', 'cite', 'code', 'data',
1789 'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em',
1790 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
1791 'h4', 'h5', 'h6', 'header', 'hgroup', 'i', 'ins', 'kbd', 'label', 'legend',
1792 'li', 'main', 'map', 'mark', 'menu', 'meter', 'nav', 'ol', 'optgroup',
1793 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt',
1794 'ruby', 's', 'samp', 'section', 'select', 'small', 'source', 'span',
1795 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
1796 'thead', 'time', 'tr', 'u', 'ul', 'var', 'video'
1797 ];
1798
1799 // Define regular expressions for <TAG/> and <TAG ATTRIBUTES/>. Doing this as
1800 // two expressions makes it easier to target <a/> without also targeting
1801 // every tag that starts with "a".
1802 var xhtmlRegExpGroup = '(' + selfClosingTagsToReplace.join('|') + ')';
1803 var whitespace = '[\\x20\\t\\r\\n\\f]';
1804 var rxhtmlTagWithoutSpaceOrAttributes = new RegExp('<' + xhtmlRegExpGroup + '\\/>', 'gi');
1805 var rxhtmlTagWithSpaceAndMaybeAttributes = new RegExp('<' + xhtmlRegExpGroup + '(' + whitespace + '[^>]*)\\/>', 'gi');
1806
1807 // jQuery 3.5 also fixed a vulnerability for when </select> appears within
1808 // an <option> or <optgroup>, but it did that in local code that we can't
1809 // backport directly. Instead, we filter such cases out. To do so, we need to
1810 // determine when jQuery would otherwise invoke the vulnerable code, which it
1811 // uses this regular expression to determine. The regular expression changed
1812 // for version 3.0.0 and changed again for 3.4.0.
1813 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4958
1814 // @see https://github.com/jquery/jquery/blob/3.0.0/dist/jquery.js#L4584
1815 // @see https://github.com/jquery/jquery/blob/3.4.0/dist/jquery.js#L4712
1816 var rtagName = /<([\w:]+)/;
1817
1818 // The regular expression that jQuery uses to determine which self-closing
1819 // tags to expand to open and close tags. This is vulnerable, because it
1820 // matches all tag names except the few excluded ones. We only use this
1821 // expression for determining vulnerability. The expression changed for
1822 // version 3, but we only need to check for vulnerability in versions 1 and 2,
1823 // so we use the expression from those versions.
1824 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4957
1825 var rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi;
1826
1827 // This is how jQuery determines the first tag in the HTML.
1828 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5521
1829 var tag = ( rtagName.exec( html ) || [ "", "" ] )[ 1 ].toLowerCase();
1830
1831 // It is not valid HTML for <option> or <optgroup> to have <select> as
1832 // either a descendant or sibling, and attempts to inject one can cause
1833 // XSS on jQuery versions before 3.5. Since this is invalid HTML and a
1834 // possible XSS attack, reject the entire string.
1835 // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11023
1836 if ((tag === 'option' || tag === 'optgroup') && html.match(/<\/?select/i)) {
1837 html = '';
1838 }
1839
1840 // Retain jQuery's prior to 3.5 conversion of pseudo-XHTML, but for only
1841 // the tags in the `selfClosingTagsToReplace` list defined above.
1842 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5518
1843 // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11022
1844 html = html.replace(rxhtmlTagWithoutSpaceOrAttributes, "<$1></$1>");
1845 html = html.replace(rxhtmlTagWithSpaceAndMaybeAttributes, "<$1$2></$1>");
1846
1847 return html;
1848 };
1849
1850 })(jQuery, _);