Merge pull request #5058 from eileenmcnaughton/test-examples
[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 // Display a date widget.
27 // example: <input crm-ui-date="myobj.datefield" />
28 // example: <input crm-ui-date="myobj.datefield" crm-ui-date-format="yy-mm-dd" />
29 // WISHLIST: use ngModel
30 .directive('crmUiDate', function ($parse, $timeout) {
31 return {
32 restrict: 'AE',
33 scope: {
34 crmUiDate: '@', // expression, model binding
35 crmUiDateFormat: '@' // expression, date format (default: "yy-mm-dd")
36 },
37 link: function (scope, element, attrs) {
38 var fmt = attrs.crmUiDateFormat ? $parse(attrs.crmUiDateFormat)() : "yy-mm-dd";
39 var model = $parse(attrs.crmUiDate);
40
41 element.addClass('dateplugin');
42 $(element).datepicker({
43 dateFormat: fmt
44 });
45
46 var updateChildren = (function() {
47 element.off('change', updateParent);
48 $(element).datepicker('setDate', model(scope.$parent));
49 element.on('change', updateParent);
50 });
51 var updateParent = (function() {
52 $timeout(function () {
53 model.assign(scope.$parent, $(element).val());
54 });
55 });
56
57 updateChildren();
58 scope.$parent.$watch(attrs.crmUiDate, updateChildren);
59 element.on('change', updateParent);
60 }
61 };
62 })
63
64 // Display a date-time widget.
65 // example: <div crm-ui-date-time="myobj.mydatetimefield"></div>
66 // WISHLIST: use ngModel
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: '~/crmUi/field.html',
109 checkbox: '~/crmUi/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 // Define a scope in which a name like "subform.foo" maps to a unique ID.
211 // 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>
212 .directive('crmUiIdScope', function () {
213 return {
214 restrict: 'EA',
215 scope: {},
216 controllerAs: 'crmUiIdCtrl',
217 controller: function($scope) {
218 var ids = {};
219 this.get = function(name) {
220 if (!ids[name]) {
221 ids[name] = "crmUiId_" + (++uidCount);
222 }
223 return ids[name];
224 };
225 },
226 link: function (scope, element, attrs) {}
227 };
228 })
229
230 // Display an HTML blurb inside an IFRAME.
231 // example: <iframe crm-ui-iframe="getHtmlContent()"></iframe>
232 .directive('crmUiIframe', function ($parse) {
233 return {
234 scope: {
235 crmUiIframe: '@' // expression which evalutes to HTML content
236 },
237 link: function (scope, elm, attrs) {
238 var iframe = $(elm)[0];
239 iframe.setAttribute('width', '100%');
240 iframe.setAttribute('frameborder', '0');
241
242 var refresh = function () {
243 // 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>';
244 var iframeHtml = scope.$parent.$eval(attrs.crmUiIframe);
245
246 var doc = iframe.document;
247 if (iframe.contentDocument) {
248 doc = iframe.contentDocument;
249 }
250 else if (iframe.contentWindow) {
251 doc = iframe.contentWindow.document;
252 }
253
254 doc.open();
255 doc.writeln(iframeHtml);
256 doc.close();
257 };
258
259 scope.$parent.$watch(attrs.crmUiIframe, refresh);
260 }
261 };
262 })
263
264 // Define a rich text editor.
265 // example: <textarea crm-ui-id="myForm.body_html" crm-ui-richtext name="body_html" ng-model="mailing.body_html"></textarea>
266 // WISHLIST: use ngModel
267 .directive('crmUiRichtext', function ($timeout) {
268 return {
269 require: '?ngModel',
270 link: function (scope, elm, attr, ngModel) {
271 var ck = CKEDITOR.replace(elm[0]);
272
273 if (!ngModel) {
274 return;
275 }
276
277 ck.on('pasteState', function () {
278 scope.$apply(function () {
279 ngModel.$setViewValue(ck.getData());
280 });
281 });
282
283 ck.on('insertText', function () {
284 $timeout(function () {
285 ngModel.$setViewValue(ck.getData());
286 });
287 });
288
289 ngModel.$render = function (value) {
290 ck.setData(ngModel.$viewValue);
291 };
292 }
293 };
294 })
295
296 // Display a lock icon (based on a boolean).
297 // example: <a crm-ui-lock binding="mymodel.boolfield"></a>
298 // example: <a crm-ui-lock
299 // binding="mymodel.boolfield"
300 // title-locked="ts('Boolfield is locked')"
301 // title-unlocked="ts('Boolfield is unlocked')"></a>
302 .directive('crmUiLock', function ($parse, $rootScope) {
303 var defaultVal = function (defaultValue) {
304 var f = function (scope) {
305 return defaultValue;
306 };
307 f.assign = function (scope, value) {
308 // ignore changes
309 };
310 return f;
311 };
312
313 // like $parse, but accepts a defaultValue in case expr is undefined
314 var parse = function (expr, defaultValue) {
315 return expr ? $parse(expr) : defaultVal(defaultValue);
316 };
317
318 return {
319 template: '',
320 link: function (scope, element, attrs) {
321 var binding = parse(attrs.binding, true);
322 var titleLocked = parse(attrs.titleLocked, ts('Locked'));
323 var titleUnlocked = parse(attrs.titleUnlocked, ts('Unlocked'));
324
325 $(element).addClass('ui-icon lock-button');
326 var refresh = function () {
327 var locked = binding(scope);
328 if (locked) {
329 $(element)
330 .removeClass('ui-icon-unlocked')
331 .addClass('ui-icon-locked')
332 .prop('title', titleLocked(scope))
333 ;
334 }
335 else {
336 $(element)
337 .removeClass('ui-icon-locked')
338 .addClass('ui-icon-unlocked')
339 .prop('title', titleUnlocked(scope))
340 ;
341 }
342 };
343
344 $(element).click(function () {
345 binding.assign(scope, !binding(scope));
346 //scope.$digest();
347 $rootScope.$digest();
348 });
349
350 scope.$watch(attrs.binding, refresh);
351 scope.$watch(attrs.titleLocked, refresh);
352 scope.$watch(attrs.titleUnlocked, refresh);
353
354 refresh();
355 }
356 };
357 })
358
359 // Display a fancy SELECT (based on select2).
360 // usage: <select crm-ui-select="{placeholder:'Something',allowClear:true,...}" ng-model="myobj.field"><option...></select>
361 .directive('crmUiSelect', function ($parse, $timeout) {
362 return {
363 require: '?ngModel',
364 scope: {
365 crmUiSelect: '@'
366 },
367 link: function (scope, element, attrs, ngModel) {
368 // In cases where UI initiates update, there may be an extra
369 // call to refreshUI, but it doesn't create a cycle.
370
371 ngModel.$render = function () {
372 $timeout(function () {
373 // ex: msg_template_id adds new item then selects it; use $timeout to ensure that
374 // new item is added before selection is made
375 $(element).select2('val', ngModel.$viewValue);
376 });
377 };
378 function refreshModel() {
379 var oldValue = ngModel.$viewValue, newValue = $(element).select2('val');
380 if (oldValue != newValue) {
381 scope.$parent.$apply(function () {
382 ngModel.$setViewValue(newValue);
383 });
384 }
385 }
386
387 function init() {
388 // TODO watch select2-options
389 var options = attrs.crmUiSelect ? scope.$parent.$eval(attrs.crmUiSelect) : {};
390 $(element).select2(options);
391 $(element).on('change', refreshModel);
392 $timeout(ngModel.$render);
393 }
394
395 init();
396 }
397 };
398 })
399
400 // example <div crm-ui-tab crm-title="ts('My Title')">...content...</div>
401 // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper
402 .directive('crmUiTab', function($parse) {
403 return {
404 require: '^crmUiTabSet',
405 restrict: 'EA',
406 scope: {
407 crmTitle: '@',
408 id: '@'
409 },
410 template: '<div ng-transclude></div>',
411 transclude: true,
412 link: function (scope, element, attrs, crmUiTabSetCtrl) {
413 crmUiTabSetCtrl.add(scope);
414 }
415 };
416 })
417
418 // 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>
419 .directive('crmUiTabSet', function() {
420 return {
421 restrict: 'EA',
422 scope: {
423 crmUiTabSet: '@'
424 },
425 templateUrl: '~/crmUi/tabset.html',
426 transclude: true,
427 controllerAs: 'crmUiTabSetCtrl',
428 controller: function($scope, $parse) {
429 var tabs = $scope.tabs = []; // array<$scope>
430 this.add = function(tab) {
431 if (!tab.id) throw "Tab is missing 'id'";
432 tabs.push(tab);
433 };
434 },
435 link: function (scope, element, attrs) {}
436 };
437 })
438
439 // Display a time-entry field.
440 // example: <input crm-ui-time="myobj.mytimefield" />
441 // WISHLIST: use ngModel
442 .directive('crmUiTime', function ($parse, $timeout) {
443 return {
444 restrict: 'AE',
445 scope: {
446 crmUiTime: '@'
447 },
448 link: function (scope, element, attrs) {
449 var model = $parse(attrs.crmUiTime);
450
451 element.addClass('crm-form-text six');
452 $(element).timeEntry({show24Hours: true});
453
454 var updateChildren = (function() {
455 element.off('change', updateParent);
456 $(element).timeEntry('setTime', model(scope.$parent));
457 element.on('change', updateParent);
458 });
459 var updateParent = (function () {
460 $timeout(function () {
461 model.assign(scope.$parent, element.val());
462 });
463 });
464
465 updateChildren();
466 scope.$parent.$watch(attrs.crmUiTime, updateChildren);
467 element.on('change', updateParent);
468 }
469 };
470 })
471
472 // Generic, field-independent form validator.
473 // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" />
474 // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" crm-ui-validate-name="myError" />
475 .directive('crmUiValidate', function() {
476 return {
477 restrict: 'EA',
478 require: 'ngModel',
479 link: function(scope, element, attrs, ngModel) {
480 var validationKey = attrs.crmUiValidateName ? attrs.crmUiValidateName : 'crmUiValidate';
481 scope.$watch(attrs.crmUiValidate, function(newValue){
482 ngModel.$setValidity(validationKey, !!newValue);
483 });
484 }
485 };
486 })
487
488 // like ng-show, but hides/displays elements using "visibility" which maintains positioning
489 // example <div crm-ui-visible="false">...content...</div>
490 .directive('crmUiVisible', function($parse) {
491 return {
492 restrict: 'EA',
493 scope: {
494 crmUiVisible: '@'
495 },
496 link: function (scope, element, attrs) {
497 var model = $parse(attrs.crmUiVisible);
498 function updatecChildren() {
499 element.css('visibility', model(scope.$parent) ? 'inherit' : 'hidden');
500 }
501 updatecChildren();
502 scope.$parent.$watch(attrs.crmUiVisible, updatecChildren);
503 }
504 };
505 })
506
507 // 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>
508 // Note: "myWizardCtrl" has various actions/properties like next() and $first().
509 // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable"
510 // WISHLIST: Allow each step to enable/disable (show/hide) itself
511 .directive('crmUiWizard', function() {
512 return {
513 restrict: 'EA',
514 scope: {
515 crmUiWizard: '@'
516 },
517 templateUrl: '~/crmUi/wizard.html',
518 transclude: true,
519 controllerAs: 'crmUiWizardCtrl',
520 controller: function($scope, $parse) {
521 var steps = $scope.steps = []; // array<$scope>
522 var crmUiWizardCtrl = this;
523 var maxVisited = 0;
524 var selectedIndex = null;
525
526 var findIndex = function() {
527 var found = null;
528 angular.forEach(steps, function(step, stepKey) {
529 if (step.selected) found = stepKey;
530 });
531 return found;
532 };
533
534 /// @return int the index of the current step
535 this.$index = function() { return selectedIndex; };
536 /// @return bool whether the currentstep is first
537 this.$first = function() { return this.$index() === 0; };
538 /// @return bool whether the current step is last
539 this.$last = function() { return this.$index() === steps.length -1; };
540 this.$maxVisit = function() { return maxVisited; };
541 this.$validStep = function() {
542 return steps[selectedIndex].isStepValid();
543 };
544 this.iconFor = function(index) {
545 if (index < this.$index()) return '√';
546 if (index === this.$index()) return '»';
547 return ' ';
548 };
549 this.isSelectable = function(step) {
550 if (step.selected) return false;
551 var result = false;
552 angular.forEach(steps, function(otherStep, otherKey) {
553 if (step === otherStep && otherKey <= maxVisited) result = true;
554 });
555 return result;
556 };
557
558 /*** @param Object step the $scope of the step */
559 this.select = function(step) {
560 angular.forEach(steps, function(otherStep, otherKey) {
561 otherStep.selected = (otherStep === step);
562 if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey;
563 });
564 selectedIndex = findIndex();
565 };
566 /*** @param Object step the $scope of the step */
567 this.add = function(step) {
568 if (steps.length === 0) {
569 step.selected = true;
570 selectedIndex = 0;
571 }
572 steps.push(step);
573 steps.sort(function(a,b){
574 return a.crmUiWizardStep - b.crmUiWizardStep;
575 });
576 selectedIndex = findIndex();
577 };
578 this.remove = function(step) {
579 var key = null;
580 angular.forEach(steps, function(otherStep, otherKey) {
581 if (otherStep === step) key = otherKey;
582 });
583 if (key !== null) {
584 steps.splice(key, 1);
585 }
586 };
587 this.goto = function(index) {
588 if (index < 0) index = 0;
589 if (index >= steps.length) index = steps.length-1;
590 this.select(steps[index]);
591 };
592 this.previous = function() { this.goto(this.$index()-1); };
593 this.next = function() { this.goto(this.$index()+1); };
594 if ($scope.crmUiWizard) {
595 $parse($scope.crmUiWizard).assign($scope.$parent, this);
596 }
597 },
598 link: function (scope, element, attrs) {}
599 };
600 })
601
602 // Use this to add extra markup to wizard
603 .directive('crmUiWizardButtons', function() {
604 return {
605 require: '^crmUiWizard',
606 restrict: 'EA',
607 scope: {},
608 template: '<span ng-transclude></span>',
609 transclude: true,
610 link: function (scope, element, attrs, crmUiWizardCtrl) {
611 var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons');
612 $(element).appendTo(realButtonsEl);
613 }
614 };
615 })
616
617 // example: <div crm-ui-wizard-step crm-title="ts('My Title')" ng-form="mySubForm">...content...</div>
618 // If there are any conditional steps, then be sure to set a weight explicitly on *all* steps to maintain ordering.
619 // example: <div crm-ui-wizard-step="100" crm-title="..." ng-if="...">...content...</div>
620 .directive('crmUiWizardStep', function() {
621 var nextWeight = 1;
622 return {
623 require: ['^crmUiWizard', 'form'],
624 restrict: 'EA',
625 scope: {
626 crmTitle: '@', // expression, evaluates to a printable string
627 crmUiWizardStep: '@' // int, a weight which determines the ordering of the steps
628 },
629 template: '<div class="crm-wizard-step" ng-show="selected" ng-transclude/></div>',
630 transclude: true,
631 link: function (scope, element, attrs, ctrls) {
632 var crmUiWizardCtrl = ctrls[0], form = ctrls[1];
633 if (scope.crmUiWizardStep) {
634 scope.crmUiWizardStep = parseInt(scope.crmUiWizardStep);
635 } else {
636 scope.crmUiWizardStep = nextWeight++;
637 }
638 scope.isStepValid = function() {
639 return form.$valid;
640 };
641 crmUiWizardCtrl.add(scope);
642 element.on('$destroy', function(){
643 crmUiWizardCtrl.remove(scope);
644 });
645 }
646 };
647 })
648
649 // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
650 // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
651 .directive('crmConfirm', function () {
652 // Helpers to calculate default options for CRM.confirm()
653 var defaultFuncs = {
654 'disable': function (options) {
655 return {
656 message: ts('Are you sure you want to disable this?'),
657 options: {no: ts('Cancel'), yes: ts('Disable')},
658 width: 300,
659 title: ts('Disable %1?', {
660 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
661 })
662 };
663 },
664 'revert': function (options) {
665 return {
666 message: ts('Are you sure you want to revert this?'),
667 options: {no: ts('Cancel'), yes: ts('Revert')},
668 width: 300,
669 title: ts('Revert %1?', {
670 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
671 })
672 };
673 },
674 'delete': function (options) {
675 return {
676 message: ts('Are you sure you want to delete this?'),
677 options: {no: ts('Cancel'), yes: ts('Delete')},
678 width: 300,
679 title: ts('Delete %1?', {
680 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
681 })
682 };
683 }
684 };
685 return {
686 template: '',
687 link: function (scope, element, attrs) {
688 $(element).click(function () {
689 var options = scope.$eval(attrs.crmConfirm);
690 var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
691 CRM.confirm(_.extend(defaults, options))
692 .on('crmConfirm:yes', function () { scope.$apply(attrs.onYes); })
693 .on('crmConfirm:no', function () { scope.$apply(attrs.onNo); });
694 });
695 }
696 };
697 })
698 .run(function($rootScope, $location) {
699 /// Example: <button ng-click="goto('home')">Go home!</button>
700 $rootScope.goto = function(path) {
701 $location.path(path);
702 };
703 // useful for debugging: $rootScope.log = console.log || function() {};
704 })
705 ;
706
707 })(angular, CRM.$, CRM._);