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