crmMailing2 - Cleanup
[civicrm-core.git] / js / angular-crm-ui.js
CommitLineData
685acae4 1/// crmUi: Sundry UI helpers
2(function (angular, $, _) {
438f2b52 3 var idCount = 0;
685acae4 4
6717e4b9
TO
5 var partialUrl = function (relPath) {
6 return CRM.resourceUrls['civicrm'] + '/partials/crmUi/' + relPath;
7 };
8
9
685acae4 10 angular.module('crmUi', [])
030dce01 11
0cbed02c
TO
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
030dce01
TO
14 .directive('crmUiAccordion', function() {
15 return {
16 scope: {
0cbed02c
TO
17 crmTitle: '@',
18 crmCollapsed: '@'
030dce01 19 },
0cbed02c 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>',
030dce01 21 transclude: true,
0cbed02c
TO
22 link: function (scope, element, attrs) {
23 scope.cssClasses = {
24 collapsed: scope.$parent.$eval(attrs.crmCollapsed)
25 };
26 }
030dce01
TO
27 };
28 })
29
489c2674
TO
30 // Display a field/row in a field list
31 // example: <div crm-ui-field crm-title="My Field"> {{mydata}} </div>
32 // example: <div crm-ui-field="myfield" crm-title="My Field"> <input name="myfield" /> </div>
33 // example: <div crm-ui-field="myfield" crm-title="My Field"> <input name="myfield" required /> </div>
34 .directive('crmUiField', function() {
35 function createReqStyle(req) {
36 return {visibility: req ? 'inherit' : 'hidden'};
37 }
38 // Note: When writing new templates, the "label" position is particular. See/patch "var label" below.
39 var templateUrls = {
40 default: partialUrl('field.html'),
41 checkbox: partialUrl('field-cb.html')
42 };
43
44 return {
45 scope: {
46 crmUiField: '@', // string, name of an HTML form element
47 crmLayout: '@', // string, "default" or "checkbox"
48 crmTitle: '@' // expression, printable title for the field
49 },
50 templateUrl: function(tElement, tAttrs){
51 var layout = tAttrs.crmLayout ? tAttrs.crmLayout : 'default';
52 return templateUrls[layout];
53 },
54 transclude: true,
55 link: function (scope, element, attrs) {
56 $(element).addClass('crm-section');
57 scope.crmTitle = attrs.crmTitle;
58 scope.crmUiField = attrs.crmUiField;
59 scope.cssClasses = {};
60 scope.crmRequiredStyle = createReqStyle(false);
61
62 // 0. Ensure that a target field has been specified
63
64 if (!attrs.crmUiField) return;
65 if (attrs.crmUiField == 'name') {
66 throw new Error('Validation monitoring does not work for field name "name"');
67 }
68
69 // 1. Figure out form and input elements
70
71 var form = $(element).closest('form');
72 var formCtrl = scope.$parent.$eval(form.attr('name'));
73 var input = $('input[name="' + attrs.crmUiField + '"],select[name="' + attrs.crmUiField + '"],textarea[name="' + attrs.crmUiField + '"]', form);
74 var label = $('>div.label >label, >label', element);
75 if (form.length != 1 || input.length != 1 || label.length != 1) {
76 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);
77 return;
78 }
79
80 // 2. Make sure that inputs are well-defined (with name+id).
81
82 if (!input.attr('id')) {
83 input.attr('id', 'crmUi_' + (++idCount));
84 }
85 $(label).attr('for', input.attr('id'));
86
87 // 3. Monitor is the "required" and "$valid" properties
88
89 if (input.attr('ng-required')) {
90 scope.crmRequiredStyle = createReqStyle(scope.$parent.$eval(input.attr('ng-required')));
91 scope.$parent.$watch(input.attr('ng-required'), function(isRequired) {
92 scope.crmRequiredStyle = createReqStyle(isRequired);
93 });
94 } else {
95 scope.crmRequiredStyle = createReqStyle(input.prop('required'));
96 }
97
98 var inputCtrl = form.attr('name') + '.' + input.attr('name');
99 scope.$parent.$watch(inputCtrl + '.$valid', function(newValue) {
100 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
101 });
102 scope.$parent.$watch(inputCtrl + '.$pristine', function(newValue) {
103 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
104 });
105 }
106 };
107 })
108
107c5cc7
TO
109 // example: <iframe crm-ui-iframe="getHtmlContent()"></iframe>
110 .directive('crmUiIframe', function ($parse) {
111 return {
112 scope: {
113 crmUiIframe: '@' // expression which evalutes to HTML content
114 },
115 link: function (scope, elm, attrs) {
116 var iframe = $(elm)[0];
117 iframe.setAttribute('width', '100%');
118 iframe.setAttribute('frameborder', '0');
119
120 var refresh = function () {
121 // 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>';
122 var iframeHtml = scope.$parent.$eval(attrs.crmUiIframe);
123
124 var doc = iframe.document;
125 if (iframe.contentDocument) {
126 doc = iframe.contentDocument;
127 }
128 else if (iframe.contentWindow) {
129 doc = iframe.contentWindow.document;
130 }
131
132 doc.open();
133 doc.writeln(iframeHtml);
134 doc.close();
135 }
136
137 scope.$parent.$watch(attrs.crmUiIframe, refresh);
138 //setTimeout(function () { refresh(); }, 50);
139 }
140 };
141 })
142
438f2b52
TO
143 // example: <form name="myForm">...<label crm-ui-label crm-for="myField">My Field</span>...<input name="myField"/>...</form>
144 //
145 // Label adapts based on <input required>, <input ng-required>, or any other validation.
146 //
147 // Note: This should work in the normal case where <label> and <input> are in roughly the same scope,
148 // but if the scopes are materially different then problems could arise.
149 .directive('crmUiLabel', function($parse) {
150 return {
151 scope: {
152 name: '@'
153 },
154 transclude: true,
155 template: '<span ng-class="cssClasses"><span ng-transclude></span> <span ng-show="crmRequired" class="crm-marker" title="This field is required.">*</span></span>',
156 link: function(scope, element, attrs) {
157 if (attrs.crmFor == 'name') {
158 throw new Error('Validation monitoring does not work for field name "name"');
159 }
160
161 // 1. Figure out form and input elements
162
163 var form = $(element).closest('form');
164 var formCtrl = scope.$parent.$eval(form.attr('name'));
165 var input = $('input[name="' + attrs.crmFor + '"],select[name="' + attrs.crmFor + '"],textarea[name="' + attrs.crmFor + '"]', form);
166 if (form.length != 1 || input.length != 1) {
1d81a305 167 if (console.log) console.log('Label cannot be matched to input element. Expected to find one form and one input[name='+attrs.crmFor+'].', form.length, input.length);
438f2b52
TO
168 return;
169 }
170
171 // 2. Make sure that inputs are well-defined (with name+id).
172
173 if (!input.attr('id')) {
174 input.attr('id', 'crmUi_' + (++idCount));
175 }
176 $(element).attr('for', input.attr('id'));
177
178 // 3. Monitor is the "required" and "$valid" properties
179
180 if (input.attr('ng-required')) {
181 scope.crmRequired = scope.$parent.$eval(input.attr('ng-required'));
182 scope.$parent.$watch(input.attr('ng-required'), function(isRequired) {
183 scope.crmRequired = isRequired;
184 });
185 } else {
186 scope.crmRequired = input.prop('required');
187 }
188
189 var inputCtrl = form.attr('name') + '.' + input.attr('name');
190 scope.cssClasses = {};
191 scope.$parent.$watch(inputCtrl + '.$valid', function(newValue) {
192 //scope.cssClasses['ng-valid'] = newValue;
193 //scope.cssClasses['ng-invalid'] = !newValue;
194 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
195 });
196 scope.$parent.$watch(inputCtrl + '.$pristine', function(newValue) {
197 //scope.cssClasses['ng-pristine'] = newValue;
198 //scope.cssClasses['ng-dirty'] = !newValue;
199 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
200 });
201
202 }
203 };
204 })
685acae4 205
206 // example: <a crm-ui-lock binding="mymodel.boolfield"></a>
207 // example: <a crm-ui-lock
208 // binding="mymodel.boolfield"
209 // title-locked="ts('Boolfield is locked')"
210 // title-unlocked="ts('Boolfield is unlocked')"></a>
211 .directive('crmUiLock', function ($parse, $rootScope) {
212 var defaultVal = function (defaultValue) {
213 var f = function (scope) {
214 return defaultValue;
215 }
216 f.assign = function (scope, value) {
217 // ignore changes
218 }
219 return f;
220 };
221
222 // like $parse, but accepts a defaultValue in case expr is undefined
223 var parse = function (expr, defaultValue) {
224 return expr ? $parse(expr) : defaultVal(defaultValue);
225 };
226
227 return {
228 template: '',
229 link: function (scope, element, attrs) {
230 var binding = parse(attrs['binding'], true);
231 var titleLocked = parse(attrs['titleLocked'], ts('Locked'));
232 var titleUnlocked = parse(attrs['titleUnlocked'], ts('Unlocked'));
233
234 $(element).addClass('ui-icon lock-button');
235 var refresh = function () {
236 var locked = binding(scope);
237 if (locked) {
238 $(element)
239 .removeClass('ui-icon-unlocked')
240 .addClass('ui-icon-locked')
241 .prop('title', titleLocked(scope))
242 ;
243 }
244 else {
245 $(element)
246 .removeClass('ui-icon-locked')
247 .addClass('ui-icon-unlocked')
248 .prop('title', titleUnlocked(scope))
249 ;
250 }
251 };
252
253 $(element).click(function () {
254 binding.assign(scope, !binding(scope));
255 //scope.$digest();
256 $rootScope.$digest();
257 });
258
259 scope.$watch(attrs.binding, refresh);
260 scope.$watch(attrs.titleLocked, refresh);
261 scope.$watch(attrs.titleUnlocked, refresh);
262
263 refresh();
264 }
265 };
266 })
030dce01
TO
267
268 // example <div crm-ui-tab crm-title="ts('My Title')">...content...</div>
5f3568fd 269 // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper
030dce01
TO
270 .directive('crmUiTab', function($parse) {
271 return {
5f3568fd
TO
272 require: '^crmUiTabSet',
273 restrict: 'EA',
030dce01 274 scope: {
5f3568fd
TO
275 crmTitle: '@',
276 id: '@'
030dce01 277 },
5f3568fd 278 template: '<div ng-transclude></div>',
030dce01 279 transclude: true,
5f3568fd
TO
280 link: function (scope, element, attrs, crmUiTabSetCtrl) {
281 crmUiTabSetCtrl.add(scope);
282 }
030dce01
TO
283 };
284 })
285
286 // 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>
287 .directive('crmUiTabSet', function() {
288 return {
5f3568fd
TO
289 restrict: 'EA',
290 scope: {
291 crmUiTabSet: '@'
292 },
293 templateUrl: partialUrl('tabset.html'),
030dce01 294 transclude: true,
5f3568fd
TO
295 controllerAs: 'crmUiTabSetCtrl',
296 controller: function($scope, $parse) {
297 var tabs = $scope.tabs = []; // array<$scope>
298 this.add = function(tab) {
299 if (!tab.id) throw "Tab is missing 'id'";
300 tabs.push(tab);
301 };
302 },
030dce01
TO
303 link: function (scope, element, attrs) {}
304 };
305 })
306
6717e4b9
TO
307 // 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>
308 // Note: "myWizardCtrl" has various actions/properties like next() and $first().
309 // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable"
310 // WISHLIST: Allow each step to enable/disable (show/hide) itself
030dce01
TO
311 .directive('crmUiWizard', function() {
312 return {
6717e4b9
TO
313 restrict: 'EA',
314 scope: {
315 crmUiWizard: '@'
316 },
317 templateUrl: partialUrl('wizard.html'),
030dce01 318 transclude: true,
6717e4b9
TO
319 controllerAs: 'crmUiWizardCtrl',
320 controller: function($scope, $parse) {
321 var steps = $scope.steps = []; // array<$scope>
322 var crmUiWizardCtrl = this;
323 var maxVisited = 0;
324 var selectedIndex = null;
325
326 var findIndex = function() {
327 var found = null;
328 angular.forEach(steps, function(step, stepKey) {
329 if (step.selected) found = stepKey;
330 });
331 return found;
332 };
333
334 /// @return int the index of the current step
335 this.$index = function() { return selectedIndex; };
336 /// @return bool whether the currentstep is first
337 this.$first = function() { return this.$index() === 0; };
338 /// @return bool whether the current step is last
339 this.$last = function() { return this.$index() === steps.length -1; };
340 this.$maxVisit = function() { return maxVisited; }
341 this.iconFor = function(index) {
342 if (index < this.$index()) return '√';
343 if (index === this.$index()) return '»';
344 return ' ';
345 }
346 this.isSelectable = function(step) {
347 if (step.selected) return false;
348 var result = false;
349 angular.forEach(steps, function(otherStep, otherKey) {
350 if (step === otherStep && otherKey <= maxVisited) result = true;
351 });
352 return result;
353 };
354
355 /*** @param Object step the $scope of the step */
356 this.select = function(step) {
357 angular.forEach(steps, function(otherStep, otherKey) {
358 otherStep.selected = (otherStep === step);
359 if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey;
360 });
361 selectedIndex = findIndex();
362 };
363 /*** @param Object step the $scope of the step */
364 this.add = function(step) {
365 if (steps.length === 0) {
366 step.selected = true;
367 selectedIndex = 0;
368 }
369 steps.push(step);
370 };
371 this.goto = function(index) {
372 if (index < 0) index = 0;
373 if (index >= steps.length) index = steps.length-1;
374 this.select(steps[index]);
375 };
376 this.previous = function() { this.goto(this.$index()-1); };
377 this.next = function() { this.goto(this.$index()+1); };
378 if ($scope.crmUiWizard) {
379 $parse($scope.crmUiWizard).assign($scope.$parent, this)
380 }
381 },
030dce01
TO
382 link: function (scope, element, attrs) {}
383 };
384 })
385
6717e4b9
TO
386 // Use this to add extra markup to wizard
387 .directive('crmUiWizardButtons', function() {
030dce01 388 return {
6717e4b9
TO
389 require: '^crmUiWizard',
390 restrict: 'EA',
391 scope: {},
392 template: '<span ng-transclude></span>',
030dce01 393 transclude: true,
6717e4b9
TO
394 link: function (scope, element, attrs, crmUiWizardCtrl) {
395 var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons');
396 $(element).appendTo(realButtonsEl);
397 }
030dce01
TO
398 };
399 })
400
401 // example <div crm-ui-wizard-step crm-title="ts('My Title')">...content...</div>
402 .directive('crmUiWizardStep', function() {
403 return {
6717e4b9
TO
404 require: '^crmUiWizard',
405 restrict: 'EA',
030dce01
TO
406 scope: {
407 crmTitle: '@'
408 },
6717e4b9 409 template: '<div class="crm-wizard-step" ng-show="selected" ng-transclude/></div>',
030dce01 410 transclude: true,
6717e4b9
TO
411 link: function (scope, element, attrs, crmUiWizardCtrl) {
412 crmUiWizardCtrl.add(scope);
413 }
030dce01
TO
414 };
415 })
416
5fb5b3cf 417 // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
4b8c8b42
TO
418 // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
419 .directive('crmConfirm', function () {
88fcc9f1 420 // Helpers to calculate default options for CRM.confirm()
4b8c8b42
TO
421 var defaultFuncs = {
422 'disable': function (options) {
423 return {
424 message: ts('Are you sure you want to disable this?'),
425 options: {no: ts('Cancel'), yes: ts('Disable')},
426 width: 300,
427 title: ts('Disable %1?', {
428 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
429 })
430 };
431 },
470a458e
TO
432 'revert': function (options) {
433 return {
434 message: ts('Are you sure you want to revert this?'),
435 options: {no: ts('Cancel'), yes: ts('Revert')},
436 width: 300,
437 title: ts('Revert %1?', {
438 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
439 })
440 };
441 },
4b8c8b42
TO
442 'delete': function (options) {
443 return {
444 message: ts('Are you sure you want to delete this?'),
445 options: {no: ts('Cancel'), yes: ts('Delete')},
446 width: 300,
447 title: ts('Delete %1?', {
448 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
449 })
450 };
451 }
452 };
453 return {
454 template: '',
455 link: function (scope, element, attrs) {
456 $(element).click(function () {
457 var options = scope.$eval(attrs['crmConfirm']);
458 var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
459 CRM.confirm(_.extend(defaults, options))
460 .on('crmConfirm:yes', function () { scope.$apply(attrs['onYes']); })
461 .on('crmConfirm:no', function () { scope.$apply(attrs['onNo']); });
462 });
463 }
464 };
465 })
02308c07
TO
466 .run(function($rootScope, $location) {
467 /// Example: <button ng-click="goto('home')">Go home!</button>
468 $rootScope.goto = function(path) {
469 $location.path(path);
470 };
4b8c8b42 471 // useful for debugging: $rootScope.log = console.log || function() {};
02308c07 472 })
685acae4 473 ;
474
5fb5b3cf 475})(angular, CRM.$, CRM._);