]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/web/js/ui/default/staff/services/ui.js
LP#1756110: fix egBasicComboBox drop-down functionality
[Evergreen.git] / Open-ILS / web / js / ui / default / staff / services / ui.js
1 /**
2   * UI tools and directives.
3   */
4 angular.module('egUiMod', ['egCoreMod', 'ui.bootstrap'])
5
6
7 /**
8  * <input focus-me="iAmOpen"/>
9  * $scope.iAmOpen = true;
10  */
11 .directive('focusMe', 
12        ['$timeout','$parse', 
13 function($timeout , $parse) {
14     return {
15         link: function(scope, element, attrs) {
16             var model = $parse(attrs.focusMe);
17             scope.$watch(model, function(value) {
18                 if(value === true) 
19                     $timeout(function() {element[0].focus()});
20             });
21             element.bind('blur', function() {
22                 $timeout(function() {
23                     scope.$apply(model.assign(scope, false));
24                 });
25             })
26         }
27     };
28 }])
29
30 /**
31  * <input blur-me="pleaseBlurMe"/>
32  * $scope.pleaseBlurMe = true
33  * Useful for de-focusing when no other obvious focus target exists
34  */
35 .directive('blurMe', 
36        ['$timeout','$parse', 
37 function($timeout , $parse) {
38     return {
39         link: function(scope, element, attrs) {
40             var model = $parse(attrs.blurMe);
41             scope.$watch(model, function(value) {
42                 if(value === true) 
43                     $timeout(function() {element[0].blur()});
44             });
45             element.bind('focus', function() {
46                 $timeout(function() {
47                     scope.$apply(model.assign(scope, false));
48                 });
49             })
50         }
51     };
52 }])
53
54
55 // <input select-me="iWantToBeSelected"/>
56 // $scope.iWantToBeSelected = true;
57 .directive('selectMe', 
58        ['$timeout','$parse', 
59 function($timeout , $parse) {
60     return {
61         link: function(scope, element, attrs) {
62             var model = $parse(attrs.selectMe);
63             scope.$watch(model, function(value) {
64                 if(value === true) 
65                     $timeout(function() {element[0].select()});
66             });
67             element.bind('blur', function() {
68                 $timeout(function() {
69                     scope.$apply(model.assign(scope, false));
70                 });
71             })
72         }
73     };
74 }])
75
76 // <select int-to-str ><option value="1">Value</option></select>
77 // use integer models for string values
78 .directive('intToStr', function() {
79     return {
80         restrict: 'A',
81         require: 'ngModel',
82         link: function(scope, element, attrs, ngModel) {
83             ngModel.$parsers.push(function(value) {
84                 return parseInt(value);
85             });
86             ngModel.$formatters.push(function(value) {
87                 return '' + value;
88             });
89         }
90     };
91 })
92
93 // <input str-to-int value="10"/>
94 .directive('strToInt', function() {
95     return {
96         restrict: 'A',
97         require: 'ngModel',
98         link: function(scope, element, attrs, ngModel) {
99             ngModel.$parsers.push(function(value) {
100                 return '' + value;
101             });
102             ngModel.$formatters.push(function(value) {
103                 return parseInt(value);
104             });
105         }
106     };
107 })
108
109 // <input float-to-str
110 .directive('floatToStr', function() {
111     return {
112         restrict: 'A',
113         require: 'ngModel',
114         link: function(scope, element, attrs, ngModel) {
115             ngModel.$parsers.push(function(value) {
116                 return parseFloat(value);
117             });
118             ngModel.$formatters.push(function(value) {
119                 return '' + value;
120             });
121         }
122     };
123 })
124
125 .directive('strToFloat', function() {
126     return {
127         restrict: 'A',
128         require: 'ngModel',
129         link: function(scope, element, attrs, ngModel) {
130             ngModel.$parsers.push(function(value) {
131                 return '' + value;
132             });
133             ngModel.$formatters.push(function(value) {
134                 return parseFloat(value);
135             });
136         }
137     };
138 })
139
140 // 'reverse' filter 
141 // <div ng-repeat="item in items | reverse">{{item.name}}</div>
142 // http://stackoverflow.com/questions/15266671/angular-ng-repeat-in-reverse
143 // TODO: perhaps this should live elsewhere
144 .filter('reverse', function() {
145     return function(items) {
146         return items.slice().reverse();
147     };
148 })
149
150 // 'date' filter
151 // Overriding the core angular date filter with a moment-js based one for
152 // better timezone and formatting support.
153 .filter('date',function() {
154
155     var formatMap = {
156         short  : 'l LT',
157         medium : 'lll',
158         long   : 'LLL',
159         full   : 'LLLL',
160
161         shortDate  : 'l',
162         mediumDate : 'll',
163         longDate   : 'LL',
164         fullDate   : 'LL',
165
166         shortTime  : 'LT',
167         mediumTime : 'LTS'
168     };
169
170     var formatReplace = [
171         [ /yyyy/g, 'YYYY' ],
172         [ /yy/g,   'YY'   ],
173         [ /y/g,    'Y'    ],
174         [ /ww/g,   'WW'   ],
175         [ /w/g,    'W'    ],
176         [ /dd/g,   'DD'   ],
177         [ /d/g,    'D'    ],
178         [ /sss/g,  'SSS'  ],
179         [ /EEEE/g, 'dddd' ],
180         [ /EEE/g,  'ddd'  ],
181         [ /Z/g,    'ZZ'   ]
182     ];
183
184     return function (date, format, tz) {
185         if (!date) return '';
186
187         if (date == 'now') 
188             date = new Date().toISOString();
189
190         if (format) {
191             var fmt = formatMap[format] || format;
192             angular.forEach(formatReplace, function (r) {
193                 fmt = fmt.replace(r[0],r[1]);
194             });
195         }
196
197         var d = moment(date);
198         if (tz && tz !== '-') d.tz(tz);
199
200         return d.isValid() ? d.format(fmt) : '';
201     }
202
203 })
204
205 // 'egOrgDate' filter
206 // Uses moment.js and moment-timezone.js to put dates into the most appropriate
207 // timezone for a given (optional) org unit based on its lib.timezone setting
208 .filter('egOrgDate',['$filter','egCore',
209              function($filter , egCore) {
210
211     var tzcache = {};
212
213     function eg_date_filter (date, fmt, ouID) {
214         if (ouID) {
215             if (angular.isObject(ouID)) {
216                 if (angular.isFunction(ouID.id)) {
217                     ouID = ouID.id();
218                 } else {
219                     ouID = ouID.id;
220                 }
221             }
222     
223             if (!tzcache[ouID]) {
224                 tzcache[ouID] = '-';
225                 egCore.org.settings('lib.timezone', ouID)
226                 .then(function(s) {
227                     tzcache[ouID] = s['lib.timezone'] || OpenSRF.tz;
228                 });
229             }
230         }
231
232         return $filter('date')(date, fmt, tzcache[ouID]);
233     }
234
235     eg_date_filter.$stateful = true;
236
237     return eg_date_filter;
238 }])
239
240 // 'egOrgDateInContext' filter
241 // Uses the egOrgDate filter to make time and date location aware, and further
242 // modifies the format if one of [short, medium, long, full] to show only the
243 // date if the optional interval parameter is day-granular.  This is
244 // particularly useful for due dates on circulations.
245 .filter('egOrgDateInContext',['$filter','egCore',
246                       function($filter , egCore) {
247
248     function eg_context_date_filter (date, format, orgID, interval) {
249         var fmt = format;
250         if (!fmt) fmt = 'shortDate';
251
252         // if this is a simple, one-word format, and it doesn't say "Date" in it...
253         if (['short','medium','long','full'].filter(function(x){return fmt == x}).length > 0 && interval) {
254             var secs = egCore.date.intervalToSeconds(interval);
255             if (secs !== null && secs % 86400 == 0) fmt += 'Date';
256         }
257
258         return $filter('egOrgDate')(date, fmt, orgID);
259     }
260
261     eg_context_date_filter.$stateful = true;
262
263     return eg_context_date_filter;
264 }])
265
266 // 'egDueDate' filter
267 // Uses the egOrgDateInContext filter to make time and date location aware, but
268 // only if the supplied interval is day-granular.  This is as wrapper for
269 // egOrgDateInContext to be used for circulation due date /only/.
270 .filter('egDueDate',['$filter','egCore',
271                       function($filter , egCore) {
272
273     function eg_context_due_date_filter (date, format, orgID, interval) {
274         if (interval) {
275             var secs = egCore.date.intervalToSeconds(interval);
276             if (secs === null || secs % 86400 != 0) {
277                 orgID = null;
278                 interval = null;
279             }
280         }
281         return $filter('egOrgDateInContext')(date, format, orgID, interval);
282     }
283
284     eg_context_due_date_filter.$stateful = true;
285
286     return eg_context_due_date_filter;
287 }])
288
289 // 'join' filter
290 // TODO: perhaps this should live elsewhere
291 .filter('join', function() {
292     return function(arr,sep) {
293         if (typeof arr == 'object' && arr.constructor == Array) {
294             return arr.join(sep || ',');
295         } else {
296             return '';
297         }
298     };
299 })
300
301 /**
302  * Progress Dialog. 
303  *
304  * egProgressDialog.open();
305  * egProgressDialog.open({value : 0});
306  * egProgressDialog.open({value : 0, max : 123});
307  * egProgressDialog.increment();
308  * egProgressDialog.increment();
309  * egProgressDialog.close();
310  *
311  * Each dialog has 2 numbers, 'max' and 'value'.
312  * The content of these values determines how the dialog displays.  
313  *
314  * There are 3 flavors:
315  *
316  * -- value is set, max is set
317  * determinate: shows a progression with a percent complete.
318  *
319  * -- value is set, max is unset
320  * semi-determinate, with a value report.  Shows a value-less
321  * <progress/>, but shows the value as a number in the dialog.
322  *
323  * This is useful in cases where the total number of items to retrieve
324  * from the server is unknown, but we know how many items we've
325  * retrieved thus far.  It helps to reinforce that something specific
326  * is happening, but we don't know when it will end.
327  *
328  * -- value is unset
329  * indeterminate: shows a generic value-less <progress/> with no 
330  * clear indication of progress.
331  *
332  * Only 1 egProgressDialog instance will be activate at a time.
333  * Each invocation of .open() destroys any existing instance.
334  */
335
336 /* Simple storage class for egProgressDialog data maintenance.
337  * This data lives outside of egProgressDialog so it can be 
338  * directly imported into egProgressDialog's $uibModalInstance.
339  */
340 .factory('egProgressData', [
341     function() {
342         var service = {}; // max/value initially unset
343
344         service.reset = function() {
345             delete service.max;
346             delete service.value;
347         }
348
349         service.hasvalue = function() {
350             return Number.isInteger(service.value);
351         }
352
353         service.hasmax = function() {
354             return Number.isInteger(service.max);
355         }
356
357         service.percent = function() {
358             if (service.hasvalue()  && 
359                 service.hasmax()    && 
360                 service.max > 0     &&
361                 service.value <= service.max)
362                 return Math.floor((service.value / service.max) * 100);
363             return 100;
364         }
365
366         return service;
367     }
368 ])
369
370 .factory('egProgressDialog', [
371             'egProgressData','$uibModal', 
372     function(egProgressData , $uibModal) {
373     var service = {};
374
375     service.open = function(args) {
376         return $uibModal.open({
377             templateUrl: './share/t_progress_dialog',
378             controller: ['$scope','$uibModalInstance','egProgressData',
379                 function( $scope , $uibModalInstance , egProgressData) {
380                     // Once the new modal instance is available, force-
381                     // kill any other instances
382                     service.close(true); 
383
384                     // Reset to an indeterminate progress bar, 
385                     // overlay with caller values.
386                     egProgressData.reset();
387                     service.update(angular.extend({}, args));
388
389                     service.currentInstance = $uibModalInstance;
390                     $scope.data = egProgressData; // tiny service
391                 }
392             ]
393         });
394     };
395
396     service.close = function(warn) {
397         if (service.currentInstance) {
398             if (warn) {
399                 console.warn("egProgressDialog replacing existing instance. "
400                     + "Only one may be open at a time.");
401             }
402             service.currentInstance.close();
403             delete service.currentInstance;
404         }
405     }
406
407     // Set the current state of the progress bar.
408     service.update = function(args) {
409         if (args.max != undefined) 
410             egProgressData.max = args.max;
411         if (args.value != undefined) 
412             egProgressData.value = args.value;
413     }
414
415     // Increment the current value.  If no amount is specified,
416     // it increments by 1.  Calling increment() on an indetermite
417     // progress bar will force it to be a (semi-)determinate bar.
418     service.increment = function(amt) {
419         if (!Number.isInteger(amt)) amt = 1;
420
421         if (!egProgressData.hasvalue())
422             egProgressData.value = 0;
423
424         egProgressData.value += amt;
425     }
426
427     return service;
428 }])
429
430 /**
431  * egAlertDialog.open({message : 'hello {{name}}'}).result.then(
432  *     function() { console.log('alert closed') });
433  */
434 .factory('egAlertDialog', 
435
436         ['$uibModal','$interpolate',
437 function($uibModal , $interpolate) {
438     var service = {};
439
440     service.open = function(message, msg_scope) {
441         return $uibModal.open({
442             templateUrl: './share/t_alert_dialog',
443             controller: ['$scope', '$uibModalInstance',
444                 function($scope, $uibModalInstance) {
445                     $scope.message = $interpolate(message)(msg_scope);
446                     $scope.ok = function() {
447                         if (msg_scope && msg_scope.ok) msg_scope.ok();
448                         $uibModalInstance.close()
449                     }
450                 }
451             ]
452         });
453     }
454
455     return service;
456 }])
457
458 /**
459  * egConfirmDialog.open("some message goes {{here}}", {
460  *  here : 'foo', ok : function() {}, cancel : function() {}},
461  *  'OK', 'Cancel');
462  */
463 .factory('egConfirmDialog', 
464     
465        ['$uibModal','$interpolate',
466 function($uibModal, $interpolate) {
467     var service = {};
468
469     service.open = function(title, message, msg_scope, ok_button_label, cancel_button_label) {
470         msg_scope = msg_scope || {};
471         return $uibModal.open({
472             templateUrl: './share/t_confirm_dialog',
473             controller: ['$scope', '$uibModalInstance',
474                 function($scope, $uibModalInstance) {
475                     $scope.title = $interpolate(title)(msg_scope);
476                     $scope.message = $interpolate(message)(msg_scope);
477                     $scope.ok_button_label = $interpolate(ok_button_label || '')(msg_scope);
478                     $scope.cancel_button_label = $interpolate(cancel_button_label || '')(msg_scope);
479                     $scope.ok = function() {
480                         if (msg_scope.ok) msg_scope.ok();
481                         $uibModalInstance.close()
482                     }
483                     $scope.cancel = function() {
484                         if (msg_scope.cancel) msg_scope.cancel();
485                         $uibModalInstance.dismiss();
486                     }
487                 }
488             ]
489         })
490     }
491
492     return service;
493 }])
494
495 /**
496  * egPromptDialog.open(
497  *    "prompt message goes {{here}}", 
498  *    promptValue,  // optional
499  *    {
500  *      here : 'foo',  
501  *      ok : function(value) {console.log(value)}, 
502  *      cancel : function() {console.log('prompt denied')}
503  *    }
504  *  );
505  */
506 .factory('egPromptDialog', 
507     
508        ['$uibModal','$interpolate',
509 function($uibModal, $interpolate) {
510     var service = {};
511
512     service.open = function(message, promptValue, msg_scope) {
513         return $uibModal.open({
514             templateUrl: './share/t_prompt_dialog',
515             controller: ['$scope', '$uibModalInstance',
516                 function($scope, $uibModalInstance) {
517                     $scope.message = $interpolate(message)(msg_scope);
518                     $scope.args = {value : promptValue || ''};
519                     $scope.focus = true;
520                     $scope.ok = function() {
521                         if (msg_scope && msg_scope.ok) msg_scope.ok($scope.args.value);
522                         $uibModalInstance.close($scope.args);
523                     }
524                     $scope.cancel = function() {
525                         if (msg_scope && msg_scope.cancel) msg_scope.cancel();
526                         $uibModalInstance.dismiss();
527                     }
528                 }
529             ]
530         })
531     }
532
533     return service;
534 }])
535
536 /**
537  * egSelectDialog.open(
538  *    "message goes {{here}}", 
539  *    list,           // ['values','for','dropdown'],
540  *    selectedValue,  // optional
541  *    {
542  *      here : 'foo',
543  *      ok : function(value) {console.log(value)}, 
544  *      cancel : function() {console.log('prompt denied')}
545  *    }
546  *  );
547  */
548 .factory('egSelectDialog', 
549     
550        ['$uibModal','$interpolate',
551 function($uibModal, $interpolate) {
552     var service = {};
553
554     service.open = function(message, inputList, selectedValue, msg_scope) {
555         return $uibModal.open({
556             templateUrl: './share/t_select_dialog',
557             controller: ['$scope', '$uibModalInstance',
558                 function($scope, $uibModalInstance) {
559                     $scope.message = $interpolate(message)(msg_scope);
560                     $scope.args = {
561                         list  : inputList,
562                         value : selectedValue
563                     };
564                     $scope.focus = true;
565                     $scope.ok = function() {
566                         if (msg_scope.ok) msg_scope.ok($scope.args.value);
567                         $uibModalInstance.close()
568                     }
569                     $scope.cancel = function() {
570                         if (msg_scope.cancel) msg_scope.cancel();
571                         $uibModalInstance.dismiss();
572                     }
573                 }
574             ]
575         })
576     }
577
578     return service;
579 }])
580
581 /**
582  * Warn on page unload and give the user a chance to avoid navigating
583  * away from the current page.  
584  * Only one handler is supported per page.
585  * NOTE: we can't use an egUnloadDialog as the dialog builder, because
586  * it renders asynchronously, which allows the page to redirect before
587  * the dialog appears.
588  */
589 .factory('egUnloadPrompt', [
590         '$window','egStrings', 
591 function($window , egStrings) {
592     var service = {attached : false};
593
594     // attach a page/scope unload prompt
595     service.attach = function($scope, msg) {
596         if (service.attached) return;
597         service.attached = true;
598
599         // handle page change
600         $($window).on('beforeunload', function() { 
601             service.clear();
602             return msg || egStrings.EG_UNLOAD_PAGE_PROMPT_MSG;
603         });
604
605         if (!$scope) return;
606
607         // If a scope was provided, attach a scope-change handler,
608         // similar to the page-page prompt.
609         service.locChangeCancel = 
610             $scope.$on('$locationChangeStart', function(evt, next, current) {
611             if (confirm(msg || egStrings.EG_UNLOAD_CTRL_PROMPT_MSG)) {
612                 // user allowed the page to change.  
613                 // Clear the unload handler.
614                 service.clear();
615             } else {
616                 evt.preventDefault();
617             }
618         });
619     };
620
621     // remove the page unload prompt
622     service.clear = function() {
623         $($window).off('beforeunload');
624         if (service.locChangeCancel)
625             service.locChangeCancel();
626         service.attached = false;
627     }
628
629     return service;
630 }])
631
632 .directive('aDisabled', function() {
633     return {
634         restrict : 'A',
635         compile: function(tElement, tAttrs, transclude) {
636             //Disable ngClick
637             tAttrs["ngClick"] = ("ng-click", "!("+tAttrs["aDisabled"]+") && ("+tAttrs["ngClick"]+")");
638
639             //Toggle "disabled" to class when aDisabled becomes true
640             return function (scope, iElement, iAttrs) {
641                 scope.$watch(iAttrs["aDisabled"], function(newValue) {
642                     if (newValue !== undefined) {
643                         iElement.toggleClass("disabled", newValue);
644                     }
645                 });
646
647                 //Disable href on click
648                 iElement.on("click", function(e) {
649                     if (scope.$eval(iAttrs["aDisabled"])) {
650                         e.preventDefault();
651                     }
652                 });
653             };
654         }
655     };
656 })
657
658 .directive('egBasicComboBox', function() {
659     return {
660         restrict: 'E',
661         replace: true,
662         scope: {
663             list: "=", // list of strings
664             selected: "=",
665             onSelect: "=",
666             egDisabled: "=",
667             allowAll: "@",
668             placeholder: "@",
669             focusMe: "=?"
670         },
671         template:
672             '<div class="input-group">'+
673                 '<input placeholder="{{placeholder}}" type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()" focus-me="focusMe">'+
674                 '<div class="input-group-btn" uib-dropdown ng-class="{open:isopen}">'+
675                     '<button type="button" ng-click="showAll()" ng-disabled="egDisabled" class="btn btn-default" uib-dropdown-toggle><span class="caret"></span></button>'+
676                     '<ul uib-dropdown-menu class="dropdown-menu-right">'+
677                         '<li ng-repeat="item in list|filter:selected:compare"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
678                         '<li ng-if="complete_list" class="divider"><span></span></li>'+
679                         '<li ng-if="complete_list" ng-repeat="item in list"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
680                     '</ul>'+
681                 '</div>'+
682             '</div>',
683         controller: ['$scope','$filter',
684             function( $scope , $filter) {
685
686                 $scope.complete_list = false;
687                 $scope.isopen = false;
688                 $scope.clickedopen = false;
689                 $scope.clickedclosed = null;
690
691                 $scope.compare = function (ex, act) {
692                     if (act === null || act === undefined) return true;
693                     if (act.toString) act = act.toString();
694                     return new RegExp(act.toLowerCase()).test(ex)
695                 }
696
697                 $scope.showAll = function () {
698
699                     $scope.clickedopen = !$scope.clickedopen;
700
701                     if ($scope.clickedclosed === null) {
702                         if (!$scope.clickedopen) {
703                             $scope.clickedclosed = true;
704                         }
705                     } else {
706                         $scope.clickedclosed = !$scope.clickedopen;
707                     }
708
709                     if ($scope.selected && $scope.selected.length > 0) $scope.complete_list = true;
710                     if (!$scope.selected || $scope.selected.length == 0) $scope.complete_list = false;
711                     $scope.makeOpen();
712                 }
713
714                 $scope.makeOpen = function () {
715                     $scope.isopen = $scope.clickedopen || ($filter('filter')(
716                         $scope.list,
717                         $scope.selected
718                     ).length > 0 && $scope.selected.length > 0);
719                     if ($scope.clickedclosed) {
720                         $scope.isopen = false;
721                         $scope.clickedclosed = null;
722                     }
723                 }
724
725                 $scope.changeValue = function (newVal) {
726                     $scope.selected = newVal;
727                     $scope.isopen = false;
728                     $scope.clickedclosed = null;
729                     $scope.clickedopen = false;
730                     if ($scope.selected.length == 0) $scope.complete_list = false;
731                     if ($scope.onSelect) $scope.onSelect();
732                 }
733
734             }
735         ]
736     };
737 })
738
739 /**
740  * Nested org unit selector modeled as a Bootstrap dropdown button.
741  */
742 .directive('egOrgSelector', function() {
743     return {
744         restrict : 'AE',
745         transclude : true,
746         replace : true, // makes styling easier
747         scope : {
748             selected : '=', // defaults to workstation or root org,
749                             // unless the nodefault attibute exists
750
751             // Each org unit is passed into this function and, for
752             // any org units where the response value is true, the
753             // org unit will not be added to the selector.
754             hiddenTest : '=',
755
756             // Each org unit is passed into this function and, for
757             // any org units where the response value is true, the
758             // org unit will not be available for selection.
759             disableTest : '=',
760
761             // if set to true, disable the UI element altogether
762             alldisabled : '@',
763
764             // Caller can either $watch(selected, ..) or register an
765             // onchange handler.
766             onchange : '=',
767
768             // optional primary drop-down button label
769             label : '@',
770
771             // optional name of settings key for persisting
772             // the last selected org unit
773             stickySetting : '@'
774         },
775
776         // any reason to move this into a TT2 template?
777         template : 
778             '<div class="btn-group eg-org-selector" uib-dropdown>'
779             + '<button type="button" class="btn btn-default" uib-dropdown-toggle ng-disabled="disable_button">'
780              + '<span style="padding-right: 5px;">{{getSelectedName()}}</span>'
781              + '<span class="caret"></span>'
782            + '</button>'
783            + '<ul uib-dropdown-menu class="scrollable-menu">'
784              + '<li ng-repeat="org in orgList" ng-hide="hiddenTest(org.id)">'
785                + '<a href ng-click="orgChanged(org)" a-disabled="disableTest(org.id)" '
786                  + 'style="padding-left: {{org.depth * 10 + 5}}px">'
787                  + '{{org.shortname}}'
788                + '</a>'
789              + '</li>'
790            + '</ul>'
791           + '</div>',
792
793         controller : ['$scope','$timeout','egCore','egStartup','egLovefield','$q',
794               function($scope , $timeout , egCore , egStartup , egLovefield , $q) {
795
796             if ($scope.alldisabled) {
797                 $scope.disable_button = $scope.alldisabled == 'true' ? true : false;
798             } else {
799                 $scope.disable_button = false;
800             }
801
802             // avoid linking the full fleshed tree to the scope by 
803             // tossing in a flattened list.
804             // --
805             // Run-time code referencing post-start data should be run
806             // from within a startup block, otherwise accessing this
807             // module before startup completes will lead to failure.
808             //
809             // controller() runs before link().
810             // This post-startup code runs after link().
811             egStartup.go(
812             ).then(
813                 function() {
814                     return egCore.env.classLoaders.aou();
815                 }
816             ).then(
817                 function() {
818
819                     $scope.orgList = egCore.org.list().map(function(org) {
820                         return {
821                             id : org.id(),
822                             shortname : org.shortname(), 
823                             depth : org.ou_type().depth()
824                         }
825                     });
826                     
827     
828                     // Apply default values
829     
830                     if ($scope.stickySetting) {
831                         var orgId = egCore.hatch.getLocalItem($scope.stickySetting);
832                         if (orgId) {
833                             $scope.selected = egCore.org.get(orgId);
834                         }
835                     }
836     
837                     if (!$scope.selected && !$scope.nodefault && egCore.auth.user()) {
838                         $scope.selected = 
839                             egCore.org.get(egCore.auth.user().ws_ou());
840                     }
841     
842                     fire_orgsel_onchange(); // no-op if nothing is selected
843                 }
844             );
845
846             /**
847              * Fire onchange handler after a timeout, so the
848              * $scope.selected value has a chance to propagate to
849              * the page controllers before the onchange fires.  This
850              * way, the caller does not have to manually capture the
851              * $scope.selected value during onchange.
852              */
853             function fire_orgsel_onchange() {
854                 if (!$scope.selected || !$scope.onchange) return;
855                 $timeout(function() {
856                     console.debug(
857                         'egOrgSelector onchange('+$scope.selected.id()+')');
858                     $scope.onchange($scope.selected)
859                 });
860             }
861
862             $scope.getSelectedName = function() {
863                 if ($scope.selected && $scope.selected.shortname)
864                     return $scope.selected.shortname();
865                 return $scope.label;
866             }
867
868             $scope.orgChanged = function(org) {
869                 $scope.selected = egCore.org.get(org.id);
870                 if ($scope.stickySetting) {
871                     egCore.hatch.setLocalItem($scope.stickySetting, org.id);
872                 }
873                 fire_orgsel_onchange();
874             }
875
876         }],
877         link : function(scope, element, attrs, egGridCtrl) {
878
879             // boolean fields are presented as value-less attributes
880             angular.forEach(
881                 ['nodefault'],
882                 function(field) {
883                     if (angular.isDefined(attrs[field]))
884                         scope[field] = true;
885                     else
886                         scope[field] = false;
887                 }
888             );
889         }
890     }
891 })
892
893 .directive('nextOnEnter', function () {
894     return function (scope, element, attrs) {
895         element.bind("keydown keypress", function (event) {
896             if(event.which === 13) {
897                 $('#'+attrs.nextOnEnter).focus();
898                 event.preventDefault();
899             }
900         });
901     };
902 })
903
904 /* http://eric.sau.pe/angularjs-detect-enter-key-ngenter/ */
905 .directive('egEnter', function () {
906     return function (scope, element, attrs) {
907         element.bind("keydown keypress", function (event) {
908             if(event.which === 13) {
909                 scope.$apply(function (){
910                     scope.$eval(attrs.egEnter);
911                 });
912  
913                 event.preventDefault();
914             }
915         });
916     };
917 })
918
919 /*
920 * Handy wrapper directive for uib-datapicker-popup
921 */
922 .directive(
923     'egDateInput', ['egStrings', 'egCore',
924     function(egStrings, egCore) {
925         return {
926             scope : {
927                 id : '@',
928                 closeText : '@',
929                 ngModel : '=',
930                 ngChange : '=',
931                 ngBlur : '=',
932                 minDate : '=?',
933                 maxDate : '=?',
934                 ngDisabled : '=',
935                 ngRequired : '=',
936                 hideDatePicker : '=',
937                 dateFormat : '=?',
938                 outOfRange : '=?',
939                 focusMe : '=?'
940             },
941             require: 'ngModel',
942             templateUrl: './share/t_datetime',
943             replace: true,
944             controller : ['$scope', function($scope) {
945                 $scope.options = {
946                     minDate : $scope.minDate,
947                     maxDate : $scope.maxDate
948                 };
949
950                 var maxDateObj = $scope.maxDate ? new Date($scope.maxDate) : null;
951                 var minDateObj = $scope.minDate ? new Date($scope.minDate) : null;
952
953                 if ($scope.outOfRange !== undefined && (maxDateObj || minDateObj)) {
954                     $scope.$watch('ngModel', function (n,o) {
955                         if (n && n != o) {
956                             var bad = false;
957                             var newdate = new Date(n);
958                             if (maxDateObj && newdate.getTime() > maxDateObj.getTime()) bad = true;
959                             if (minDateObj && newdate.getTime() < minDateObj.getTime()) bad = true;
960                             $scope.outOfRange = bad;
961                         }
962                     });
963                 }
964             }],
965             link : function(scope, elm, attrs) {
966                 if (!scope.closeText)
967                     scope.closeText = egStrings.EG_DATE_INPUT_CLOSE_TEXT;
968
969                 if ('showTimePicker' in attrs)
970                     scope.showTimePicker = true;
971
972                 var default_format = 'mediumDate';
973                 egCore.org.settings(['format.date']).then(function(set) {
974                     default_format = set['format.date'];
975                     scope.date_format = (scope.dateFormat) ?
976                         scope.dateFormat :
977                         default_format;
978                 });
979             }
980         };
981     }
982 ])
983
984 /*
985  *  egFmValueSelector - widget for selecting a value from list specified
986  *                      by IDL class
987  */
988 .directive('egFmValueSelector', function() {
989     return {
990         restrict : 'E',
991         transclude : true,
992         scope : {
993             idlClass : '@',
994             ngModel : '=',
995
996             // optional filter for refining the set of rows that
997             // get returned. Example:
998             //
999             // filter="{'column':{'=':null}}"
1000             filter : '=',
1001
1002             // optional name of settings key for persisting
1003             // the last selected value
1004             stickySetting : '@',
1005
1006             // optional OU setting for fetching default value;
1007             // used only if sticky setting not set
1008             ouSetting : '@'
1009         },
1010         require: 'ngModel',
1011         templateUrl : './share/t_fm_value_selector',
1012         controller : ['$scope','egCore', function($scope , egCore) {
1013
1014             $scope.org = egCore.org; // for use in the link function
1015             $scope.auth = egCore.auth; // for use in the link function
1016             $scope.hatch = egCore.hatch // for use in the link function
1017
1018             function flatten_linked_values(cls, list) {
1019                 var results = [];
1020                 var fields = egCore.idl.classes[cls].fields;
1021                 var id_field;
1022                 var selector;
1023                 angular.forEach(fields, function(fld) {
1024                     if (fld.datatype == 'id') {
1025                         id_field = fld.name;
1026                         selector = fld.selector ? fld.selector : id_field;
1027                         return;
1028                     }
1029                 });
1030                 angular.forEach(list, function(item) {
1031                     var rec = egCore.idl.toHash(item);
1032                     results.push({
1033                         id : rec[id_field],
1034                         name : rec[selector]
1035                     });
1036                 });
1037                 return results;
1038             }
1039
1040             var search = {};
1041             search[egCore.idl.classes[$scope.idlClass].pkey] = {'!=' : null};
1042             if ($scope.filter) {
1043                 angular.extend(search, $scope.filter);
1044             }
1045             egCore.pcrud.search(
1046                 $scope.idlClass, search, {}, {atomic : true}
1047             ).then(function(list) {
1048                 $scope.linked_values = flatten_linked_values($scope.idlClass, list);
1049             });
1050
1051             $scope.handleChange = function(value) {
1052                 if ($scope.stickySetting) {
1053                     egCore.hatch.setLocalItem($scope.stickySetting, value);
1054                 }
1055             }
1056
1057         }],
1058         link : function(scope, element, attrs) {
1059             if (scope.stickySetting && (angular.isUndefined(scope.ngModel) || (scope.ngModel === null))) {
1060                 var value = scope.hatch.getLocalItem(scope.stickySetting);
1061                 scope.ngModel = value;
1062             }
1063             if (scope.ouSetting && (angular.isUndefined(scope.ngModel) || (scope.ngModel === null))) {
1064                 scope.org.settings([scope.ouSetting], scope.auth.user().ws_ou())
1065                 .then(function(set) {
1066                     var value = parseInt(set[scope.ouSetting]);
1067                     if (!isNaN(value))
1068                         scope.ngModel = value;
1069                 });
1070             }
1071         }
1072     }
1073 })
1074
1075 /*
1076  *  egShareDepthSelector - widget for selecting a share depth
1077  */
1078 .directive('egShareDepthSelector', function() {
1079     return {
1080         restrict : 'E',
1081         transclude : true,
1082         scope : {
1083             ngModel : '=',
1084         },
1085         require: 'ngModel',
1086         templateUrl : './share/t_share_depth_selector',
1087         controller : ['$scope','egCore', function($scope , egCore) {
1088             $scope.values = [];
1089             egCore.pcrud.search('aout',
1090                 { id : {'!=' : null} },
1091                 { order_by : {aout : ['depth', 'name']} },
1092                 { atomic : true }
1093             ).then(function(list) {
1094                 var scratch = [];
1095                 angular.forEach(list, function(aout) {
1096                     var depth = parseInt(aout.depth());
1097                     if (depth in scratch) {
1098                         scratch[depth].push(aout.name());
1099                     } else {
1100                         scratch[depth] = [ aout.name() ]
1101                     }
1102                 });
1103                 scratch.forEach(function(val, idx) {
1104                     $scope.values.push({ id : idx,  name : scratch[idx].join(' / ') });
1105                 });
1106             });
1107         }]
1108     }
1109 })
1110
1111 /*
1112  * egHelpPopover - a helpful widget
1113  */
1114 .directive('egHelpPopover', function() {
1115     return {
1116         restrict : 'E',
1117         transclude : true,
1118         scope : {
1119             helpText : '@',
1120             helpLink : '@'
1121         },
1122         templateUrl : './share/t_help_popover',
1123         controller : ['$scope','$sce', function($scope , $sce) {
1124             if ($scope.helpLink) {
1125                 $scope.helpHtml = $sce.trustAsHtml(
1126                     '<a target="_new" href="' + $scope.helpLink + '">' +
1127                     $scope.helpText + '</a>'
1128                 );
1129             }
1130         }]
1131     }
1132 })
1133
1134 .factory('egWorkLog', ['egCore', function(egCore) {
1135     var service = {};
1136
1137     service.retrieve_all = function() {
1138         var workLog = egCore.hatch.getLocalItem('eg.work_log') || [];
1139         var patronLog = egCore.hatch.getLocalItem('eg.patron_log') || [];
1140
1141         return { 'work_log' : workLog, 'patron_log' : patronLog };
1142     }
1143
1144     service.record = function(message,data) {
1145         var max_entries;
1146         var max_patrons;
1147         if (typeof egCore != 'undefined') {
1148             if (typeof egCore.env != 'undefined') {
1149                 if (typeof egCore.env.aous != 'undefined') {
1150                     max_entries = egCore.env.aous['ui.admin.work_log.max_entries'];
1151                     max_patrons = egCore.env.aous['ui.admin.patron_log.max_entries'];
1152                 } else {
1153                     console.log('worklog: missing egCore.env.aous');
1154                 }
1155             } else {
1156                 console.log('worklog: missing egCore.env');
1157             }
1158         } else {
1159             console.log('worklog: missing egCore');
1160         }
1161         if (!max_entries) {
1162             if (typeof egCore.org != 'undefined') {
1163                 if (typeof egCore.org.cachedSettings != 'undefined') {
1164                     max_entries = egCore.org.cachedSettings['ui.admin.work_log.max_entries'];
1165                 } else {
1166                     console.log('worklog: missing egCore.org.cachedSettings');
1167                 }
1168             } else {
1169                 console.log('worklog: missing egCore.org');
1170             }
1171         }
1172         if (!max_patrons) {
1173             if (typeof egCore.org != 'undefined') {
1174                 if (typeof egCore.org.cachedSettings != 'undefined') {
1175                     max_patrons = egCore.org.cachedSettings['ui.admin.patron_log.max_entries'];
1176                 } else {
1177                     console.log('worklog: missing egCore.org.cachedSettings');
1178                 }
1179             } else {
1180                 console.log('worklog: missing egCore.org');
1181             }
1182         }
1183         if (!max_entries) {
1184             max_entries = 20;
1185             console.log('worklog: defaulting to max_entries = ' + max_entries);
1186         }
1187         if (!max_patrons) {
1188             max_patrons = 10;
1189             console.log('worklog: defaulting to max_patrons = ' + max_patrons);
1190         }
1191
1192         var workLog = egCore.hatch.getLocalItem('eg.work_log') || [];
1193         var patronLog = egCore.hatch.getLocalItem('eg.patron_log') || [];
1194         var entry = {
1195             'when' : new Date(),
1196             'msg' : message,
1197             'action' : data.action,
1198             'actor' : egCore.auth.user().usrname()
1199         };
1200         if (data.action == 'checkin') {
1201             entry['item'] = data.response.params.copy_barcode;
1202             entry['item_id'] = data.response.data.acp.id();
1203             if (data.response.data.au) {
1204                 entry['user'] = data.response.data.au.family_name();
1205                 entry['patron_id'] = data.response.data.au.id();
1206             }
1207         }
1208         if (data.action == 'checkout') {
1209             entry['item'] = data.response.params.copy_barcode;
1210             entry['user'] = data.response.data.au.family_name();
1211             entry['item_id'] = data.response.data.acp.id();
1212             entry['patron_id'] = data.response.data.au.id();
1213         }
1214         if (data.action == 'noncat_checkout') {
1215             entry['user'] = data.response.data.au.family_name();
1216             entry['patron_id'] = data.response.data.au.id();
1217         }
1218         if (data.action == 'renew') {
1219             entry['item'] = data.response.params.copy_barcode;
1220             entry['user'] = data.response.data.au.family_name();
1221             entry['item_id'] = data.response.data.acp.id();
1222             entry['patron_id'] = data.response.data.au.id();
1223         }
1224         if (data.action == 'requested_hold'
1225             || data.action == 'edited_patron'
1226             || data.action == 'registered_patron'
1227             || data.action == 'paid_bill') {
1228             entry['patron_id'] = data.patron_id;
1229         }
1230         if (data.action == 'requested_hold') {
1231             entry['hold_id'] = data.hold_id;
1232         }
1233         if (data.action == 'paid_bill') {
1234             entry['amount'] = data.total_amount;
1235         }
1236
1237         workLog.push( entry );
1238         if (workLog.length > max_entries) workLog.shift();
1239         egCore.hatch.setLocalItem('eg.work_log',workLog); // hatch JSONifies the data, so should be okay re: memory leaks?
1240
1241         if (entry['patron_id']) {
1242             var temp = [];
1243             for (var i = 0; i < patronLog.length; i++) { // filter out any matching patron
1244                 if (patronLog[i]['patron_id'] != entry['patron_id']) temp.push(patronLog[i]);
1245             }
1246             temp.push( entry );
1247             if (temp.length > max_patrons) temp.shift();
1248             patronLog = temp;
1249             egCore.hatch.setLocalItem('eg.patron_log',patronLog);
1250         }
1251
1252         console.log('worklog',entry);
1253     }
1254
1255     return service;
1256 }]);