Merge pull request #23896 from colemanw/afformManaged
[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 if (!params.contact_type) {
813 createLinks = CRM.config.entityRef.links[entity];
814 }
815 else if (typeof params.contact_type === 'string') {
816 createLinks = _.where(CRM.config.entityRef.links[entity], {type: params.contact_type});
817 } else {
818 // lets assume it's an array with filters such as IN etc
819 createLinks = [];
820 _.each(params.contact_type, function(types) {
821 _.each(types, function(type) {
822 createLinks.push(_.findWhere(CRM.config.entityRef.links[entity], {type: type}));
823 });
824 });
825 }
826 }
827 _.each(createLinks, function(link) {
828 markup += ' <a class="crm-add-entity crm-hover-button" href="' + link.url + '">' +
829 '<i class="crm-i ' + (link.icon || 'fa-plus-circle') + '" aria-hidden="true"></i> ' +
830 _.escape(link.label) + '</a>';
831 });
832 markup += '</div>';
833 return markup;
834 }
835
836 function getEntityRefFilters($el) {
837 var
838 entity = $el.data('api-entity'),
839 filters = CRM.config.entityRef.filters[entity] || [],
840 params = $.extend({params: {}}, $el.data('api-params') || {}).params,
841 result = [];
842 _.each(filters, function(filter) {
843 _.defaults(filter, {type: 'select', 'attributes': {}, entity: entity});
844 if (!params[filter.key]) {
845 // Filter out options if params don't match its condition
846 if (filter.condition && !_.isMatch(params, _.pick(filter.condition, _.keys(params)))) {
847 return;
848 }
849 result.push(filter);
850 }
851 else if (filter.key == 'contact_type' && typeof params.contact_sub_type === 'undefined') {
852 result.push(filter);
853 }
854 });
855 return result;
856 }
857
858 /**
859 * Provide markup for entity ref filters
860 */
861 function entityRefFiltersMarkup($el) {
862 var
863 filters = getEntityRefFilters($el),
864 filter = $el.data('user-filter') || {},
865 filterSpec = filter.key ? _.find(filters, {key: filter.key}) : null;
866 if (!filters.length) {
867 return '';
868 }
869 var markup = '<div class="crm-entityref-filters">' +
870 '<select class="crm-entityref-filter-key' + (filter.key ? ' active' : '') + '">' +
871 '<option value="">' + _.escape(ts('Refine search...')) + '</option>' +
872 CRM.utils.renderOptions(filters, filter.key) +
873 '</select>' + entityRefFilterValueMarkup($el, filter, filterSpec) + '</div>';
874 return markup;
875 }
876
877 /**
878 * Provide markup for entity ref filter value field
879 */
880 function entityRefFilterValueMarkup($el, filter, filterSpec) {
881 var markup = '';
882 if (filterSpec) {
883 var attrs = '',
884 attributes = _.cloneDeep(filterSpec.attributes);
885 if (filterSpec.type !== 'select') {
886 attributes.type = filterSpec.type;
887 attributes.value = typeof filter.value !== 'undefined' ? filter.value : '';
888 }
889 attributes.class = 'crm-entityref-filter-value' + (filter.value ? ' active' : '');
890 $.each(attributes, function (attr, val) {
891 attrs += ' ' + attr + '="' + val + '"';
892 });
893 if (filterSpec.type === 'select') {
894 var fieldName = _.last(filter.key.split('.')),
895 options = [{key: '', value: ts('- select -')}];
896 if (filterSpec.options) {
897 options = options.concat(getEntityRefFilterOptions(fieldName, $el, filterSpec));
898 }
899 markup = '<select' + attrs + '>' + CRM.utils.renderOptions(options, filter.value) + '</select>';
900 } else {
901 markup = '<input' + attrs + '/>';
902 }
903 }
904 return markup;
905 }
906
907 /**
908 * Render the entity ref filter value field
909 */
910 function renderEntityRefFilterValue($el) {
911 var
912 filter = $el.data('user-filter') || {},
913 filterSpec = filter.key ? _.find(getEntityRefFilters($el), {key: filter.key}) : null,
914 $keyField = $('.crm-entityref-filter-key', '#select2-drop'),
915 $valField = null;
916 if (filterSpec) {
917 $('.crm-entityref-filter-value', '#select2-drop').remove();
918 $valField = $(entityRefFilterValueMarkup($el, filter, filterSpec));
919 $keyField.after($valField);
920 if (filterSpec.type === 'select') {
921 loadEntityRefFilterOptions(filter, filterSpec, $valField, $el);
922 }
923 } else {
924 $('.crm-entityref-filter-value', '#select2-drop').hide().val('').change();
925 }
926 }
927
928 /**
929 * Fetch options for a filter from cache or ajax api
930 */
931 function loadEntityRefFilterOptions(filter, filterSpec, $valField, $el) {
932 // Fieldname may be prefixed with joins - strip those out
933 var fieldName = _.last(filter.key.split('.'));
934 if (filterSpec.options) {
935 CRM.utils.setOptions($valField, getEntityRefFilterOptions(fieldName, $el, filterSpec), false, filter.value);
936 return;
937 }
938 $('.crm-entityref-filters select', '#select2-drop').prop('disabled', true);
939 CRM.api3(filterSpec.entity, 'getoptions', {field: fieldName, context: 'search', sequential: 1})
940 .done(function(result) {
941 var entity = $el.data('api-entity').toLowerCase();
942 // Store options globally so we don't have to look them up again
943 filterSpec.options = result.values;
944 $('.crm-entityref-filters select', '#select2-drop').prop('disabled', false);
945 CRM.utils.setOptions($valField, getEntityRefFilterOptions(fieldName, $el, filterSpec), false, filter.value);
946 });
947 }
948
949 function getEntityRefFilterOptions(fieldName, $el, filterSpec) {
950 var values = _.cloneDeep(filterSpec.options),
951 params = $.extend({params: {}}, $el.data('api-params') || {}).params;
952 if (fieldName === 'contact_type' && params.contact_type) {
953 values = _.remove(values, function(option) {
954 return option.key.indexOf(params.contact_type + '__') === 0;
955 });
956 }
957 return values;
958 }
959
960 //CRM-15598 - Override url validator method to allow relative url's (e.g. /index.htm)
961 $.validator.addMethod("url", function(value, element) {
962 if (/^\//.test(value)) {
963 // Relative url: prepend dummy path for validation.
964 value = 'http://domain.tld' + value;
965 }
966 // From jQuery Validation Plugin v1.12.0
967 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);
968 });
969
970 /**
971 * Wrapper for jQuery validate initialization function; supplies defaults
972 */
973 $.fn.crmValidate = function(params) {
974 return $(this).each(function () {
975 var validator = $(this).validate();
976 var that = this;
977 validator.settings = $.extend({}, validator.settings, CRM.validate._defaults, CRM.validate.params);
978 // Call our custom validation handler.
979 $(validator.currentForm).on("invalid-form.validate", validator.settings.invalidHandler);
980 // Call any post-initialization callbacks
981 if (CRM.validate.functions && CRM.validate.functions.length) {
982 $.each(CRM.validate.functions, function(i, func) {
983 func.call(that);
984 });
985 }
986 });
987 };
988
989 // Submit-once
990 var submitted = [],
991 submitButton;
992 function submitOnceForm(e) {
993 if (e.isDefaultPrevented()) {
994 return;
995 }
996 if (_.contains(submitted, e.target)) {
997 return false;
998 }
999 submitted.push(e.target);
1000 // Spin submit button icon
1001 if (submitButton && $(submitButton, e.target).length) {
1002 // Dialog button
1003 if ($(e.target).closest('.ui-dialog .crm-ajax-container')) {
1004 var identifier = $(submitButton).attr('name') || $(submitButton).attr('href');
1005 if (identifier) {
1006 submitButton = $(e.target).closest('.ui-dialog').find('button[data-identifier="' + identifier + '"]')[0] || submitButton;
1007 }
1008 }
1009 var $icon = $(submitButton).siblings('.crm-i').add('.crm-i, .ui-icon', submitButton);
1010 $icon.data('origClass', $icon.attr('class')).removeClass().addClass('crm-i crm-submit-icon fa-spinner fa-pulse');
1011 }
1012 }
1013
1014 // If form fails validation, restore button icon and reset the submitted array
1015 function submitFormInvalid(form) {
1016 submitted = [];
1017 $('.crm-i.crm-submit-icon').each(function() {
1018 if ($(this).data('origClass')) {
1019 $(this).removeClass().addClass($(this).data('origClass'));
1020 }
1021 });
1022 }
1023
1024 // Initialize widgets
1025 $(document)
1026 .on('crmLoad', function(e) {
1027 $('table.row-highlight', e.target)
1028 .off('.rowHighlight')
1029 .on('change.rowHighlight', 'input.select-row, input.select-rows', function (e, data) {
1030 var filter, $table = $(this).closest('table');
1031 if ($(this).hasClass('select-rows')) {
1032 filter = $(this).prop('checked') ? ':not(:checked)' : ':checked';
1033 $('input.select-row' + filter, $table).prop('checked', $(this).prop('checked')).trigger('change', 'master-selected');
1034 }
1035 else {
1036 $(this).closest('tr').toggleClass('crm-row-selected', $(this).prop('checked'));
1037 if (data !== 'master-selected') {
1038 $('input.select-rows', $table).prop('checked', $(".select-row:not(':checked')", $table).length < 1);
1039 }
1040 }
1041 })
1042 .find('input.select-row:checked').parents('tr').addClass('crm-row-selected');
1043 $('.crm-sortable-list', e.target).sortable();
1044 $('table.crm-sortable', e.target).DataTable();
1045 $('table.crm-ajax-table', e.target).each(function() {
1046 var
1047 $table = $(this),
1048 script = CRM.config.resourceBase + 'js/jquery/jquery.crmAjaxTable.js',
1049 $accordion = $table.closest('.crm-accordion-wrapper.collapsed, .crm-collapsible.collapsed');
1050 // For tables hidden by collapsed accordions, wait.
1051 if ($accordion.length) {
1052 $accordion.one('crmAccordion:open', function() {
1053 CRM.loadScript(script).done(function() {
1054 $table.crmAjaxTable();
1055 });
1056 });
1057 } else {
1058 CRM.loadScript(script).done(function() {
1059 $table.crmAjaxTable();
1060 });
1061 }
1062 });
1063 if ($("input:radio[name=radio_ts]").length == 1) {
1064 $("input:radio[name=radio_ts]").prop("checked", true);
1065 }
1066 $('.crm-select2:not(.select2-offscreen, .select2-container)', e.target).crmSelect2();
1067 $('.crm-form-entityref:not(.select2-offscreen, .select2-container)', e.target).crmEntityRef();
1068 $('select.crm-chain-select-control', e.target).off('.chainSelect').on('change.chainSelect', chainSelect);
1069 $('.crm-form-text[data-crm-datepicker]', e.target).each(function() {
1070 $(this).crmDatepicker($(this).data('crmDatepicker'));
1071 });
1072 $('.crm-editable', e.target).not('thead *').each(function() {
1073 var $el = $(this);
1074 CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmEditable.js').done(function() {
1075 $el.crmEditable();
1076 });
1077 });
1078 // Cache Form Input initial values
1079 $('form[data-warn-changes] :input', e.target).each(function() {
1080 $(this).data('crm-initial-value', $(this).is(':checkbox, :radio') ? $(this).prop('checked') : $(this).val());
1081 });
1082 $('textarea.crm-form-wysiwyg', e.target).each(function() {
1083 if ($(this).hasClass("collapsed")) {
1084 CRM.wysiwyg.createCollapsed(this);
1085 } else {
1086 CRM.wysiwyg.create(this);
1087 }
1088 });
1089 // Submit once handlers
1090 $('form[data-submit-once]', e.target)
1091 .submit(submitOnceForm)
1092 .on('invalid-form', submitFormInvalid);
1093 $('form[data-submit-once] button[type=submit]', e.target).click(function(e) {
1094 submitButton = e.target;
1095 });
1096 })
1097 .on('dialogopen', function(e) {
1098 var $el = $(e.target);
1099 $('body').addClass('ui-dialog-open');
1100 // Modal dialogs should disable scrollbars
1101 if ($el.dialog('option', 'modal')) {
1102 $el.addClass('modal-dialog');
1103 $('body').css({overflow: 'hidden'});
1104 }
1105 $el.parent().find('.ui-dialog-titlebar .ui-icon-closethick').removeClass('ui-icon-closethick').addClass('fa-times');
1106 // Add resize button
1107 if ($el.parent().hasClass('crm-container') && $el.dialog('option', 'resizable')) {
1108 $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}));
1109 $('.crm-dialog-titlebar-resize', $el.parent()).click(function(e) {
1110 if ($el.data('origSize')) {
1111 $el.dialog('option', $el.data('origSize'));
1112 $el.data('origSize', null);
1113 $(this).button('option', 'icons', {primary: 'fa-expand'});
1114 } else {
1115 var menuHeight = $('#civicrm-menu').outerHeight();
1116 if ($('body').hasClass('crm-menubar-below-cms-menu')) {
1117 menuHeight += $('#civicrm-menu').offset().top;
1118 }
1119 $el.data('origSize', {
1120 position: {my: 'center', at: 'center center+' + (menuHeight / 2), of: window},
1121 width: $el.dialog('option', 'width'),
1122 height: $el.dialog('option', 'height')
1123 });
1124 $el.dialog('option', {width: '100%', height: ($(window).height() - menuHeight), position: {my: "top", at: "top+"+menuHeight, of: window}});
1125 $(this).button('option', 'icons', {primary: 'fa-compress'});
1126 }
1127 $el.trigger('dialogresize');
1128 e.preventDefault();
1129 });
1130 }
1131 })
1132 .on('dialogclose', function(e) {
1133 // Restore scrollbars when closing modal
1134 if ($('.ui-dialog .modal-dialog:visible').not(e.target).length < 1) {
1135 $('body').css({overflow: ''});
1136 }
1137 if ($('.ui-dialog-content:visible').not(e.target).length < 1) {
1138 $('body').removeClass('ui-dialog-open');
1139 }
1140 })
1141 .on('submit', function(e) {
1142 // CRM-14353 - disable changes warn when submitting a form
1143 $('[data-warn-changes]').attr('data-warn-changes', 'false');
1144 });
1145
1146 // CRM-14353 - Warn of unsaved changes for forms which have opted in
1147 window.onbeforeunload = function() {
1148 if (CRM.utils.initialValueChanged($('form[data-warn-changes=true]:visible'))) {
1149 return ts('You have unsaved changes.');
1150 }
1151 };
1152
1153 $.fn.crmtooltip = function () {
1154 var TOOLTIP_HIDE_DELAY = 300;
1155
1156 $(document)
1157 .on('mouseover', 'a.crm-summary-link:not(.crm-processed)', function (e) {
1158 $(this).addClass('crm-processed crm-tooltip-active');
1159 var topDistance = e.pageY - $(window).scrollTop();
1160 if (topDistance < 300 || topDistance < $(this).children('.crm-tooltip-wrapper').height()) {
1161 $(this).addClass('crm-tooltip-down');
1162 }
1163 if (!$(this).children('.crm-tooltip-wrapper').length) {
1164 var tooltipContents = $(this)[0].hasAttribute('data-tooltip-url') ? $(this).attr('data-tooltip-url') : this.href;
1165 $(this).append('<div class="crm-tooltip-wrapper"><div class="crm-tooltip"></div></div>');
1166 $(this).children().children('.crm-tooltip')
1167 .html('<div class="crm-loading-element"></div>')
1168 .load(tooltipContents);
1169 }
1170 })
1171 .on('mouseleave', 'a.crm-summary-link', function () {
1172 var tooltipLink = $(this);
1173 setTimeout(function () {
1174 if (tooltipLink.filter(':hover').length === 0) {
1175 tooltipLink.removeClass('crm-processed crm-tooltip-active crm-tooltip-down');
1176 }
1177 }, TOOLTIP_HIDE_DELAY);
1178 })
1179 .on('click', 'a.crm-summary-link', false);
1180 };
1181
1182 var helpDisplay, helpPrevious;
1183 // Non-ajax example:
1184 // CRM.help('Example title', 'Here is some text to describe this example');
1185 // Ajax example (will load help id "foo" from templates/CRM/bar.tpl):
1186 // CRM.help('Example title', {id: 'foo', file: 'CRM/bar'});
1187 CRM.help = function (title, params, url) {
1188 var ajax = typeof params !== 'string';
1189 if (helpDisplay && helpDisplay.close) {
1190 // If the same link is clicked twice, just close the display
1191 if (helpDisplay.isOpen && _.isEqual(helpPrevious, params)) {
1192 helpDisplay.close();
1193 return;
1194 }
1195 helpDisplay.close();
1196 }
1197 helpPrevious = _.cloneDeep(params);
1198 helpDisplay = CRM.alert(ajax ? '...' : params, title, 'crm-help ' + (ajax ? 'crm-msg-loading' : 'info'), {expires: 0});
1199 if (ajax) {
1200 if (!url) {
1201 url = CRM.url('civicrm/ajax/inline');
1202 params.class_name = 'CRM_Core_Page_Inline_Help';
1203 params.type = 'page';
1204 }
1205 $.ajax(url, {
1206 data: params,
1207 dataType: 'html',
1208 success: function (data) {
1209 $('#crm-notification-container .crm-help .notify-content:last').html(data);
1210 $('#crm-notification-container .crm-help').removeClass('crm-msg-loading').addClass('info');
1211 },
1212 error: function () {
1213 $('#crm-notification-container .crm-help .notify-content:last').html('Unable to load help file.');
1214 $('#crm-notification-container .crm-help').removeClass('crm-msg-loading').addClass('error');
1215 }
1216 });
1217 }
1218 };
1219 /**
1220 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1221 */
1222 CRM.status = function(options, deferred) {
1223 // 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.
1224 if (typeof options === 'string') {
1225 return CRM.status({start: options, success: options, error: options})[deferred === 'error' ? 'reject' : 'resolve']();
1226 }
1227 var opts = $.extend({
1228 start: ts('Saving...'),
1229 success: ts('Saved'),
1230 error: function(data) {
1231 var msg = $.isPlainObject(data) && data.error_message;
1232 CRM.alert(msg || ts('Sorry an error occurred and your information was not saved'), ts('Error'), 'error');
1233 }
1234 }, options || {});
1235 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>')
1236 .appendTo('body');
1237 $msg.css('min-width', $msg.width());
1238 function handle(status, data) {
1239 var endMsg = typeof(opts[status]) === 'function' ? opts[status](data) : opts[status];
1240 if (endMsg) {
1241 $msg.removeClass('status-start').addClass('status-' + status).find('.crm-status-box-msg').text(endMsg);
1242 window.setTimeout(function() {
1243 $msg.fadeOut('slow', function() {
1244 $msg.remove();
1245 });
1246 }, 2000);
1247 } else {
1248 $msg.remove();
1249 }
1250 }
1251 return (deferred || new $.Deferred())
1252 .done(function(data) {
1253 // If the server returns an error msg call the error handler
1254 var status = $.isPlainObject(data) && (data.is_error || data.status === 'error') ? 'error' : 'success';
1255 handle(status, data);
1256 })
1257 .fail(function(data) {
1258 handle('error', data);
1259 });
1260 };
1261
1262 // Convert an Angular promise to a jQuery promise
1263 CRM.toJqPromise = function(aPromise) {
1264 var jqDeferred = $.Deferred();
1265 aPromise.then(
1266 function(data) { jqDeferred.resolve(data); },
1267 function(data) { jqDeferred.reject(data); }
1268 // should we also handle progress events?
1269 );
1270 return jqDeferred.promise();
1271 };
1272
1273 CRM.toAPromise = function($q, jqPromise) {
1274 var aDeferred = $q.defer();
1275 jqPromise.then(
1276 function(data) { aDeferred.resolve(data); },
1277 function(data) { aDeferred.reject(data); }
1278 // should we also handle progress events?
1279 );
1280 return aDeferred.promise;
1281 };
1282
1283 /**
1284 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1285 */
1286 CRM.alert = function (text, title, type, options) {
1287 type = type || 'alert';
1288 title = title || '';
1289 options = options || {};
1290 if ($('#crm-notification-container').length) {
1291 var params = {
1292 text: text,
1293 title: title,
1294 type: type
1295 };
1296 // By default, don't expire errors and messages containing links
1297 var extra = {
1298 expires: (type == 'error' || text.indexOf('<a ') > -1) ? 0 : (text ? 10000 : 5000),
1299 unique: true
1300 };
1301 options = $.extend(extra, options);
1302 options.expires = (options.expires === false || !CRM.config.allowAlertAutodismissal) ? 0 : parseInt(options.expires, 10);
1303 if (options.unique && options.unique !== '0') {
1304 $('#crm-notification-container .ui-notify-message').each(function () {
1305 if (title === $('h1', this).html() && text === $('.notify-content', this).html()) {
1306 $('.icon.ui-notify-close', this).click();
1307 }
1308 });
1309 }
1310 return $('#crm-notification-container').notify('create', params, options);
1311 }
1312 else {
1313 if (title.length) {
1314 text = title + "\n" + text;
1315 }
1316 // strip html tags as they are not parsed in standard alerts
1317 alert($("<div/>").html(text).text());
1318 return null;
1319 }
1320 };
1321
1322 /**
1323 * Close whichever alert contains the given node
1324 *
1325 * @param node
1326 */
1327 CRM.closeAlertByChild = function (node) {
1328 $(node).closest('.ui-notify-message').find('.icon.ui-notify-close').click();
1329 };
1330
1331 /**
1332 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1333 */
1334 CRM.confirm = function (options) {
1335 var dialog, url, msg, buttons = [], settings = {
1336 title: ts('Confirm'),
1337 message: ts('Are you sure you want to continue?'),
1338 url: null,
1339 width: 'auto',
1340 height: 'auto',
1341 resizable: false,
1342 dialogClass: 'crm-container crm-confirm',
1343 close: function () {
1344 $(this).dialog('destroy').remove();
1345 },
1346 options: {
1347 no: ts('Cancel'),
1348 yes: ts('Continue')
1349 }
1350 };
1351 if (options && options.url) {
1352 settings.resizable = true;
1353 settings.height = '50%';
1354 }
1355 $.extend(settings, ($.isFunction(options) ? arguments[1] : options) || {});
1356 settings = CRM.utils.adjustDialogDefaults(settings);
1357 if (!settings.buttons && $.isPlainObject(settings.options)) {
1358 $.each(settings.options, function(op, label) {
1359 buttons.push({
1360 text: label,
1361 'data-op': op,
1362 icons: {primary: op === 'no' ? 'fa-times' : 'fa-check'},
1363 click: function() {
1364 var event = $.Event('crmConfirm:' + op);
1365 $(this).trigger(event);
1366 if (!event.isDefaultPrevented()) {
1367 dialog.dialog('close');
1368 }
1369 }
1370 });
1371 });
1372 // Order buttons so that "no" goes on the right-hand side
1373 settings.buttons = _.sortBy(buttons, 'data-op').reverse();
1374 }
1375 url = settings.url;
1376 msg = url ? '' : settings.message;
1377 delete settings.options;
1378 delete settings.message;
1379 delete settings.url;
1380 dialog = $('<div class="crm-confirm-dialog"></div>').html(msg || '').dialog(settings);
1381 if ($.isFunction(options)) {
1382 dialog.on('crmConfirm:yes', options);
1383 }
1384 if (url) {
1385 CRM.loadPage(url, {target: dialog});
1386 }
1387 else {
1388 dialog.trigger('crmLoad');
1389 }
1390 return dialog;
1391 };
1392
1393 /** provides a local copy of ts for a domain */
1394 CRM.ts = function(domain) {
1395 return function(message, options) {
1396 if (domain) {
1397 options = $.extend(options || {}, {domain: domain});
1398 }
1399 return ts(message, options);
1400 };
1401 };
1402
1403 CRM.addStrings = function(domain, strings) {
1404 var bucket = (domain == 'civicrm' ? 'strings' : 'strings::' + domain);
1405 CRM[bucket] = CRM[bucket] || {};
1406 _.extend(CRM[bucket], strings);
1407 };
1408
1409 /**
1410 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1411 */
1412 $.fn.crmError = function (text, title, options) {
1413 title = title || '';
1414 text = text || '';
1415 options = options || {};
1416
1417 var extra = {
1418 expires: 0
1419 }, label;
1420 if ($(this).length) {
1421 if (title === '') {
1422 label = $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]').not('[generated=true]');
1423 if (label.length) {
1424 label.addClass('crm-error');
1425 var $label = label.clone();
1426 if (text === '' && $('.crm-marker', $label).length > 0) {
1427 text = $('.crm-marker', $label).attr('title');
1428 }
1429 $('.crm-marker', $label).remove();
1430 title = $label.text();
1431 }
1432 }
1433 $(this).addClass('crm-error');
1434 }
1435 var msg = CRM.alert(text, title, 'error', $.extend(extra, options));
1436 if ($(this).length) {
1437 var ele = $(this);
1438 setTimeout(function () {
1439 ele.one('change', function () {
1440 if (msg && msg.close) msg.close();
1441 ele.removeClass('crm-error');
1442 if (label) {
1443 label.removeClass('crm-error');
1444 }
1445 });
1446 }, 1000);
1447 }
1448 return msg;
1449 };
1450
1451 // Display system alerts through js notifications
1452 function messagesFromMarkup() {
1453 $('div.messages:visible', this).not('.help').not('.no-popup').each(function () {
1454 var text, title = '';
1455 $(this).removeClass('status messages');
1456 var type = $(this).attr('class').split(' ')[0] || 'alert';
1457 type = type.replace('crm-', '');
1458 $('.icon', this).remove();
1459 if ($('.msg-text', this).length > 0) {
1460 text = $('.msg-text', this).html();
1461 title = $('.msg-title', this).html();
1462 }
1463 else {
1464 text = $(this).html();
1465 }
1466 var options = $(this).data('options') || {};
1467 $(this).remove();
1468 // Duplicates were already removed server-side
1469 options.unique = false;
1470 CRM.alert(text, title, type, options);
1471 });
1472 // Handle qf form errors
1473 $('form :input.error', this).one('blur', function() {
1474 $('.ui-notify-message.error a.ui-notify-close').click();
1475 $(this).removeClass('error');
1476 $(this).next('span.crm-error').remove();
1477 $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]')
1478 .removeClass('crm-error')
1479 .find('.crm-error').removeClass('crm-error');
1480 });
1481 }
1482
1483 /**
1484 * Improve blockUI when used with jQuery dialog
1485 */
1486 var originalBlock = $.fn.block,
1487 originalUnblock = $.fn.unblock;
1488
1489 $.fn.block = function(opts) {
1490 if ($(this).is('.ui-dialog-content')) {
1491 originalBlock.call($(this).parents('.ui-dialog'), opts);
1492 return $(this);
1493 }
1494 return originalBlock.call(this, opts);
1495 };
1496 $.fn.unblock = function(opts) {
1497 if ($(this).is('.ui-dialog-content')) {
1498 originalUnblock.call($(this).parents('.ui-dialog'), opts);
1499 return $(this);
1500 }
1501 return originalUnblock.call(this, opts);
1502 };
1503
1504 // Preprocess all CRM ajax calls to display messages
1505 $(document).ajaxSuccess(function(event, xhr, settings) {
1506 try {
1507 if ((!settings.dataType || settings.dataType == 'json') && xhr.responseText) {
1508 var response = $.parseJSON(xhr.responseText);
1509 if (typeof(response.crmMessages) == 'object') {
1510 $.each(response.crmMessages, function(n, msg) {
1511 CRM.alert(msg.text, msg.title, msg.type, msg.options);
1512 });
1513 }
1514 if (response.backtrace) {
1515 CRM.console('log', response.backtrace);
1516 }
1517 if (typeof response.deprecated === 'string') {
1518 CRM.console('warn', response.deprecated);
1519 }
1520 }
1521 }
1522 // Ignore errors thrown by parseJSON
1523 catch (e) {}
1524 });
1525
1526 $(function () {
1527 $.blockUI.defaults.message = null;
1528 $.blockUI.defaults.ignoreIfBlocked = true;
1529
1530 if ($('#crm-container').hasClass('crm-public')) {
1531 $.fn.select2.defaults.dropdownCssClass = $.ui.dialog.prototype.options.dialogClass = 'crm-container crm-public';
1532 }
1533
1534 // Trigger crmLoad on initial content for consistency. It will also be triggered for ajax-loaded content.
1535 $('.crm-container').trigger('crmLoad');
1536
1537 if ($('#crm-notification-container').length) {
1538 // Initialize notifications
1539 $('#crm-notification-container').notify();
1540 messagesFromMarkup.call($('#crm-container'));
1541 }
1542
1543 $('body')
1544 // bind the event for image popup
1545 .on('click', 'a.crm-image-popup', function(e) {
1546 CRM.confirm({
1547 title: ts('Preview'),
1548 resizable: true,
1549 // Prevent overlap with the menubar
1550 maxHeight: $(window).height() - 30,
1551 position: {my: 'center', at: 'center center+15', of: window},
1552 message: '<div class="crm-custom-image-popup"><img style="max-width: 100%" src="' + $(this).attr('href') + '"></div>',
1553 options: null
1554 });
1555 e.preventDefault();
1556 })
1557
1558 .on('click', function (event) {
1559 $('.btn-slide-active').removeClass('btn-slide-active').find('.panel').hide();
1560 if ($(event.target).is('.btn-slide')) {
1561 $(event.target).addClass('btn-slide-active').find('.panel').show();
1562 }
1563 })
1564
1565 // Handle clear button for form elements
1566 .on('click', 'a.crm-clear-link', function() {
1567 $(this).css({visibility: 'hidden'}).siblings('.crm-form-radio:checked').prop('checked', false).trigger('change', ['crmClear']);
1568 $(this).siblings('input:text').val('').trigger('change', ['crmClear']);
1569 return false;
1570 })
1571 .on('change keyup', 'input.crm-form-radio:checked, input[allowclear=1]', function(e, context) {
1572 if (context !== 'crmClear' && ($(this).is(':checked') || ($(this).is('[allowclear=1]') && $(this).val()))) {
1573 $(this).siblings('.crm-clear-link').css({visibility: ''});
1574 }
1575 if (context !== 'crmClear' && $(this).is('[allowclear=1]') && $(this).val() === '') {
1576 $(this).siblings('.crm-clear-link').css({visibility: 'hidden'});
1577 }
1578 })
1579
1580 // Allow normal clicking of links within accordions
1581 .on('click.crmAccordions', 'div.crm-accordion-header a, .collapsible-title a', function (e) {
1582 e.stopPropagation();
1583 })
1584 // Handle accordions
1585 .on('click.crmAccordions', '.crm-accordion-header, .crm-collapsible .collapsible-title', function (e) {
1586 var action = 'open';
1587 if ($(this).parent().hasClass('collapsed')) {
1588 $(this).next().css('display', 'none').slideDown(200);
1589 }
1590 else {
1591 $(this).next().css('display', 'block').slideUp(200);
1592 action = 'close';
1593 }
1594 $(this).parent().toggleClass('collapsed').trigger('crmAccordion:' + action);
1595 e.preventDefault();
1596 });
1597
1598 $().crmtooltip();
1599 });
1600
1601 /**
1602 * Collapse or expand an accordion
1603 * @param speed
1604 */
1605 $.fn.crmAccordionToggle = function (speed) {
1606 $(this).each(function () {
1607 var action = 'open';
1608 if ($(this).hasClass('collapsed')) {
1609 $('.crm-accordion-body', this).first().css('display', 'none').slideDown(speed);
1610 }
1611 else {
1612 $('.crm-accordion-body', this).first().css('display', 'block').slideUp(speed);
1613 action = 'close';
1614 }
1615 $(this).toggleClass('collapsed').trigger('crmAccordion:' + action);
1616 });
1617 };
1618
1619 /**
1620 * Clientside currency formatting
1621 * @param number value
1622 * @param [optional] boolean onlyNumber - if true, we return formatted amount without currency sign
1623 * @param [optional] string format - currency representation of the number 1234.56
1624 * @return string
1625 */
1626 var currencyTemplate;
1627 CRM.formatMoney = function(value, onlyNumber, format) {
1628 var precision, decimal, separator, sign, i, j, result;
1629 if (value === 'init' && format) {
1630 currencyTemplate = format;
1631 return;
1632 }
1633 format = format || currencyTemplate;
1634 if ((result = /1(.?)234(.?)56/.exec(format)) !== null) { // If value is formatted to 2 decimals
1635 precision = 2;
1636 }
1637 else if ((result = /1(.?)234(.?)6/.exec(format)) !== null) { // If value is formatted to 1 decimal
1638 precision = 1;
1639 }
1640 else if ((result = /1(.?)235/.exec(format)) !== null) { // If value is formatted to zero decimals
1641 precision = false;
1642 }
1643 else {
1644 return 'Invalid format passed to CRM.formatMoney';
1645 }
1646 separator = result[1];
1647 decimal = precision ? result[2] : false;
1648 sign = (value < 0) ? '-' : '';
1649 //extracting the absolute value of the integer part of the number and converting to string
1650 i = parseInt(value = Math.abs(value).toFixed(2)) + '';
1651 j = ((j = i.length) > 3) ? j % 3 : 0;
1652 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) : '');
1653 if (onlyNumber) {
1654 return result;
1655 }
1656 switch (precision) {
1657 case 2:
1658 return format.replace(/1.*234.*56/, result);
1659 case 1:
1660 return format.replace(/1.*234.*6/, result);
1661 case false:
1662 return format.replace(/1.*235/, result);
1663 }
1664 };
1665
1666 CRM.angRequires = function(name) {
1667 return CRM.angular.requires[name] || [];
1668 };
1669
1670 CRM.console = function(method, title, msg) {
1671 if (window.console) {
1672 method = $.isFunction(console[method]) ? method : 'log';
1673 if (msg === undefined) {
1674 return console[method](title);
1675 } else {
1676 return console[method](title, msg);
1677 }
1678 }
1679 };
1680
1681 // Sugar methods for window.localStorage, with a fallback for older browsers
1682 var cacheItems = {};
1683 CRM.cache = {
1684 get: function (name, defaultValue) {
1685 try {
1686 if (localStorage.getItem('CRM' + name) !== null) {
1687 return JSON.parse(localStorage.getItem('CRM' + name));
1688 }
1689 } catch(e) {}
1690 return cacheItems[name] === undefined ? defaultValue : cacheItems[name];
1691 },
1692 set: function (name, value) {
1693 try {
1694 localStorage.setItem('CRM' + name, JSON.stringify(value));
1695 } catch(e) {}
1696 cacheItems[name] = value;
1697 },
1698 clear: function(name) {
1699 try {
1700 localStorage.removeItem('CRM' + name);
1701 } catch(e) {}
1702 delete cacheItems[name];
1703 }
1704 };
1705
1706
1707
1708 // Determine if a user has a given permission.
1709 // @see CRM_Core_Resources::addPermissions
1710 CRM.checkPerm = function(perm) {
1711 return CRM.permissions && CRM.permissions[perm];
1712 };
1713
1714 // Round while preserving sigfigs
1715 CRM.utils.sigfig = function(n, digits) {
1716 var len = ("" + n).length;
1717 var scale = Math.pow(10.0, len-digits);
1718 return Math.round(n / scale) * scale;
1719 };
1720
1721 /**
1722 * Create a js Date object from a unix timestamp or a yyyy-mm-dd string
1723 * @param input
1724 * @returns {Date}
1725 */
1726 CRM.utils.makeDate = function(input) {
1727 switch (typeof input) {
1728 case 'object':
1729 // already a date object
1730 return input;
1731
1732 case 'string':
1733 // convert iso format with or without dashes
1734 input = input.replace(/[- :]/g, '');
1735 var output = $.datepicker.parseDate('yymmdd', input.substr(0, 8));
1736 if (input.length === 14) {
1737 output.setHours(
1738 parseInt(input.substr(8, 2), 10),
1739 parseInt(input.substr(10, 2), 10),
1740 parseInt(input.substr(12, 2), 10)
1741 );
1742 }
1743 return output;
1744
1745 case 'number':
1746 // convert unix timestamp
1747 return new Date(input * 1000);
1748 }
1749 throw 'Invalid input passed to CRM.utils.makeDate';
1750 };
1751
1752 /**
1753 * Format a date (and optionally time) for output to the user
1754 *
1755 * @param {string|int|Date} input
1756 * Input may be a js Date object, a unix timestamp or a 'yyyy-mm-dd' string
1757 * @param {string|null} dateFormat
1758 * A string like 'yy-mm-dd' or null to use the system default
1759 * @param {int|bool} timeFormat
1760 * Leave empty to omit time from the output (default)
1761 * Or pass 12, 24, or true to use the system default for 12/24hr format
1762 * @returns {string}
1763 */
1764 CRM.utils.formatDate = function(input, dateFormat, timeFormat) {
1765 if (!input) {
1766 return '';
1767 }
1768 var date = CRM.utils.makeDate(input),
1769 output = $.datepicker.formatDate(dateFormat || CRM.config.dateInputFormat, date);
1770 if (timeFormat) {
1771 var hour = date.getHours(),
1772 min = date.getMinutes(),
1773 suf = '';
1774 if (timeFormat === 12 || (timeFormat === true && !CRM.config.timeIs24Hr)) {
1775 suf = ' ' + (hour < 12 ? ts('AM') : ts('PM'));
1776 if (hour === 0 || hour > 12) {
1777 hour = Math.abs(hour - 12);
1778 }
1779 } else if (hour < 10) {
1780 hour = '0' + hour;
1781 }
1782 output += ' ' + hour + ':' + (min < 10 ? '0' : '') + min + suf;
1783 }
1784 return output;
1785 };
1786
1787 // Used to set appropriate text color for a given background
1788 CRM.utils.colorContrast = function (hexcolor) {
1789 hexcolor = hexcolor.replace(/[ #]/g, '');
1790 var r = parseInt(hexcolor.substr(0, 2), 16),
1791 g = parseInt(hexcolor.substr(2, 2), 16),
1792 b = parseInt(hexcolor.substr(4, 2), 16),
1793 yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
1794 return (yiq >= 128) ? 'black' : 'white';
1795 };
1796
1797 // CVE-2015-9251 - Prevent auto-execution of scripts when no explicit dataType was provided
1798 $.ajaxPrefilter(function(s) {
1799 if (s.crossDomain) {
1800 s.contents.script = false;
1801 }
1802 });
1803
1804 // 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.
1805 $.htmlPrefilter = function(html) {
1806 // Prior to jQuery 3.5, jQuery converted XHTML-style self-closing tags to
1807 // their XML equivalent: e.g., "<div />" to "<div></div>". This is
1808 // problematic for several reasons, including that it's vulnerable to XSS
1809 // attacks. However, since this was jQuery's behavior for many years, many
1810 // Drupal modules and jQuery plugins may be relying on it. Therefore, we
1811 // preserve that behavior, but for a limited set of tags only, that we believe
1812 // to not be vulnerable. This is the set of HTML tags that satisfy all of the
1813 // following conditions:
1814 // - In DOMPurify's list of HTML tags. If an HTML tag isn't safe enough to
1815 // appear in that list, then we don't want to mess with it here either.
1816 // @see https://github.com/cure53/DOMPurify/blob/2.0.11/dist/purify.js#L128
1817 // - A normal element (not a void, template, text, or foreign element).
1818 // @see https://html.spec.whatwg.org/multipage/syntax.html#elements-2
1819 // - An element that is still defined by the current HTML specification
1820 // (not a deprecated element), because we do not want to rely on how
1821 // browsers parse deprecated elements.
1822 // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element
1823 // - Not 'html', 'head', or 'body', because this pseudo-XHTML expansion is
1824 // designed for fragments, not entire documents.
1825 // - Not 'colgroup', because due to an idiosyncrasy of jQuery's original
1826 // regular expression, it didn't match on colgroup, and we don't want to
1827 // introduce a behavior change for that.
1828 var selfClosingTagsToReplace = [
1829 'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo',
1830 'blockquote', 'button', 'canvas', 'caption', 'cite', 'code', 'data',
1831 'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em',
1832 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
1833 'h4', 'h5', 'h6', 'header', 'hgroup', 'i', 'ins', 'kbd', 'label', 'legend',
1834 'li', 'main', 'map', 'mark', 'menu', 'meter', 'nav', 'ol', 'optgroup',
1835 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt',
1836 'ruby', 's', 'samp', 'section', 'select', 'small', 'source', 'span',
1837 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
1838 'thead', 'time', 'tr', 'u', 'ul', 'var', 'video'
1839 ];
1840
1841 // Define regular expressions for <TAG/> and <TAG ATTRIBUTES/>. Doing this as
1842 // two expressions makes it easier to target <a/> without also targeting
1843 // every tag that starts with "a".
1844 var xhtmlRegExpGroup = '(' + selfClosingTagsToReplace.join('|') + ')';
1845 var whitespace = '[\\x20\\t\\r\\n\\f]';
1846 var rxhtmlTagWithoutSpaceOrAttributes = new RegExp('<' + xhtmlRegExpGroup + '\\/>', 'gi');
1847 var rxhtmlTagWithSpaceAndMaybeAttributes = new RegExp('<' + xhtmlRegExpGroup + '(' + whitespace + '[^>]*)\\/>', 'gi');
1848
1849 // jQuery 3.5 also fixed a vulnerability for when </select> appears within
1850 // an <option> or <optgroup>, but it did that in local code that we can't
1851 // backport directly. Instead, we filter such cases out. To do so, we need to
1852 // determine when jQuery would otherwise invoke the vulnerable code, which it
1853 // uses this regular expression to determine. The regular expression changed
1854 // for version 3.0.0 and changed again for 3.4.0.
1855 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4958
1856 // @see https://github.com/jquery/jquery/blob/3.0.0/dist/jquery.js#L4584
1857 // @see https://github.com/jquery/jquery/blob/3.4.0/dist/jquery.js#L4712
1858 var rtagName = /<([\w:]+)/;
1859
1860 // The regular expression that jQuery uses to determine which self-closing
1861 // tags to expand to open and close tags. This is vulnerable, because it
1862 // matches all tag names except the few excluded ones. We only use this
1863 // expression for determining vulnerability. The expression changed for
1864 // version 3, but we only need to check for vulnerability in versions 1 and 2,
1865 // so we use the expression from those versions.
1866 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4957
1867 var rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi;
1868
1869 // This is how jQuery determines the first tag in the HTML.
1870 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5521
1871 var tag = ( rtagName.exec( html ) || [ "", "" ] )[ 1 ].toLowerCase();
1872
1873 // It is not valid HTML for <option> or <optgroup> to have <select> as
1874 // either a descendant or sibling, and attempts to inject one can cause
1875 // XSS on jQuery versions before 3.5. Since this is invalid HTML and a
1876 // possible XSS attack, reject the entire string.
1877 // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11023
1878 if ((tag === 'option' || tag === 'optgroup') && html.match(/<\/?select/i)) {
1879 html = '';
1880 }
1881
1882 // Retain jQuery's prior to 3.5 conversion of pseudo-XHTML, but for only
1883 // the tags in the `selfClosingTagsToReplace` list defined above.
1884 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5518
1885 // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11022
1886 html = html.replace(rxhtmlTagWithoutSpaceOrAttributes, "<$1></$1>");
1887 html = html.replace(rxhtmlTagWithSpaceAndMaybeAttributes, "<$1$2></$1>");
1888
1889 return html;
1890 };
1891
1892 })(jQuery, _);