web staff : patron search, more grid integration
authorBill Erickson <berick@esilibrary.com>
Tue, 8 Apr 2014 16:40:39 +0000 (12:40 -0400)
committerBill Erickson <berick@esilibrary.com>
Tue, 8 Apr 2014 16:40:39 +0000 (12:40 -0400)
Signed-off-by: Bill Erickson <berick@esilibrary.com>
Open-ILS/src/templates/staff/circ/patron/t_search.tt2
Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
Open-ILS/src/templates/staff/parts/t_autogrid.tt2
Open-ILS/web/js/ui/default/staff/circ/patron/app.js
Open-ILS/web/js/ui/default/staff/services/grid.js

index f6dcb0c..29fe17b 100644 (file)
@@ -7,23 +7,23 @@
 
 <div class="row" id="patron-search-form-row">
   <div class="col-md-11">
-    <form ng-submit="search(args)" id="patron-search-form" 
+    <form ng-submit="search(searchArgs)" id="patron-search-form" 
         role="form" class="form-horizontal">
 
       <div class="form-group">
         <div class="col-md-2">
-          <input type="text" class="form-control" ng-model="args.card" 
+          <input type="text" class="form-control" ng-model="searchArgs.card" 
             placeholder="[% l('Barcode') %]" focus-me="focusMe"/><!--"vim-->
         </div>
 
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.family_name" placeholder="[% l('Last Name') %]"/>
+            ng-model="searchArgs.family_name" placeholder="[% l('Last Name') %]"/>
         </div>
 
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.first_given_name" placeholder="[% l('First Name') %]"/>
+            ng-model="searchArgs.first_given_name" placeholder="[% l('First Name') %]"/>
         </div>
 
         <div class="col-md-2">
@@ -31,7 +31,7 @@
         </div>
 
         <div class="col-md-2">
-          <input type="reset" class="btn btn-default" ng-click="args={}" 
+          <input type="reset" class="btn btn-default" ng-click="searchArgs={}" 
             value="[% l('Clear Form') %]"/>
         </div>
 
       <div class="form-group" ng-show="showExtras">
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.second_given_name" placeholder="[% l('Middle Name') %]"/>
+            ng-model="searchArgs.second_given_name" placeholder="[% l('Middle Name') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.alias" placeholder="[% l('Alias') %]"/>
+            ng-model="searchArgs.alias" placeholder="[% l('Alias') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.usrname" placeholder="[% l('Username') %]"/>
+            ng-model="searchArgs.usrname" placeholder="[% l('Username') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.email" placeholder="[% l('Email') %]"/>
+            ng-model="searchArgs.email" placeholder="[% l('Email') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.ident" placeholder="[% l('Identification') %]"/>
+            ng-model="searchArgs.ident" placeholder="[% l('Identification') %]"/>
         </div>
       </div>
 
       <div class="form-group" ng-show="showExtras">
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.id" placeholder="[% l('Database ID') %]"/>
+            ng-model="searchArgs.id" placeholder="[% l('Database ID') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.phone" placeholder="[% l('Phone') %]"/>
+            ng-model="searchArgs.phone" placeholder="[% l('Phone') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.street1" placeholder="[% l('Street 1') %]"/>
+            ng-model="searchArgs.street1" placeholder="[% l('Street 1') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.street2" placeholder="[% l('Street 2') %]"/>
+            ng-model="searchArgs.street2" placeholder="[% l('Street 2') %]"/>
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
-            ng-model="args.city" placeholder="[% l('City') %]"/>
+            ng-model="searchArgs.city" placeholder="[% l('City') %]"/>
         </div>
       </div>
 
       <div class="form-group" ng-show="showExtras">
         <div class="col-md-2">
-          <input type="text" class="form-control" ng-model="args.state" 
+          <input type="text" class="form-control" ng-model="searchArgs.state" 
             placeholder="[% l('State') %]" title="[% l('State') %]"/>
         </div>
 
         <div class="col-md-2">
-          <input type="text" class="form-control" ng-model="args.post_code" 
+          <input type="text" class="form-control" ng-model="searchArgs.post_code" 
             placeholder="[% l('Post Code') %]" title="[% l('Post Code') %]"/>
         </div>
 
         <div class="col-md-2">
           <input type="text" class="form-control"  
             placeholder="[% l('Profile Group') %]"
-            ng-model="args.profile"
+            ng-model="searchArgs.profile"
             typeahead="grp as grp.name for grp in profiles | filter:$viewValue" 
             typeahead-editable="false" />
         </div>
         <div class="col-md-2">
           <input type="text" class="form-control" 
             placeholder="[% l('Home Library') %]"
-            ng-model="args.home_ou"
+            ng-model="searchArgs.home_ou"
             typeahead="org as org.shortname for org in org_units | filter:$viewValue" 
             typeahead-editable="false" />
         </div>
         <div class="col-md-2">
           <div class="checkbox">
             <label>
-              <input type="checkbox" ng-model="args.inactive"/>
+              <input type="checkbox" ng-model="searchArgs.inactive"/>
               [% l('Include Inactive?') %]
             </label>
           </div>
index 312055c..2166382 100644 (file)
@@ -1,18 +1,17 @@
 <eg-grid
   idl-class="au" sort="[]" id-field="id"
-  initial-offset="cachedSearchOffset"
-  features="-sort,-display"
+  features="-sort,-display,-multisort"
   main-label="[% l('Patron Search Results') %]"
   items-provider="patronSearchGridProvider"
   persist-key="eg.staff.circ.patron.search">
   <eg-grid-field label="[% ('ID') %]" path='id' visible></eg-grid-field>
   <eg-grid-field label="[% ('Card') %]" path='card.barcode' visible></eg-grid-field>
-  <eg-grid-field label="[% ('Last Name') %]" path='family_name' visible sortable></eg-grid-field>
-  <eg-grid-field label="[% ('First Name') %]" path='first_given_name' visible sortable></eg-grid-field>
-  <eg-grid-field label="[% ('Middle Name') %]" path='second_given_name' visible sortable></eg-grid-field>
-  <eg-grid-field label="[% ('DoB') %]" path='dob' visible sortable></eg-grid-field>
+  <eg-grid-field label="[% ('Last Name') %]" path='family_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('First Name') %]" path='first_given_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('Middle Name') %]" path='second_given_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('DoB') %]" path='dob' visible sortable multisortable></eg-grid-field>
   <eg-grid-field label="[% ('Home Library') %]" path='home_ou.shortname' visible></eg-grid-field>
-  <eg-grid-field label="[% ('Created On') %]" path='create_date' visible sortable></eg-grid-field>
+  <eg-grid-field label="[% ('Created On') %]" path='create_date' visible sortable multisortable></eg-grid-field>
 
   <eg-grid-field label="[% ('Mailing:Street 1') %]" path='mailing_address.street1' visible></eg-grid-field>
   <eg-grid-field label="[% ('Mailing:Street 2') %]" path='mailing_address.street2'></eg-grid-field>
index 3dca865..d343921 100644 (file)
         style="flex:{{grid.indexFlex + grid.selectorFlex}}">
       <div class="eg-grid-conf-cell-entry">[% l('Expand') %]</div>
       <div class="eg-grid-conf-cell-entry">[% l('Shrink') %]</div>
-      <div class="eg-grid-conf-cell-entry">[% l('Sort') %]</div>
+      <div class="eg-grid-conf-cell-entry" ng-if="!grid.disableMultiSort">[% l('Sort') %]</div>
     </div>
     <div class="eg-grid-cell"
       ng-repeat="col in grid.columnsProvider.columns"
           <span class="glyphicon glyphicon-fast-backward"></span>
         </a>
       </div>
-      <div class="eg-grid-conf-cell-entry">
-        <input type='number' ng-model="col.sort" 
-          title="[% l('Sort Priority / Direction') %]" style='width:2.3em'/>
+      <div class="eg-grid-conf-cell-entry" ng-if="!grid.disableMultiSort">
+        <div ng-if="col.multisortable">
+          <input type='number' ng-model="col.sort"
+            title="[% l('Sort Priority / Direction') %]" style='width:2.3em'/>
+        </div>
       </div>
     </div>
   </div>
index 3d82f6d..eddf008 100644 (file)
@@ -230,20 +230,29 @@ function($scope,  $q,  $filter,  egNet,  egAuth,  egUser,  patronSvc,  egEnv,  e
  */
 .controller('PatronSearchCtrl',
        ['$scope','$q','$routeParams','$timeout','$window','$location','egEnv',
-       '$filter','egIDL','egNet','egAuth','egEvent','egList','egUser','patronSvc',
-       'egGridFlatDataProvider',
+       '$filter','egIDL','egNet','egAuth','egEvent','egList','egUser',
+       'patronSvc', 'egGridFlatDataProvider',
 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egEnv,
-        $filter,  egIDL,  egNet,  egAuth,  egEvent,  egList,  egUser,  patronSvc,
-        egGridFlatDataProvider) {
+        $filter,  egIDL,  egNet,  egAuth,  egEvent,  egList,  egUser,  
+        patronSvc , egGridDataProvider) {
 
     $scope.initTab('search');
     $scope.focusMe = true;
-    $scope.args = $location.search();
+    $scope.searchArgs = {};
 
-    console.log('PatronSearchCtrl');
-    
-    // our data provider is a modified flat data provider
-    var provider = egGridFlatDataProvider.instance({});
+    if (patronSvc.lastSearch) {
+        // populate the search form with our cached search info
+        angular.forEach(patronSvc.lastSearch.search, function(val, key) {
+            $scope.searchArgs[key] = val.value;
+        });
+    }
+
+    var provider = egGridDataProvider.instance({});
+    provider.initialize = function() {
+        return {offset : 0};
+    }
+
+    // show the user summary for the first selected user
     provider.select = function(items) {
         if (items[0]) {
             var user = items[0];
@@ -252,61 +261,81 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egEnv,
     }
 
     provider.get = function(offset, count, onitem) {
-        console.log('get ' + $location.search() + ' : ' + provider._revision);
         var deferred = $q.defer();
 
-            /*
-            if (args.id) {
-                retrieveUsers([args.id]);
-       egUser.get(id).then(function(user) {
-                patronSvc.localFlesh(user);
-                $scope.patrons.items[idx] = user;
-            });
-
-            } else {
-                sendSearch(args);
-            }
-            */
-
-        var search = compileSearch($location.search());
-
-        /*
-        if (Object.keys(search).length == 0 && 
-                offset == patronSvc.cachedSearchOffset && 
-                patronSvc.patrons.length) {
-            // accessing the page without a cached search
-            // see if we have a cached result set
-            angular.forEach(patronSvc.patrons, function(p) { onitem(p) });
-            return;
-        }
-        */
-
-        //patronSvc.cachedSearchOffset = offset;
-        patronSvc.patrons = [];
+        var search = compileSearch($scope.searchArgs);
+        if (Object.keys(search) == 0) return $q.when();
 
         var home_ou = search.home_ou;
         delete search.home_ou;
         var inactive = search.inactive;
         delete search.inactive;
 
-        console.debug('patron search ' + js2JSON(search));
+        var fullSearch = {
+            search : search,
+            count : count,
+            sort : compileSort(),
+            inactive : inactive,
+            home_ou : home_ou,
+            offset : offset
+        };
+
+        if (patronSvc.lastSearch) {
+            // search repeated, return the cached results
+            if (angular.equals(fullSearch, patronSvc.lastSearch)) {
+                console.log('patron search returning ' + 
+                    patronSvc.patrons.length + ' cached results');
+                
+                // notify has to happen after returning the promise
+                $timeout(
+                    function() {
+                        angular.forEach(patronSvc.patrons, function(user) {
+                            deferred.notify(user);
+                        });
+                        deferred.resolve();
+                    }
+                );
+                return deferred.promise;
+            }
+        } else {
+            patronSvc.lastSearch = fullSearch;
+        }
+
+        if (fullSearch.search.id) {
+            // search by user id performs a direct ID lookup
+            var userId = fullSearch.search.id.value;
+            $timeout(
+                function() {
+                    egUser.get(userId).then(function(user) {
+                        patronSvc.localFlesh(user);
+                        patronSvc.patrons = [user];
+                        deferred.notify(user);
+                        deferred.resolve();
+                    });
+                }
+            );
+            return deferred.promise;
+        }
+
+        patronSvc.patrons = [];
         egNet.request(
             'open-ils.actor',
             'open-ils.actor.patron.search.advanced.fleshed',
             egAuth.token(), 
-            search, 
-            count,
-            compileSort(),
-            inactive,
-            home_ou,
+            fullSearch.search, 
+            fullSearch.count,
+            fullSearch.sort,
+            fullSearch.inactive,
+            fullSearch.home_ou,
             egUser.defaultFleshFields,
-            offset
+            fullSearch.offset
 
         ).then(
             function() { deferred.resolve() },
             null, // onerror
             function(user) {
                 patronSvc.localFlesh(user); // inline
+                patronSvc.patrons.push(user);
                 deferred.notify(user);
             }
         );
@@ -317,6 +346,7 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egEnv,
     provider.itemFieldValue = function(item, column) {
         return provider.nestedItemFieldValue(item, column);
     };
+
     $scope.patronSearchGridProvider = provider;
 
     // typeahead doesn't filter correctly with full hash objects, so
@@ -349,10 +379,10 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egEnv,
         angular.forEach(args, function(val, key) {
             if (!val) return;
             if (key == 'profile') {
-                //search.profile = {value : args.profile.id, group : 0};
-                search.profile = {value : search.profile, group : 0};
+                search.profile = {value : args.profile.id, group : 0};
+                //search.profile = {value : search.profile, group : 0};
             } else if (key == 'home_ou') {
-                //search.home_ou = args.home_ou.id; // passed separately
+                search.home_ou = args.home_ou.id; // passed separately
             } else if (key == 'inactive') {
                 search.inactive = val;
             } else {
@@ -397,168 +427,20 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egEnv,
             }
         );
 
-        console.log('sort = ' + js2JSON(sort));
         return sort;
     }
 
-    // alt form which receives fleshed user objects
-    /*
-    function sendSearch(args) {
-        search = compileSearch(args);
-
-        var home_ou = search.home_ou;
-        delete search.home_ou;
-        var inactive = search.inactive;
-        delete search.inactive;
-
-        console.debug('patron search ' + js2JSON(search));
-        egNet.request(
-            'open-ils.actor',
-            'open-ils.actor.patron.search.advanced.fleshed',
-            egAuth.token(), 
-            search, 
-            50 //
-            compileSort(),
-            inactive,
-            home_ou,
-            egUser.defaultFleshFields
-
-        ).then(null, null, function(user) {
-            patronSvc.localFlesh(user);
-            $scope.patrons.items[$scope.patrons.items.length] = user;
+    // search form submit action; tells the results grid to
+    // refresh itself.
+    $scope.search = function(args) { // args === $scope.searchArgs
+        if (args && Object.keys(args).length) 
             $scope.patronSearchGridProvider.increment();
-        });
-    };
-    */
-
-    // fetch users by id and add them to the patrons list
-    /*
-    function retrieveUsers(ids) {
-        angular.forEach(ids, function(id, idx) {
-            // capture idx to maintain search results order
-            egUser.get(id).then(function(user) {
-                patronSvc.localFlesh(user);
-                $scope.patrons.items[idx] = user;
-            });
-        });
     }
-    */
-
-    // collect form args fire patron search
-    $scope.search = function(args) {
-        if (args && Object.keys(args).length) {
-            //$scope.searchArgs = args;
-            //$scope.patronSearchGridProvider.increment();
-
-            if (args.profile) args.profile = args.profile.id;
-            if (args.home_ou) args.home_ou = args.home_ou.id;
-       
-            $location.search(args);
-
-            /*
-            $scope.
-            $scope.patrons.reset();
-            if (args.id) {
-                retrieveUsers([args.id]);
-            } else {
-                sendSearch(args);
-            }
-            */
-        }
-    }
-
-    // manage table row selection
-    /*
-    $scope.onPatronClick = function($event, user) {
-        $scope.lastSelected = user;
-
-        // control-click / command-click (mac) selects 
-        // or deselects a row without altering other rows
-        if ($event.ctrlKey || $event.metaKey) { 
-            $scope.patrons.toggleOneSelection(user.id());                         
-
-        // middle-click opens new tab for the patron
-        } else if ($event.which == 2) {
-
-            var url = $location.absUrl().replace(
-                /patron\/search.*$/, 
-                'patron/' + user.id() + '/checkout'
-            );
-            $window.open(url);
-
-        } else {                                                               
-            // vanilla click selects the patron as the current default
-            $scope.patrons.selectOne(user.id());                                  
-            patronSvc.setDefault(null, user);
-        }                                                                      
-    }
-    */
 
+    // TODO: move this into the (forthcoming) grid row activate action
     $scope.onPatronDblClick = function($event, user) {
         $location.path('/circ/patron/' + user.id() + '/checkout');
     }
-
-    // opens a new tab for each selected user at /checkout
-    // TODO: Chrome will only open one tab per user action (click,
-    // etc.). subsequent tabs open new windows (blocked by default).
-    // The only way around this I'm seeing is to use a chrome extension
-    // http://stackoverflow.com/questions/16749907/window-open-behaviour-in-chrome-tabs-windows
-    // for now, skip this feature and support control-click to open 
-    // multiple patrons instead.
-    /*
-    $scope.openSelectedPatrons = function() {
-        angular.forEach(
-            $scope.patrons.selectedItems(),
-            function(patron) {
-                var url = $location.absUrl();
-                url = url.replace(/patron\/search.*$/, 
-                    'patron/' + patron.id() + '/checkout');
-                $window.open(url);
-            }
-        );
-    }
-    */
-
-    // handled up/down arrow events while the patrons results table is focused.
-    // disabled for now, since there are some UI issues to work out first:
-    // 1. up/down while a browser scroll bar is visible causes the browser to 
-    // scroll, which makes sense, but is a little jarring.  An overflow/scroll
-    // container would be better -- requires a non-table solution.. TODO
-    // 2. if table hover Bootstrap css is used, even though the currently
-    // selected row changes with arrow up/down, the mouse continues to 
-    // hover in its original position, making the hovered row appear to be
-    // selected (style-wise) even when it's not.  Disabling table-hover
-    // CSS works, but table-hover is useful, so...
-
-    /* TODO: MOVE ME INTO GRID.js
-    $scope.navigateResults = function($event) {
-        // we can't select the next/previous user if we don't know 
-        // which user was selected last.  this should never happen, though.
-        if (!$scope.lastSelected) return;
-
-        var user;
-        if ($event.which == 40) { // down arrow
-            angular.forEach(
-                $scope.patrons.items,
-                function(item, idx) {
-                    if (item.id() == $scope.lastSelected.id())
-                        user = $scope.patrons.items[idx+1];
-                }
-            )
-        } else if ($event.which == 38) { // up arrow
-            angular.forEach(
-                $scope.patrons.items,
-                function(item, idx) {
-                    if (item.id() == $scope.lastSelected.id())
-                        user = $scope.patrons.items[idx-1];
-                }
-            )
-        }
-        
-        if (user) $scope.onPatronClick($event, user);
-    }
-    */
-
 }])
 
 /** * Manages patron summary view
@@ -567,7 +449,7 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egEnv,
        ['$scope','$q','egNet','egAuth','egEvent','patronSvc',
 function($scope,  $q,  egNet,  egAuth,  egEvent,  patronSvc) {
     // may not need this ctrl at all, since all data 
-    // come directly from the scope
+    // comes directly from the scope
 }])
 
 /**
index eb64e7a..1d1dbb0 100644 (file)
@@ -32,7 +32,10 @@ angular.module('egGridMod',
             itemsProvider : '=',
 
             // comma-separated list of supported or disabled grid features
-            // TODO: examples
+            // supported features:
+            //  -display : columns are hidden by default
+            //  -sort    : columns are unsortable by default 
+            //  -multisort : sort priorities config disabled by default
             features : '@',
 
             initialOffset : '=',
@@ -60,7 +63,7 @@ angular.module('egGridMod',
             var grid = this;
 
             grid.init = function() {
-                grid.offset = $scope.initialOffset || 0;
+                grid.offset = 0;
                 grid.limit = 25;
                 grid.items = [];
                 grid.selected = {}; // idField-based
@@ -80,7 +83,8 @@ angular.module('egGridMod',
                 grid.columnsProvider = egGridColumnsProvider.instance({
                     idlClass : grid.idlClass,
                     defaultToHidden : (grid.features.indexOf('-display') > -1),
-                    defaultToNoSort : (grid.features.indexOf('-sort') > -1)
+                    defaultToNoSort : (grid.features.indexOf('-sort') > -1),
+                    defaultToNoMultiSort : (grid.features.indexOf('-multisort') > -1)
                 });
 
                 if ($scope.autoFields) {
@@ -112,6 +116,16 @@ angular.module('egGridMod',
                     });
                 }
 
+                // this allows the caller to pass in initializtion 
+                // values, like offset, for cases when the caller may
+                // be caching grid data between route loads.
+                var conf = grid.dataProvider.initialize();
+                if (conf) {
+                    angular.forEach(conf, function(val, key) {
+                        if (val !== null) grid[key] = val;
+                    });
+                }
+
                 grid.compileSort();
                 $scope.grid = grid;
             }
@@ -348,8 +362,12 @@ angular.module('egGridMod',
             grid.toggleConfDisplay = function() {
                 if (grid.showGridConf) {
                     grid.showGridConf = false;
-                    grid.compileSort();
-                    grid.collect();
+                    if (grid.columnsProvider.hasSortableColumn()) {
+                        // only refresh the grid if the user has the
+                        // ability to modify the sort priorities.
+                        grid.compileSort();
+                        grid.collect();
+                    }
                 } else {
                     grid.showGridConf = true;
                 }
@@ -479,7 +497,14 @@ angular.module('egGridMod',
 
             // boolean fields are presented as value-less attributes
             angular.forEach(
-                ['visible', 'hidden', 'sortable', 'nonsortable'],
+                [
+                    'visible', 
+                    'hidden', 
+                    'sortable', 
+                    'nonsortable',
+                    'multisortable',
+                    'nonmultisortable',
+                ],
                 function(field) {
                     if (angular.isDefined(attrs[field]))
                         scope[field] = true;
@@ -499,6 +524,16 @@ angular.module('egGridMod',
         cols.idlClass = args.idlClass;
         cols.defaultToHidden = args.defaultToHidden;
         cols.defaultToNoSort = args.defaultToNoSort;
+        cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
+
+        // returns true if any columns are sortable
+        cols.hasSortableColumn = function() {
+            return cols.columns.filter(
+                function(col) {
+                    return col.sortable || col.multisortable;
+                }
+            ).length > 0;
+        }
 
         cols.showAllColumns = function() {
             angular.forEach(cols.columns, function(column) {
@@ -560,11 +595,13 @@ angular.module('egGridMod',
                 path  : colSpec.path,
                 flex  : Number(colSpec.flex) || 2,
                 sort  : Number(colSpec.sort) || 0,
-                sortable    : colSpec.sortable,
-                nonsortable : colSpec.nonsortable,
-                visible     : colSpec.visible,
-                hidden      : colSpec.hidden,
-                datatype    : colSpec.datatype
+                visible  : colSpec.visible,
+                hidden   : colSpec.hidden,
+                datatype : colSpec.datatype,
+                sortable : colSpec.sortable,
+                nonsortable      : colSpec.nonsortable,
+                multisortable    : colSpec.multisortable,
+                nonmultisortable : colSpec.nonmultisortable
             };
 
             if (!column.name) column.name = column.path;
@@ -576,6 +613,10 @@ angular.module('egGridMod',
             if (column.sortable || (!cols.defaultToNoSort && !column.nonsortable))
                 column.sortable = true;
 
+            if (column.multisortable || 
+                (!cols.defaultToNoMultiSort && !column.nonmultisortable))
+                column.multisortable = true;
+
             cols.columns.push(column);
 
             if (fromIDL) return;
@@ -636,57 +677,52 @@ angular.module('egGridMod',
     }
 }])
 
-// Factory service for egGridDataManager instances, which are
-// responsible for collecting flattened grid data.
-.factory('egGridFlatDataProvider', 
+
+/*
+ * Generic data provider template class.  This is basically an abstract
+ * class factory service whose instances can be locally modified to 
+ * meet the needs of each individual grid.
+ */
+.factory('egGridDataProvider', 
            ['$filter','egNet','egAuth','egIDL',
     function($filter , egNet , egAuth , egIDL) {
 
-        function FlatDataProvider(args) {
+        function GridDataProvider(args) {
             var gridData = this;
 
-            gridData.idlClass = args.idlClass;
-            gridData.query = args.query;
-            gridData.columnsProvider = args.columnsProvider;
             gridData.sort = [];
             gridData._revision = 0;
+            gridData.query = args.query;
+            gridData.idlClass = args.idlClass;
+            gridData.columnsProvider = args.columnsProvider;
+
+            gridData.initialize = function() {
+                return {};
+            }
 
             gridData.revision = function() {
                 return gridData._revision;
             }
 
+            // incrementing the revision tells the grid that a data
+            // refresh is needed.
             gridData.increment = function() {
                 gridData._revision++;
             }
 
-            gridData.get = function(index, count, onresponse) {
-
-                // fetch data for all currently visible columns
-                var queryFields = {}
-                angular.forEach(gridData.columnsProvider.columns, function(col) {
-                    if (gridData.columnsProvider.visible[col.name]) 
-                        queryFields[col.name] = col.path;
-                });
+            // returns a promise whose notify() delivers items
+            gridData.get = function(index, count) {
+                console.error("egGridDataProvider.get() not implemented");
+            }
 
-                return egNet.request(
-                    'open-ils.fielder',
-                    'open-ils.fielder.flattened_search',
-                    egAuth.token(), gridData.idlClass, queryFields,
-                    gridData.query,
-                    {   sort : gridData.sort,
-                        limit : count,
-                        offset : index
-                    }
-                );
+            // called when one or more items are selected in the grid
+            gridData.select = function(items) {
             }
 
-            gridData.itemFieldValue = function(item, column) {
-                // all of our data is flat
+            gridData.flatItemFieldValue = function(item, column) {
                 return item[column.name];
             }
 
-            // utility function which may be useful for other grid data 
-            // providers.
             // given an object and a dot-separated path to a field,
             // extract the value of the field.  The path can refer
             // to function names or object attributes.  If the final
@@ -728,7 +764,47 @@ angular.module('egGridMod',
 
         return {
             instance : function(args) {
-                return new FlatDataProvider(args);
+                return new GridDataProvider(args);
+            }
+        };
+    }
+])
+
+
+// Factory service for egGridDataManager instances, which are
+// responsible for collecting flattened grid data.
+.factory('egGridFlatDataProvider', 
+           ['egNet','egAuth','egGridDataProvider',
+    function(egNet , egAuth , egGridDataProvider) {
+
+        return {
+            instance : function(args) {
+                var provider = egGridDataProvider.instance(args);
+
+                provider.get = function(offset, count) {
+
+                    // find all of the currently visible columns
+                    var queryFields = {}
+                    angular.forEach(provider.columnsProvider.columns, 
+                        function(col) {
+                            if (provider.columnsProvider.visible[col.name]) 
+                                queryFields[col.name] = col.path;
+                        }
+                    );
+
+                    return egNet.request(
+                        'open-ils.fielder',
+                        'open-ils.fielder.flattened_search',
+                        egAuth.token(), provider.idlClass, queryFields,
+                        provider.query,
+                        {   sort : provider.sort,
+                            limit : count,
+                            offset : index
+                        }
+                    );
+                }
+                provider.itemFieldValue = provider.flatItemFieldValue;
+                return provider;
             }
         };
     }