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