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