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