CRM-15578 - crmUiDate, crmDateTime, crmTime - Don't both with crmWatcher
[civicrm-core.git] / js / angular-crm-ui.js
1 /// crmUi: Sundry UI helpers
2 (function (angular, $, _) {
3 var idCount = 0;
4
5 var partialUrl = function (relPath) {
6 return CRM.resourceUrls['civicrm'] + '/partials/crmUi/' + relPath;
7 };
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="myfield" crm-title="My Field"> <input name="myfield" /> </div>
104 // example: <div crm-ui-field="myfield" crm-title="My Field"> <input name="myfield" required /> </div>
105 .directive('crmUiField', function() {
106 function createReqStyle(req) {
107 return {visibility: req ? 'inherit' : 'hidden'};
108 }
109 // Note: When writing new templates, the "label" position is particular. See/patch "var label" below.
110 var templateUrls = {
111 default: partialUrl('field.html'),
112 checkbox: partialUrl('field-cb.html')
113 };
114
115 return {
116 scope: {
117 crmUiField: '@', // string, name of an HTML form element
118 crmLayout: '@', // string, "default" or "checkbox"
119 crmTitle: '@' // expression, printable title for the field
120 },
121 templateUrl: function(tElement, tAttrs){
122 var layout = tAttrs.crmLayout ? tAttrs.crmLayout : 'default';
123 return templateUrls[layout];
124 },
125 transclude: true,
126 link: function (scope, element, attrs) {
127 $(element).addClass('crm-section');
128 scope.crmTitle = attrs.crmTitle;
129 scope.crmUiField = attrs.crmUiField;
130 scope.cssClasses = {};
131 scope.crmRequiredStyle = createReqStyle(false);
132
133 // 0. Ensure that a target field has been specified
134
135 if (!attrs.crmUiField) return;
136 if (attrs.crmUiField == 'name') {
137 throw new Error('Validation monitoring does not work for field name "name"');
138 }
139
140 // 1. Figure out form and input elements
141
142 var form = $(element).closest('form');
143 var formCtrl = scope.$parent.$eval(form.attr('name'));
144 var input = $('input[name="' + attrs.crmUiField + '"],select[name="' + attrs.crmUiField + '"],textarea[name="' + attrs.crmUiField + '"]', form);
145 var label = $('>div.label >label, >label', element);
146 if (form.length != 1 || input.length != 1 || label.length != 1) {
147 if (console.log) console.log('Label cannot be matched to input element. Expected to find one form and one input[name='+attrs.crmUiField+'].', form.length, input.length, label.length);
148 return;
149 }
150
151 // 2. Make sure that inputs are well-defined (with name+id).
152
153 if (!input.attr('id')) {
154 input.attr('id', 'crmUi_' + (++idCount));
155 }
156 $(label).attr('for', input.attr('id'));
157
158 // 3. Monitor is the "required" and "$valid" properties
159
160 if (input.attr('ng-required')) {
161 scope.crmRequiredStyle = createReqStyle(scope.$parent.$eval(input.attr('ng-required')));
162 scope.$parent.$watch(input.attr('ng-required'), function(isRequired) {
163 scope.crmRequiredStyle = createReqStyle(isRequired);
164 });
165 } else {
166 scope.crmRequiredStyle = createReqStyle(input.prop('required'));
167 }
168
169 var inputCtrl = form.attr('name') + '.' + input.attr('name');
170 scope.$parent.$watch(inputCtrl + '.$valid', function(newValue) {
171 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
172 });
173 scope.$parent.$watch(inputCtrl + '.$pristine', function(newValue) {
174 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
175 });
176 }
177 };
178 })
179
180 // example: <iframe crm-ui-iframe="getHtmlContent()"></iframe>
181 .directive('crmUiIframe', function ($parse) {
182 return {
183 scope: {
184 crmUiIframe: '@' // expression which evalutes to HTML content
185 },
186 link: function (scope, elm, attrs) {
187 var iframe = $(elm)[0];
188 iframe.setAttribute('width', '100%');
189 iframe.setAttribute('frameborder', '0');
190
191 var refresh = function () {
192 // 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>';
193 var iframeHtml = scope.$parent.$eval(attrs.crmUiIframe);
194
195 var doc = iframe.document;
196 if (iframe.contentDocument) {
197 doc = iframe.contentDocument;
198 }
199 else if (iframe.contentWindow) {
200 doc = iframe.contentWindow.document;
201 }
202
203 doc.open();
204 doc.writeln(iframeHtml);
205 doc.close();
206 }
207
208 scope.$parent.$watch(attrs.crmUiIframe, refresh);
209 //setTimeout(function () { refresh(); }, 50);
210 }
211 };
212 })
213
214 // example: <a crm-ui-lock binding="mymodel.boolfield"></a>
215 // example: <a crm-ui-lock
216 // binding="mymodel.boolfield"
217 // title-locked="ts('Boolfield is locked')"
218 // title-unlocked="ts('Boolfield is unlocked')"></a>
219 .directive('crmUiLock', function ($parse, $rootScope) {
220 var defaultVal = function (defaultValue) {
221 var f = function (scope) {
222 return defaultValue;
223 }
224 f.assign = function (scope, value) {
225 // ignore changes
226 }
227 return f;
228 };
229
230 // like $parse, but accepts a defaultValue in case expr is undefined
231 var parse = function (expr, defaultValue) {
232 return expr ? $parse(expr) : defaultVal(defaultValue);
233 };
234
235 return {
236 template: '',
237 link: function (scope, element, attrs) {
238 var binding = parse(attrs['binding'], true);
239 var titleLocked = parse(attrs['titleLocked'], ts('Locked'));
240 var titleUnlocked = parse(attrs['titleUnlocked'], ts('Unlocked'));
241
242 $(element).addClass('ui-icon lock-button');
243 var refresh = function () {
244 var locked = binding(scope);
245 if (locked) {
246 $(element)
247 .removeClass('ui-icon-unlocked')
248 .addClass('ui-icon-locked')
249 .prop('title', titleLocked(scope))
250 ;
251 }
252 else {
253 $(element)
254 .removeClass('ui-icon-locked')
255 .addClass('ui-icon-unlocked')
256 .prop('title', titleUnlocked(scope))
257 ;
258 }
259 };
260
261 $(element).click(function () {
262 binding.assign(scope, !binding(scope));
263 //scope.$digest();
264 $rootScope.$digest();
265 });
266
267 scope.$watch(attrs.binding, refresh);
268 scope.$watch(attrs.titleLocked, refresh);
269 scope.$watch(attrs.titleUnlocked, refresh);
270
271 refresh();
272 }
273 };
274 })
275
276 // usage: <select crm-ui-select="{placeholder:'Something',allowClear:true,...}" crm-ui-select-model="myobj.field"><option...></select>
277 .directive('crmUiSelect', function ($parse) {
278 return {
279 scope: {
280 crmUiSelect: '@',
281 crmUiSelectModel: '@',
282 crmUiSelectChange: '@'
283 },
284 link: function (scope, element, attrs) {
285 var model = $parse(attrs.crmUiSelectModel);
286
287 // In cases where UI initiates update, there may be an extra
288 // call to refreshUI, but it doesn't create a cycle.
289
290 function refreshUI() {
291 $(element).select2('val', model(scope.$parent));
292 }
293 function refreshModel() {
294 var oldValue = model(scope.$parent), newValue = $(element).select2('val');
295 if (oldValue != newValue) {
296 scope.$parent.$apply(function(){
297 model.assign(scope.$parent, newValue);
298 });
299 if (attrs.crmUiSelectChange) {
300 scope.$parent.$eval(attrs.crmUiSelectChange);
301 }
302 }
303 }
304 function init() {
305 // TODO watch select2-options
306 var options = attrs.crmUiSelect ? scope.$parent.$eval(attrs.crmUiSelect) : {};
307 $(element).select2(options);
308 $(element).on('change', refreshModel);
309 setTimeout(refreshUI, 0);
310 scope.$parent.$watch(attrs.crmUiSelectModel, refreshUI);
311 }
312 init();
313 }
314 };
315 })
316
317 // example <div crm-ui-tab crm-title="ts('My Title')">...content...</div>
318 // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper
319 .directive('crmUiTab', function($parse) {
320 return {
321 require: '^crmUiTabSet',
322 restrict: 'EA',
323 scope: {
324 crmTitle: '@',
325 id: '@'
326 },
327 template: '<div ng-transclude></div>',
328 transclude: true,
329 link: function (scope, element, attrs, crmUiTabSetCtrl) {
330 crmUiTabSetCtrl.add(scope);
331 }
332 };
333 })
334
335 // 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>
336 .directive('crmUiTabSet', function() {
337 return {
338 restrict: 'EA',
339 scope: {
340 crmUiTabSet: '@'
341 },
342 templateUrl: partialUrl('tabset.html'),
343 transclude: true,
344 controllerAs: 'crmUiTabSetCtrl',
345 controller: function($scope, $parse) {
346 var tabs = $scope.tabs = []; // array<$scope>
347 this.add = function(tab) {
348 if (!tab.id) throw "Tab is missing 'id'";
349 tabs.push(tab);
350 };
351 },
352 link: function (scope, element, attrs) {}
353 };
354 })
355
356 // example: <input crm-ui-time="myobj.mytimefield" />
357 .directive('crmUiTime', function ($parse, $timeout) {
358 return {
359 restrict: 'AE',
360 scope: {
361 crmUiTime: '@'
362 },
363 link: function (scope, element, attrs) {
364 var model = $parse(attrs.crmUiTime);
365
366 element.addClass('crm-form-text six');
367 $(element).timeEntry({show24Hours: true});
368
369 var updateChildren = (function() {
370 element.off('change', updateParent);
371 $(element).timeEntry('setTime', model(scope.$parent));
372 element.on('change', updateParent);
373 });
374 var updateParent = (function () {
375 $timeout(function () {
376 model.assign(scope.$parent, element.val());
377 });
378 });
379
380 updateChildren();
381 scope.$parent.$watch(attrs.crmUiTime, updateChildren);
382 element.on('change', updateParent);
383 }
384 }
385 })
386
387 // 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>
388 // Note: "myWizardCtrl" has various actions/properties like next() and $first().
389 // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable"
390 // WISHLIST: Allow each step to enable/disable (show/hide) itself
391 .directive('crmUiWizard', function() {
392 return {
393 restrict: 'EA',
394 scope: {
395 crmUiWizard: '@'
396 },
397 templateUrl: partialUrl('wizard.html'),
398 transclude: true,
399 controllerAs: 'crmUiWizardCtrl',
400 controller: function($scope, $parse) {
401 var steps = $scope.steps = []; // array<$scope>
402 var crmUiWizardCtrl = this;
403 var maxVisited = 0;
404 var selectedIndex = null;
405
406 var findIndex = function() {
407 var found = null;
408 angular.forEach(steps, function(step, stepKey) {
409 if (step.selected) found = stepKey;
410 });
411 return found;
412 };
413
414 /// @return int the index of the current step
415 this.$index = function() { return selectedIndex; };
416 /// @return bool whether the currentstep is first
417 this.$first = function() { return this.$index() === 0; };
418 /// @return bool whether the current step is last
419 this.$last = function() { return this.$index() === steps.length -1; };
420 this.$maxVisit = function() { return maxVisited; }
421 this.iconFor = function(index) {
422 if (index < this.$index()) return '√';
423 if (index === this.$index()) return '»';
424 return ' ';
425 }
426 this.isSelectable = function(step) {
427 if (step.selected) return false;
428 var result = false;
429 angular.forEach(steps, function(otherStep, otherKey) {
430 if (step === otherStep && otherKey <= maxVisited) result = true;
431 });
432 return result;
433 };
434
435 /*** @param Object step the $scope of the step */
436 this.select = function(step) {
437 angular.forEach(steps, function(otherStep, otherKey) {
438 otherStep.selected = (otherStep === step);
439 if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey;
440 });
441 selectedIndex = findIndex();
442 };
443 /*** @param Object step the $scope of the step */
444 this.add = function(step) {
445 if (steps.length === 0) {
446 step.selected = true;
447 selectedIndex = 0;
448 }
449 steps.push(step);
450 };
451 this.goto = function(index) {
452 if (index < 0) index = 0;
453 if (index >= steps.length) index = steps.length-1;
454 this.select(steps[index]);
455 };
456 this.previous = function() { this.goto(this.$index()-1); };
457 this.next = function() { this.goto(this.$index()+1); };
458 if ($scope.crmUiWizard) {
459 $parse($scope.crmUiWizard).assign($scope.$parent, this)
460 }
461 },
462 link: function (scope, element, attrs) {}
463 };
464 })
465
466 // Use this to add extra markup to wizard
467 .directive('crmUiWizardButtons', function() {
468 return {
469 require: '^crmUiWizard',
470 restrict: 'EA',
471 scope: {},
472 template: '<span ng-transclude></span>',
473 transclude: true,
474 link: function (scope, element, attrs, crmUiWizardCtrl) {
475 var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons');
476 $(element).appendTo(realButtonsEl);
477 }
478 };
479 })
480
481 // example <div crm-ui-wizard-step crm-title="ts('My Title')">...content...</div>
482 .directive('crmUiWizardStep', function() {
483 return {
484 require: '^crmUiWizard',
485 restrict: 'EA',
486 scope: {
487 crmTitle: '@'
488 },
489 template: '<div class="crm-wizard-step" ng-show="selected" ng-transclude/></div>',
490 transclude: true,
491 link: function (scope, element, attrs, crmUiWizardCtrl) {
492 crmUiWizardCtrl.add(scope);
493 }
494 };
495 })
496
497 // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
498 // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
499 .directive('crmConfirm', function () {
500 // Helpers to calculate default options for CRM.confirm()
501 var defaultFuncs = {
502 'disable': function (options) {
503 return {
504 message: ts('Are you sure you want to disable this?'),
505 options: {no: ts('Cancel'), yes: ts('Disable')},
506 width: 300,
507 title: ts('Disable %1?', {
508 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
509 })
510 };
511 },
512 'revert': function (options) {
513 return {
514 message: ts('Are you sure you want to revert this?'),
515 options: {no: ts('Cancel'), yes: ts('Revert')},
516 width: 300,
517 title: ts('Revert %1?', {
518 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
519 })
520 };
521 },
522 'delete': function (options) {
523 return {
524 message: ts('Are you sure you want to delete this?'),
525 options: {no: ts('Cancel'), yes: ts('Delete')},
526 width: 300,
527 title: ts('Delete %1?', {
528 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
529 })
530 };
531 }
532 };
533 return {
534 template: '',
535 link: function (scope, element, attrs) {
536 $(element).click(function () {
537 var options = scope.$eval(attrs['crmConfirm']);
538 var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
539 CRM.confirm(_.extend(defaults, options))
540 .on('crmConfirm:yes', function () { scope.$apply(attrs['onYes']); })
541 .on('crmConfirm:no', function () { scope.$apply(attrs['onNo']); });
542 });
543 }
544 };
545 })
546 .run(function($rootScope, $location) {
547 /// Example: <button ng-click="goto('home')">Go home!</button>
548 $rootScope.goto = function(path) {
549 $location.path(path);
550 };
551 // useful for debugging: $rootScope.log = console.log || function() {};
552 })
553 ;
554
555 })(angular, CRM.$, CRM._);