CRM-15578 - crmUiTabSet - Basic implementation
[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
438f2b52
TO
30 // example: <form name="myForm">...<label crm-ui-label crm-for="myField">My Field</span>...<input name="myField"/>...</form>
31 //
32 // Label adapts based on <input required>, <input ng-required>, or any other validation.
33 //
34 // Note: This should work in the normal case where <label> and <input> are in roughly the same scope,
35 // but if the scopes are materially different then problems could arise.
36 .directive('crmUiLabel', function($parse) {
37 return {
38 scope: {
39 name: '@'
40 },
41 transclude: true,
42 template: '<span ng-class="cssClasses"><span ng-transclude></span> <span ng-show="crmRequired" class="crm-marker" title="This field is required.">*</span></span>',
43 link: function(scope, element, attrs) {
44 if (attrs.crmFor == 'name') {
45 throw new Error('Validation monitoring does not work for field name "name"');
46 }
47
48 // 1. Figure out form and input elements
49
50 var form = $(element).closest('form');
51 var formCtrl = scope.$parent.$eval(form.attr('name'));
52 var input = $('input[name="' + attrs.crmFor + '"],select[name="' + attrs.crmFor + '"],textarea[name="' + attrs.crmFor + '"]', form);
53 if (form.length != 1 || input.length != 1) {
54 if (console.log) console.log('Label cannot be matched to input element. Expected to find one form and one input.', form.length, input.length);
55 return;
56 }
57
58 // 2. Make sure that inputs are well-defined (with name+id).
59
60 if (!input.attr('id')) {
61 input.attr('id', 'crmUi_' + (++idCount));
62 }
63 $(element).attr('for', input.attr('id'));
64
65 // 3. Monitor is the "required" and "$valid" properties
66
67 if (input.attr('ng-required')) {
68 scope.crmRequired = scope.$parent.$eval(input.attr('ng-required'));
69 scope.$parent.$watch(input.attr('ng-required'), function(isRequired) {
70 scope.crmRequired = isRequired;
71 });
72 } else {
73 scope.crmRequired = input.prop('required');
74 }
75
76 var inputCtrl = form.attr('name') + '.' + input.attr('name');
77 scope.cssClasses = {};
78 scope.$parent.$watch(inputCtrl + '.$valid', function(newValue) {
79 //scope.cssClasses['ng-valid'] = newValue;
80 //scope.cssClasses['ng-invalid'] = !newValue;
81 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
82 });
83 scope.$parent.$watch(inputCtrl + '.$pristine', function(newValue) {
84 //scope.cssClasses['ng-pristine'] = newValue;
85 //scope.cssClasses['ng-dirty'] = !newValue;
86 scope.cssClasses['crm-error'] = !scope.$parent.$eval(inputCtrl + '.$valid') && !scope.$parent.$eval(inputCtrl + '.$pristine');
87 });
88
89 }
90 };
91 })
685acae4 92
93 // example: <a crm-ui-lock binding="mymodel.boolfield"></a>
94 // example: <a crm-ui-lock
95 // binding="mymodel.boolfield"
96 // title-locked="ts('Boolfield is locked')"
97 // title-unlocked="ts('Boolfield is unlocked')"></a>
98 .directive('crmUiLock', function ($parse, $rootScope) {
99 var defaultVal = function (defaultValue) {
100 var f = function (scope) {
101 return defaultValue;
102 }
103 f.assign = function (scope, value) {
104 // ignore changes
105 }
106 return f;
107 };
108
109 // like $parse, but accepts a defaultValue in case expr is undefined
110 var parse = function (expr, defaultValue) {
111 return expr ? $parse(expr) : defaultVal(defaultValue);
112 };
113
114 return {
115 template: '',
116 link: function (scope, element, attrs) {
117 var binding = parse(attrs['binding'], true);
118 var titleLocked = parse(attrs['titleLocked'], ts('Locked'));
119 var titleUnlocked = parse(attrs['titleUnlocked'], ts('Unlocked'));
120
121 $(element).addClass('ui-icon lock-button');
122 var refresh = function () {
123 var locked = binding(scope);
124 if (locked) {
125 $(element)
126 .removeClass('ui-icon-unlocked')
127 .addClass('ui-icon-locked')
128 .prop('title', titleLocked(scope))
129 ;
130 }
131 else {
132 $(element)
133 .removeClass('ui-icon-locked')
134 .addClass('ui-icon-unlocked')
135 .prop('title', titleUnlocked(scope))
136 ;
137 }
138 };
139
140 $(element).click(function () {
141 binding.assign(scope, !binding(scope));
142 //scope.$digest();
143 $rootScope.$digest();
144 });
145
146 scope.$watch(attrs.binding, refresh);
147 scope.$watch(attrs.titleLocked, refresh);
148 scope.$watch(attrs.titleUnlocked, refresh);
149
150 refresh();
151 }
152 };
153 })
030dce01
TO
154
155 // example <div crm-ui-tab crm-title="ts('My Title')">...content...</div>
5f3568fd 156 // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper
030dce01
TO
157 .directive('crmUiTab', function($parse) {
158 return {
5f3568fd
TO
159 require: '^crmUiTabSet',
160 restrict: 'EA',
030dce01 161 scope: {
5f3568fd
TO
162 crmTitle: '@',
163 id: '@'
030dce01 164 },
5f3568fd 165 template: '<div ng-transclude></div>',
030dce01 166 transclude: true,
5f3568fd
TO
167 link: function (scope, element, attrs, crmUiTabSetCtrl) {
168 crmUiTabSetCtrl.add(scope);
169 }
030dce01
TO
170 };
171 })
172
173 // 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>
174 .directive('crmUiTabSet', function() {
175 return {
5f3568fd
TO
176 restrict: 'EA',
177 scope: {
178 crmUiTabSet: '@'
179 },
180 templateUrl: partialUrl('tabset.html'),
030dce01 181 transclude: true,
5f3568fd
TO
182 controllerAs: 'crmUiTabSetCtrl',
183 controller: function($scope, $parse) {
184 var tabs = $scope.tabs = []; // array<$scope>
185 this.add = function(tab) {
186 if (!tab.id) throw "Tab is missing 'id'";
187 tabs.push(tab);
188 };
189 },
030dce01
TO
190 link: function (scope, element, attrs) {}
191 };
192 })
193
6717e4b9
TO
194 // 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>
195 // Note: "myWizardCtrl" has various actions/properties like next() and $first().
196 // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable"
197 // WISHLIST: Allow each step to enable/disable (show/hide) itself
030dce01
TO
198 .directive('crmUiWizard', function() {
199 return {
6717e4b9
TO
200 restrict: 'EA',
201 scope: {
202 crmUiWizard: '@'
203 },
204 templateUrl: partialUrl('wizard.html'),
030dce01 205 transclude: true,
6717e4b9
TO
206 controllerAs: 'crmUiWizardCtrl',
207 controller: function($scope, $parse) {
208 var steps = $scope.steps = []; // array<$scope>
209 var crmUiWizardCtrl = this;
210 var maxVisited = 0;
211 var selectedIndex = null;
212
213 var findIndex = function() {
214 var found = null;
215 angular.forEach(steps, function(step, stepKey) {
216 if (step.selected) found = stepKey;
217 });
218 return found;
219 };
220
221 /// @return int the index of the current step
222 this.$index = function() { return selectedIndex; };
223 /// @return bool whether the currentstep is first
224 this.$first = function() { return this.$index() === 0; };
225 /// @return bool whether the current step is last
226 this.$last = function() { return this.$index() === steps.length -1; };
227 this.$maxVisit = function() { return maxVisited; }
228 this.iconFor = function(index) {
229 if (index < this.$index()) return '√';
230 if (index === this.$index()) return '»';
231 return ' ';
232 }
233 this.isSelectable = function(step) {
234 if (step.selected) return false;
235 var result = false;
236 angular.forEach(steps, function(otherStep, otherKey) {
237 if (step === otherStep && otherKey <= maxVisited) result = true;
238 });
239 return result;
240 };
241
242 /*** @param Object step the $scope of the step */
243 this.select = function(step) {
244 angular.forEach(steps, function(otherStep, otherKey) {
245 otherStep.selected = (otherStep === step);
246 if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey;
247 });
248 selectedIndex = findIndex();
249 };
250 /*** @param Object step the $scope of the step */
251 this.add = function(step) {
252 if (steps.length === 0) {
253 step.selected = true;
254 selectedIndex = 0;
255 }
256 steps.push(step);
257 };
258 this.goto = function(index) {
259 if (index < 0) index = 0;
260 if (index >= steps.length) index = steps.length-1;
261 this.select(steps[index]);
262 };
263 this.previous = function() { this.goto(this.$index()-1); };
264 this.next = function() { this.goto(this.$index()+1); };
265 if ($scope.crmUiWizard) {
266 $parse($scope.crmUiWizard).assign($scope.$parent, this)
267 }
268 },
030dce01
TO
269 link: function (scope, element, attrs) {}
270 };
271 })
272
6717e4b9
TO
273 // Use this to add extra markup to wizard
274 .directive('crmUiWizardButtons', function() {
030dce01 275 return {
6717e4b9
TO
276 require: '^crmUiWizard',
277 restrict: 'EA',
278 scope: {},
279 template: '<span ng-transclude></span>',
030dce01 280 transclude: true,
6717e4b9
TO
281 link: function (scope, element, attrs, crmUiWizardCtrl) {
282 var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons');
283 $(element).appendTo(realButtonsEl);
284 }
030dce01
TO
285 };
286 })
287
288 // example <div crm-ui-wizard-step crm-title="ts('My Title')">...content...</div>
289 .directive('crmUiWizardStep', function() {
290 return {
6717e4b9
TO
291 require: '^crmUiWizard',
292 restrict: 'EA',
030dce01
TO
293 scope: {
294 crmTitle: '@'
295 },
6717e4b9 296 template: '<div class="crm-wizard-step" ng-show="selected" ng-transclude/></div>',
030dce01 297 transclude: true,
6717e4b9
TO
298 link: function (scope, element, attrs, crmUiWizardCtrl) {
299 crmUiWizardCtrl.add(scope);
300 }
030dce01
TO
301 };
302 })
303
5fb5b3cf 304 // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
4b8c8b42
TO
305 // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
306 .directive('crmConfirm', function () {
88fcc9f1 307 // Helpers to calculate default options for CRM.confirm()
4b8c8b42
TO
308 var defaultFuncs = {
309 'disable': function (options) {
310 return {
311 message: ts('Are you sure you want to disable this?'),
312 options: {no: ts('Cancel'), yes: ts('Disable')},
313 width: 300,
314 title: ts('Disable %1?', {
315 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
316 })
317 };
318 },
470a458e
TO
319 'revert': function (options) {
320 return {
321 message: ts('Are you sure you want to revert this?'),
322 options: {no: ts('Cancel'), yes: ts('Revert')},
323 width: 300,
324 title: ts('Revert %1?', {
325 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
326 })
327 };
328 },
4b8c8b42
TO
329 'delete': function (options) {
330 return {
331 message: ts('Are you sure you want to delete this?'),
332 options: {no: ts('Cancel'), yes: ts('Delete')},
333 width: 300,
334 title: ts('Delete %1?', {
335 1: options.obj.title || options.obj.label || options.obj.name || ts('the record')
336 })
337 };
338 }
339 };
340 return {
341 template: '',
342 link: function (scope, element, attrs) {
343 $(element).click(function () {
344 var options = scope.$eval(attrs['crmConfirm']);
345 var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
346 CRM.confirm(_.extend(defaults, options))
347 .on('crmConfirm:yes', function () { scope.$apply(attrs['onYes']); })
348 .on('crmConfirm:no', function () { scope.$apply(attrs['onNo']); });
349 });
350 }
351 };
352 })
02308c07
TO
353 .run(function($rootScope, $location) {
354 /// Example: <button ng-click="goto('home')">Go home!</button>
355 $rootScope.goto = function(path) {
356 $location.path(path);
357 };
4b8c8b42 358 // useful for debugging: $rootScope.log = console.log || function() {};
02308c07 359 })
685acae4 360 ;
361
5fb5b3cf 362})(angular, CRM.$, CRM._);