]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/web/js/ui/default/staff/services/grid.js
LP#1697954: Provide client-side sorting for grids that can use it
[Evergreen.git] / Open-ILS / web / js / ui / default / staff / services / grid.js
1 angular.module('egGridMod', 
2     ['egCoreMod', 'egUiMod', 'ui.bootstrap'])
3
4 .directive('egGrid', function() {
5     return {
6         restrict : 'AE',
7         transclude : true,
8         scope : {
9
10             // IDL class hint (e.g. "aou")
11             idlClass : '@',
12
13             // default page size
14             pageSize : '@',
15
16             // if true, grid columns are derived from all non-virtual
17             // fields on the base idlClass
18             autoFields : '@',
19
20             // grid preferences will be stored / retrieved with this key
21             persistKey : '@',
22
23             // field whose value is unique and may be used for item
24             // reference / lookup.  This will usually be someting like
25             // "id".  This is not needed when using autoFields, since we
26             // can determine the primary key directly from the IDL.
27             idField : '@',
28
29             // Reference to externally provided egGridDataProvider
30             itemsProvider : '=',
31
32             // Reference to externally provided item-selection handler
33             onSelect : '=',
34
35             // Reference to externally provided after-item-selection handler
36             afterSelect : '=',
37
38             // comma-separated list of supported or disabled grid features
39             // supported features:
40             //  startSelected : init the grid with all rows selected by default
41             //  allowAll : add an "All" option to row count (really 10000)
42             //  -menu : don't show any menu buttons (or use space for them)
43             //  -picker : don't show the column picker
44             //  -pagination : don't show any pagination elements, and set
45             //                the limit to 10000
46             //  -actions : don't show the actions dropdown
47             //  -index : don't show the row index column (can't use "index"
48             //           as the idField in this case)
49             //  -display : columns are hidden by default
50             //  -sort    : columns are unsortable by default 
51             //  -multisort : sort priorities config disabled by default
52             //  -multiselect : only one row at a time can be selected;
53             //                 choosing this also disables the checkbox
54             //                 column
55             features : '@',
56
57             // optional primary grid label
58             mainLabel : '@',
59
60             // if true, use the IDL class label as the mainLabel
61             autoLabel : '=', 
62
63             // optional context menu label
64             menuLabel : '@',
65
66             dateformat : '@', // optional: passed down to egGridValueFilter
67
68             // Hash of control functions.
69             //
70             //  These functions are defined by the calling scope and 
71             //  invoked as-is by the grid w/ the specified parameters.
72             //
73             //  collectStarted    : function() {}
74             //  itemRetrieved     : function(item) {}
75             //  allItemsRetrieved : function() {}
76             //
77             //  ---
78             //  If defined, the grid will watch the return value from
79             //  the function defined at watchQuery on each digest and 
80             //  re-draw the grid when query changes occur.
81             //
82             //  watchQuery : function() { /* return grid query */ }
83             //
84             //  ---------------
85             //  These functions are defined by the grid and thus
86             //  replace any values defined for these attributes from the
87             //  calling scope.
88             //
89             //  activateItem  : function(item) {}
90             //  allItems      : function(allItems) {}
91             //  selectedItems : function(selected) {}
92             //  selectItems   : function(ids) {}
93             //  setQuery      : function(queryStruct) {} // causes reload
94             //  setSort       : function(sortSturct) {} // causes reload
95             gridControls : '=',
96         },
97
98         // TODO: avoid hard-coded url
99         templateUrl : '/eg/staff/share/t_autogrid', 
100
101         link : function(scope, element, attrs) {     
102             // link() is called after page compilation, which means our
103             // eg-grid-field's have been parsed and loaded.  Now it's 
104             // safe to perform our initial page load.
105
106             // load auto fields after eg-grid-field's so they are not clobbered
107             scope.handleAutoFields();
108             scope.collect();
109
110             scope.grid_element = element;
111             $(element)
112                 .find('.eg-grid-content-body')
113                 .bind('contextmenu', scope.showActionContextMenu);
114         },
115
116         controller : [
117                     '$scope','$q','egCore','egGridFlatDataProvider','$location',
118                     'egGridColumnsProvider','$filter','$window','$sce','$timeout',
119             function($scope,  $q , egCore,  egGridFlatDataProvider , $location,
120                      egGridColumnsProvider , $filter , $window , $sce , $timeout) {
121
122             var grid = this;
123
124             grid.init = function() {
125                 grid.offset = 0;
126                 $scope.items = [];
127                 $scope.showGridConf = false;
128                 grid.totalCount = -1;
129                 $scope.selected = {};
130                 $scope.actionGroups = [{actions:[]}]; // Grouped actions for selected items
131                 $scope.menuItems = []; // global actions
132
133                 // returns true if any rows are selected.
134                 $scope.hasSelected = function() {
135                     return grid.getSelectedItems().length > 0 };
136
137                 var features = ($scope.features) ? 
138                     $scope.features.split(',') : [];
139                 delete $scope.features;
140
141                 $scope.showIndex = (features.indexOf('-index') == -1);
142
143                 $scope.allowAll = (features.indexOf('allowAll') > -1);
144                 $scope.startSelected = $scope.selectAll = (features.indexOf('startSelected') > -1);
145                 $scope.showActions = (features.indexOf('-actions') == -1);
146                 $scope.showPagination = (features.indexOf('-pagination') == -1);
147                 $scope.showPicker = (features.indexOf('-picker') == -1);
148
149                 $scope.showMenu = (features.indexOf('-menu') == -1);
150
151                 // remove some unneeded values from the scope to reduce bloat
152
153                 grid.idlClass = $scope.idlClass;
154                 delete $scope.idlClass;
155
156                 grid.persistKey = $scope.persistKey;
157                 delete $scope.persistKey;
158
159                 var stored_limit = 0;
160                 if ($scope.showPagination) {
161                     if (grid.persistKey) {
162                         var stored_limit = Number(
163                             egCore.hatch.getLocalItem('eg.grid.' + grid.persistKey + '.limit')
164                         );
165                     }
166                 } else {
167                     stored_limit = 10000; // maybe support "Inf"?
168                 }
169
170                 grid.limit = Number(stored_limit) || Number($scope.pageSize) || 25;
171
172                 grid.indexField = $scope.idField;
173                 delete $scope.idField;
174
175                 grid.dataProvider = $scope.itemsProvider;
176
177                 if (!grid.indexField && grid.idlClass)
178                     grid.indexField = egCore.idl.classes[grid.idlClass].pkey;
179
180                 grid.columnsProvider = egGridColumnsProvider.instance({
181                     idlClass : grid.idlClass,
182                     clientSort : (features.indexOf('clientsort') > -1 && features.indexOf('-clientsort') == -1),
183                     defaultToHidden : (features.indexOf('-display') > -1),
184                     defaultToNoSort : (features.indexOf('-sort') > -1),
185                     defaultToNoMultiSort : (features.indexOf('-multisort') > -1),
186                     defaultDateFormat : $scope.dateformat
187                 });
188                 $scope.canMultiSelect = (features.indexOf('-multiselect') == -1);
189
190                 $scope.handleAutoFields = function() {
191                     if ($scope.autoFields) {
192                         if (grid.autoLabel) {
193                             $scope.mainLabel = 
194                                 egCore.idl.classes[grid.idlClass].label;
195                         }
196                         grid.columnsProvider.compileAutoColumns();
197                         delete $scope.autoFields;
198                     }
199                 }
200    
201                 if (!grid.dataProvider) {
202                     // no provider, um, provided.
203                     // Use a flat data provider
204
205                     grid.selfManagedData = true;
206                     grid.dataProvider = egGridFlatDataProvider.instance({
207                         indexField : grid.indexField,
208                         idlClass : grid.idlClass,
209                         columnsProvider : grid.columnsProvider,
210                         query : $scope.query
211                     });
212                 }
213
214                 grid.dataProvider.columnsProvider = grid.columnsProvider;
215
216                 $scope.itemFieldValue = grid.dataProvider.itemFieldValue;
217                 $scope.indexValue = function(item) {
218                     return grid.indexValue(item)
219                 };
220
221                 grid.applyControlFunctions();
222
223                 grid.loadConfig().then(function() { 
224                     // link columns to scope after loadConfig(), since it
225                     // replaces the columns array.
226                     $scope.columns = grid.columnsProvider.columns;
227                 });
228
229                 // NOTE: grid.collect() is first called from link(), not here.
230             }
231
232             // link our control functions into the gridControls 
233             // scope object so the caller can access them.
234             grid.applyControlFunctions = function() {
235
236                 // we use some of these controls internally, so sett
237                 // them up even if the caller doesn't request them.
238                 var controls = $scope.gridControls || {};
239
240                 controls.columnMap = function() {
241                     var m = {};
242                     angular.forEach(grid.columnsProvider.columns, function (c) {
243                         m[c.name] = c;
244                     });
245                     return m;
246                 }
247
248                 controls.columnsProvider = function() {
249                     return grid.columnsProvider;
250                 }
251
252                 // link in the control functions
253                 controls.selectedItems = function() {
254                     return grid.getSelectedItems()
255                 }
256
257                 controls.allItems = function() {
258                     return $scope.items;
259                 }
260
261                 controls.selectItems = function(ids) {
262                     if (!ids) return;
263                     $scope.selected = {};
264                     angular.forEach(ids, function(i) {
265                         $scope.selected[''+i] = true;
266                     });
267                 }
268
269                 // if the caller provided a functional setQuery,
270                 // extract the value before replacing it
271                 if (controls.setQuery) {
272                     grid.dataProvider.query = 
273                         controls.setQuery();
274                 }
275
276                 controls.setQuery = function(query) {
277                     grid.dataProvider.query = query;
278                     controls.refresh();
279                 }
280
281                 if (controls.watchQuery) {
282                     // capture the initial query value
283                     grid.dataProvider.query = controls.watchQuery();
284
285                     // watch for changes
286                     $scope.gridWatchQuery = controls.watchQuery;
287                     $scope.$watch('gridWatchQuery()', function(newv) {
288                         controls.setQuery(newv);
289                     }, true);
290                 }
291
292                 // if the caller provided a functional setSort
293                 // extract the value before replacing it
294                 grid.dataProvider.sort = 
295                     controls.setSort ?  controls.setSort() : [];
296
297                 controls.setSort = function(sort) {
298                     controls.refresh();
299                 }
300
301                 controls.refresh = function(noReset) {
302                     if (!noReset) grid.offset = 0;
303                     grid.collect();
304                 }
305
306                 controls.setLimit = function(limit,forget) {
307                     if (!forget && grid.persistKey)
308                         egCore.hatch.setLocalItem('eg.grid.' + grid.persistKey + '.limit', limit);
309                     grid.limit = limit;
310                 }
311                 controls.getLimit = function() {
312                     return grid.limit;
313                 }
314                 controls.setOffset = function(offset) {
315                     grid.offset = offset;
316                 }
317                 controls.getOffset = function() {
318                     return grid.offset;
319                 }
320
321                 controls.saveConfig = function () {
322                     return $scope.saveConfig();
323                 }
324
325                 grid.dataProvider.refresh = controls.refresh;
326                 grid.controls = controls;
327             }
328
329             // If a menu item provides its own HTML template, translate it,
330             // using the menu item for the template scope.
331             // note: $sce is required to avoid security restrictions and
332             // is OK here, since the template comes directly from a
333             // local HTML template (not user input).
334             $scope.translateMenuItemTemplate = function(item) {
335                 var html = egCore.strings.$replace(item.template, {item : item});
336                 return $sce.trustAsHtml(html);
337             }
338
339             // add a new (global) grid menu item
340             grid.addMenuItem = function(item) {
341                 $scope.menuItems.push(item);
342                 var handler = item.handler;
343                 item.handler = function() {
344                     $scope.gridMenuIsOpen = false; // close menu
345                     if (handler) {
346                         handler(item, 
347                             item.handlerData, grid.getSelectedItems());
348                     }
349                 }
350             }
351
352             // add a selected-items action
353             grid.addAction = function(act) {
354                 var done = false;
355                 $scope.actionGroups.forEach(function(g){
356                     if (g.label === act.group) {
357                         g.actions.push(act);
358                         done = true;
359                     }
360                 });
361                 if (!done) {
362                     $scope.actionGroups.push({
363                         label : act.group,
364                         actions : [ act ]
365                     });
366                 }
367             }
368
369             // remove the stored column configuration preferenc, then recover 
370             // the column visibility information from the initial page load.
371             $scope.resetColumns = function() {
372                 $scope.gridColumnPickerIsOpen = false;
373                 egCore.hatch.removeItem('eg.grid.' + grid.persistKey)
374                 .then(function() {
375                     grid.columnsProvider.reset(); 
376                     if (grid.selfManagedData) grid.collect();
377                 });
378             }
379
380             $scope.showAllColumns = function() {
381                 $scope.gridColumnPickerIsOpen = false;
382                 grid.columnsProvider.showAllColumns();
383                 if (grid.selfManagedData) grid.collect();
384             }
385
386             $scope.hideAllColumns = function() {
387                 $scope.gridColumnPickerIsOpen = false;
388                 grid.columnsProvider.hideAllColumns();
389                 // note: no need to fetch new data if no columns are visible
390             }
391
392             $scope.toggleColumnVisibility = function(col) {
393                 $scope.gridColumnPickerIsOpen = false;
394                 col.visible = !col.visible;
395
396                 // egGridFlatDataProvider only retrieves data to be
397                 // displayed.  When column visibility changes, it's
398                 // necessary to fetch the newly visible column data.
399                 if (grid.selfManagedData) grid.collect();
400             }
401
402             // save the columns configuration (position, sort, width) to
403             // eg.grid.<persist-key>
404             $scope.saveConfig = function() {
405                 $scope.gridColumnPickerIsOpen = false;
406
407                 if (!grid.persistKey) {
408                     console.warn(
409                         "Cannot save settings without a grid persist-key");
410                     return;
411                 }
412
413                 // only store information about visible columns.
414                 var conf = grid.columnsProvider.columns.filter(
415                     function(col) {return Boolean(col.visible) });
416
417                 // now scrunch the data down to just the needed info
418                 conf = conf.map(function(col) {
419                     var c = {name : col.name}
420                     // Apart from the name, only store non-default values.
421                     // No need to store col.visible, since that's implicit
422                     if (col.align != 'left') c.align = col.align;
423                     if (col.flex != 2) c.flex = col.flex;
424                     if (Number(col.sort)) c.sort = Number(c.sort);
425                     return c;
426                 });
427
428                 egCore.hatch.setItem('eg.grid.' + grid.persistKey, conf)
429                 .then(function() { 
430                     // Save operation performed from the grid configuration UI.
431                     // Hide the configuration UI and re-draw w/ sort applied
432                     if ($scope.showGridConf) 
433                         $scope.toggleConfDisplay();
434                 });
435             }
436
437
438             // load the columns configuration (position, sort, width) from
439             // eg.grid.<persist-key> and apply the loaded settings to the
440             // columns on our columnsProvider
441             grid.loadConfig = function() {
442                 if (!grid.persistKey) return $q.when();
443
444                 return egCore.hatch.getItem('eg.grid.' + grid.persistKey)
445                 .then(function(conf) {
446                     if (!conf) return;
447
448                     var columns = grid.columnsProvider.columns;
449                     var new_cols = [];
450
451                     angular.forEach(conf, function(col) {
452                         var grid_col = columns.filter(
453                             function(c) {return c.name == col.name})[0];
454
455                         if (!grid_col) {
456                             // saved column does not match a column in the 
457                             // current grid.  skip it.
458                             return;
459                         }
460
461                         grid_col.align = col.align || 'left';
462                         grid_col.flex = col.flex || 2;
463                         grid_col.sort = col.sort || 0;
464                         // all saved columns are assumed to be true
465                         grid_col.visible = true;
466                         if (new_cols
467                                 .filter(function (c) {
468                                     return c.name == grid_col.name;
469                                 }).length == 0
470                         )
471                             new_cols.push(grid_col);
472                     });
473
474                     // columns which are not expressed within the saved 
475                     // configuration are marked as non-visible and 
476                     // appended to the end of the new list of columns.
477                     angular.forEach(columns, function(col) {
478                         var found = conf.filter(
479                             function(c) {return (c.name == col.name)})[0];
480                         if (!found) {
481                             col.visible = false;
482                             new_cols.push(col);
483                         }
484                     });
485
486                     grid.columnsProvider.columns = new_cols;
487                     grid.compileSort();
488
489                 });
490             }
491
492             $scope.onContextMenu = function($event) {
493                 var col = angular.element($event.target).attr('column');
494                 console.log('selected column ' + col);
495             }
496
497             $scope.page = function() {
498                 return (grid.offset / grid.limit) + 1;
499             }
500
501             $scope.goToPage = function(page) {
502                 page = Number(page);
503                 if (angular.isNumber(page) && page > 0) {
504                     grid.offset = (page - 1) * grid.limit;
505                     grid.collect();
506                 }
507             }
508
509             $scope.offset = function(o) {
510                 if (angular.isNumber(o))
511                     grid.offset = o;
512                 return grid.offset 
513             }
514
515             $scope.limit = function(l) { 
516                 if (angular.isNumber(l)) {
517                     if (grid.persistKey)
518                         egCore.hatch.setLocalItem('eg.grid.' + grid.persistKey + '.limit', l);
519                     grid.limit = l;
520                 }
521                 return grid.limit 
522             }
523
524             $scope.onFirstPage = function() {
525                 return grid.offset == 0;
526             }
527
528             $scope.hasNextPage = function() {
529                 // we have less data than requested, there must
530                 // not be any more pages
531                 if (grid.count() < grid.limit) return false;
532
533                 // if the total count is not known, assume that a full
534                 // page of data implies more pages are available.
535                 if (grid.totalCount == -1) return true;
536
537                 // we have a full page of data, but is there more?
538                 return grid.totalCount > (grid.offset + grid.count());
539             }
540
541             $scope.incrementPage = function() {
542                 grid.offset += grid.limit;
543                 grid.collect();
544             }
545
546             $scope.decrementPage = function() {
547                 if (grid.offset < grid.limit) {
548                     grid.offset = 0;
549                 } else {
550                     grid.offset -= grid.limit;
551                 }
552                 grid.collect();
553             }
554
555             // number of items loaded for the current page of results
556             grid.count = function() {
557                 return $scope.items.length;
558             }
559
560             // returns the unique identifier value for the provided item
561             // for internal consistency, indexValue is always coerced 
562             // into a string.
563             grid.indexValue = function(item) {
564                 if (angular.isObject(item)) {
565                     if (item !== null) {
566                         if (angular.isFunction(item[grid.indexField]))
567                             return ''+item[grid.indexField]();
568                         return ''+item[grid.indexField]; // flat data
569                     }
570                 }
571                 // passed a non-object; assume it's an index
572                 return ''+item; 
573             }
574
575             // fires the hide handler function for a context action
576             $scope.actionHide = function(action) {
577                 if (typeof action.hide == 'undefined') {
578                     return false;
579                 }
580                 if (angular.isFunction(action.hide))
581                     return action.hide(action);
582                 return action.hide;
583             }
584
585             // fires the disable handler function for a context action
586             $scope.actionDisable = function(action) {
587                 if (typeof action.disabled == 'undefined') {
588                     return false;
589                 }
590                 if (angular.isFunction(action.disabled))
591                     return action.disabled(action);
592                 return action.disabled;
593             }
594
595             // fires the action handler function for a context action
596             $scope.actionLauncher = function(action) {
597                 if (!action.handler) {
598                     console.error(
599                         'No handler specified for "' + action.label + '"');
600                 } else {
601
602                     try {
603                         action.handler(grid.getSelectedItems());
604                     } catch(E) {
605                         console.error('Error executing handler for "' 
606                             + action.label + '" => ' + E + "\n" + E.stack);
607                     }
608
609                     if ($scope.action_context_showing) $scope.hideActionContextMenu();
610                 }
611
612             }
613
614             $scope.hideActionContextMenu = function () {
615                 $($scope.menu_dom).css({
616                     display: '',
617                     width: $scope.action_context_width,
618                     top: $scope.action_context_y,
619                     left: $scope.action_context_x
620                 });
621                 $($scope.action_context_parent).append($scope.menu_dom);
622                 $scope.action_context_oldy = $scope.action_context_oldx = 0;
623                 $('body').unbind('click.remove_context_menu_'+$scope.action_context_index);
624                 $scope.action_context_showing = false;
625             }
626
627             $scope.action_context_showing = false;
628             $scope.showActionContextMenu = function ($event) {
629
630                 // Have to gather these here, instead of inside link()
631                 if (!$scope.menu_dom) $scope.menu_dom = $($scope.grid_element).find('.grid-action-dropdown')[0];
632                 if (!$scope.action_context_parent) $scope.action_context_parent = $($scope.menu_dom).parent();
633
634                 if (!grid.getSelectedItems().length) // Nothing selected, fire the click event
635                     $event.target.click();
636
637                 if (!$scope.action_context_showing) {
638                     $scope.action_context_width = $($scope.menu_dom).css('width');
639                     $scope.action_context_y = $($scope.menu_dom).css('top');
640                     $scope.action_context_x = $($scope.menu_dom).css('left');
641                     $scope.action_context_showing = true;
642                     $scope.action_context_index = Math.floor((Math.random() * 1000) + 1);
643
644                     $('body').append($($scope.menu_dom));
645                     $('body').bind('click.remove_context_menu_'+$scope.action_context_index, $scope.hideActionContextMenu);
646                 }
647
648                 $($scope.menu_dom).css({
649                     display: 'block',
650                     width: $scope.action_context_width,
651                     top: $event.pageY,
652                     left: $event.pageX
653                 });
654
655                 return false;
656             }
657
658             // returns the list of selected item objects
659             grid.getSelectedItems = function() {
660                 return $scope.items.filter(
661                     function(item) {
662                         return Boolean($scope.selected[grid.indexValue(item)]);
663                     }
664                 );
665             }
666
667             grid.getItemByIndex = function(index) {
668                 for (var i = 0; i < $scope.items.length; i++) {
669                     var item = $scope.items[i];
670                     if (grid.indexValue(item) == index) 
671                         return item;
672                 }
673             }
674
675             // selects one row after deselecting all of the others
676             grid.selectOneItem = function(index) {
677                 $scope.selected = {};
678                 $scope.selected[index] = true;
679             }
680
681             // selects or deselects an item, without affecting the others.
682             // returns true if the item is selected; false if de-selected.
683             // we overwrite the object so that we can watch $scope.selected
684             grid.toggleSelectOneItem = function(index) {
685                 if ($scope.selected[index]) {
686                     delete $scope.selected[index];
687                     $scope.selected = angular.copy($scope.selected);
688                     return false;
689                 } else {
690                     $scope.selected[index] = true;
691                     $scope.selected = angular.copy($scope.selected);
692                     return true;
693                 }
694             }
695
696             $scope.updateSelected = function () { 
697                     return $scope.selected = angular.copy($scope.selected);
698             };
699
700             grid.selectAllItems = function() {
701                 angular.forEach($scope.items, function(item) {
702                     $scope.selected[grid.indexValue(item)] = true
703                 }); 
704                 $scope.selected = angular.copy($scope.selected);
705             }
706
707             $scope.$watch('selectAll', function(newVal) {
708                 if (newVal) {
709                     grid.selectAllItems();
710                 } else {
711                     $scope.selected = {};
712                 }
713             });
714
715             if ($scope.onSelect) {
716                 $scope.$watch('selected', function(newVal) {
717                     $scope.onSelect(grid.getSelectedItems());
718                     if ($scope.afterSelect) $scope.afterSelect();
719                 });
720             }
721
722             // returns true if item1 appears in the list before item2;
723             // false otherwise.  this is slightly more efficient that
724             // finding the position of each then comparing them.
725             // item1 / item2 may be an item or an item index
726             grid.itemComesBefore = function(itemOrIndex1, itemOrIndex2) {
727                 var idx1 = grid.indexValue(itemOrIndex1);
728                 var idx2 = grid.indexValue(itemOrIndex2);
729
730                 // use for() for early exit
731                 for (var i = 0; i < $scope.items.length; i++) {
732                     var idx = grid.indexValue($scope.items[i]);
733                     if (idx == idx1) return true;
734                     if (idx == idx2) return false;
735                 }
736                 return false;
737             }
738
739             // 0-based position of item in the current data set
740             grid.indexOf = function(item) {
741                 var idx = grid.indexValue(item);
742                 for (var i = 0; i < $scope.items.length; i++) {
743                     if (grid.indexValue($scope.items[i]) == idx)
744                         return i;
745                 }
746                 return -1;
747             }
748
749             grid.modifyColumnFlex = function(column, val) {
750                 column.flex += val;
751                 // prevent flex:0;  use hiding instead
752                 if (column.flex < 1)
753                     column.flex = 1;
754             }
755             $scope.modifyColumnFlex = function(col, val) {
756                 $scope.lastModColumn = col.name;
757                 grid.modifyColumnFlex(col, val);
758             }
759
760             grid.modifyColumnPos = function(col, diff) {
761                 var srcIdx, targetIdx;
762                 angular.forEach(grid.columnsProvider.columns,
763                     function(c, i) { if (c.name == col.name) srcIdx = i });
764
765                 targetIdx = srcIdx + diff;
766                 if (targetIdx < 0) {
767                     targetIdx = 0;
768                 } else if (targetIdx >= grid.columnsProvider.columns.length) {
769                     // Target index follows the last visible column.
770                     var lastVisible = 0;
771                     angular.forEach(grid.columnsProvider.columns, 
772                         function(column, idx) {
773                             if (column.visible) lastVisible = idx;
774                         }
775                     );
776                     targetIdx = lastVisible + 1;
777                 }
778
779                 // Splice column out of old position, insert at new position.
780                 grid.columnsProvider.columns.splice(srcIdx, 1);
781                 grid.columnsProvider.columns.splice(targetIdx, 0, col);
782             }
783
784             $scope.modifyColumnPos = function(col, diff) {
785                 $scope.lastModColumn = col.name;
786                 return grid.modifyColumnPos(col, diff);
787             }
788
789
790             // handles click, control-click, and shift-click
791             $scope.handleRowClick = function($event, item) {
792                 var index = grid.indexValue(item);
793
794                 var origSelected = Object.keys($scope.selected);
795
796                 if (!$scope.canMultiSelect) {
797                     grid.selectOneItem(index);
798                     grid.lastSelectedItemIndex = index;
799                     return;
800                 }
801
802                 if ($event.ctrlKey || $event.metaKey /* mac command */) {
803                     // control-click
804                     if (grid.toggleSelectOneItem(index)) 
805                         grid.lastSelectedItemIndex = index;
806
807                 } else if ($event.shiftKey) { 
808                     // shift-click
809
810                     if (!grid.lastSelectedItemIndex || 
811                             index == grid.lastSelectedItemIndex) {
812                         grid.selectOneItem(index);
813                         grid.lastSelectedItemIndex = index;
814
815                     } else {
816
817                         var selecting = false;
818                         var ascending = grid.itemComesBefore(
819                             grid.lastSelectedItemIndex, item);
820                         var startPos = 
821                             grid.indexOf(grid.lastSelectedItemIndex);
822
823                         // update to new last-selected
824                         grid.lastSelectedItemIndex = index;
825
826                         // select each row between the last selected and 
827                         // currently selected items
828                         while (true) {
829                             startPos += ascending ? 1 : -1;
830                             var curItem = $scope.items[startPos];
831                             if (!curItem) break;
832                             var curIdx = grid.indexValue(curItem);
833                             $scope.selected[curIdx] = true;
834                             if (curIdx == index) break; // all done
835                         }
836                         $scope.selected = angular.copy($scope.selected);
837                     }
838                         
839                 } else {
840                     grid.selectOneItem(index);
841                     grid.lastSelectedItemIndex = index;
842                 }
843             }
844
845             // Builds a sort expression from column sort priorities.
846             // called on page load and any time the priorities are modified.
847             grid.compileSort = function() {
848                 var sortList = grid.columnsProvider.columns.filter(
849                     function(col) { return Number(col.sort) != 0 }
850                 ).sort( 
851                     function(a, b) { 
852                         if (Math.abs(a.sort) < Math.abs(b.sort))
853                             return -1;
854                         return 1;
855                     }
856                 );
857
858                 if (sortList.length) {
859                     grid.dataProvider.sort = sortList.map(function(col) {
860                         var blob = {};
861                         blob[col.name] = col.sort < 0 ? 'desc' : 'asc';
862                         return blob;
863                     });
864                 }
865             }
866
867             // builds a sort expression using a single column, 
868             // toggling between ascending and descending sort.
869             $scope.quickSort = function(col_name) {
870                 var sort = grid.dataProvider.sort;
871                 if (sort && sort.length &&
872                     sort[0] == col_name) {
873                     var blob = {};
874                     blob[col_name] = 'desc';
875                     grid.dataProvider.sort = [blob];
876                 } else {
877                     grid.dataProvider.sort = [col_name];
878                 }
879
880                 grid.offset = 0;
881                 grid.collect();
882             }
883
884             // show / hide the grid configuration row
885             $scope.toggleConfDisplay = function() {
886                 if ($scope.showGridConf) {
887                     $scope.showGridConf = false;
888                     if (grid.columnsProvider.hasSortableColumn()) {
889                         // only refresh the grid if the user has the
890                         // ability to modify the sort priorities.
891                         grid.compileSort();
892                         grid.offset = 0;
893                         grid.collect();
894                     }
895                 } else {
896                     $scope.showGridConf = true;
897                 }
898
899                 delete $scope.lastModColumn;
900                 $scope.gridColumnPickerIsOpen = false;
901             }
902
903             // called when a dragged column is dropped onto itself
904             // or any other column
905             grid.onColumnDrop = function(target) {
906                 if (angular.isUndefined(target)) return;
907                 if (target == grid.dragColumn) return;
908                 var srcIdx, targetIdx, srcCol;
909                 angular.forEach(grid.columnsProvider.columns,
910                     function(col, idx) {
911                         if (col.name == grid.dragColumn) {
912                             srcIdx = idx;
913                             srcCol = col;
914                         } else if (col.name == target) {
915                             targetIdx = idx;
916                         }
917                     }
918                 );
919
920                 if (srcIdx < targetIdx) targetIdx--;
921
922                 // move src column from old location to new location in 
923                 // the columns array, then force a page refresh
924                 grid.columnsProvider.columns.splice(srcIdx, 1);
925                 grid.columnsProvider.columns.splice(targetIdx, 0, srcCol);
926                 $scope.$apply(); 
927             }
928
929             // prepares a string for inclusion within a CSV document
930             // by escaping commas and quotes and removing newlines.
931             grid.csvDatum = function(str) {
932                 str = ''+str;
933                 if (!str) return '';
934                 str = str.replace(/\n/g, '');
935                 if (str.match(/\,/) || str.match(/"/)) {                                     
936                     str = str.replace(/"/g, '""');
937                     str = '"' + str + '"';                                           
938                 } 
939                 return str;
940             }
941
942             // sets the download file name and inserts the current CSV
943             // into a Blob URL for browser download.
944             $scope.generateCSVExportURL = function() {
945                 $scope.gridColumnPickerIsOpen = false;
946
947                 // let the file name describe the grid
948                 $scope.csvExportFileName = 
949                     ($scope.mainLabel || grid.persistKey || 'eg_grid_data')
950                     .replace(/\s+/g, '_') + '_' + $scope.page();
951
952                 // toss the CSV into a Blob and update the export URL
953                 var csv = grid.generateCSV();
954                 var blob = new Blob([csv], {type : 'text/plain'});
955                 $scope.csvExportURL = 
956                     ($window.URL || $window.webkitURL).createObjectURL(blob);
957             }
958
959             $scope.printCSV = function() {
960                 $scope.gridColumnPickerIsOpen = false;
961                 egCore.print.print({
962                     context : 'default', 
963                     content : grid.generateCSV(),
964                     content_type : 'text/plain'
965                 });
966             }
967
968             // generates CSV for the currently visible grid contents
969             grid.generateCSV = function() {
970                 var csvStr = '';
971                 var colCount = grid.columnsProvider.columns.length;
972
973                 // columns
974                 angular.forEach(grid.columnsProvider.columns,
975                     function(col) {
976                         if (!col.visible) return;
977                         csvStr += grid.csvDatum(col.label);
978                         csvStr += ',';
979                     }
980                 );
981
982                 csvStr = csvStr.replace(/,$/,'\n');
983
984                 // items
985                 angular.forEach($scope.items, function(item) {
986                     angular.forEach(grid.columnsProvider.columns, 
987                         function(col) {
988                             if (!col.visible) return;
989                             // bare value
990                             var val = grid.dataProvider.itemFieldValue(item, col);
991                             // filtered value (dates, etc.)
992                             val = $filter('egGridValueFilter')(val, col);
993                             csvStr += grid.csvDatum(val);
994                             csvStr += ',';
995                         }
996                     );
997                     csvStr = csvStr.replace(/,$/,'\n');
998                 });
999
1000                 return csvStr;
1001             }
1002
1003             // Interpolate the value for column.linkpath within the context
1004             // of the row item to generate the final link URL.
1005             $scope.generateLinkPath = function(col, item) {
1006                 return egCore.strings.$replace(col.linkpath, {item : item});
1007             }
1008
1009             // If a column provides its own HTML template, translate it,
1010             // using the current item for the template scope.
1011             // note: $sce is required to avoid security restrictions and
1012             // is OK here, since the template comes directly from a
1013             // local HTML template (not user input).
1014             $scope.translateCellTemplate = function(col, item) {
1015                 var html = egCore.strings.$replace(col.template, {item : item});
1016                 return $sce.trustAsHtml(html);
1017             }
1018
1019             $scope.collect = function() { grid.collect() }
1020
1021             // asks the dataProvider for a page of data
1022             grid.collect = function() {
1023
1024                 // avoid firing the collect if there is nothing to collect.
1025                 if (grid.selfManagedData && !grid.dataProvider.query) return;
1026
1027                 if (grid.collecting) return; // avoid parallel collect()
1028                 grid.collecting = true;
1029
1030                 console.debug('egGrid.collect() offset=' 
1031                     + grid.offset + '; limit=' + grid.limit);
1032
1033                 // ensure all of our dropdowns are closed
1034                 // TODO: git rid of these and just use dropdown-toggle, 
1035                 // which is more reliable.
1036                 $scope.gridColumnPickerIsOpen = false;
1037                 $scope.gridRowCountIsOpen = false;
1038                 $scope.gridPageSelectIsOpen = false;
1039
1040                 $scope.items = [];
1041                 $scope.selected = {};
1042
1043                 // Inform the caller we've asked the data provider
1044                 // for data.  This is useful for knowing when collection
1045                 // has started (e.g. to display a progress dialg) when 
1046                 // using the stock (flattener) data provider, where the 
1047                 // user is not directly defining a get() handler.
1048                 if (grid.controls.collectStarted)
1049                     grid.controls.collectStarted(grid.offset, grid.limit);
1050
1051                 grid.dataProvider.get(grid.offset, grid.limit).then(
1052                 function() {
1053                     if (grid.controls.allItemsRetrieved)
1054                         grid.controls.allItemsRetrieved();
1055                 },
1056                 null, 
1057                 function(item) {
1058                     if (item) {
1059                         $scope.items.push(item)
1060                         if (grid.controls.itemRetrieved)
1061                             grid.controls.itemRetrieved(item);
1062                         if ($scope.selectAll)
1063                             $scope.selected[grid.indexValue(item)] = true
1064                     }
1065                 }).finally(function() { 
1066                     console.debug('egGrid.collect() complete');
1067                     grid.collecting = false 
1068                     $scope.selected = angular.copy($scope.selected);
1069                 });
1070             }
1071
1072             grid.init();
1073         }]
1074     };
1075 })
1076
1077 /**
1078  * eg-grid-field : used for collecting custom field data from the templates.
1079  * This directive does not direct display, it just passes data up to the 
1080  * parent grid.
1081  */
1082 .directive('egGridField', function() {
1083     return {
1084         require : '^egGrid',
1085         restrict : 'AE',
1086         scope : {
1087             flesher: '=', // optional; function that can flesh a linked field, given the value
1088             comparator: '=', // optional; function that can sort the thing at the end of 'path' 
1089             name  : '@', // required; unique name
1090             path  : '@', // optional; flesh path
1091             ignore: '@', // optional; fields to ignore when path is a wildcard
1092             label : '@', // optional; display label
1093             flex  : '@',  // optional; default flex width
1094             align  : '@',  // optional; default alignment, left/center/right
1095             dateformat : '@', // optional: passed down to egGridValueFilter
1096
1097             // if a field is part of an IDL object, but we are unable to
1098             // determine the class, because it's nested within a hash
1099             // (i.e. we can't navigate directly to the object via the IDL),
1100             // idlClass lets us specify the class.  This is particularly
1101             // useful for nested wildcard fields.
1102             parentIdlClass : '@', 
1103
1104             // optional: for non-IDL columns, specifying a datatype
1105             // lets the caller control which display filter is used.
1106             // datatype should match the standard IDL datatypes.
1107             datatype : '@'
1108         },
1109         link : function(scope, element, attrs, egGridCtrl) {
1110
1111             // boolean fields are presented as value-less attributes
1112             angular.forEach(
1113                 [
1114                     'visible', 
1115                     'hidden', 
1116                     'sortable', 
1117                     'nonsortable',
1118                     'multisortable',
1119                     'nonmultisortable',
1120                     'required' // if set, always fetch data for this column
1121                 ],
1122                 function(field) {
1123                     if (angular.isDefined(attrs[field]))
1124                         scope[field] = true;
1125                 }
1126             );
1127
1128             // any HTML content within the field is its custom template
1129             var tmpl = element.html();
1130             if (tmpl && !tmpl.match(/^\s*$/))
1131                 scope.template = tmpl
1132
1133             egGridCtrl.columnsProvider.add(scope);
1134             scope.$destroy();
1135         }
1136     };
1137 })
1138
1139 /**
1140  * eg-grid-action : used for specifying actions which may be applied
1141  * to items within the grid.
1142  */
1143 .directive('egGridAction', function() {
1144     return {
1145         require : '^egGrid',
1146         restrict : 'AE',
1147         transclude : true,
1148         scope : {
1149             group   : '@', // Action group, ungrouped if not set
1150             label   : '@', // Action label
1151             handler : '=',  // Action function handler
1152             hide    : '=',
1153             disabled : '=', // function
1154             divider : '='
1155         },
1156         link : function(scope, element, attrs, egGridCtrl) {
1157             egGridCtrl.addAction({
1158                 hide  : scope.hide,
1159                 group : scope.group,
1160                 label : scope.label,
1161                 divider : scope.divider,
1162                 handler : scope.handler,
1163                 disabled : scope.disabled,
1164             });
1165             scope.$destroy();
1166         }
1167     };
1168 })
1169
1170 .factory('egGridColumnsProvider', ['egCore', function(egCore) {
1171
1172     function ColumnsProvider(args) {
1173         var cols = this;
1174         cols.columns = [];
1175         cols.stockVisible = [];
1176         cols.idlClass = args.idlClass;
1177         cols.clientSort = args.clientSort;
1178         cols.defaultToHidden = args.defaultToHidden;
1179         cols.defaultToNoSort = args.defaultToNoSort;
1180         cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
1181         cols.defaultDateFormat = args.defaultDateFormat;
1182
1183         // resets column width, visibility, and sort behavior
1184         // Visibility resets to the visibility settings defined in the 
1185         // template (i.e. the original egGridField values).
1186         cols.reset = function() {
1187             angular.forEach(cols.columns, function(col) {
1188                 col.align = 'left';
1189                 col.flex = 2;
1190                 col.sort = 0;
1191                 if (cols.stockVisible.indexOf(col.name) > -1) {
1192                     col.visible = true;
1193                 } else {
1194                     col.visible = false;
1195                 }
1196             });
1197         }
1198
1199         // returns true if any columns are sortable
1200         cols.hasSortableColumn = function() {
1201             return cols.columns.filter(
1202                 function(col) {
1203                     return col.sortable || col.multisortable;
1204                 }
1205             ).length > 0;
1206         }
1207
1208         cols.showAllColumns = function() {
1209             angular.forEach(cols.columns, function(column) {
1210                 column.visible = true;
1211             });
1212         }
1213
1214         cols.hideAllColumns = function() {
1215             angular.forEach(cols.columns, function(col) {
1216                 delete col.visible;
1217             });
1218         }
1219
1220         cols.indexOf = function(name) {
1221             for (var i = 0; i < cols.columns.length; i++) {
1222                 if (cols.columns[i].name == name) 
1223                     return i;
1224             }
1225             return -1;
1226         }
1227
1228         cols.findColumn = function(name) {
1229             return cols.columns[cols.indexOf(name)];
1230         }
1231
1232         cols.compileAutoColumns = function() {
1233             var idl_class = egCore.idl.classes[cols.idlClass];
1234
1235             angular.forEach(
1236                 idl_class.fields,
1237                 function(field) {
1238                     if (field.virtual) return;
1239                     if (field.datatype == 'link' || field.datatype == 'org_unit') {
1240                         // if the field is a link and the linked class has a
1241                         // "selector" field specified, use the selector field
1242                         // as the display field for the columns.
1243                         // flattener will take care of the fleshing.
1244                         if (field['class']) {
1245                             var selector_field = egCore.idl.classes[field['class']].fields
1246                                 .filter(function(f) { return Boolean(f.selector) })[0];
1247                             if (selector_field) {
1248                                 field.path = field.name + '.' + selector_field.selector;
1249                             }
1250                         }
1251                     }
1252                     cols.add(field, true);
1253                 }
1254             );
1255         }
1256
1257         // if a column definition has a path with a wildcard, create
1258         // columns for all non-virtual fields at the specified 
1259         // position in the path.
1260         cols.expandPath = function(colSpec) {
1261
1262             var ignoreList = [];
1263             if (colSpec.ignore)
1264                 ignoreList = colSpec.ignore.split(' ');
1265
1266             var dotpath = colSpec.path.replace(/\.?\*$/,'');
1267             var class_obj;
1268             var idl_field;
1269
1270             if (colSpec.parentIdlClass) {
1271                 class_obj = egCore.idl.classes[colSpec.parentIdlClass];
1272             } else {
1273                 class_obj = egCore.idl.classes[cols.idlClass];
1274             }
1275             var idl_parent = class_obj;
1276             var old_field_label = '';
1277
1278             if (!class_obj) return;
1279
1280             console.debug('egGrid: auto dotpath is: ' + dotpath);
1281             var path_parts = dotpath.split(/\./);
1282
1283             // find the IDL class definition for the last element in the
1284             // path before the .*
1285             // an empty path_parts means expand the root class
1286             if (path_parts) {
1287                 var old_field;
1288                 for (var path_idx in path_parts) {
1289                     old_field = idl_field;
1290
1291                     var part = path_parts[path_idx];
1292                     idl_field = class_obj.field_map[part];
1293
1294                     // unless we're at the end of the list, this field should
1295                     // link to another class.
1296                     if (idl_field && idl_field['class'] && (
1297                         idl_field.datatype == 'link' || 
1298                         idl_field.datatype == 'org_unit')) {
1299                         if (old_field_label) old_field_label += ' : ';
1300                         old_field_label += idl_field.label;
1301                         class_obj = egCore.idl.classes[idl_field['class']];
1302                         if (old_field) idl_parent = old_field;
1303                     } else {
1304                         if (path_idx < (path_parts.length - 1)) {
1305                             // we ran out of classes to hop through before
1306                             // we ran out of path components
1307                             console.error("egGrid: invalid IDL path: " + dotpath);
1308                         }
1309                     }
1310                 }
1311             }
1312
1313             if (class_obj) {
1314                 angular.forEach(class_obj.fields, function(field) {
1315
1316                     // Only show wildcard fields where we have data to show
1317                     // Virtual and un-fleshed links will not have any data.
1318                     if (field.virtual ||
1319                         (field.datatype == 'link' || field.datatype == 'org_unit') ||
1320                         ignoreList.indexOf(field.name) > -1
1321                     )
1322                         return;
1323
1324                     var col = cols.cloneFromScope(colSpec);
1325                     col.path = (dotpath ? dotpath + '.' + field.name : field.name);
1326
1327                     // log line below is very chatty.  disable until needed.
1328                     // console.debug('egGrid: field: ' +field.name + '; parent field: ' + js2JSON(idl_parent));
1329                     cols.add(col, false, true, 
1330                         {idl_parent : idl_parent, idl_field : field, idl_class : class_obj, field_parent_label : old_field_label });
1331                 });
1332
1333                 cols.columns = cols.columns.sort(
1334                     function(a, b) {
1335                         if (a.explicit) return -1;
1336                         if (b.explicit) return 1;
1337
1338                         if (a.idlclass && b.idlclass) {
1339                             if (a.idlclass < b.idlclass) return -1;
1340                             if (b.idlclass < a.idlclass) return 1;
1341                         }
1342
1343                         if (a.path && b.path && a.path.lastIndexOf('.') && b.path.lastIndexOf('.')) {
1344                             if (a.path.substring(0, a.path.lastIndexOf('.')) < b.path.substring(0, b.path.lastIndexOf('.'))) return -1;
1345                             if (b.path.substring(0, b.path.lastIndexOf('.')) < a.path.substring(0, a.path.lastIndexOf('.'))) return 1;
1346                         }
1347
1348                         if (a.label && b.label) {
1349                             if (a.label < b.label) return -1;
1350                             if (b.label < a.label) return 1;
1351                         }
1352
1353                         return a.name < b.name ? -1 : 1;
1354                     }
1355                 );
1356
1357
1358             } else {
1359                 console.error(
1360                     "egGrid: wildcard path does not resolve to an object: "
1361                     + dotpath);
1362             }
1363         }
1364
1365         // angular.clone(scopeObject) is not permittable.  Manually copy
1366         // the fields over that we need (so the scope object can go away).
1367         cols.cloneFromScope = function(colSpec) {
1368             return {
1369                 flesher  : colSpec.flesher,
1370                 comparator  : colSpec.comparator,
1371                 name  : colSpec.name,
1372                 label : colSpec.label,
1373                 path  : colSpec.path,
1374                 align  : colSpec.align || 'left',
1375                 flex  : Number(colSpec.flex) || 2,
1376                 sort  : Number(colSpec.sort) || 0,
1377                 required : colSpec.required,
1378                 linkpath : colSpec.linkpath,
1379                 template : colSpec.template,
1380                 visible  : colSpec.visible,
1381                 hidden   : colSpec.hidden,
1382                 datatype : colSpec.datatype,
1383                 sortable : colSpec.sortable,
1384                 nonsortable      : colSpec.nonsortable,
1385                 multisortable    : colSpec.multisortable,
1386                 nonmultisortable : colSpec.nonmultisortable,
1387                 dateformat       : colSpec.dateformat,
1388                 parentIdlClass   : colSpec.parentIdlClass
1389             };
1390         }
1391
1392
1393         // Add a column to the columns collection.
1394         // Columns may come from a slim eg-columns-field or 
1395         // directly from the IDL.
1396         cols.add = function(colSpec, fromIDL, fromExpand, idl_info) {
1397
1398             // First added column with the specified path takes precedence.
1399             // This allows for specific definitions followed by wildcard
1400             // definitions.  If a match is found, back out.
1401             if (cols.columns.filter(function(c) {
1402                 return (c.path == colSpec.path) })[0]) {
1403                 console.debug('skipping pre-existing column ' + colSpec.path);
1404                 return;
1405             }
1406
1407             var column = fromExpand ? colSpec : cols.cloneFromScope(colSpec);
1408
1409             if (column.path && column.path.match(/\*$/)) 
1410                 return cols.expandPath(colSpec);
1411
1412             if (!fromExpand) column.explicit = true;
1413
1414             if (!column.name) column.name = column.path;
1415             if (!column.path) column.path = column.name;
1416
1417             if (column.visible || (!cols.defaultToHidden && !column.hidden))
1418                 column.visible = true;
1419
1420             if (column.sortable || (!cols.defaultToNoSort && !column.nonsortable))
1421                 column.sortable = true;
1422
1423             if (column.multisortable || 
1424                 (!cols.defaultToNoMultiSort && !column.nonmultisortable))
1425                 column.multisortable = true;
1426
1427             if (cols.defaultDateFormat && ! column.dateformat) {
1428                 column.dateformat = cols.defaultDateFormat;
1429             }
1430
1431             cols.columns.push(column);
1432
1433             // Track which columns are visible by default in case we
1434             // need to reset column visibility
1435             if (column.visible) 
1436                 cols.stockVisible.push(column.name);
1437
1438             if (fromIDL) return; // directly from egIDL.  nothing left to do.
1439
1440             // lookup the matching IDL field
1441             if (!idl_info && cols.idlClass) 
1442                 idl_info = cols.idlFieldFromPath(column.path);
1443
1444             if (!idl_info) {
1445                 // column is not represented within the IDL
1446                 column.adhoc = true; 
1447                 return; 
1448             }
1449
1450             column.datatype = idl_info.idl_field.datatype;
1451             
1452             if (!column.label) {
1453                 column.label = idl_info.idl_field.label || column.name;
1454             }
1455
1456             if (fromExpand && idl_info.idl_class) {
1457                 column.idlclass = '';
1458                 if (idl_info.field_parent_label && idl_info.idl_parent.label != idl_info.idl_class.label) {
1459                     column.idlclass = (idl_info.field_parent_label || idl_info.idl_parent.label || idl_info.idl_parent.name);
1460                 } else {
1461                     column.idlclass += idl_info.idl_class.label || idl_info.idl_class.name;
1462                 }
1463             }
1464         },
1465
1466         // finds the IDL field from the dotpath, using the columns
1467         // idlClass as the base.
1468         cols.idlFieldFromPath = function(dotpath) {
1469             var class_obj = egCore.idl.classes[cols.idlClass];
1470             var path_parts = dotpath.split(/\./);
1471
1472             var idl_parent;
1473             var idl_field;
1474             for (var path_idx in path_parts) {
1475                 var part = path_parts[path_idx];
1476                 idl_parent = idl_field;
1477                 idl_field = class_obj.field_map[part];
1478
1479                 if (idl_field) {
1480                     if (idl_field['class'] && (
1481                         idl_field.datatype == 'link' || 
1482                         idl_field.datatype == 'org_unit')) {
1483                         class_obj = egCore.idl.classes[idl_field['class']];
1484                     }
1485                 } else {
1486                     return null;
1487                 }
1488             }
1489
1490             return {
1491                 idl_parent: idl_parent,
1492                 idl_field : idl_field,
1493                 idl_class : class_obj
1494             };
1495         }
1496     }
1497
1498     return {
1499         instance : function(args) { return new ColumnsProvider(args) }
1500     }
1501 }])
1502
1503
1504 /*
1505  * Generic data provider template class.  This is basically an abstract
1506  * class factory service whose instances can be locally modified to 
1507  * meet the needs of each individual grid.
1508  */
1509 .factory('egGridDataProvider', 
1510            ['$q','$timeout','$filter','egCore',
1511     function($q , $timeout , $filter , egCore) {
1512
1513         function GridDataProvider(args) {
1514             var gridData = this;
1515             if (!args) args = {};
1516
1517             gridData.sort = [];
1518             gridData.get = args.get;
1519             gridData.query = args.query;
1520             gridData.idlClass = args.idlClass;
1521             gridData.columnsProvider = args.columnsProvider;
1522
1523             // Delivers a stream of array data via promise.notify()
1524             // Useful for passing an array of data to egGrid.get()
1525             // If a count is provided, the array will be trimmed to
1526             // the range defined by count and offset
1527             gridData.arrayNotifier = function(arr, offset, count) {
1528                 if (!arr || arr.length == 0) return $q.when();
1529
1530                 if (gridData.columnsProvider.clientSort
1531                     && gridData.sort
1532                     && gridData.sort.length > 0
1533                 ) {
1534                     var sorter_cache = [];
1535                     arr.sort(function(a,b) {
1536                         for (var si = 0; si < gridData.sort.length; si++) {
1537                             if (!sorter_cache[si]) { // Build sort structure on first comparison, reuse thereafter
1538                                 var field = gridData.sort[si];
1539                                 var dir = 'asc';
1540
1541                                 if (angular.isObject(field)) {
1542                                     dir = Object.values(field)[0];
1543                                     field = Object.keys(field)[0];
1544                                 }
1545
1546                                 var path = gridData.columnsProvider.findColumn(field).path || field;
1547                                 var comparator = gridData.columnsProvider.findColumn(field).comparator ||
1548                                     function (x,y) { if (x < y) return -1; if (x > y) return 1; return 0 };
1549
1550                                 sorter_cache[si] = {
1551                                     field       : path,
1552                                     dir         : dir,
1553                                     comparator  : comparator
1554                                 };
1555                             }
1556
1557                             var sc = sorter_cache[si];
1558
1559                             var af,bf;
1560
1561                             if (a._isfieldmapper || angular.isFunction(a[sc.field])) {
1562                                 try {af = a[sc.field](); bf = b[sc.field]() } catch (e) {};
1563                             } else {
1564                                 af = a[sc.field]; bf = b[sc.field];
1565                             }
1566                             if (af === undefined && sc.field.indexOf('.') > -1) { // assume an object, not flat path
1567                                 var parts = sc.field.split('.');
1568                                 af = a;
1569                                 bf = b;
1570                                 angular.forEach(parts, function (p) {
1571                                     if (af) {
1572                                         if (af._isfieldmapper || angular.isFunction(af[p])) af = af[p]();
1573                                         else af = af[p];
1574                                     }
1575                                     if (bf) {
1576                                         if (bf._isfieldmapper || angular.isFunction(bf[p])) bf = bf[p]();
1577                                         else bf = bf[p];
1578                                     }
1579                                 });
1580                             }
1581
1582                             if (af === undefined) af = null;
1583                             if (bf === undefined) bf = null;
1584
1585                             if (af === null && bf !== null) return 1;
1586                             if (bf === null && af !== null) return -1;
1587
1588                             if (!(bf === null && af === null)) {
1589                                 var partial = sc.comparator(af,bf);
1590                                 if (partial) {
1591                                     if (sc.dir == 'desc') {
1592                                         if (partial > 0) return -1;
1593                                         return 1;
1594                                     }
1595                                     return partial;
1596                                 }
1597                             }
1598                         }
1599
1600                         return 0;
1601                     });
1602                 }
1603
1604                 if (count) arr = arr.slice(offset, offset + count);
1605                 var def = $q.defer();
1606                 // promise notifications are only witnessed when delivered
1607                 // after the caller has his hands on the promise object
1608                 $timeout(function() {
1609                     angular.forEach(arr, def.notify);
1610                     def.resolve();
1611                 });
1612                 return def.promise;
1613             }
1614
1615             // Calls the grid refresh function.  Once instantiated, the
1616             // grid will replace this function with it's own refresh()
1617             gridData.refresh = function(noReset) { }
1618
1619             if (!gridData.get) {
1620                 // returns a promise whose notify() delivers items
1621                 gridData.get = function(index, count) {
1622                     console.error("egGridDataProvider.get() not implemented");
1623                 }
1624             }
1625
1626             // attempts a flat field lookup first.  If the column is not
1627             // found on the top-level object, attempts a nested lookup
1628             // TODO: consider a caching layer to speed up template 
1629             // rendering, particularly for nested objects?
1630             gridData.itemFieldValue = function(item, column) {
1631                 var val;
1632                 if (column.name in item) {
1633                     if (typeof item[column.name] == 'function') {
1634                         val = item[column.name]();
1635                     } else {
1636                         val = item[column.name];
1637                     }
1638                 } else {
1639                     val = gridData.nestedItemFieldValue(item, column);
1640                 }
1641
1642                 return val;
1643             }
1644
1645             // TODO: deprecate me
1646             gridData.flatItemFieldValue = function(item, column) {
1647                 console.warn('gridData.flatItemFieldValue deprecated; '
1648                     + 'leave provider.itemFieldValue unset');
1649                 return item[column.name];
1650             }
1651
1652             // given an object and a dot-separated path to a field,
1653             // extract the value of the field.  The path can refer
1654             // to function names or object attributes.  If the final
1655             // value is an IDL field, run the value through its
1656             // corresponding output filter.
1657             gridData.nestedItemFieldValue = function(obj, column) {
1658                 item = obj; // keep a copy around
1659
1660                 if (obj === null || obj === undefined || obj === '') return '';
1661                 if (!column.path) return obj;
1662
1663                 var idl_field;
1664                 var parts = column.path.split('.');
1665
1666                 angular.forEach(parts, function(step, idx) {
1667                     // object is not fleshed to the expected extent
1668                     if (typeof obj != 'object') {
1669                         if (typeof obj != 'undefined' && column.flesher) {
1670                             obj = column.flesher(obj, column, item);
1671                         } else {
1672                             obj = '';
1673                             return;
1674                         }
1675                     }
1676
1677                     if (!obj) return '';
1678
1679                     var cls = obj.classname;
1680                     if (cls && (class_obj = egCore.idl.classes[cls])) {
1681                         idl_field = class_obj.field_map[step];
1682                         obj = obj[step] ? obj[step]() : '';
1683                     } else {
1684                         if (angular.isFunction(obj[step])) {
1685                             obj = obj[step]();
1686                         } else {
1687                             obj = obj[step];
1688                         }
1689                     }
1690                 });
1691
1692                 // We found a nested IDL object which may or may not have 
1693                 // been configured as a top-level column.  Grab the datatype.
1694                 if (idl_field && !column.datatype) 
1695                     column.datatype = idl_field.datatype;
1696
1697                 if (obj === null || obj === undefined || obj === '') return '';
1698                 return obj;
1699             }
1700         }
1701
1702         return {
1703             instance : function(args) {
1704                 return new GridDataProvider(args);
1705             }
1706         };
1707     }
1708 ])
1709
1710
1711 // Factory service for egGridDataManager instances, which are
1712 // responsible for collecting flattened grid data.
1713 .factory('egGridFlatDataProvider', 
1714            ['$q','egCore','egGridDataProvider',
1715     function($q , egCore , egGridDataProvider) {
1716
1717         return {
1718             instance : function(args) {
1719                 var provider = egGridDataProvider.instance(args);
1720
1721                 provider.get = function(offset, count) {
1722
1723                     // no query means no call
1724                     if (!provider.query || 
1725                             angular.equals(provider.query, {})) 
1726                         return $q.when();
1727
1728                     // find all of the currently visible columns
1729                     var queryFields = {}
1730                     angular.forEach(provider.columnsProvider.columns, 
1731                         function(col) {
1732                             // only query IDL-tracked columns
1733                             if (!col.adhoc && (col.required || col.visible))
1734                                 queryFields[col.name] = col.path;
1735                         }
1736                     );
1737
1738                     return egCore.net.request(
1739                         'open-ils.fielder',
1740                         'open-ils.fielder.flattened_search',
1741                         egCore.auth.token(), provider.idlClass, 
1742                         queryFields, provider.query,
1743                         {   sort : provider.sort,
1744                             limit : count,
1745                             offset : offset
1746                         }
1747                     );
1748                 }
1749                 //provider.itemFieldValue = provider.flatItemFieldValue;
1750                 return provider;
1751             }
1752         };
1753     }
1754 ])
1755
1756 .directive('egGridColumnDragSource', function() {
1757     return {
1758         restrict : 'A',
1759         require : '^egGrid',
1760         link : function(scope, element, attrs, egGridCtrl) {
1761             angular.element(element).attr('draggable', 'true');
1762
1763             element.bind('dragstart', function(e) {
1764                 egGridCtrl.dragColumn = attrs.column;
1765                 egGridCtrl.dragType = attrs.dragType || 'move'; // or resize
1766                 egGridCtrl.colResizeDir = 0;
1767
1768                 if (egGridCtrl.dragType == 'move') {
1769                     // style the column getting moved
1770                     angular.element(e.target).addClass(
1771                         'eg-grid-column-move-handle-active');
1772                 }
1773             });
1774
1775             element.bind('dragend', function(e) {
1776                 if (egGridCtrl.dragType == 'move') {
1777                     angular.element(e.target).removeClass(
1778                         'eg-grid-column-move-handle-active');
1779                 }
1780             });
1781         }
1782     };
1783 })
1784
1785 .directive('egGridColumnDragDest', function() {
1786     return {
1787         restrict : 'A',
1788         require : '^egGrid',
1789         link : function(scope, element, attrs, egGridCtrl) {
1790
1791             element.bind('dragover', function(e) { // required for drop
1792                 e.stopPropagation();
1793                 e.preventDefault();
1794                 e.dataTransfer.dropEffect = 'move';
1795
1796                 if (egGridCtrl.colResizeDir == 0) return; // move
1797
1798                 var cols = egGridCtrl.columnsProvider;
1799                 var srcCol = egGridCtrl.dragColumn;
1800                 var srcColIdx = cols.indexOf(srcCol);
1801
1802                 if (egGridCtrl.colResizeDir == -1) {
1803                     if (cols.indexOf(attrs.column) <= srcColIdx) {
1804                         egGridCtrl.modifyColumnFlex(
1805                             egGridCtrl.columnsProvider.findColumn(
1806                                 egGridCtrl.dragColumn), -1);
1807                         if (cols.columns[srcColIdx+1]) {
1808                             // source column shrinks by one, column to the
1809                             // right grows by one.
1810                             egGridCtrl.modifyColumnFlex(
1811                                 cols.columns[srcColIdx+1], 1);
1812                         }
1813                         scope.$apply();
1814                     }
1815                 } else {
1816                     if (cols.indexOf(attrs.column) > srcColIdx) {
1817                         egGridCtrl.modifyColumnFlex( 
1818                             egGridCtrl.columnsProvider.findColumn(
1819                                 egGridCtrl.dragColumn), 1);
1820                         if (cols.columns[srcColIdx+1]) {
1821                             // source column grows by one, column to the 
1822                             // right grows by one.
1823                             egGridCtrl.modifyColumnFlex(
1824                                 cols.columns[srcColIdx+1], -1);
1825                         }
1826
1827                         scope.$apply();
1828                     }
1829                 }
1830             });
1831
1832             element.bind('dragenter', function(e) {
1833                 e.stopPropagation();
1834                 e.preventDefault();
1835                 if (egGridCtrl.dragType == 'move') {
1836                     angular.element(e.target).addClass('eg-grid-col-hover');
1837                 } else {
1838                     // resize grips are on the right side of each column.
1839                     // dragenter will either occur on the source column 
1840                     // (dragging left) or the column to the right.
1841                     if (egGridCtrl.colResizeDir == 0) {
1842                         if (egGridCtrl.dragColumn == attrs.column) {
1843                             egGridCtrl.colResizeDir = -1; // west
1844                         } else {
1845                             egGridCtrl.colResizeDir = 1; // east
1846                         }
1847                     }
1848                 }
1849             });
1850
1851             element.bind('dragleave', function(e) {
1852                 e.stopPropagation();
1853                 e.preventDefault();
1854                 if (egGridCtrl.dragType == 'move') {
1855                     angular.element(e.target).removeClass('eg-grid-col-hover');
1856                 }
1857             });
1858
1859             element.bind('drop', function(e) {
1860                 e.stopPropagation();
1861                 e.preventDefault();
1862                 egGridCtrl.colResizeDir = 0;
1863                 if (egGridCtrl.dragType == 'move') {
1864                     angular.element(e.target).removeClass('eg-grid-col-hover');
1865                     egGridCtrl.onColumnDrop(attrs.column); // move the column
1866                 }
1867             });
1868         }
1869     };
1870 })
1871  
1872 .directive('egGridMenuItem', function() {
1873     return {
1874         restrict : 'AE',
1875         require : '^egGrid',
1876         scope : {
1877             label : '@',  
1878             checkbox : '@',  
1879             checked : '=',  
1880             standalone : '=',  
1881             handler : '=', // onclick handler function
1882             divider : '=', // if true, show a divider only
1883             handlerData : '=', // if set, passed as second argument to handler
1884             disabled : '=', // function
1885             hidden : '=' // function
1886         },
1887         link : function(scope, element, attrs, egGridCtrl) {
1888             egGridCtrl.addMenuItem({
1889                 checkbox : scope.checkbox,
1890                 checked : scope.checked ? true : false,
1891                 label : scope.label,
1892                 standalone : scope.standalone ? true : false,
1893                 handler : scope.handler,
1894                 divider : scope.divider,
1895                 disabled : scope.disabled,
1896                 hidden : scope.hidden,
1897                 handlerData : scope.handlerData
1898             });
1899             scope.$destroy();
1900         }
1901     };
1902 })
1903
1904
1905
1906 /**
1907  * Translates bare IDL object values into display values.
1908  * 1. Passes dates through the angular date filter
1909  * 2. Translates bools to Booleans so the browser can display translated 
1910  *    value.  (Though we could manually translate instead..)
1911  * Others likely to follow...
1912  */
1913 .filter('egGridValueFilter', ['$filter', function($filter) {                         
1914     return function(value, column) {                                             
1915         switch(column.datatype) {                                                
1916             case 'bool':                                                       
1917                 switch(value) {
1918                     // Browser will translate true/false for us                    
1919                     case 't' : 
1920                     case '1' :  // legacy
1921                     case true:
1922                         return ''+true;
1923                     case 'f' : 
1924                     case '0' :  // legacy
1925                     case false:
1926                         return ''+false;
1927                     // value may be null,  '', etc.
1928                     default : return '';
1929                 }
1930             case 'timestamp':                                                  
1931                 // canned angular date filter FTW                              
1932                 if (!column.dateformat) 
1933                     column.dateformat = 'shortDate';
1934                 return $filter('date')(value, column.dateformat);
1935             case 'money':                                                  
1936                 return $filter('currency')(value);
1937             default:                                                           
1938                 return value;                                                  
1939         }                                                                      
1940     }                                                                          
1941 }]);
1942