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