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