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