Commit | Line | Data |
---|---|---|
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._); |