1 // https://civicrm.org/licensing
4 var cj
= CRM
.$ = jQuery
;
8 * Short-named function for string translation, defined in global scope so it's available everywhere.
10 * @param text string for translating
11 * @param params object key:value of additional parameters
15 function ts(text
, params
) {
17 var d
= (params
&& params
.domain
) ? ('strings::' + params
.domain
) : null;
18 if (d
&& CRM
[d
] && CRM
[d
][text
]) {
21 else if (CRM
.strings
[text
]) {
22 text
= CRM
.strings
[text
];
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-'));
31 return text
.replace(/%-crmescaped-/g, '%');
36 // Legacy code - ignore warnings
37 /* jshint ignore:start */
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
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 ...)
50 function on_load_init_blocks(showBlocks
, hideBlocks
, elementType
) {
51 if (elementType
== null) {
52 elementType
= 'block';
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
;
65 alert('showBlocks array item not in .tpl = ' + showBlocks
[i
]);
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';
77 alert('showBlocks array item not in .tpl = ' + hideBlocks
[i
]);
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).
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
94 function showHideByValue(trigger_field_id
, trigger_value
, target_element_id
, target_element_type
, field_type
, invert
) {
97 if (field_type
== 'select') {
98 var trigger
= trigger_value
.split("|");
99 var selectedOptionValue
= cj('#' + trigger_field_id
).val();
101 target
= target_element_id
.split("|");
102 for (j
= 0; j
< target
.length
; j
++) {
104 cj('#' + target
[j
]).show();
107 cj('#' + target
[j
]).hide();
109 for (var i
= 0; i
< trigger
.length
; i
++) {
110 if (selectedOptionValue
== trigger
[i
]) {
112 cj('#' + target
[j
]).hide();
115 cj('#' + target
[j
]).show();
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')) {
128 cj('#' + target
[j
]).hide();
131 cj('#' + target
[j
]).show();
136 cj('#' + target
[j
]).show();
139 cj('#' + target
[j
]).hide();
150 * Function to show / hide the row in optionFields
152 * @param index string, element whose innerHTML is to hide else will show the hidden row.
154 function showHideRow(index
) {
156 cj('tr#optionField_' + index
).hide();
157 if (cj('table#optionField tr:hidden:first').length
) {
158 cj('div#optionFieldLink').show();
162 cj('table#optionField tr:hidden:first').show();
163 if (!cj('table#optionField tr:hidden:last').length
) {
164 cj('div#optionFieldLink').hide();
170 /* jshint ignore:end */
172 if (!CRM
.utils
) CRM
.utils
= {};
173 if (!CRM
.strings
) CRM
.strings
= {};
174 if (!CRM
.vars
) CRM
.vars
= {};
176 (function ($, _
, undefined) {
178 /* jshint validthis: true */
180 // Theme classes for unattached elements
181 $.fn
.select2
.defaults
.dropdownCssClass
= $.ui
.dialog
.prototype.options
.dialogClass
= 'crm-container';
183 // https://github.com/ivaynberg/select2/pull/2090
184 $.fn
.select2
.defaults
.width
= 'resolve';
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
;
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
);
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
);
202 if ($(el
).is('.crm-form-date-wrapper .crm-hidden-date')) {
203 $(el
).siblings().prop('disabled', value
);
208 var scriptsLoaded
= {};
209 CRM
.loadScript = function(url
, appendCacheCode
) {
210 if (!scriptsLoaded
[url
]) {
211 var script
= document
.createElement('script'),
213 if (appendCacheCode
!== false) {
214 src
+= (_
.includes(url
, '?') ? '&r=' : '?r=') + CRM
.config
.resourceCacheCode
;
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
;
223 scriptsLoaded
[url
].resolve();
226 // Make jQuery global available while script is loading
227 if (window
.jQuery
!== CRM
.$) {
228 CRM
.CMSjQuery
= window
.jQuery
;
229 window
.jQuery
= CRM
.$;
232 document
.getElementsByTagName("head")[0].appendChild(script
);
234 return scriptsLoaded
[url
];
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
244 CRM
.utils
.setOptions = function(select
, options
, placeholder
, value
) {
245 $(select
).each(function() {
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');
257 if (typeof placeholder
=== 'string') {
258 if ($elect
.is('[multiple]')) {
259 select
.attr('placeholder', placeholder
);
261 newOptions
= '<option value="">' + placeholder
+ '</option>' + newOptions
;
264 $elect
.append(newOptions
);
266 $elect
.trigger('crmOptionsUpdated', $.extend({}, options
)).trigger('change');
272 * Render an option list
273 * @param options {array}
274 * @param val {string} default value
275 * @param escapeHtml {bool}
278 CRM
.utils
.renderOptions = function(options
, val
, escapeHtml
) {
280 esc
= escapeHtml
=== false ? _
.identity
: _
.escape
;
281 if (!$.isArray(val
)) {
284 _
.each(options
, function(option
) {
285 if (option
.children
) {
286 rendered
+= '<optgroup label="' + esc(option
.value
) + '">' +
287 CRM
.utils
.renderOptions(option
.children
, val
) +
290 var selected
= ($.inArray('' + option
.key
, val
) > -1) ? 'selected="selected"' : '';
291 rendered
+= '<option value="' + esc(option
.key
) + '"' + selected
+ '>' + esc(option
.value
) + '</option>';
297 CRM
.utils
.getOptions = function(select
) {
299 $('option', select
).each(function() {
300 var option
= {key
: $(this).attr('value'), value
: $(this).text()};
301 if (option
.key
!== '') {
302 options
.push(option
);
308 function chainSelect() {
309 var $form
= $(this).closest('form'),
310 $target
= $('select[data-name="' + $(this).data('target') + '"]', $form
),
311 data
= $target
.data(),
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();
317 if (!(val
&& val
.length
)) {
318 CRM
.utils
.setOptions($target
.blur(), [], data
.emptyPrompt
);
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
));
329 * Compare Form Input values against cached initial value.
331 * @return {Boolean} true if changes have been made.
333 CRM
.utils
.initialValueChanged = function(el
) {
335 $(':input:visible, .select2-container:visible+:input:hidden', el
).not('[type=submit], [type=button], .crm-action-menu, :disabled').each(function () {
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
)) {
348 * This provides defaults for ui.dialog which either need to be calculated or are different from global defaults
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);
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%';
367 else if (screenWidth
< 1400) {
368 settings
.width
= '' + parseInt(percentage
+gap
-((screenWidth
- 700)/7*(gap)/100), 10) + '%';
374 function formatCrmSelect2(row
) {
375 var icon
= row
.icon
|| $(row
.element
).data('icon'),
376 color
= row
.color
|| $(row
.element
).data('color'),
377 description
= row
.description
|| $(row
.element
).data('description'),
380 ret
+= '<i class="crm-i ' + icon
+ '" aria-hidden="true"></i> ';
383 ret
+= '<span class="crm-select-item-color" style="background-color: ' + color
+ '"></span> ';
385 return ret
+ _
.escape(row
.text
) + (description
? '<div class="crm-select2-row-description"><p>' + _
.escape(description
) + '</p></div>' : '');
389 * Helper to generate an icon with alt text.
391 * See also smarty `{icon}` and CRM_Core_Page::crmIcon() functions
394 * The Font Awesome icon class to use.
396 * Alt text to display.
397 * @param mixed condition
398 * This will only display if this is truthy.
401 * The formatted icon markup.
403 CRM
.utils
.formatIcon = function (icon
, text
, condition
) {
404 if (typeof condition
!== 'undefined' && !condition
) {
410 text
= _
.escape(text
);
411 title
= ' title="' + text
+ '"';
412 sr
= '<span class="sr-only">' + text
+ '</span>';
414 return '<i class="crm-i ' + icon
+ '"' + title
+ ' aria-hidden="true"></i>' + sr
;
418 * Wrapper for select2 initialization function; supplies defaults
419 * @param options object
421 $.fn
.crmSelect2 = function(options
) {
422 if (options
=== 'destroy') {
423 return $(this).each(function() {
425 .removeClass('crm-ajax-select')
430 return $(this).each(function () {
435 allowClear
: !$el
.hasClass('required'),
436 formatResult
: formatCrmSelect2
,
437 formatSelection
: formatCrmSelect2
440 // quickform doesn't support optgroups so here's a hack :(
441 // Instead of using wrapAll or similar that repeatedly appends options to the group and redraw the page (=> very slow on large lists),
442 // build bulk HTML and insert in single shot
444 $('option[value^=crm_optgroup]', this).each(function () {
446 $(this).nextUntil('option[value^=crm_optgroup]').each(function () {
447 groupHtml
+= this.outerHTML
;
449 optGroups
[$(this).text()] = groupHtml
;
452 var replacedHtml
= '';
453 for (var groupLabel
in optGroups
) {
454 replacedHtml
+= '<optgroup label="' + groupLabel
+ '">' + optGroups
[groupLabel
] + '</optgroup>';
457 $el
.html(replacedHtml
);
460 // quickform does not support disabled option, so yet another hack to
461 // add disabled property for option values
462 $('option[value^=crm_disabled_opt]', this).attr('disabled', 'disabled');
464 // Placeholder icon - total hack hikacking the escapeMarkup function but select2 3.5 dosn't have any other callbacks for this :(
465 if ($el
.is('[class*=fa-]')) {
466 settings
.escapeMarkup = function (m
) {
467 var out
= _
.escape(m
),
468 placeholder
= settings
.placeholder
|| $el
.data('placeholder') || $el
.attr('placeholder') || $('option[value=""]', $el
).text();
469 if (m
.length
&& placeholder
=== m
) {
470 iconClass
= $el
.attr('class').match(/(fa-\S*)/)[1];
471 out
= '<i class="crm-i ' + iconClass
+ '" aria-hidden="true"></i> ' + out
;
478 .on('select2-loaded.crmSelect2', function() {
479 // Use description as title for each option
480 $('.crm-select2-row-description', '#select2-drop').each(function() {
481 $(this).closest('.select2-result-label').attr('title', $(this).text());
483 // Collapsible optgroups should be expanded when searching
484 if ($('#select2-drop.collapsible-optgroups-enabled .select2-search input.select2-input').val()) {
485 $('#select2-drop.collapsible-optgroups-enabled li.select2-result-with-children')
486 .addClass('optgroup-expanded');
489 // Handle collapsible optgroups
490 .on('select2-open', function(e
) {
491 var isCollapsible
= $(e
.target
).hasClass('collapsible-optgroups');
493 .off('.collapseOptionGroup')
494 .toggleClass('collapsible-optgroups-enabled', isCollapsible
);
497 .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
498 $(this).parent().toggleClass('optgroup-expanded');
500 // If the first item in the list is an optgroup, expand it
501 .find('li.select2-result-with-children:first-child').addClass('optgroup-expanded');
504 .on('select2-close', function() {
505 $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
508 // Defaults for single-selects
509 if ($el
.is('select:not([multiple])')) {
510 settings
.minimumResultsForSearch
= 10;
511 if ($('option:first', this).val() === '') {
512 settings
.placeholderOption
= 'first';
515 $.extend(settings
, $el
.data('select-params') || {}, options
|| {});
517 $el
.addClass('crm-ajax-select');
519 $el
.select2(settings
);
524 * @see CRM_Core_Form::addEntityRef for docs
525 * @param options object
527 $.fn
.crmEntityRef = function(options
) {
528 if (options
=== 'destroy') {
529 return $(this).each(function() {
530 var entity
= $(this).data('api-entity') || '';
533 .removeClass('crm-form-entityref crm-' + _
.kebabCase(entity
) + '-ref')
534 .crmSelect2('destroy');
537 options
= options
|| {};
538 options
.select
= options
.select
|| {};
539 return $(this).each(function() {
541 $el
= $(this).off('.crmEntity'),
542 entity
= options
.entity
|| $el
.data('api-entity') || 'Contact',
546 id
: 'user_contact_id',
547 label
: ts('Select Current User'),
548 icon
: 'fa-user-circle-o'
551 // Legacy: fix entity name if passed in as snake case
552 if (entity
.charAt(0).toUpperCase() !== entity
.charAt(0)) {
553 entity
= _
.capitalize(_
.camelCase(entity
));
555 $el
.data('api-entity', entity
);
556 $el
.data('select-params', $.extend({}, $el
.data('select-params') || {}, options
.select
));
557 $el
.data('api-params', $.extend(true, {}, $el
.data('api-params') || {}, options
.api
));
558 $el
.data('create-links', options
.create
|| $el
.data('create-links'));
559 var staticItems
= options
.static || $el
.data('static') || [];
560 _
.each(staticItems
, function(option
, i
) {
561 if (_
.isString(option
)) {
562 staticItems
[i
] = staticPresets
[option
];
566 function staticItemMarkup() {
567 if (!staticItems
.length
) {
570 var markup
= '<div class="crm-entityref-links crm-entityref-links-static">';
571 _
.each(staticItems
, function(link
) {
572 markup
+= ' <a class="crm-hover-button" href="#' + link
.id
+ '">' +
573 '<i class="crm-i ' + link
.icon
+ '" aria-hidden="true"></i> ' +
574 _
.escape(link
.label
) + '</a>';
580 $el
.addClass('crm-form-entityref crm-' + _
.kebabCase(entity
) + '-ref');
582 // Use select2 ajax helper instead of CRM.api3 because it provides more value
584 url
: CRM
.url('civicrm/ajax/rest'),
586 data: function (input
, page_num
) {
587 var params
= getEntityRefApiParams($el
);
588 params
.input
= input
;
589 params
.page_num
= page_num
;
591 entity
: $el
.data('api-entity'),
593 json
: JSON
.stringify(params
)
596 results: function(data
) {
597 return {more
: data
.more_results
, results
: data
.values
|| []};
600 minimumInputLength
: 1,
601 formatResult
: CRM
.utils
.formatSelect2Result
,
602 formatSelection
: formatEntityRefSelection
,
603 escapeMarkup
: _
.identity
,
604 initSelection: function($el
, callback
) {
606 multiple
= !!$el
.data('select-params').multiple
,
608 stored
= ($el
.data('entity-value') || []).concat(staticItems
);
612 var idsNeeded
= _
.difference(val
.split(','), _
.pluck(stored
, 'id'));
613 var existing
= _
.remove(stored
, function(item
) {
614 return _
.includes(val
.split(','), item
.id
);
616 // If we already have this data, just return it
617 if (!idsNeeded
.length
) {
618 callback(multiple
? existing
: existing
[0]);
620 var params
= $.extend({}, $el
.data('api-params') || {}, {id
: idsNeeded
.join(',')});
621 CRM
.api3($el
.data('api-entity'), 'getlist', params
).done(function(result
) {
622 callback(multiple
? result
.values
.concat(existing
) : result
.values
[0]);
623 // Trigger change (store data to avoid an infinite loop of lookups)
624 $el
.data('entity-value', result
.values
).trigger('change');
629 // Create new items inline - works for tags
630 if ($el
.data('create-links') && entity
=== 'Tag') {
631 selectParams
.createSearchChoice = function(term
, data
) {
632 if (!_
.findKey(data
, {label
: term
})) {
633 return {id
: "0", term
: term
, label
: term
+ ' (' + ts('new tag') + ')'};
636 selectParams
.tokenSeparators
= [','];
637 selectParams
.createSearchChoicePosition
= 'bottom';
638 $el
.on('select2-selecting.crmEntity', function(e
) {
641 e
.object
.label
= e
.object
.term
;
642 CRM
.api3(entity
, 'create', $.extend({name
: e
.object
.term
}, $el
.data('api-params').params
|| {}))
643 .done(function(created
) {
645 val
= $el
.select2('val'),
646 data
= $el
.select2('data'),
647 item
= {id
: created
.id
, label
: e
.object
.term
};
649 $el
.select2('data', item
, true);
651 else if ($.isArray(val
) && $.inArray("0", val
) > -1) {
652 _
.remove(data
, {id
: "0"});
654 $el
.select2('data', data
, true);
661 selectParams
.formatInputTooShort = function() {
662 var txt
= $el
.data('select-params').formatInputTooShort
|| $.fn
.select2
.defaults
.formatInputTooShort
.call(this);
663 txt
+= entityRefFiltersMarkup($el
) + staticItemMarkup() + renderEntityRefCreateLinks($el
);
666 selectParams
.formatNoMatches = function() {
667 var txt
= $el
.data('select-params').formatNoMatches
|| $.fn
.select2
.defaults
.formatNoMatches
;
668 txt
+= entityRefFiltersMarkup($el
) + renderEntityRefCreateLinks($el
);
671 $el
.on('select2-open.crmEntity', function() {
675 .on('click.crmEntity', 'a.crm-add-entity', function(e
) {
676 var extra
= $el
.data('api-params').extra
,
677 formUrl
= $(this).attr('href') + '&returnExtra=display_name,sort_name' + (extra
? (',' + extra
) : '');
678 $el
.select2('close');
679 CRM
.loadForm(formUrl
, {
680 dialog
: {width
: '50%', height
: 220}
681 }).on('crmFormSuccess', function(e
, data
) {
682 if (data
.status
=== 'success' && data
.id
) {
683 if (!data
.crmMessages
) {
684 CRM
.status(ts('%1 Created', {1: data
.label
|| data
.extra
.display_name
}));
686 data
.label
= data
.label
|| data
.extra
.sort_name
;
687 if ($el
.select2('container').hasClass('select2-container-multi')) {
688 var selection
= $el
.select2('data');
689 selection
.push(data
);
690 $el
.select2('data', selection
, true);
692 $el
.select2('data', data
, true);
698 .on('click.crmEntity', '.crm-entityref-links-static a', function(e
) {
699 var id
= $(this).attr('href').substr(1),
700 item
= _
.findWhere(staticItems
, {id
: id
});
701 $el
.select2('close');
702 if ($el
.select2('container').hasClass('select2-container-multi')) {
703 var selection
= $el
.select2('data');
704 if (!_
.findWhere(selection
, {id
: id
})) {
705 selection
.push(item
);
706 $el
.select2('data', selection
, true);
709 $el
.select2('data', item
, true);
713 .on('change.crmEntity', '.crm-entityref-filter-value', function() {
714 var filter
= $el
.data('user-filter') || {};
715 filter
.value
= $(this).val();
716 $(this).toggleClass('active', !!filter
.value
);
717 $el
.data('user-filter', filter
);
718 if (filter
.value
&& $(this).is('select')) {
719 // Once a filter has been chosen, rerender create links and refocus the search box
720 $el
.select2('close');
723 $('.crm-entityref-links-create', '#select2-drop').replaceWith(renderEntityRefCreateLinks($el
));
726 .on('change.crmEntity', 'select.crm-entityref-filter-key', function() {
727 var filter
= {key
: $(this).val()};
728 $(this).toggleClass('active', !!filter
.key
);
729 $el
.data('user-filter', filter
);
730 renderEntityRefFilterValue($el
);
731 $('.crm-entityref-filter-key', '#select2-drop').focus();
735 $el
.crmSelect2($.extend(settings
, $el
.data('select-params'), selectParams
));
740 * Combine api-params with user-filter
744 function getEntityRefApiParams($el
) {
746 params
= $.extend({params
: {}}, $el
.data('api-params') || {}),
747 // Prevent original data from being modified - $.extend and _.clone don't cut it, they pass nested objects by reference!
748 combined
= _
.cloneDeep(params
),
749 filter
= $.extend({}, $el
.data('user-filter') || {});
750 if (filter
.key
&& filter
.value
) {
751 // Fieldname may be prefixed with joins
752 var fieldName
= _
.last(filter
.key
.split('.'));
753 // Special case for contact type/sub-type combo
754 if (fieldName
=== 'contact_type' && (filter
.value
.indexOf('__') > 0)) {
755 combined
.params
[filter
.key
] = filter
.value
.split('__')[0];
756 combined
.params
[filter
.key
.replace('contact_type', 'contact_sub_type')] = filter
.value
.split('__')[1];
758 // Allow json-encoded api filters e.g. {"BETWEEN":[123,456]}
759 combined
.params
[filter
.key
] = filter
.value
.charAt(0) === '{' ? $.parseJSON(filter
.value
) : filter
.value
;
765 CRM
.utils
.copyAttributes = function ($source
, $target
, attributes
) {
766 _
.each(attributes
, function(name
) {
767 if ($source
.attr(name
) !== undefined) {
768 $target
.attr(name
, $source
.attr(name
));
773 CRM
.utils
.formatSelect2Result = function (row
) {
774 var markup
= '<div class="crm-select2-row">';
775 if (row
.image
!== undefined) {
776 markup
+= '<div class="crm-select2-image"><img src="' + row
.image
+ '"/></div>';
778 else if (row
.icon_class
) {
779 markup
+= '<div class="crm-select2-icon"><div class="crm-icon ' + row
.icon_class
+ '-icon"></div></div>';
781 markup
+= '<div><div class="crm-select2-row-label '+(row
.label_class
|| '')+'">' +
782 (row
.color
? '<span class="crm-select-item-color" style="background-color: ' + row
.color
+ '"></span> ' : '') +
783 (row
.icon
? '<i class="crm-i ' + row
.icon
+ '" aria-hidden="true"></i> ' : '') +
784 _
.escape((row
.prefix
!== undefined ? row
.prefix
+ ' ' : '') + row
.label
+ (row
.suffix
!== undefined ? ' ' + row
.suffix
: '')) +
786 '<div class="crm-select2-row-description">';
787 $.each(row
.description
|| [], function(k
, text
) {
788 markup
+= '<p>' + _
.escape(text
) + '</p> ';
790 markup
+= '</div></div></div>';
794 function formatEntityRefSelection(row
) {
795 return (row
.color
? '<span class="crm-select-item-color" style="background-color: ' + row
.color
+ '"></span> ' : '') +
796 _
.escape((row
.prefix
!== undefined ? row
.prefix
+ ' ' : '') + row
.label
+ (row
.suffix
!== undefined ? ' ' + row
.suffix
: ''));
799 function renderEntityRefCreateLinks($el
) {
801 createLinks
= $el
.data('create-links'),
802 params
= getEntityRefApiParams($el
).params
,
803 entity
= $el
.data('api-entity'),
804 markup
= '<div class="crm-entityref-links crm-entityref-links-create">';
805 if (!createLinks
|| (createLinks
=== true && !CRM
.config
.entityRef
.links
[entity
])) {
808 if (createLinks
=== true) {
809 createLinks
= params
.contact_type
? _
.where(CRM
.config
.entityRef
.links
[entity
], {type
: params
.contact_type
}) : CRM
.config
.entityRef
.links
[entity
];
811 _
.each(createLinks
, function(link
) {
812 markup
+= ' <a class="crm-add-entity crm-hover-button" href="' + link
.url
+ '">' +
813 '<i class="crm-i ' + (link
.icon
|| 'fa-plus-circle') + '" aria-hidden="true"></i> ' +
814 _
.escape(link
.label
) + '</a>';
820 function getEntityRefFilters($el
) {
822 entity
= $el
.data('api-entity'),
823 filters
= CRM
.config
.entityRef
.filters
[entity
] || [],
824 params
= $.extend({params
: {}}, $el
.data('api-params') || {}).params
,
826 _
.each(filters
, function(filter
) {
827 _
.defaults(filter
, {type
: 'select', 'attributes': {}, entity
: entity
});
828 if (!params
[filter
.key
]) {
829 // Filter out options if params don't match its condition
830 if (filter
.condition
&& !_
.isMatch(params
, _
.pick(filter
.condition
, _
.keys(params
)))) {
835 else if (filter
.key
== 'contact_type' && typeof params
.contact_sub_type
=== 'undefined') {
843 * Provide markup for entity ref filters
845 function entityRefFiltersMarkup($el
) {
847 filters
= getEntityRefFilters($el
),
848 filter
= $el
.data('user-filter') || {},
849 filterSpec
= filter
.key
? _
.find(filters
, {key
: filter
.key
}) : null;
850 if (!filters
.length
) {
853 var markup
= '<div class="crm-entityref-filters">' +
854 '<select class="crm-entityref-filter-key' + (filter
.key
? ' active' : '') + '">' +
855 '<option value="">' + _
.escape(ts('Refine search...')) + '</option>' +
856 CRM
.utils
.renderOptions(filters
, filter
.key
) +
857 '</select>' + entityRefFilterValueMarkup($el
, filter
, filterSpec
) + '</div>';
862 * Provide markup for entity ref filter value field
864 function entityRefFilterValueMarkup($el
, filter
, filterSpec
) {
868 attributes
= _
.cloneDeep(filterSpec
.attributes
);
869 if (filterSpec
.type
!== 'select') {
870 attributes
.type
= filterSpec
.type
;
871 attributes
.value
= typeof filter
.value
!== 'undefined' ? filter
.value
: '';
873 attributes
.class = 'crm-entityref-filter-value' + (filter
.value
? ' active' : '');
874 $.each(attributes
, function (attr
, val
) {
875 attrs
+= ' ' + attr
+ '="' + val
+ '"';
877 if (filterSpec
.type
=== 'select') {
878 var fieldName
= _
.last(filter
.key
.split('.')),
879 options
= [{key
: '', value
: ts('- select -')}];
880 if (filterSpec
.options
) {
881 options
= options
.concat(getEntityRefFilterOptions(fieldName
, $el
, filterSpec
));
883 markup
= '<select' + attrs
+ '>' + CRM
.utils
.renderOptions(options
, filter
.value
) + '</select>';
885 markup
= '<input' + attrs
+ '/>';
892 * Render the entity ref filter value field
894 function renderEntityRefFilterValue($el
) {
896 filter
= $el
.data('user-filter') || {},
897 filterSpec
= filter
.key
? _
.find(getEntityRefFilters($el
), {key
: filter
.key
}) : null,
898 $keyField
= $('.crm-entityref-filter-key', '#select2-drop'),
901 $('.crm-entityref-filter-value', '#select2-drop').remove();
902 $valField
= $(entityRefFilterValueMarkup($el
, filter
, filterSpec
));
903 $keyField
.after($valField
);
904 if (filterSpec
.type
=== 'select') {
905 loadEntityRefFilterOptions(filter
, filterSpec
, $valField
, $el
);
908 $('.crm-entityref-filter-value', '#select2-drop').hide().val('').change();
913 * Fetch options for a filter from cache or ajax api
915 function loadEntityRefFilterOptions(filter
, filterSpec
, $valField
, $el
) {
916 // Fieldname may be prefixed with joins - strip those out
917 var fieldName
= _
.last(filter
.key
.split('.'));
918 if (filterSpec
.options
) {
919 CRM
.utils
.setOptions($valField
, getEntityRefFilterOptions(fieldName
, $el
, filterSpec
), false, filter
.value
);
922 $('.crm-entityref-filters select', '#select2-drop').prop('disabled', true);
923 CRM
.api3(filterSpec
.entity
, 'getoptions', {field
: fieldName
, context
: 'search', sequential
: 1})
924 .done(function(result
) {
925 var entity
= $el
.data('api-entity').toLowerCase();
926 // Store options globally so we don't have to look them up again
927 filterSpec
.options
= result
.values
;
928 $('.crm-entityref-filters select', '#select2-drop').prop('disabled', false);
929 CRM
.utils
.setOptions($valField
, getEntityRefFilterOptions(fieldName
, $el
, filterSpec
), false, filter
.value
);
933 function getEntityRefFilterOptions(fieldName
, $el
, filterSpec
) {
934 var values
= _
.cloneDeep(filterSpec
.options
),
935 params
= $.extend({params
: {}}, $el
.data('api-params') || {}).params
;
936 if (fieldName
=== 'contact_type' && params
.contact_type
) {
937 values
= _
.remove(values
, function(option
) {
938 return option
.key
.indexOf(params
.contact_type
+ '__') === 0;
944 //CRM-15598 - Override url validator method to allow relative url's (e.g. /index.htm)
945 $.validator
.addMethod("url", function(value
, element
) {
946 if (/^\//.test(value
)) {
947 // Relative url: prepend dummy path for validation.
948 value
= 'http://domain.tld' + value
;
950 // From jQuery Validation Plugin v1.12.0
951 return this.optional(element
) || /^(https?|s?ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i.test(value
);
955 * Wrapper for jQuery validate initialization function; supplies defaults
957 $.fn
.crmValidate = function(params
) {
958 return $(this).each(function () {
959 var validator
= $(this).validate();
961 validator
.settings
= $.extend({}, validator
.settings
, CRM
.validate
._defaults
, CRM
.validate
.params
);
962 // Call our custom validation handler.
963 $(validator
.currentForm
).on("invalid-form.validate", validator
.settings
.invalidHandler
);
964 // Call any post-initialization callbacks
965 if (CRM
.validate
.functions
&& CRM
.validate
.functions
.length
) {
966 $.each(CRM
.validate
.functions
, function(i
, func
) {
976 function submitOnceForm(e
) {
977 if (e
.isDefaultPrevented()) {
980 if (_
.contains(submitted
, e
.target
)) {
983 submitted
.push(e
.target
);
984 // Spin submit button icon
985 if (submitButton
&& $(submitButton
, e
.target
).length
) {
987 if ($(e
.target
).closest('.ui-dialog .crm-ajax-container')) {
988 var identifier
= $(submitButton
).attr('name') || $(submitButton
).attr('href');
990 submitButton
= $(e
.target
).closest('.ui-dialog').find('button[data-identifier="' + identifier
+ '"]')[0] || submitButton
;
993 var $icon
= $(submitButton
).siblings('.crm-i').add('.crm-i, .ui-icon', submitButton
);
994 $icon
.data('origClass', $icon
.attr('class')).removeClass().addClass('crm-i crm-submit-icon fa-spinner fa-pulse');
998 // If form fails validation, restore button icon and reset the submitted array
999 function submitFormInvalid(form
) {
1001 $('.crm-i.crm-submit-icon').each(function() {
1002 if ($(this).data('origClass')) {
1003 $(this).removeClass().addClass($(this).data('origClass'));
1008 // Initialize widgets
1010 .on('crmLoad', function(e
) {
1011 $('table.row-highlight', e
.target
)
1012 .off('.rowHighlight')
1013 .on('change.rowHighlight', 'input.select-row, input.select-rows', function (e
, data
) {
1014 var filter
, $table
= $(this).closest('table');
1015 if ($(this).hasClass('select-rows')) {
1016 filter
= $(this).prop('checked') ? ':not(:checked)' : ':checked';
1017 $('input.select-row' + filter
, $table
).prop('checked', $(this).prop('checked')).trigger('change', 'master-selected');
1020 $(this).closest('tr').toggleClass('crm-row-selected', $(this).prop('checked'));
1021 if (data
!== 'master-selected') {
1022 $('input.select-rows', $table
).prop('checked', $(".select-row:not(':checked')", $table
).length
< 1);
1026 .find('input.select-row:checked').parents('tr').addClass('crm-row-selected');
1027 $('.crm-sortable-list', e
.target
).sortable();
1028 $('table.crm-sortable', e
.target
).DataTable();
1029 $('table.crm-ajax-table', e
.target
).each(function() {
1032 script
= CRM
.config
.resourceBase
+ 'js/jquery/jquery.crmAjaxTable.js',
1033 $accordion
= $table
.closest('.crm-accordion-wrapper.collapsed, .crm-collapsible.collapsed');
1034 // For tables hidden by collapsed accordions, wait.
1035 if ($accordion
.length
) {
1036 $accordion
.one('crmAccordion:open', function() {
1037 CRM
.loadScript(script
).done(function() {
1038 $table
.crmAjaxTable();
1042 CRM
.loadScript(script
).done(function() {
1043 $table
.crmAjaxTable();
1047 if ($("input:radio[name=radio_ts]").length
== 1) {
1048 $("input:radio[name=radio_ts]").prop("checked", true);
1050 $('.crm-select2:not(.select2-offscreen, .select2-container)', e
.target
).crmSelect2();
1051 $('.crm-form-entityref:not(.select2-offscreen, .select2-container)', e
.target
).crmEntityRef();
1052 $('select.crm-chain-select-control', e
.target
).off('.chainSelect').on('change.chainSelect', chainSelect
);
1053 $('.crm-form-text[data-crm-datepicker]', e
.target
).each(function() {
1054 $(this).crmDatepicker($(this).data('crmDatepicker'));
1056 $('.crm-editable', e
.target
).not('thead *').each(function() {
1058 CRM
.loadScript(CRM
.config
.resourceBase
+ 'js/jquery/jquery.crmEditable.js').done(function() {
1062 // Cache Form Input initial values
1063 $('form[data-warn-changes] :input', e
.target
).each(function() {
1064 $(this).data('crm-initial-value', $(this).is(':checkbox, :radio') ? $(this).prop('checked') : $(this).val());
1066 $('textarea.crm-form-wysiwyg', e
.target
).each(function() {
1067 if ($(this).hasClass("collapsed")) {
1068 CRM
.wysiwyg
.createCollapsed(this);
1070 CRM
.wysiwyg
.create(this);
1073 // Submit once handlers
1074 $('form[data-submit-once]', e
.target
)
1075 .submit(submitOnceForm
)
1076 .on('invalid-form', submitFormInvalid
);
1077 $('form[data-submit-once] button[type=submit]', e
.target
).click(function(e
) {
1078 submitButton
= e
.target
;
1081 .on('dialogopen', function(e
) {
1082 var $el
= $(e
.target
);
1083 $('body').addClass('ui-dialog-open');
1084 // Modal dialogs should disable scrollbars
1085 if ($el
.dialog('option', 'modal')) {
1086 $el
.addClass('modal-dialog');
1087 $('body').css({overflow
: 'hidden'});
1089 $el
.parent().find('.ui-dialog-titlebar .ui-icon-closethick').removeClass('ui-icon-closethick').addClass('fa-times');
1090 // Add resize button
1091 if ($el
.parent().hasClass('crm-container') && $el
.dialog('option', 'resizable')) {
1092 $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}));
1093 $('.crm-dialog-titlebar-resize', $el
.parent()).click(function(e
) {
1094 if ($el
.data('origSize')) {
1095 $el
.dialog('option', $el
.data('origSize'));
1096 $el
.data('origSize', null);
1097 $(this).button('option', 'icons', {primary
: 'fa-expand'});
1099 var menuHeight
= $('#civicrm-menu').outerHeight();
1100 if ($('body').hasClass('crm-menubar-below-cms-menu')) {
1101 menuHeight
+= $('#civicrm-menu').offset().top
;
1103 $el
.data('origSize', {
1104 position
: {my
: 'center', at
: 'center center+' + (menuHeight
/ 2), of: window
},
1105 width
: $el
.dialog('option', 'width'),
1106 height
: $el
.dialog('option', 'height')
1108 $el
.dialog('option', {width
: '100%', height
: ($(window
).height() - menuHeight
), position
: {my
: "top", at
: "top+"+menuHeight
, of: window
}});
1109 $(this).button('option', 'icons', {primary
: 'fa-compress'});
1111 $el
.trigger('dialogresize');
1116 .on('dialogclose', function(e
) {
1117 // Restore scrollbars when closing modal
1118 if ($('.ui-dialog .modal-dialog:visible').not(e
.target
).length
< 1) {
1119 $('body').css({overflow
: ''});
1121 if ($('.ui-dialog-content:visible').not(e
.target
).length
< 1) {
1122 $('body').removeClass('ui-dialog-open');
1125 .on('submit', function(e
) {
1126 // CRM-14353 - disable changes warn when submitting a form
1127 $('[data-warn-changes]').attr('data-warn-changes', 'false');
1130 // CRM-14353 - Warn of unsaved changes for forms which have opted in
1131 window
.onbeforeunload = function() {
1132 if (CRM
.utils
.initialValueChanged($('form[data-warn-changes=true]:visible'))) {
1133 return ts('You have unsaved changes.');
1137 $.fn
.crmtooltip = function () {
1138 var TOOLTIP_HIDE_DELAY
= 300;
1141 .on('mouseover', 'a.crm-summary-link:not(.crm-processed)', function (e
) {
1142 $(this).addClass('crm-processed crm-tooltip-active');
1143 var topDistance
= e
.pageY
- $(window
).scrollTop();
1144 if (topDistance
< 300 || topDistance
< $(this).children('.crm-tooltip-wrapper').height()) {
1145 $(this).addClass('crm-tooltip-down');
1147 if (!$(this).children('.crm-tooltip-wrapper').length
) {
1148 $(this).append('<div class="crm-tooltip-wrapper"><div class="crm-tooltip"></div></div>');
1149 $(this).children().children('.crm-tooltip')
1150 .html('<div class="crm-loading-element"></div>')
1154 .on('mouseleave', 'a.crm-summary-link', function () {
1155 var tooltipLink
= $(this);
1156 setTimeout(function () {
1157 if (tooltipLink
.filter(':hover').length
=== 0) {
1158 tooltipLink
.removeClass('crm-processed crm-tooltip-active crm-tooltip-down');
1160 }, TOOLTIP_HIDE_DELAY
);
1162 .on('click', 'a.crm-summary-link', false);
1165 var helpDisplay
, helpPrevious
;
1166 // Non-ajax example:
1167 // CRM.help('Example title', 'Here is some text to describe this example');
1168 // Ajax example (will load help id "foo" from templates/CRM/bar.tpl):
1169 // CRM.help('Example title', {id: 'foo', file: 'CRM/bar'});
1170 CRM
.help = function (title
, params
, url
) {
1171 var ajax
= typeof params
!== 'string';
1172 if (helpDisplay
&& helpDisplay
.close
) {
1173 // If the same link is clicked twice, just close the display
1174 if (helpDisplay
.isOpen
&& _
.isEqual(helpPrevious
, params
)) {
1175 helpDisplay
.close();
1178 helpDisplay
.close();
1180 helpPrevious
= _
.cloneDeep(params
);
1181 helpDisplay
= CRM
.alert(ajax
? '...' : params
, title
, 'crm-help ' + (ajax
? 'crm-msg-loading' : 'info'), {expires
: 0});
1184 url
= CRM
.url('civicrm/ajax/inline');
1185 params
.class_name
= 'CRM_Core_Page_Inline_Help';
1186 params
.type
= 'page';
1191 success: function (data
) {
1192 $('#crm-notification-container .crm-help .notify-content:last').html(data
);
1193 $('#crm-notification-container .crm-help').removeClass('crm-msg-loading').addClass('info');
1195 error: function () {
1196 $('#crm-notification-container .crm-help .notify-content:last').html('Unable to load help file.');
1197 $('#crm-notification-container .crm-help').removeClass('crm-msg-loading').addClass('error');
1203 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1205 CRM
.status = function(options
, deferred
) {
1206 // 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.
1207 if (typeof options
=== 'string') {
1208 return CRM
.status({start
: options
, success
: options
, error
: options
})[deferred
=== 'error' ? 'reject' : 'resolve']();
1210 var opts
= $.extend({
1211 start
: ts('Saving...'),
1212 success
: ts('Saved'),
1213 error: function(data
) {
1214 var msg
= $.isPlainObject(data
) && data
.error_message
;
1215 CRM
.alert(msg
|| ts('Sorry an error occurred and your information was not saved'), ts('Error'), 'error');
1218 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>')
1220 $msg
.css('min-width', $msg
.width());
1221 function handle(status
, data
) {
1222 var endMsg
= typeof(opts
[status
]) === 'function' ? opts
[status
](data
) : opts
[status
];
1224 $msg
.removeClass('status-start').addClass('status-' + status
).find('.crm-status-box-msg').text(endMsg
);
1225 window
.setTimeout(function() {
1226 $msg
.fadeOut('slow', function() {
1234 return (deferred
|| new $.Deferred())
1235 .done(function(data
) {
1236 // If the server returns an error msg call the error handler
1237 var status
= $.isPlainObject(data
) && (data
.is_error
|| data
.status
=== 'error') ? 'error' : 'success';
1238 handle(status
, data
);
1240 .fail(function(data
) {
1241 handle('error', data
);
1245 // Convert an Angular promise to a jQuery promise
1246 CRM
.toJqPromise = function(aPromise
) {
1247 var jqDeferred
= $.Deferred();
1249 function(data
) { jqDeferred
.resolve(data
); },
1250 function(data
) { jqDeferred
.reject(data
); }
1251 // should we also handle progress events?
1253 return jqDeferred
.promise();
1256 CRM
.toAPromise = function($q
, jqPromise
) {
1257 var aDeferred
= $q
.defer();
1259 function(data
) { aDeferred
.resolve(data
); },
1260 function(data
) { aDeferred
.reject(data
); }
1261 // should we also handle progress events?
1263 return aDeferred
.promise
;
1267 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1269 CRM
.alert = function (text
, title
, type
, options
) {
1270 type
= type
|| 'alert';
1271 title
= title
|| '';
1272 options
= options
|| {};
1273 if ($('#crm-notification-container').length
) {
1279 // By default, don't expire errors and messages containing links
1281 expires
: (type
== 'error' || text
.indexOf('<a ') > -1) ? 0 : (text
? 10000 : 5000),
1284 options
= $.extend(extra
, options
);
1285 options
.expires
= (options
.expires
=== false || !CRM
.config
.allowAlertAutodismissal
) ? 0 : parseInt(options
.expires
, 10);
1286 if (options
.unique
&& options
.unique
!== '0') {
1287 $('#crm-notification-container .ui-notify-message').each(function () {
1288 if (title
=== $('h1', this).html() && text
=== $('.notify-content', this).html()) {
1289 $('.icon.ui-notify-close', this).click();
1293 return $('#crm-notification-container').notify('create', params
, options
);
1297 text
= title
+ "\n" + text
;
1299 // strip html tags as they are not parsed in standard alerts
1300 alert($("<div/>").html(text
).text());
1306 * Close whichever alert contains the given node
1310 CRM
.closeAlertByChild = function (node
) {
1311 $(node
).closest('.ui-notify-message').find('.icon.ui-notify-close').click();
1315 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1317 CRM
.confirm = function (options
) {
1318 var dialog
, url
, msg
, buttons
= [], settings
= {
1319 title
: ts('Confirm'),
1320 message
: ts('Are you sure you want to continue?'),
1325 dialogClass
: 'crm-container crm-confirm',
1326 close: function () {
1327 $(this).dialog('destroy').remove();
1334 if (options
&& options
.url
) {
1335 settings
.resizable
= true;
1336 settings
.height
= '50%';
1338 $.extend(settings
, ($.isFunction(options
) ? arguments
[1] : options
) || {});
1339 settings
= CRM
.utils
.adjustDialogDefaults(settings
);
1340 if (!settings
.buttons
&& $.isPlainObject(settings
.options
)) {
1341 $.each(settings
.options
, function(op
, label
) {
1345 icons
: {primary
: op
=== 'no' ? 'fa-times' : 'fa-check'},
1347 var event
= $.Event('crmConfirm:' + op
);
1348 $(this).trigger(event
);
1349 if (!event
.isDefaultPrevented()) {
1350 dialog
.dialog('close');
1355 // Order buttons so that "no" goes on the right-hand side
1356 settings
.buttons
= _
.sortBy(buttons
, 'data-op').reverse();
1359 msg
= url
? '' : settings
.message
;
1360 delete settings
.options
;
1361 delete settings
.message
;
1362 delete settings
.url
;
1363 dialog
= $('<div class="crm-confirm-dialog"></div>').html(msg
|| '').dialog(settings
);
1364 if ($.isFunction(options
)) {
1365 dialog
.on('crmConfirm:yes', options
);
1368 CRM
.loadPage(url
, {target
: dialog
});
1371 dialog
.trigger('crmLoad');
1376 /** provides a local copy of ts for a domain */
1377 CRM
.ts = function(domain
) {
1378 return function(message
, options
) {
1380 options
= $.extend(options
|| {}, {domain
: domain
});
1382 return ts(message
, options
);
1386 CRM
.addStrings = function(domain
, strings
) {
1387 var bucket
= (domain
== 'civicrm' ? 'strings' : 'strings::' + domain
);
1388 CRM
[bucket
] = CRM
[bucket
] || {};
1389 _
.extend(CRM
[bucket
], strings
);
1393 * @see https://docs.civicrm.org/dev/en/latest/framework/ui/#notifications-and-confirmations
1395 $.fn
.crmError = function (text
, title
, options
) {
1396 title
= title
|| '';
1398 options
= options
|| {};
1403 if ($(this).length
) {
1405 label
= $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]').not('[generated=true]');
1407 label
.addClass('crm-error');
1408 var $label
= label
.clone();
1409 if (text
=== '' && $('.crm-marker', $label
).length
> 0) {
1410 text
= $('.crm-marker', $label
).attr('title');
1412 $('.crm-marker', $label
).remove();
1413 title
= $label
.text();
1416 $(this).addClass('crm-error');
1418 var msg
= CRM
.alert(text
, title
, 'error', $.extend(extra
, options
));
1419 if ($(this).length
) {
1421 setTimeout(function () {
1422 ele
.one('change', function () {
1423 if (msg
&& msg
.close
) msg
.close();
1424 ele
.removeClass('crm-error');
1426 label
.removeClass('crm-error');
1434 // Display system alerts through js notifications
1435 function messagesFromMarkup() {
1436 $('div.messages:visible', this).not('.help').not('.no-popup').each(function () {
1437 var text
, title
= '';
1438 $(this).removeClass('status messages');
1439 var type
= $(this).attr('class').split(' ')[0] || 'alert';
1440 type
= type
.replace('crm-', '');
1441 $('.icon', this).remove();
1442 if ($('.msg-text', this).length
> 0) {
1443 text
= $('.msg-text', this).html();
1444 title
= $('.msg-title', this).html();
1447 text
= $(this).html();
1449 var options
= $(this).data('options') || {};
1451 // Duplicates were already removed server-side
1452 options
.unique
= false;
1453 CRM
.alert(text
, title
, type
, options
);
1455 // Handle qf form errors
1456 $('form :input.error', this).one('blur', function() {
1457 $('.ui-notify-message.error a.ui-notify-close').click();
1458 $(this).removeClass('error');
1459 $(this).next('span.crm-error').remove();
1460 $('label[for="' + $(this).attr('name') + '"], label[for="' + $(this).attr('id') + '"]')
1461 .removeClass('crm-error')
1462 .find('.crm-error').removeClass('crm-error');
1467 * Improve blockUI when used with jQuery dialog
1469 var originalBlock
= $.fn
.block
,
1470 originalUnblock
= $.fn
.unblock
;
1472 $.fn
.block = function(opts
) {
1473 if ($(this).is('.ui-dialog-content')) {
1474 originalBlock
.call($(this).parents('.ui-dialog'), opts
);
1477 return originalBlock
.call(this, opts
);
1479 $.fn
.unblock = function(opts
) {
1480 if ($(this).is('.ui-dialog-content')) {
1481 originalUnblock
.call($(this).parents('.ui-dialog'), opts
);
1484 return originalUnblock
.call(this, opts
);
1487 // Preprocess all CRM ajax calls to display messages
1488 $(document
).ajaxSuccess(function(event
, xhr
, settings
) {
1490 if ((!settings
.dataType
|| settings
.dataType
== 'json') && xhr
.responseText
) {
1491 var response
= $.parseJSON(xhr
.responseText
);
1492 if (typeof(response
.crmMessages
) == 'object') {
1493 $.each(response
.crmMessages
, function(n
, msg
) {
1494 CRM
.alert(msg
.text
, msg
.title
, msg
.type
, msg
.options
);
1497 if (response
.backtrace
) {
1498 CRM
.console('log', response
.backtrace
);
1500 if (typeof response
.deprecated
=== 'string') {
1501 CRM
.console('warn', response
.deprecated
);
1505 // Ignore errors thrown by parseJSON
1510 $.blockUI
.defaults
.message
= null;
1511 $.blockUI
.defaults
.ignoreIfBlocked
= true;
1513 if ($('#crm-container').hasClass('crm-public')) {
1514 $.fn
.select2
.defaults
.dropdownCssClass
= $.ui
.dialog
.prototype.options
.dialogClass
= 'crm-container crm-public';
1517 // Trigger crmLoad on initial content for consistency. It will also be triggered for ajax-loaded content.
1518 $('.crm-container').trigger('crmLoad');
1520 if ($('#crm-notification-container').length
) {
1521 // Initialize notifications
1522 $('#crm-notification-container').notify();
1523 messagesFromMarkup
.call($('#crm-container'));
1527 // bind the event for image popup
1528 .on('click', 'a.crm-image-popup', function(e
) {
1530 title
: ts('Preview'),
1532 // Prevent overlap with the menubar
1533 maxHeight
: $(window
).height() - 30,
1534 position
: {my
: 'center', at
: 'center center+15', of: window
},
1535 message
: '<div class="crm-custom-image-popup"><img style="max-width: 100%" src="' + $(this).attr('href') + '"></div>',
1541 .on('click', function (event
) {
1542 $('.btn-slide-active').removeClass('btn-slide-active').find('.panel').hide();
1543 if ($(event
.target
).is('.btn-slide')) {
1544 $(event
.target
).addClass('btn-slide-active').find('.panel').show();
1548 // Handle clear button for form elements
1549 .on('click', 'a.crm-clear-link', function() {
1550 $(this).css({visibility
: 'hidden'}).siblings('.crm-form-radio:checked').prop('checked', false).trigger('change', ['crmClear']);
1551 $(this).siblings('input:text').val('').trigger('change', ['crmClear']);
1554 .on('change keyup', 'input.crm-form-radio:checked, input[allowclear=1]', function(e
, context
) {
1555 if (context
!== 'crmClear' && ($(this).is(':checked') || ($(this).is('[allowclear=1]') && $(this).val()))) {
1556 $(this).siblings('.crm-clear-link').css({visibility
: ''});
1558 if (context
!== 'crmClear' && $(this).is('[allowclear=1]') && $(this).val() === '') {
1559 $(this).siblings('.crm-clear-link').css({visibility
: 'hidden'});
1563 // Allow normal clicking of links within accordions
1564 .on('click.crmAccordions', 'div.crm-accordion-header a, .collapsible-title a', function (e
) {
1565 e
.stopPropagation();
1567 // Handle accordions
1568 .on('click.crmAccordions', '.crm-accordion-header, .crm-collapsible .collapsible-title', function (e
) {
1569 var action
= 'open';
1570 if ($(this).parent().hasClass('collapsed')) {
1571 $(this).next().css('display', 'none').slideDown(200);
1574 $(this).next().css('display', 'block').slideUp(200);
1577 $(this).parent().toggleClass('collapsed').trigger('crmAccordion:' + action
);
1585 * Collapse or expand an accordion
1588 $.fn
.crmAccordionToggle = function (speed
) {
1589 $(this).each(function () {
1590 var action
= 'open';
1591 if ($(this).hasClass('collapsed')) {
1592 $('.crm-accordion-body', this).first().css('display', 'none').slideDown(speed
);
1595 $('.crm-accordion-body', this).first().css('display', 'block').slideUp(speed
);
1598 $(this).toggleClass('collapsed').trigger('crmAccordion:' + action
);
1603 * Clientside currency formatting
1604 * @param number value
1605 * @param [optional] boolean onlyNumber - if true, we return formatted amount without currency sign
1606 * @param [optional] string format - currency representation of the number 1234.56
1609 var currencyTemplate
;
1610 CRM
.formatMoney = function(value
, onlyNumber
, format
) {
1611 var precision
, decimal, separator
, sign
, i
, j
, result
;
1612 if (value
=== 'init' && format
) {
1613 currencyTemplate
= format
;
1616 format
= format
|| currencyTemplate
;
1617 if ((result
= /1(.?)234(.?)56/.exec(format
)) !== null) { // If value is formatted to 2 decimals
1620 else if ((result
= /1(.?)234(.?)6/.exec(format
)) !== null) { // If value is formatted to 1 decimal
1623 else if ((result
= /1(.?)235/.exec(format
)) !== null) { // If value is formatted to zero decimals
1627 return 'Invalid format passed to CRM.formatMoney';
1629 separator
= result
[1];
1630 decimal = precision
? result
[2] : false;
1631 sign
= (value
< 0) ? '-' : '';
1632 //extracting the absolute value of the integer part of the number and converting to string
1633 i
= parseInt(value
= Math
.abs(value
).toFixed(2)) + '';
1634 j
= ((j
= i
.length
) > 3) ? j
% 3 : 0;
1635 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) : '');
1639 switch (precision
) {
1641 return format
.replace(/1.*234.*56/, result
);
1643 return format
.replace(/1.*234.*6/, result
);
1645 return format
.replace(/1.*235/, result
);
1649 CRM
.angRequires = function(name
) {
1650 return CRM
.angular
.requires
[name
] || [];
1653 CRM
.console = function(method
, title
, msg
) {
1654 if (window
.console
) {
1655 method
= $.isFunction(console
[method
]) ? method
: 'log';
1656 if (msg
=== undefined) {
1657 return console
[method
](title
);
1659 return console
[method
](title
, msg
);
1664 // Sugar methods for window.localStorage, with a fallback for older browsers
1665 var cacheItems
= {};
1667 get: function (name
, defaultValue
) {
1669 if (localStorage
.getItem('CRM' + name
) !== null) {
1670 return JSON
.parse(localStorage
.getItem('CRM' + name
));
1673 return cacheItems
[name
] === undefined ? defaultValue
: cacheItems
[name
];
1675 set: function (name
, value
) {
1677 localStorage
.setItem('CRM' + name
, JSON
.stringify(value
));
1679 cacheItems
[name
] = value
;
1681 clear: function(name
) {
1683 localStorage
.removeItem('CRM' + name
);
1685 delete cacheItems
[name
];
1691 // Determine if a user has a given permission.
1692 // @see CRM_Core_Resources::addPermissions
1693 CRM
.checkPerm = function(perm
) {
1694 return CRM
.permissions
&& CRM
.permissions
[perm
];
1697 // Round while preserving sigfigs
1698 CRM
.utils
.sigfig = function(n
, digits
) {
1699 var len
= ("" + n
).length
;
1700 var scale
= Math
.pow(10.0, len
-digits
);
1701 return Math
.round(n
/ scale
) * scale
;
1705 * Create a js Date object from a unix timestamp or a yyyy-mm-dd string
1709 CRM
.utils
.makeDate = function(input
) {
1710 switch (typeof input
) {
1712 // already a date object
1716 // convert iso format with or without dashes
1717 input
= input
.replace(/[- :]/g, '');
1718 var output
= $.datepicker
.parseDate('yymmdd', input
.substr(0, 8));
1719 if (input
.length
=== 14) {
1721 parseInt(input
.substr(8, 2), 10),
1722 parseInt(input
.substr(10, 2), 10),
1723 parseInt(input
.substr(12, 2), 10)
1729 // convert unix timestamp
1730 return new Date(input
* 1000);
1732 throw 'Invalid input passed to CRM.utils.makeDate';
1736 * Format a date (and optionally time) for output to the user
1738 * @param {string|int|Date} input
1739 * Input may be a js Date object, a unix timestamp or a 'yyyy-mm-dd' string
1740 * @param {string|null} dateFormat
1741 * A string like 'yy-mm-dd' or null to use the system default
1742 * @param {int|bool} timeFormat
1743 * Leave empty to omit time from the output (default)
1744 * Or pass 12, 24, or true to use the system default for 12/24hr format
1747 CRM
.utils
.formatDate = function(input
, dateFormat
, timeFormat
) {
1751 var date
= CRM
.utils
.makeDate(input
),
1752 output
= $.datepicker
.formatDate(dateFormat
|| CRM
.config
.dateInputFormat
, date
);
1754 var hour
= date
.getHours(),
1755 min
= date
.getMinutes(),
1757 if (timeFormat
=== 12 || (timeFormat
=== true && !CRM
.config
.timeIs24Hr
)) {
1758 suf
= ' ' + (hour
< 12 ? ts('AM') : ts('PM'));
1759 if (hour
=== 0 || hour
> 12) {
1760 hour
= Math
.abs(hour
- 12);
1762 } else if (hour
< 10) {
1765 output
+= ' ' + hour
+ ':' + (min
< 10 ? '0' : '') + min
+ suf
;
1770 // Used to set appropriate text color for a given background
1771 CRM
.utils
.colorContrast = function (hexcolor
) {
1772 hexcolor
= hexcolor
.replace(/[ #]/g, '');
1773 var r
= parseInt(hexcolor
.substr(0, 2), 16),
1774 g
= parseInt(hexcolor
.substr(2, 2), 16),
1775 b
= parseInt(hexcolor
.substr(4, 2), 16),
1776 yiq
= ((r
* 299) + (g
* 587) + (b
* 114)) / 1000;
1777 return (yiq
>= 128) ? 'black' : 'white';
1780 // CVE-2015-9251 - Prevent auto-execution of scripts when no explicit dataType was provided
1781 $.ajaxPrefilter(function(s
) {
1782 if (s
.crossDomain
) {
1783 s
.contents
.script
= false;
1787 // 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.
1788 $.htmlPrefilter = function(html
) {
1789 // Prior to jQuery 3.5, jQuery converted XHTML-style self-closing tags to
1790 // their XML equivalent: e.g., "<div />" to "<div></div>". This is
1791 // problematic for several reasons, including that it's vulnerable to XSS
1792 // attacks. However, since this was jQuery's behavior for many years, many
1793 // Drupal modules and jQuery plugins may be relying on it. Therefore, we
1794 // preserve that behavior, but for a limited set of tags only, that we believe
1795 // to not be vulnerable. This is the set of HTML tags that satisfy all of the
1796 // following conditions:
1797 // - In DOMPurify's list of HTML tags. If an HTML tag isn't safe enough to
1798 // appear in that list, then we don't want to mess with it here either.
1799 // @see https://github.com/cure53/DOMPurify/blob/2.0.11/dist/purify.js#L128
1800 // - A normal element (not a void, template, text, or foreign element).
1801 // @see https://html.spec.whatwg.org/multipage/syntax.html#elements-2
1802 // - An element that is still defined by the current HTML specification
1803 // (not a deprecated element), because we do not want to rely on how
1804 // browsers parse deprecated elements.
1805 // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element
1806 // - Not 'html', 'head', or 'body', because this pseudo-XHTML expansion is
1807 // designed for fragments, not entire documents.
1808 // - Not 'colgroup', because due to an idiosyncrasy of jQuery's original
1809 // regular expression, it didn't match on colgroup, and we don't want to
1810 // introduce a behavior change for that.
1811 var selfClosingTagsToReplace
= [
1812 'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo',
1813 'blockquote', 'button', 'canvas', 'caption', 'cite', 'code', 'data',
1814 'datalist', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em',
1815 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
1816 'h4', 'h5', 'h6', 'header', 'hgroup', 'i', 'ins', 'kbd', 'label', 'legend',
1817 'li', 'main', 'map', 'mark', 'menu', 'meter', 'nav', 'ol', 'optgroup',
1818 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt',
1819 'ruby', 's', 'samp', 'section', 'select', 'small', 'source', 'span',
1820 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
1821 'thead', 'time', 'tr', 'u', 'ul', 'var', 'video'
1824 // Define regular expressions for <TAG/> and <TAG ATTRIBUTES/>. Doing this as
1825 // two expressions makes it easier to target <a/> without also targeting
1826 // every tag that starts with "a".
1827 var xhtmlRegExpGroup
= '(' + selfClosingTagsToReplace
.join('|') + ')';
1828 var whitespace
= '[\\x20\\t\\r\\n\\f]';
1829 var rxhtmlTagWithoutSpaceOrAttributes
= new RegExp('<' + xhtmlRegExpGroup
+ '\\/>', 'gi');
1830 var rxhtmlTagWithSpaceAndMaybeAttributes
= new RegExp('<' + xhtmlRegExpGroup
+ '(' + whitespace
+ '[^>]*)\\/>', 'gi');
1832 // jQuery 3.5 also fixed a vulnerability for when </select> appears within
1833 // an <option> or <optgroup>, but it did that in local code that we can't
1834 // backport directly. Instead, we filter such cases out. To do so, we need to
1835 // determine when jQuery would otherwise invoke the vulnerable code, which it
1836 // uses this regular expression to determine. The regular expression changed
1837 // for version 3.0.0 and changed again for 3.4.0.
1838 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4958
1839 // @see https://github.com/jquery/jquery/blob/3.0.0/dist/jquery.js#L4584
1840 // @see https://github.com/jquery/jquery/blob/3.4.0/dist/jquery.js#L4712
1841 var rtagName
= /<([\w:]+)/;
1843 // The regular expression that jQuery uses to determine which self-closing
1844 // tags to expand to open and close tags. This is vulnerable, because it
1845 // matches all tag names except the few excluded ones. We only use this
1846 // expression for determining vulnerability. The expression changed for
1847 // version 3, but we only need to check for vulnerability in versions 1 and 2,
1848 // so we use the expression from those versions.
1849 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L4957
1850 var rxhtmlTag
= /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi;
1852 // This is how jQuery determines the first tag in the HTML.
1853 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5521
1854 var tag
= ( rtagName
.exec( html
) || [ "", "" ] )[ 1 ].toLowerCase();
1856 // It is not valid HTML for <option> or <optgroup> to have <select> as
1857 // either a descendant or sibling, and attempts to inject one can cause
1858 // XSS on jQuery versions before 3.5. Since this is invalid HTML and a
1859 // possible XSS attack, reject the entire string.
1860 // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11023
1861 if ((tag
=== 'option' || tag
=== 'optgroup') && html
.match(/<\/?select/i)) {
1865 // Retain jQuery's prior to 3.5 conversion of pseudo-XHTML, but for only
1866 // the tags in the `selfClosingTagsToReplace` list defined above.
1867 // @see https://github.com/jquery/jquery/blob/1.5/jquery.js#L5518
1868 // @see https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11022
1869 html
= html
.replace(rxhtmlTagWithoutSpaceOrAttributes
, "<$1></$1>");
1870 html
= html
.replace(rxhtmlTagWithSpaceAndMaybeAttributes
, "<$1$2></$1>");