Merge pull request #23902 from civicrm/5.51
[civicrm-core.git] / ang / crmUi.js
1 /// crmUi: Sundry UI helpers
2 (function (angular, $, _) {
3
4 var uidCount = 0,
5 pageTitle = 'CiviCRM',
6 documentTitle = 'CiviCRM';
7
8 angular.module('crmUi', CRM.angRequires('crmUi'))
9
10 // example <div crm-ui-accordion crm-title="ts('My Title')" crm-collapsed="true">...content...</div>
11 // WISHLIST: crmCollapsed should support two-way/continuous binding
12 .directive('crmUiAccordion', function() {
13 return {
14 scope: {
15 crmUiAccordion: '='
16 },
17 template: '<div ng-class="cssClasses"><div class="crm-accordion-header">{{crmUiAccordion.title}} <a crm-ui-help="help" ng-if="help"></a></div><div class="crm-accordion-body" ng-transclude></div></div>',
18 transclude: true,
19 link: function (scope, element, attrs) {
20 scope.cssClasses = {
21 'crm-accordion-wrapper': true,
22 collapsed: scope.crmUiAccordion.collapsed
23 };
24 scope.help = null;
25 scope.$watch('crmUiAccordion', function(crmUiAccordion) {
26 if (crmUiAccordion && crmUiAccordion.help) {
27 scope.help = crmUiAccordion.help.clone({}, {
28 title: crmUiAccordion.title
29 });
30 }
31 });
32 }
33 };
34 })
35
36 // Examples:
37 // crmUiAlert({text: 'My text', title: 'My title', type: 'error'});
38 // crmUiAlert({template: '<a ng-click="ok()">Hello</a>', scope: $scope.$new()});
39 // var h = crmUiAlert({templateUrl: '~/crmFoo/alert.html', scope: $scope.$new()});
40 // ... h.close(); ...
41 .service('crmUiAlert', function($compile, $rootScope, $templateRequest, $q) {
42 var count = 0;
43 return function crmUiAlert(params) {
44 var id = 'crmUiAlert_' + (++count);
45 var tpl = null;
46 if (params.templateUrl) {
47 tpl = $templateRequest(params.templateUrl);
48 }
49 else if (params.template) {
50 tpl = params.template;
51 }
52 if (tpl) {
53 params.text = '<div id="' + id + '"></div>'; // temporary stub
54 }
55 var result = CRM.alert(params.text, params.title, params.type, params.options);
56 if (tpl) {
57 $q.when(tpl, function(html) {
58 var scope = params.scope || $rootScope.$new();
59 var linker = $compile(html);
60 $('#' + id).append($(linker(scope)));
61 });
62 }
63 return result;
64 };
65 })
66
67 // Simple wrapper around $.crmDatepicker.
68 // example with no time input: <input crm-ui-datepicker="{time: false}" ng-model="myobj.datefield"/>
69 // example with custom date format: <input crm-ui-datepicker="{date: 'm/d/y'}" ng-model="myobj.datefield"/>
70 .directive('crmUiDatepicker', function () {
71 return {
72 restrict: 'AE',
73 require: 'ngModel',
74 scope: {
75 crmUiDatepicker: '='
76 },
77 link: function (scope, element, attrs, ngModel) {
78 ngModel.$render = function () {
79 element.val(ngModel.$viewValue).change();
80 };
81
82 element
83 .crmDatepicker(scope.crmUiDatepicker)
84 .on('change', function() {
85 var requiredLength = 19;
86 if (scope.crmUiDatepicker && scope.crmUiDatepicker.time === false) {
87 requiredLength = 10;
88 }
89 if (scope.crmUiDatepicker && scope.crmUiDatepicker.date === false) {
90 requiredLength = 8;
91 }
92 ngModel.$setValidity('incompleteDateTime', !($(this).val().length && $(this).val().length !== requiredLength));
93 });
94 }
95 };
96 })
97
98 // Display debug information (if available)
99 // For richer DX, checkout Batarang/ng-inspector (Chrome/Safari), or AngScope/ng-inspect (Firefox).
100 // example: <div crm-ui-debug="myobject" />
101 .directive('crmUiDebug', function ($location) {
102 return {
103 restrict: 'AE',
104 scope: {
105 crmUiDebug: '@'
106 },
107 template: function() {
108 var args = $location.search();
109 if (args && args.angularDebug) {
110 var jsonTpl = (CRM.angular.modules.indexOf('jsonFormatter') < 0) ? '<pre>{{data|json}}</pre>' : '<json-formatter json="data" open="1"></json-formatter>';
111 return '<div crm-ui-accordion=\'{title: ts("Debug (%1)", {1: crmUiDebug}), collapsed: true}\'>' + jsonTpl + '</div>';
112 }
113 return '';
114 },
115 link: function(scope, element, attrs) {
116 var args = $location.search();
117 if (args && args.angularDebug) {
118 scope.ts = CRM.ts(null);
119 scope.$parent.$watch(attrs.crmUiDebug, function(data) {
120 scope.data = data;
121 });
122 }
123 }
124 };
125 })
126
127 // Display a field/row in a field list
128 // example: <div crm-ui-field="{title: ts('My Field')}"> {{mydata}} </div>
129 // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field')}"> <input crm-ui-id="subform.myfield" name="myfield" /> </div>
130 // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field')}"> <input crm-ui-id="subform.myfield" name="myfield" required /> </div>
131 // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field'), help: hs('help_field_name'), required: true}"> {{mydata}} </div>
132 .directive('crmUiField', function() {
133 // Note: When writing new templates, the "label" position is particular. See/patch "var label" below.
134 var templateUrls = {
135 default: '~/crmUi/field.html',
136 checkbox: '~/crmUi/field-cb.html'
137 };
138
139 return {
140 require: '^crmUiIdScope',
141 restrict: 'EA',
142 scope: {
143 // {title, name, help, helpFile}
144 crmUiField: '='
145 },
146 templateUrl: function(tElement, tAttrs){
147 var layout = tAttrs.crmLayout ? tAttrs.crmLayout : 'default';
148 return templateUrls[layout];
149 },
150 transclude: true,
151 link: function (scope, element, attrs, crmUiIdCtrl) {
152 $(element).addClass('crm-section');
153 scope.help = null;
154 scope.$watch('crmUiField', function(crmUiField) {
155 if (crmUiField && crmUiField.help) {
156 scope.help = crmUiField.help.clone({}, {
157 title: crmUiField.title
158 });
159 }
160 });
161 }
162 };
163 })
164
165 // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div>
166 .directive('crmUiId', function () {
167 return {
168 require: '^crmUiIdScope',
169 restrict: 'EA',
170 link: {
171 pre: function (scope, element, attrs, crmUiIdCtrl) {
172 var id = crmUiIdCtrl.get(attrs.crmUiId);
173 element.attr('id', id);
174 }
175 }
176 };
177 })
178
179 // for example, see crmUiHelp
180 .service('crmUiHelp', function(){
181 // example: var h = new FieldHelp({id: 'foo'}); h.open();
182 function FieldHelp(options) {
183 this.options = options;
184 }
185 angular.extend(FieldHelp.prototype, {
186 get: function(n) {
187 return this.options[n];
188 },
189 open: function open() {
190 CRM.help(this.options.title, {id: this.options.id, file: this.options.file});
191 },
192 clone: function clone(options, defaults) {
193 return new FieldHelp(angular.extend({}, defaults, this.options, options));
194 }
195 });
196
197 // example: var hs = crmUiHelp({file: 'CRM/Foo/Bar'});
198 return function(defaults){
199 // example: hs('myfield')
200 // example: hs({id: 'myfield', title: 'Foo Bar', file: 'Whiz/Bang'})
201 return function(options) {
202 if (_.isString(options)) {
203 options = {id: options};
204 }
205 return new FieldHelp(angular.extend({}, defaults, options));
206 };
207 };
208 })
209
210 // Display a help icon
211 // Example: Use a default *.hlp file
212 // scope.hs = crmUiHelp({file: 'Path/To/Help/File'});
213 // HTML: <a crm-ui-help="hs({title:ts('My Field'), id:'my_field'})">
214 // Example: Use an explicit *.hlp file
215 // HTML: <a crm-ui-help="hs({title:ts('My Field'), id:'my_field', file:'CRM/Foo/Bar'})">
216 .directive('crmUiHelp', function() {
217 return {
218 restrict: 'EA',
219 link: function(scope, element, attrs) {
220 setTimeout(function() {
221 var crmUiHelp = scope.$eval(attrs.crmUiHelp);
222 var title = crmUiHelp && crmUiHelp.get('title') ? ts('%1 Help', {1: crmUiHelp.get('title')}) : ts('Help');
223 element.attr('title', title);
224 }, 50);
225
226 element
227 .addClass('helpicon')
228 .attr('href', '#')
229 .on('click', function(e) {
230 e.preventDefault();
231 scope.$eval(attrs.crmUiHelp).open();
232 });
233 }
234 };
235 })
236
237 // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div>
238 .directive('crmUiFor', function ($parse, $timeout) {
239 return {
240 require: '^crmUiIdScope',
241 restrict: 'EA',
242 template: '<span ng-class="cssClasses"><span ng-transclude/><span crm-ui-visible="crmIsRequired" class="crm-marker" title="This field is required.">*</span></span>',
243 transclude: true,
244 link: function (scope, element, attrs, crmUiIdCtrl) {
245 scope.crmIsRequired = false;
246 scope.cssClasses = {};
247
248 if (!attrs.crmUiFor) return;
249
250 var id = crmUiIdCtrl.get(attrs.crmUiFor);
251 element.attr('for', id);
252 var ngModel = null;
253
254 var updateCss = function () {
255 scope.cssClasses['crm-error'] = !ngModel.$valid && !ngModel.$pristine;
256 };
257
258 // Note: if target element is dynamically generated (eg via ngInclude), then it may not be available
259 // immediately for initialization. Use retries/retryDelay to initialize such elements.
260 var init = function (retries, retryDelay) {
261 var input = $('#' + id);
262 if (input.length === 0 && !attrs.crmUiForceRequired) {
263 if (retries) {
264 $timeout(function(){
265 init(retries-1, retryDelay);
266 }, retryDelay);
267 }
268 return;
269 }
270
271 if (attrs.crmUiForceRequired) {
272 scope.crmIsRequired = true;
273 return;
274 }
275
276 var tgtScope = scope;//.$parent;
277 if (attrs.crmDepth) {
278 for (var i = attrs.crmDepth; i > 0; i--) {
279 tgtScope = tgtScope.$parent;
280 }
281 }
282
283 if (input.attr('ng-required')) {
284 scope.crmIsRequired = scope.$parent.$eval(input.attr('ng-required'));
285 scope.$parent.$watch(input.attr('ng-required'), function (isRequired) {
286 scope.crmIsRequired = isRequired;
287 });
288 }
289 else {
290 scope.crmIsRequired = input.prop('required');
291 }
292
293 ngModel = $parse(attrs.crmUiFor)(tgtScope);
294 if (ngModel) {
295 ngModel.$viewChangeListeners.push(updateCss);
296 }
297 };
298
299 $timeout(function(){
300 init(3, 100);
301 });
302 }
303 };
304 })
305
306 // Define a scope in which a name like "subform.foo" maps to a unique ID.
307 // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div>
308 .directive('crmUiIdScope', function () {
309 return {
310 restrict: 'EA',
311 scope: {},
312 controllerAs: 'crmUiIdCtrl',
313 controller: function($scope) {
314 var ids = {};
315 this.get = function(name) {
316 if (!ids[name]) {
317 ids[name] = "crmUiId_" + (++uidCount);
318 }
319 return ids[name];
320 };
321 },
322 link: function (scope, element, attrs) {}
323 };
324 })
325
326 // Display an HTML blurb inside an IFRAME.
327 // example: <iframe crm-ui-iframe="getHtmlContent()"></iframe>
328 // example: <iframe crm-ui-iframe crm-ui-iframe-src="getUrl()"></iframe>
329 .directive('crmUiIframe', function ($parse) {
330 return {
331 scope: {
332 crmUiIframeSrc: '@', // expression which evaluates to a URL
333 crmUiIframe: '@' // expression which evaluates to HTML content
334 },
335 link: function (scope, elm, attrs) {
336 var iframe = $(elm)[0];
337 iframe.setAttribute('width', '100%');
338 iframe.setAttribute('height', '250px');
339 iframe.setAttribute('frameborder', '0');
340
341 var refresh = function () {
342 if (attrs.crmUiIframeSrc) {
343 iframe.setAttribute('src', scope.$parent.$eval(attrs.crmUiIframeSrc));
344 }
345 else {
346 var iframeHtml = scope.$parent.$eval(attrs.crmUiIframe);
347
348 var doc = iframe.document;
349 if (iframe.contentDocument) {
350 doc = iframe.contentDocument;
351 }
352 else if (iframe.contentWindow) {
353 doc = iframe.contentWindow.document;
354 }
355
356 doc.open();
357 doc.writeln(iframeHtml);
358 doc.close();
359 }
360 };
361
362 // If the iframe is in a dialog, respond to resize events
363 $(elm).parent().on('dialogresize dialogopen', function(e, ui) {
364 $(this).css({padding: '0', margin: '0', overflow: 'hidden'});
365 iframe.setAttribute('height', '' + $(this).innerHeight() + 'px');
366 });
367
368 $(elm).parent().on('dialogresize', function(e, ui) {
369 iframe.setAttribute('class', 'resized');
370 });
371
372 scope.$parent.$watch(attrs.crmUiIframe, refresh);
373 }
374 };
375 })
376
377 // Example:
378 // <a ng-click="$broadcast('my-insert-target', 'some new text')>Insert</a>
379 // <textarea crm-ui-insert-rx='my-insert-target'></textarea>
380 .directive('crmUiInsertRx', function() {
381 return {
382 link: function(scope, element, attrs) {
383 scope.$on(attrs.crmUiInsertRx, function(e, tokenName) {
384 CRM.wysiwyg.insert(element, tokenName);
385 $(element).select2('close').select2('val', '');
386 CRM.wysiwyg.focus(element);
387 });
388 }
389 };
390 })
391
392 // Define a rich text editor.
393 // example: <textarea crm-ui-id="myForm.body_html" crm-ui-richtext name="body_html" ng-model="mailing.body_html"></textarea>
394 .directive('crmUiRichtext', function ($timeout) {
395 return {
396 require: '?ngModel',
397 link: function (scope, elm, attr, ngModel) {
398
399 var editor = CRM.wysiwyg.create(elm);
400 if (!ngModel) {
401 return;
402 }
403
404 if (attr.ngBlur) {
405 $(elm).on('blur', function() {
406 $timeout(function() {
407 scope.$eval(attr.ngBlur);
408 });
409 });
410 }
411
412 ngModel.$render = function(value) {
413 editor.done(function() {
414 CRM.wysiwyg.setVal(elm, ngModel.$viewValue || '');
415 });
416 };
417 }
418 };
419 })
420
421 // Display a lock icon (based on a boolean).
422 // example: <a crm-ui-lock binding="mymodel.boolfield"></a>
423 // example: <a crm-ui-lock
424 // binding="mymodel.boolfield"
425 // title-locked="ts('Boolfield is locked')"
426 // title-unlocked="ts('Boolfield is unlocked')"></a>
427 .directive('crmUiLock', function ($parse, $rootScope) {
428 var defaultVal = function (defaultValue) {
429 var f = function (scope) {
430 return defaultValue;
431 };
432 f.assign = function (scope, value) {
433 // ignore changes
434 };
435 return f;
436 };
437
438 // like $parse, but accepts a defaultValue in case expr is undefined
439 var parse = function (expr, defaultValue) {
440 return expr ? $parse(expr) : defaultVal(defaultValue);
441 };
442
443 return {
444 template: '',
445 link: function (scope, element, attrs) {
446 var binding = parse(attrs.binding, true);
447 var titleLocked = parse(attrs.titleLocked, ts('Locked'));
448 var titleUnlocked = parse(attrs.titleUnlocked, ts('Unlocked'));
449
450 $(element).addClass('crm-i lock-button');
451 var refresh = function () {
452 var locked = binding(scope);
453 if (locked) {
454 $(element)
455 .removeClass('fa-unlock')
456 .addClass('fa-lock')
457 .prop('title', titleLocked(scope))
458 ;
459 }
460 else {
461 $(element)
462 .removeClass('fa-lock')
463 .addClass('fa-unlock')
464 .prop('title', titleUnlocked(scope))
465 ;
466 }
467 };
468
469 $(element).click(function () {
470 binding.assign(scope, !binding(scope));
471 //scope.$digest();
472 $rootScope.$digest();
473 });
474
475 scope.$watch(attrs.binding, refresh);
476 scope.$watch(attrs.titleLocked, refresh);
477 scope.$watch(attrs.titleUnlocked, refresh);
478
479 refresh();
480 }
481 };
482 })
483
484 // CrmUiOrderCtrl is a controller class which manages sort orderings.
485 // Ex:
486 // JS: $scope.myOrder = new CrmUiOrderCtrl(['+field1', '-field2]);
487 // $scope.myOrder.toggle('field1');
488 // $scope.myOrder.setDir('field2', '');
489 // HTML: <tr ng-repeat="... | order:myOrder.get()">...</tr>
490 .service('CrmUiOrderCtrl', function(){
491 function CrmUiOrderCtrl(defaults){
492 this.values = defaults;
493 }
494 angular.extend(CrmUiOrderCtrl.prototype, {
495 get: function get() {
496 return this.values;
497 },
498 getDir: function getDir(name) {
499 if (this.values.indexOf(name) >= 0 || this.values.indexOf('+' + name) >= 0) {
500 return '+';
501 }
502 if (this.values.indexOf('-' + name) >= 0) {
503 return '-';
504 }
505 return '';
506 },
507 // @return bool TRUE if something is removed
508 remove: function remove(name) {
509 var idx = this.values.indexOf(name);
510 if (idx >= 0) {
511 this.values.splice(idx, 1);
512 return true;
513 }
514 else {
515 return false;
516 }
517 },
518 setDir: function setDir(name, dir) {
519 return this.toggle(name, dir);
520 },
521 // Toggle sort order on a field.
522 // To set a specific order, pass optional parameter 'next' ('+', '-', or '').
523 toggle: function toggle(name, next) {
524 if (!next && next !== '') {
525 next = '+';
526 if (this.remove(name) || this.remove('+' + name)) {
527 next = '-';
528 }
529 if (this.remove('-' + name)) {
530 next = '';
531 }
532 }
533
534 if (next == '+') {
535 this.values.unshift('+' + name);
536 }
537 else if (next == '-') {
538 this.values.unshift('-' + name);
539 }
540 }
541 });
542 return CrmUiOrderCtrl;
543 })
544
545 // Define a controller which manages sort order. You may interact with the controller
546 // directly ("myOrder.toggle('fieldname')") order using the helper, crm-ui-order-by.
547 // example:
548 // <span crm-ui-order="{var: 'myOrder', defaults: {'-myField'}}"></span>
549 // <th><a crm-ui-order-by="[myOrder,'myField']">My Field</a></th>
550 // <tr ng-repeat="... | order:myOrder.get()">...</tr>
551 // <button ng-click="myOrder.toggle('myField')">
552 .directive('crmUiOrder', function(CrmUiOrderCtrl) {
553 return {
554 link: function(scope, element, attrs){
555 var options = angular.extend({var: 'crmUiOrderBy'}, scope.$eval(attrs.crmUiOrder));
556 scope[options.var] = new CrmUiOrderCtrl(options.defaults);
557 }
558 };
559 })
560
561 // For usage, see crmUiOrder (above)
562 .directive('crmUiOrderBy', function() {
563 return {
564 link: function(scope, element, attrs) {
565 function updateClass(crmUiOrderCtrl, name) {
566 var dir = crmUiOrderCtrl.getDir(name);
567 element
568 .toggleClass('sorting_asc', dir === '+')
569 .toggleClass('sorting_desc', dir === '-')
570 .toggleClass('sorting', dir === '');
571 }
572
573 element.on('click', function(e){
574 var tgt = scope.$eval(attrs.crmUiOrderBy);
575 tgt[0].toggle(tgt[1]);
576 updateClass(tgt[0], tgt[1]);
577 e.preventDefault();
578 scope.$digest();
579 });
580
581 var tgt = scope.$eval(attrs.crmUiOrderBy);
582 updateClass(tgt[0], tgt[1]);
583 }
584 };
585 })
586
587 // Display a fancy SELECT (based on select2).
588 // usage: <select crm-ui-select="{placeholder:'Something',allowClear:true,...}" ng-model="myobj.field"><option...></select>
589 .directive('crmUiSelect', function ($parse, $timeout) {
590 return {
591 require: '?ngModel',
592 priority: 1,
593 scope: {
594 crmUiSelect: '='
595 },
596 link: function (scope, element, attrs, ngModel) {
597 // In cases where UI initiates update, there may be an extra
598 // call to refreshUI, but it doesn't create a cycle.
599
600 if (ngModel) {
601 ngModel.$render = function () {
602 $timeout(function () {
603 // ex: msg_template_id adds new item then selects it; use $timeout to ensure that
604 // new item is added before selection is made
605 var newVal = _.cloneDeep(ngModel.$modelValue);
606 // Fix possible data-type mismatch
607 if (typeof newVal === 'string' && element.select2('container').hasClass('select2-container-multi')) {
608 newVal = newVal.length ? newVal.split(',') : [];
609 }
610 element.select2('val', newVal);
611 });
612 };
613 }
614 function refreshModel() {
615 var oldValue = ngModel.$viewValue, newValue = element.select2('val');
616 if (oldValue != newValue) {
617 scope.$parent.$apply(function () {
618 ngModel.$setViewValue(newValue);
619 });
620 }
621 }
622
623 function init() {
624 // TODO watch select2-options
625 element.crmSelect2(scope.crmUiSelect || {});
626 if (ngModel) {
627 element.on('change', refreshModel);
628 }
629 }
630
631 // If using ngOptions, wait for them to load
632 if (attrs.ngOptions) {
633 $timeout(init);
634 } else {
635 init();
636 }
637 }
638 };
639 })
640
641 // Use a select2 widget as a pick-list. Instead of updating ngModel, the select2 widget will fire an event.
642 // This similar to ngModel+ngChange, except that value is never stored in a model. It is only fired in the event.
643 // usage: <select crm-ui-select='{...}' on-crm-ui-select="alert("User picked this item: " + selection)"></select>
644 .directive('onCrmUiSelect', function () {
645 return {
646 priority: 10,
647 link: function (scope, element, attrs) {
648 element.on('select2-selecting', function(e) {
649 e.preventDefault();
650 element.select2('close').select2('val', '');
651 scope.$apply(function() {
652 scope.$eval(attrs.onCrmUiSelect, {selection: e.val});
653 });
654 });
655 }
656 };
657 })
658
659 // Render a crmEntityRef widget
660 // usage: <input crm-entityref="{entity: 'Contact', select: {allowClear:true}}" ng-model="myobj.field" />
661 .directive('crmEntityref', function ($parse, $timeout) {
662 return {
663 require: '?ngModel',
664 scope: {
665 crmEntityref: '='
666 },
667 link: function (scope, element, attrs, ngModel) {
668 // In cases where UI initiates update, there may be an extra
669 // call to refreshUI, but it doesn't create a cycle.
670
671 ngModel.$render = function () {
672 $timeout(function () {
673 // ex: msg_template_id adds new item then selects it; use $timeout to ensure that
674 // new item is added before selection is made
675 var newVal = _.cloneDeep(ngModel.$modelValue);
676 // Fix possible data-type mismatch
677 if (typeof newVal === 'string' && element.select2('container').hasClass('select2-container-multi')) {
678 newVal = newVal.length ? newVal.split(',') : [];
679 }
680 element.select2('val', newVal);
681 });
682 };
683 function refreshModel() {
684 var oldValue = ngModel.$viewValue, newValue = element.select2('val');
685 if (oldValue != newValue) {
686 scope.$parent.$apply(function () {
687 ngModel.$setViewValue(newValue);
688 });
689 }
690 }
691
692 function init() {
693 // TODO can we infer "entity" from model?
694 element.crmEntityRef(scope.crmEntityref || {});
695 element.on('change', refreshModel);
696 $timeout(ngModel.$render);
697 }
698
699 init();
700 }
701 };
702 })
703
704 // validate multiple email text
705 // usage: <input crm-multiple-email type="text" ng-model="myobj.field" />
706 .directive('crmMultipleEmail', function ($parse, $timeout) {
707 return {
708 require: 'ngModel',
709 link: function(scope, element, attrs, ctrl) {
710 ctrl.$parsers.unshift(function(viewValue) {
711 // if empty value provided simply bypass validation
712 if (_.isEmpty(viewValue)) {
713 ctrl.$setValidity('crmMultipleEmail', true);
714 return viewValue;
715 }
716
717 // split email string on basis of comma
718 var emails = viewValue.split(',');
719 // regex pattern for single email
720 var emailRegex = /\S+@\S+\.\S+/;
721
722 var validityArr = emails.map(function(str){
723 return emailRegex.test(str.trim());
724 });
725
726 if ($.inArray(false, validityArr) > -1) {
727 ctrl.$setValidity('crmMultipleEmail', false);
728 } else {
729 ctrl.$setValidity('crmMultipleEmail', true);
730 }
731 return viewValue;
732 });
733 }
734 };
735 })
736 // example <div crm-ui-tab id="tab-1" crm-title="ts('My Title')" count="3">...content...</div>
737 // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper
738 .directive('crmUiTab', function($parse) {
739 return {
740 require: '^crmUiTabSet',
741 restrict: 'EA',
742 scope: {
743 crmTitle: '@',
744 crmIcon: '@',
745 count: '@',
746 id: '@'
747 },
748 template: '<div ng-transclude></div>',
749 transclude: true,
750 link: function (scope, element, attrs, crmUiTabSetCtrl) {
751 crmUiTabSetCtrl.add(scope);
752 }
753 };
754 })
755
756 // example: <div crm-ui-tab-set><div crm-ui-tab crm-title="Tab 1">...</div><div crm-ui-tab crm-title="Tab 2">...</div></div>
757 .directive('crmUiTabSet', function() {
758 return {
759 restrict: 'EA',
760 scope: {
761 crmUiTabSet: '@',
762 tabSetOptions: '@'
763 },
764 templateUrl: '~/crmUi/tabset.html',
765 transclude: true,
766 controllerAs: 'crmUiTabSetCtrl',
767 controller: function($scope, $element, $timeout) {
768 var init;
769 $scope.tabs = [];
770 this.add = function(tab) {
771 if (!tab.id) throw "Tab is missing 'id'";
772 $scope.tabs.push(tab);
773
774 // Init jQuery.tabs() once all tabs have been added
775 if (init) {
776 $timeout.cancel(init);
777 }
778 init = $timeout(function() {
779 $element.find('.crm-tabset').tabs($scope.tabSetOptions);
780 });
781 };
782 }
783 };
784 })
785
786 // Generic, field-independent form validator.
787 // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" />
788 // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" crm-ui-validate-name="myError" />
789 .directive('crmUiValidate', function() {
790 return {
791 restrict: 'EA',
792 require: 'ngModel',
793 link: function(scope, element, attrs, ngModel) {
794 var validationKey = attrs.crmUiValidateName ? attrs.crmUiValidateName : 'crmUiValidate';
795 scope.$watch(attrs.crmUiValidate, function(newValue){
796 ngModel.$setValidity(validationKey, !!newValue);
797 });
798 }
799 };
800 })
801
802 // like ng-show, but hides/displays elements using "visibility" which maintains positioning
803 // example <div crm-ui-visible="false">...content...</div>
804 .directive('crmUiVisible', function($parse) {
805 return {
806 restrict: 'EA',
807 scope: {
808 crmUiVisible: '@'
809 },
810 link: function (scope, element, attrs) {
811 var model = $parse(attrs.crmUiVisible);
812 function updatecChildren() {
813 element.css('visibility', model(scope.$parent) ? 'inherit' : 'hidden');
814 }
815 updatecChildren();
816 scope.$parent.$watch(attrs.crmUiVisible, updatecChildren);
817 }
818 };
819 })
820
821 // example: <div crm-ui-wizard="myWizardCtrl"><div crm-ui-wizard-step crm-title="ts('Step 1')">...</div><div crm-ui-wizard-step crm-title="ts('Step 2')">...</div></div>
822 // example with custom nav classes: <div crm-ui-wizard crm-ui-wizard-nav-class="ng-animate-out ...">...</div>
823 // Note: "myWizardCtrl" has various actions/properties like next() and $first().
824 // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable"
825 // WISHLIST: Allow each step to enable/disable (show/hide) itself
826 .directive('crmUiWizard', function() {
827 return {
828 restrict: 'EA',
829 scope: {
830 crmUiWizard: '@',
831 crmUiWizardNavClass: '@' // string, A list of classes that will be added to the nav items
832 },
833 templateUrl: '~/crmUi/wizard.html',
834 transclude: true,
835 controllerAs: 'crmUiWizardCtrl',
836 controller: function($scope, $parse) {
837 var steps = $scope.steps = []; // array<$scope>
838 var crmUiWizardCtrl = this;
839 var maxVisited = 0;
840 var selectedIndex = null;
841
842 var findIndex = function() {
843 var found = null;
844 angular.forEach(steps, function(step, stepKey) {
845 if (step.selected) found = stepKey;
846 });
847 return found;
848 };
849
850 /// @return int the index of the current step
851 this.$index = function() { return selectedIndex; };
852 /// @return bool whether the currentstep is first
853 this.$first = function() { return this.$index() === 0; };
854 /// @return bool whether the current step is last
855 this.$last = function() { return this.$index() === steps.length -1; };
856 this.$maxVisit = function() { return maxVisited; };
857 this.$validStep = function() {
858 return steps[selectedIndex] && steps[selectedIndex].isStepValid();
859 };
860 this.iconFor = function(index) {
861 if (index < this.$index()) return 'crm-i fa-check';
862 if (index === this.$index()) return 'crm-i fa-angle-double-right';
863 return '';
864 };
865 this.isSelectable = function(step) {
866 if (step.selected) return false;
867 return this.$validStep();
868 };
869
870 /*** @param Object step the $scope of the step */
871 this.select = function(step) {
872 angular.forEach(steps, function(otherStep, otherKey) {
873 otherStep.selected = (otherStep === step);
874 if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey;
875 });
876 selectedIndex = findIndex();
877 };
878 /*** @param Object step the $scope of the step */
879 this.add = function(step) {
880 if (steps.length === 0) {
881 step.selected = true;
882 selectedIndex = 0;
883 }
884 steps.push(step);
885 steps.sort(function(a,b){
886 return a.crmUiWizardStep - b.crmUiWizardStep;
887 });
888 selectedIndex = findIndex();
889 };
890 this.remove = function(step) {
891 var key = null;
892 angular.forEach(steps, function(otherStep, otherKey) {
893 if (otherStep === step) key = otherKey;
894 });
895 if (key !== null) {
896 steps.splice(key, 1);
897 }
898 };
899 this.goto = function(index) {
900 if (index < 0) index = 0;
901 if (index >= steps.length) index = steps.length-1;
902 this.select(steps[index]);
903 };
904 this.previous = function() { this.goto(this.$index()-1); };
905 this.next = function() { this.goto(this.$index()+1); };
906 if ($scope.crmUiWizard) {
907 $parse($scope.crmUiWizard).assign($scope.$parent, this);
908 }
909 },
910 link: function (scope, element, attrs) {
911 scope.ts = CRM.ts(null);
912
913 element.find('.crm-wizard-buttons button[ng-click^=crmUiWizardCtrl]').click(function () {
914 // These values are captured inside the click handler to ensure the
915 // positions/sizes of the elements are captured at the time of the
916 // click vs. at the time this directive is initialized.
917 var topOfWizard = element.offset().top;
918 var heightOfMenu = $('#civicrm-menu').height() || 0;
919
920 $('html')
921 // stop any other animations that might be happening...
922 .stop()
923 // gracefully slide the user to the top of the wizard
924 .animate({scrollTop: topOfWizard - heightOfMenu}, 1000);
925 });
926 }
927 };
928 })
929
930 // Use this to add extra markup to wizard
931 .directive('crmUiWizardButtons', function() {
932 return {
933 require: '^crmUiWizard',
934 restrict: 'EA',
935 scope: {},
936 template: '<span ng-transclude></span>',
937 transclude: true,
938 link: function (scope, element, attrs, crmUiWizardCtrl) {
939 var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons');
940 $(element).appendTo(realButtonsEl);
941 }
942 };
943 })
944
945 // Example for Font Awesome: <button crm-icon="fa-check">Save</button>
946 // Example for jQuery UI (deprecated): <button crm-icon="fa-check">Save</button>
947 .directive('crmIcon', function() {
948 return {
949 restrict: 'EA',
950 link: function (scope, element, attrs) {
951 if (element.is('[crm-ui-tab]')) {
952 // handled in crmUiTab ctrl
953 return;
954 }
955 if (attrs.crmIcon.substring(0,3) == 'fa-') {
956 $(element).prepend('<i class="crm-i ' + attrs.crmIcon + '" aria-hidden="true"></i> ');
957 }
958 else {
959 $(element).prepend('<span class="icon ui-icon-' + attrs.crmIcon + '"></span> ');
960 }
961 // Add crm-* class to non-bootstrap buttons
962 if ($(element).is('button:not(.btn)')) {
963 $(element).addClass('crm-button');
964 }
965 }
966 };
967 })
968
969 // example: <div crm-ui-wizard-step crm-title="ts('My Title')" ng-form="mySubForm">...content...</div>
970 // If there are any conditional steps, then be sure to set a weight explicitly on *all* steps to maintain ordering.
971 // example: <div crm-ui-wizard-step="100" crm-title="..." ng-if="...">...content...</div>
972 // example with custom classes: <div crm-ui-wizard-step="100" crm-ui-wizard-step-class="ng-animate-out ...">...content...</div>
973 .directive('crmUiWizardStep', function() {
974 var nextWeight = 1;
975 return {
976 require: ['^crmUiWizard', 'form'],
977 restrict: 'EA',
978 scope: {
979 crmTitle: '@', // expression, evaluates to a printable string
980 crmUiWizardStep: '@', // int, a weight which determines the ordering of the steps
981 crmUiWizardStepClass: '@' // string, A list of classes that will be added to the template
982 },
983 template: '<div class="crm-wizard-step {{crmUiWizardStepClass}}" ng-show="selected" ng-transclude/></div>',
984 transclude: true,
985 link: function (scope, element, attrs, ctrls) {
986 var crmUiWizardCtrl = ctrls[0], form = ctrls[1];
987 if (scope.crmUiWizardStep) {
988 scope.crmUiWizardStep = parseInt(scope.crmUiWizardStep);
989 } else {
990 scope.crmUiWizardStep = nextWeight++;
991 }
992 scope.isStepValid = function() {
993 return form.$valid;
994 };
995 crmUiWizardCtrl.add(scope);
996 scope.$on('$destroy', function(){
997 crmUiWizardCtrl.remove(scope);
998 });
999 }
1000 };
1001 })
1002
1003 // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
1004 // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
1005 // Example: <button crm-confirm="{templateUrl: '~/path/to/view.html', export: {foo: bar}}" on-yes="frobnicate(123)">Frobincate</button>
1006 .directive('crmConfirm', function ($compile, $rootScope, $templateRequest, $q) {
1007 // Helpers to calculate default options for CRM.confirm()
1008 var defaultFuncs = {
1009 'disable': function (options) {
1010 return {
1011 message: ts('Are you sure you want to disable this?'),
1012 options: {no: ts('Cancel'), yes: ts('Disable')},
1013 width: 300,
1014 title: ts('Disable %1?', {
1015 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
1016 })
1017 };
1018 },
1019 'revert': function (options) {
1020 return {
1021 message: ts('Are you sure you want to revert this?'),
1022 options: {no: ts('Cancel'), yes: ts('Revert')},
1023 width: 300,
1024 title: ts('Revert %1?', {
1025 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
1026 })
1027 };
1028 },
1029 'delete': function (options) {
1030 return {
1031 message: ts('Are you sure you want to delete this?'),
1032 options: {no: ts('Cancel'), yes: ts('Delete')},
1033 width: 300,
1034 title: ts('Delete %1?', {
1035 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
1036 })
1037 };
1038 }
1039 };
1040 var confirmCount = 0;
1041 return {
1042 link: function (scope, element, attrs) {
1043 $(element).click(function () {
1044 var options = scope.$eval(attrs.crmConfirm);
1045 if (attrs.title && !options.title) {
1046 options.title = attrs.title;
1047 }
1048 var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
1049
1050 var tpl = null, stubId = null;
1051 if (!options.message) {
1052 if (options.templateUrl) {
1053 tpl = $templateRequest(options.templateUrl);
1054 }
1055 else if (options.template) {
1056 tpl = options.template;
1057 }
1058 if (tpl) {
1059 stubId = 'crmUiConfirm_' + (++confirmCount);
1060 options.message = '<div id="' + stubId + '"></div>';
1061 }
1062 }
1063
1064 CRM.confirm(_.extend(defaults, options))
1065 .on('crmConfirm:yes', function() { scope.$apply(attrs.onYes); })
1066 .on('crmConfirm:no', function() { scope.$apply(attrs.onNo); });
1067
1068 if (tpl && stubId) {
1069 $q.when(tpl, function(html) {
1070 var scope = options.scope || $rootScope.$new();
1071 if (options.export) {
1072 angular.extend(scope, options.export);
1073 }
1074 var linker = $compile(html);
1075 $('#' + stubId).append($(linker(scope)));
1076 });
1077 }
1078 });
1079 }
1080 };
1081 })
1082
1083 // Sets document title & page title; attempts to override CMS title markup for the latter
1084 // WARNING: Use only once per route!
1085 // WARNING: This directive works only if your AngularJS base page does not
1086 // set a custom title (i.e., it has an initial title of "CiviCRM"). See the
1087 // global variables pageTitle and documentTitle.
1088 // Example (same title for both): <h1 crm-page-title>{{ts('Hello')}}</h1>
1089 // Example (separate document title): <h1 crm-document-title="ts('Hello')" crm-page-title><i class="crm-i fa-flag" aria-hidden="true"></i>{{ts('Hello')}}</h1>
1090 .directive('crmPageTitle', function($timeout) {
1091 return {
1092 scope: {
1093 crmDocumentTitle: '='
1094 },
1095 link: function(scope, $el, attrs) {
1096 function update() {
1097 $timeout(function() {
1098 var newPageTitle = _.trim($el.html()),
1099 newDocumentTitle = scope.crmDocumentTitle || $el.text(),
1100 h1Count = 0;
1101 document.title = $('title').text().replace(documentTitle, newDocumentTitle);
1102 // If the CMS has already added title markup to the page, use it
1103 $('h1').not('.crm-container h1').each(function() {
1104 if ($(this).hasClass('crm-page-title') || _.trim($(this).html()) === pageTitle) {
1105 $(this).addClass('crm-page-title').html(newPageTitle);
1106 $el.hide();
1107 ++h1Count;
1108 }
1109 });
1110 if (!h1Count) {
1111 $el.show();
1112 }
1113 pageTitle = newPageTitle;
1114 documentTitle = newDocumentTitle;
1115 });
1116 }
1117
1118 scope.$watch(function() {return scope.crmDocumentTitle + $el.html();}, update);
1119 }
1120 };
1121 })
1122
1123 // Single-line editable text using ngModel & html5 contenteditable
1124 // Supports a `placeholder` attribute which shows up if empty and no `default-value`.
1125 // The `default-value` attribute will force a value if empty (mutually-exclusive with `placeholder`).
1126 // Usage: <span crm-ui-editable ng-model="model.text" placeholder="Enter text"></span>
1127 .directive("crmUiEditable", function() {
1128 return {
1129 restrict: "A",
1130 require: "ngModel",
1131 scope: {
1132 defaultValue: '='
1133 },
1134 link: function(scope, element, attrs, ngModel) {
1135 var ts = CRM.ts();
1136
1137 function read() {
1138 var htmlVal = element.html();
1139 if (!htmlVal) {
1140 htmlVal = scope.defaultValue || '';
1141 element.text(htmlVal);
1142 }
1143 ngModel.$setViewValue(htmlVal);
1144 }
1145
1146 ngModel.$render = function() {
1147 element.text(ngModel.$viewValue || scope.defaultValue || '');
1148 };
1149
1150 // Special handling for enter and escape keys
1151 element.on('keydown', function(e) {
1152 // Enter: prevent line break and save
1153 if (e.which === 13) {
1154 e.preventDefault();
1155 element.blur();
1156 }
1157 // Escape: undo
1158 if (e.which === 27) {
1159 element.text(ngModel.$viewValue || scope.defaultValue || '');
1160 element.blur();
1161 }
1162 });
1163
1164 element.on("blur change", function() {
1165 scope.$apply(read);
1166 });
1167
1168 element.attr('contenteditable', 'true');
1169 }
1170 };
1171 })
1172
1173 .run(function($rootScope, $location) {
1174 /// Example: <button ng-click="goto('home')">Go home!</button>
1175 $rootScope.goto = function(path) {
1176 $location.path(path);
1177 };
1178 // useful for debugging: $rootScope.log = console.log || function() {};
1179 });
1180
1181 })(angular, CRM.$, CRM._);