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