INFRA-132 - Fix jshint warnings in js/*.js
[civicrm-core.git] / js / angular-crm-ui.js
1 /// crmUi: Sundry UI helpers
2 (function (angular, $, _) {
3
4 var uidCount = 0;
5
6 var partialUrl = function (relPath) {
7 return CRM.resourceUrls.civicrm + '/partials/crmUi/' + relPath;
8 };
9
10 angular.module('crmUi', [])
11
12 // example <div crm-ui-accordion crm-title="ts('My Title')" crm-collapsed="true">...content...</div>
13 // WISHLIST: crmCollapsed should support two-way/continous binding
14 .directive('crmUiAccordion', function() {
15 return {
16 scope: {
17 crmTitle: '@',
18 crmCollapsed: '@'
19 },
20 template: '<div class="crm-accordion-wrapper" ng-class="cssClasses"><div class="crm-accordion-header">{{$parent.$eval(crmTitle)}}</div><div class="crm-accordion-body" ng-transclude></div></div>',
21 transclude: true,
22 link: function (scope, element, attrs) {
23 scope.cssClasses = {
24 collapsed: scope.$parent.$eval(attrs.crmCollapsed)
25 };
26 }
27 };
28 })
29
30 // example: <input crm-ui-date="myobj.datefield" />
31 // example: <input crm-ui-date="myobj.datefield" crm-ui-date-format="yy-mm-dd" />
32 .directive('crmUiDate', function ($parse, $timeout) {
33 return {
34 restrict: 'AE',
35 scope: {
36 crmUiDate: '@', // expression, model binding
37 crmUiDateFormat: '@' // expression, date format (default: "yy-mm-dd")
38 },
39 link: function (scope, element, attrs) {
40 var fmt = attrs.crmUiDateFormat ? $parse(attrs.crmUiDateFormat)() : "yy-mm-dd";
41 var model = $parse(attrs.crmUiDate);
42
43 element.addClass('dateplugin');
44 $(element).datepicker({
45 dateFormat: fmt
46 });
47
48 var updateChildren = (function() {
49 element.off('change', updateParent);
50 $(element).datepicker('setDate', model(scope.$parent));
51 element.on('change', updateParent);
52 });
53 var updateParent = (function() {
54 $timeout(function () {
55 model.assign(scope.$parent, $(element).val());
56 });
57 });
58
59 updateChildren();
60 scope.$parent.$watch(attrs.crmUiDate, updateChildren);
61 element.on('change', updateParent);
62 }
63 };
64 })
65
66 // example: <div crm-ui-date-time="myobj.mydatetimefield"></div>
67 .directive('crmUiDateTime', function ($parse) {
68 return {
69 restrict: 'AE',
70 scope: {
71 crmUiDateTime: '@'
72 },
73 template: '<input crm-ui-date="dtparts.date" placeholder="{{dateLabel}}"/> <input crm-ui-time="dtparts.time" placeholder="{{timeLabel}}"/>',
74 link: function (scope, element, attrs) {
75 var model = $parse(attrs.crmUiDateTime);
76 scope.dateLabel = ts('Date');
77 scope.timeLabel = ts('Time');
78
79 var updateChildren = (function () {
80 var value = model(scope.$parent);
81 if (value) {
82 var dtparts = value.split(/ /);
83 scope.dtparts = {date: dtparts[0], time: dtparts[1]};
84 }
85 else {
86 scope.dtparts = {date: '', time: ''};
87 }
88 });
89 var updateParent = (function () {
90 model.assign(scope.$parent, scope.dtparts.date + " " + scope.dtparts.time);
91 });
92
93 updateChildren();
94 scope.$parent.$watch(attrs.crmUiDateTime, updateChildren);
95 scope.$watch('dtparts.date', updateParent);
96 scope.$watch('dtparts.time', updateParent);
97 }
98 };
99 })
100
101 // Display a field/row in a field list
102 // example: <div crm-ui-field crm-title="My Field"> {{mydata}} </div>
103 // example: <div crm-ui-field="subform.myfield" crm-title="'My Field'"> <input crm-ui-id="subform.myfield" name="myfield" /> </div>
104 // example: <div crm-ui-field="subform.myfield" crm-title="'My Field'"> <input crm-ui-id="subform.myfield" name="myfield" required /> </div>
105 .directive('crmUiField', function() {
106 // Note: When writing new templates, the "label" position is particular. See/patch "var label" below.
107 var templateUrls = {
108 default: partialUrl('field.html'),
109 checkbox: partialUrl('field-cb.html')
110 };
111
112 return {
113 require: '^crmUiIdScope',
114 restrict: 'EA',
115 scope: {
116 crmUiField: '@',
117 crmTitle: '@'
118 },
119 templateUrl: function(tElement, tAttrs){
120 var layout = tAttrs.crmLayout ? tAttrs.crmLayout : 'default';
121 return templateUrls[layout];
122 },
123 transclude: true,
124 link: function (scope, element, attrs, crmUiIdCtrl) {
125 $(element).addClass('crm-section');
126 scope.crmUiField = attrs.crmUiField;
127 scope.crmTitle = attrs.crmTitle;
128 }
129 };
130 })
131
132 // 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>
133 .directive('crmUiId', function () {
134 return {
135 require: '^crmUiIdScope',
136 restrict: 'EA',
137 link: {
138 pre: function (scope, element, attrs, crmUiIdCtrl) {
139 var id = crmUiIdCtrl.get(attrs.crmUiId);
140 element.attr('id', id);
141 }
142 }
143 };
144 })
145
146 // 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>
147 .directive('crmUiFor', function ($parse, $timeout) {
148 return {
149 require: '^crmUiIdScope',
150 restrict: 'EA',
151 template: '<span ng-class="cssClasses"><span ng-transclude/><span crm-ui-visible="crmIsRequired" class="crm-marker" title="This field is required.">*</span></span>',
152 transclude: true,
153 link: function (scope, element, attrs, crmUiIdCtrl) {
154 scope.crmIsRequired = false;
155 scope.cssClasses = {};
156
157 if (!attrs.crmUiFor) return;
158
159 var id = crmUiIdCtrl.get(attrs.crmUiFor);
160 element.attr('for', id);
161 var ngModel = null;
162
163 var updateCss = function () {
164 scope.cssClasses['crm-error'] = !ngModel.$valid && !ngModel.$pristine;
165 };
166
167 // Note: if target element is dynamically generated (eg via ngInclude), then it may not be available
168 // immediately for initialization. Use retries/retryDelay to initialize such elements.
169 var init = function (retries, retryDelay) {
170 var input = $('#' + id);
171 if (input.length == 0) {
172 if (retries) {
173 $timeout(function(){
174 init(retries-1, retryDelay);
175 }, retryDelay);
176 }
177 return;
178 }
179
180 var tgtScope = scope;//.$parent;
181 if (attrs.crmDepth) {
182 for (var i = attrs.crmDepth; i > 0; i--) {
183 tgtScope = tgtScope.$parent;
184 }
185 }
186
187 if (input.attr('ng-required')) {
188 scope.crmIsRequired = scope.$parent.$eval(input.attr('ng-required'));
189 scope.$parent.$watch(input.attr('ng-required'), function (isRequired) {
190 scope.crmIsRequired = isRequired;
191 });
192 }
193 else {
194 scope.crmIsRequired = input.prop('required');
195 }
196
197 ngModel = $parse(attrs.crmUiFor)(tgtScope);
198 if (ngModel) {
199 ngModel.$viewChangeListeners.push(updateCss);
200 }
201 };
202
203 $timeout(function(){
204 init(3, 100);
205 });
206 }
207 };
208 })
209
210 // 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>
211 .directive('crmUiIdScope', function () {
212 return {
213 restrict: 'EA',
214 scope: {},
215 controllerAs: 'crmUiIdCtrl',
216 controller: function($scope) {
217 var ids = {};
218 this.get = function(name) {
219 if (!ids[name]) {
220 ids[name] = "crmUiId_" + (++uidCount);
221 }
222 return ids[name];
223 };
224 },
225 link: function (scope, element, attrs) {}
226 };
227 })
228
229 // example: <iframe crm-ui-iframe="getHtmlContent()"></iframe>
230 .directive('crmUiIframe', function ($parse) {
231 return {
232 scope: {
233 crmUiIframe: '@' // expression which evalutes to HTML content
234 },
235 link: function (scope, elm, attrs) {
236 var iframe = $(elm)[0];
237 iframe.setAttribute('width', '100%');
238 iframe.setAttribute('frameborder', '0');
239
240 var refresh = function () {
241 // var iframeHtml = '<html><head><base target="_blank"></head><body onload="parent.document.getElementById(\'' + iframe.id + '\').style.height=document.body.scrollHeight + \'px\'"><scr' + 'ipt type="text/javascript" src="https://gist.github.com/' + iframeId + '.js"></sc' + 'ript></body></html>';
242 var iframeHtml = scope.$parent.$eval(attrs.crmUiIframe);
243
244 var doc = iframe.document;
245 if (iframe.contentDocument) {
246 doc = iframe.contentDocument;
247 }
248 else if (iframe.contentWindow) {
249 doc = iframe.contentWindow.document;
250 }
251
252 doc.open();
253 doc.writeln(iframeHtml);
254 doc.close();
255 };
256
257 scope.$parent.$watch(attrs.crmUiIframe, refresh);
258 //setTimeout(function () { refresh(); }, 50);
259 }
260 };
261 })
262
263 // example: <textarea crm-ui-id="myForm.body_html" crm-ui-richtext name="body_html" ng-model="mailing.body_html"></textarea>
264 .directive('crmUiRichtext', function ($timeout) {
265 return {
266 require: '?ngModel',
267 link: function (scope, elm, attr, ngModel) {
268 var ck = CKEDITOR.replace(elm[0]);
269
270 if (!ngModel) {
271 return;
272 }
273
274 ck.on('pasteState', function () {
275 scope.$apply(function () {
276 ngModel.$setViewValue(ck.getData());
277 });
278 });
279
280 ck.on('insertText', function () {
281 $timeout(function () {
282 ngModel.$setViewValue(ck.getData());
283 });
284 });
285
286 ngModel.$render = function (value) {
287 ck.setData(ngModel.$viewValue);
288 };
289 }
290 };
291 })
292
293 // example: <a crm-ui-lock binding="mymodel.boolfield"></a>
294 // example: <a crm-ui-lock
295 // binding="mymodel.boolfield"
296 // title-locked="ts('Boolfield is locked')"
297 // title-unlocked="ts('Boolfield is unlocked')"></a>
298 .directive('crmUiLock', function ($parse, $rootScope) {
299 var defaultVal = function (defaultValue) {
300 var f = function (scope) {
301 return defaultValue;
302 };
303 f.assign = function (scope, value) {
304 // ignore changes
305 };
306 return f;
307 };
308
309 // like $parse, but accepts a defaultValue in case expr is undefined
310 var parse = function (expr, defaultValue) {
311 return expr ? $parse(expr) : defaultVal(defaultValue);
312 };
313
314 return {
315 template: '',
316 link: function (scope, element, attrs) {
317 var binding = parse(attrs.binding, true);
318 var titleLocked = parse(attrs.titleLocked, ts('Locked'));
319 var titleUnlocked = parse(attrs.titleUnlocked, ts('Unlocked'));
320
321 $(element).addClass('ui-icon lock-button');
322 var refresh = function () {
323 var locked = binding(scope);
324 if (locked) {
325 $(element)
326 .removeClass('ui-icon-unlocked')
327 .addClass('ui-icon-locked')
328 .prop('title', titleLocked(scope))
329 ;
330 }
331 else {
332 $(element)
333 .removeClass('ui-icon-locked')
334 .addClass('ui-icon-unlocked')
335 .prop('title', titleUnlocked(scope))
336 ;
337 }
338 };
339
340 $(element).click(function () {
341 binding.assign(scope, !binding(scope));
342 //scope.$digest();
343 $rootScope.$digest();
344 });
345
346 scope.$watch(attrs.binding, refresh);
347 scope.$watch(attrs.titleLocked, refresh);
348 scope.$watch(attrs.titleUnlocked, refresh);
349
350 refresh();
351 }
352 };
353 })
354
355 // usage: <select crm-ui-select="{placeholder:'Something',allowClear:true,...}" ng-model="myobj.field"><option...></select>
356 .directive('crmUiSelect', function ($parse, $timeout) {
357 return {
358 require: '?ngModel',
359 scope: {
360 crmUiSelect: '@'
361 },
362 link: function (scope, element, attrs, ngModel) {
363 // In cases where UI initiates update, there may be an extra
364 // call to refreshUI, but it doesn't create a cycle.
365
366 ngModel.$render = function () {
367 $timeout(function () {
368 // ex: msg_template_id adds new item then selects it; use $timeout to ensure that
369 // new item is added before selection is made
370 $(element).select2('val', ngModel.$viewValue);
371 });
372 };
373 function refreshModel() {
374 var oldValue = ngModel.$viewValue, newValue = $(element).select2('val');
375 if (oldValue != newValue) {
376 scope.$parent.$apply(function () {
377 ngModel.$setViewValue(newValue);
378 });
379 }
380 }
381
382 function init() {
383 // TODO watch select2-options
384 var options = attrs.crmUiSelect ? scope.$parent.$eval(attrs.crmUiSelect) : {};
385 $(element).select2(options);
386 $(element).on('change', refreshModel);
387 $timeout(ngModel.$render);
388 }
389
390 init();
391 }
392 };
393 })
394
395 // example <div crm-ui-tab crm-title="ts('My Title')">...content...</div>
396 // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper
397 .directive('crmUiTab', function($parse) {
398 return {
399 require: '^crmUiTabSet',
400 restrict: 'EA',
401 scope: {
402 crmTitle: '@',
403 id: '@'
404 },
405 template: '<div ng-transclude></div>',
406 transclude: true,
407 link: function (scope, element, attrs, crmUiTabSetCtrl) {
408 crmUiTabSetCtrl.add(scope);
409 }
410 };
411 })
412
413 // 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>
414 .directive('crmUiTabSet', function() {
415 return {
416 restrict: 'EA',
417 scope: {
418 crmUiTabSet: '@'
419 },
420 templateUrl: partialUrl('tabset.html'),
421 transclude: true,
422 controllerAs: 'crmUiTabSetCtrl',
423 controller: function($scope, $parse) {
424 var tabs = $scope.tabs = []; // array<$scope>
425 this.add = function(tab) {
426 if (!tab.id) throw "Tab is missing 'id'";
427 tabs.push(tab);
428 };
429 },
430 link: function (scope, element, attrs) {}
431 };
432 })
433
434 // example: <input crm-ui-time="myobj.mytimefield" />
435 .directive('crmUiTime', function ($parse, $timeout) {
436 return {
437 restrict: 'AE',
438 scope: {
439 crmUiTime: '@'
440 },
441 link: function (scope, element, attrs) {
442 var model = $parse(attrs.crmUiTime);
443
444 element.addClass('crm-form-text six');
445 $(element).timeEntry({show24Hours: true});
446
447 var updateChildren = (function() {
448 element.off('change', updateParent);
449 $(element).timeEntry('setTime', model(scope.$parent));
450 element.on('change', updateParent);
451 });
452 var updateParent = (function () {
453 $timeout(function () {
454 model.assign(scope.$parent, element.val());
455 });
456 });
457
458 updateChildren();
459 scope.$parent.$watch(attrs.crmUiTime, updateChildren);
460 element.on('change', updateParent);
461 }
462 };
463 })
464
465 // like ng-show, but hides/displays elements using "visibility" which maintains positioning
466 // example <div crm-ui-visible="false">...content...</div>
467 .directive('crmUiVisible', function($parse) {
468 return {
469 restrict: 'EA',
470 scope: {
471 crmUiVisible: '@'
472 },
473 link: function (scope, element, attrs) {
474 var model = $parse(attrs.crmUiVisible);
475 function updatecChildren() {
476 element.css('visibility', model(scope.$parent) ? 'inherit' : 'hidden');
477 }
478 updatecChildren();
479 scope.$parent.$watch(attrs.crmUiVisible, updatecChildren);
480 }
481 };
482 })
483
484 // 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>
485 // Note: "myWizardCtrl" has various actions/properties like next() and $first().
486 // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable"
487 // WISHLIST: Allow each step to enable/disable (show/hide) itself
488 .directive('crmUiWizard', function() {
489 return {
490 restrict: 'EA',
491 scope: {
492 crmUiWizard: '@'
493 },
494 templateUrl: partialUrl('wizard.html'),
495 transclude: true,
496 controllerAs: 'crmUiWizardCtrl',
497 controller: function($scope, $parse) {
498 var steps = $scope.steps = []; // array<$scope>
499 var crmUiWizardCtrl = this;
500 var maxVisited = 0;
501 var selectedIndex = null;
502
503 var findIndex = function() {
504 var found = null;
505 angular.forEach(steps, function(step, stepKey) {
506 if (step.selected) found = stepKey;
507 });
508 return found;
509 };
510
511 /// @return int the index of the current step
512 this.$index = function() { return selectedIndex; };
513 /// @return bool whether the currentstep is first
514 this.$first = function() { return this.$index() === 0; };
515 /// @return bool whether the current step is last
516 this.$last = function() { return this.$index() === steps.length -1; };
517 this.$maxVisit = function() { return maxVisited; };
518 this.iconFor = function(index) {
519 if (index < this.$index()) return '√';
520 if (index === this.$index()) return '»';
521 return ' ';
522 };
523 this.isSelectable = function(step) {
524 if (step.selected) return false;
525 var result = false;
526 angular.forEach(steps, function(otherStep, otherKey) {
527 if (step === otherStep && otherKey <= maxVisited) result = true;
528 });
529 return result;
530 };
531
532 /*** @param Object step the $scope of the step */
533 this.select = function(step) {
534 angular.forEach(steps, function(otherStep, otherKey) {
535 otherStep.selected = (otherStep === step);
536 if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey;
537 });
538 selectedIndex = findIndex();
539 };
540 /*** @param Object step the $scope of the step */
541 this.add = function(step) {
542 if (steps.length === 0) {
543 step.selected = true;
544 selectedIndex = 0;
545 }
546 steps.push(step);
547 steps.sort(function(a,b){
548 return a.crmUiWizardStep - b.crmUiWizardStep;
549 });
550 selectedIndex = findIndex();
551 };
552 this.remove = function(step) {
553 var key = null;
554 angular.forEach(steps, function(otherStep, otherKey) {
555 if (otherStep === step) key = otherKey;
556 });
557 if (key != null) {
558 steps.splice(key, 1);
559 }
560 };
561 this.goto = function(index) {
562 if (index < 0) index = 0;
563 if (index >= steps.length) index = steps.length-1;
564 this.select(steps[index]);
565 };
566 this.previous = function() { this.goto(this.$index()-1); };
567 this.next = function() { this.goto(this.$index()+1); };
568 if ($scope.crmUiWizard) {
569 $parse($scope.crmUiWizard).assign($scope.$parent, this);
570 }
571 },
572 link: function (scope, element, attrs) {}
573 };
574 })
575
576 // Use this to add extra markup to wizard
577 .directive('crmUiWizardButtons', function() {
578 return {
579 require: '^crmUiWizard',
580 restrict: 'EA',
581 scope: {},
582 template: '<span ng-transclude></span>',
583 transclude: true,
584 link: function (scope, element, attrs, crmUiWizardCtrl) {
585 var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons');
586 $(element).appendTo(realButtonsEl);
587 }
588 };
589 })
590
591 // example: <div crm-ui-wizard-step crm-title="ts('My Title')">...content...</div>
592 // If there are any conditional steps, then be sure to set a weight explicitly on *all* steps to maintain ordering.
593 // example: <div crm-ui-wizard-step="100" crm-title="..." ng-if="...">...content...</div>
594 .directive('crmUiWizardStep', function() {
595 var nextWeight = 1;
596 return {
597 require: '^crmUiWizard',
598 restrict: 'EA',
599 scope: {
600 crmTitle: '@', // expression, evaluates to a printable string
601 crmUiWizardStep: '@' // int, a weight which determines the ordering of the steps
602 },
603 template: '<div class="crm-wizard-step" ng-show="selected" ng-transclude/></div>',
604 transclude: true,
605 link: function (scope, element, attrs, crmUiWizardCtrl) {
606 if (scope.crmUiWizardStep) {
607 scope.crmUiWizardStep = parseInt(scope.crmUiWizardStep);
608 } else {
609 scope.crmUiWizardStep = nextWeight++;
610 }
611 crmUiWizardCtrl.add(scope);
612 element.on('$destroy', function(){
613 crmUiWizardCtrl.remove(scope);
614 });
615 }
616 };
617 })
618
619 // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
620 // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
621 .directive('crmConfirm', function () {
622 // Helpers to calculate default options for CRM.confirm()
623 var defaultFuncs = {
624 'disable': function (options) {
625 return {
626 message: ts('Are you sure you want to disable this?'),
627 options: {no: ts('Cancel'), yes: ts('Disable')},
628 width: 300,
629 title: ts('Disable %1?', {
630 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
631 })
632 };
633 },
634 'revert': function (options) {
635 return {
636 message: ts('Are you sure you want to revert this?'),
637 options: {no: ts('Cancel'), yes: ts('Revert')},
638 width: 300,
639 title: ts('Revert %1?', {
640 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
641 })
642 };
643 },
644 'delete': function (options) {
645 return {
646 message: ts('Are you sure you want to delete this?'),
647 options: {no: ts('Cancel'), yes: ts('Delete')},
648 width: 300,
649 title: ts('Delete %1?', {
650 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
651 })
652 };
653 }
654 };
655 return {
656 template: '',
657 link: function (scope, element, attrs) {
658 $(element).click(function () {
659 var options = scope.$eval(attrs.crmConfirm);
660 var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
661 CRM.confirm(_.extend(defaults, options))
662 .on('crmConfirm:yes', function () { scope.$apply(attrs.onYes); })
663 .on('crmConfirm:no', function () { scope.$apply(attrs.onNo); });
664 });
665 }
666 };
667 })
668 .run(function($rootScope, $location) {
669 /// Example: <button ng-click="goto('home')">Go home!</button>
670 $rootScope.goto = function(path) {
671 $location.path(path);
672 };
673 // useful for debugging: $rootScope.log = console.log || function() {};
674 })
675 ;
676
677 })(angular, CRM.$, CRM._);