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