web staff : side-porting grid
authorBill Erickson <berick@esilibrary.com>
Sun, 6 Apr 2014 18:03:53 +0000 (14:03 -0400)
committerBill Erickson <berick@esilibrary.com>
Sun, 6 Apr 2014 18:03:53 +0000 (14:03 -0400)
Signed-off-by: Bill Erickson <berick@esilibrary.com>
Open-ILS/src/templates/staff/css/style.css.tt2
Open-ILS/src/templates/staff/parts/t_autogrid.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/grid.js [new file with mode: 0644]

index 0a48f53..c524fa0 100644 (file)
@@ -86,3 +86,182 @@ table.list tr.selected td {
 .pad-horiz {padding : 0px 10px 0px 10px; }
 .pad-vert {padding : 20px 0px 10px 0px;}
 
+
+/* ----------------------------------------------------------------------
+ * Grid
+ * ---------------------------------------------------------------------- */
+
+.eg-grid-primary-label {
+  font-weight: bold;
+  font-size: 120%;
+}
+
+/* odd/even row styling */
+.eg-grid-content-body > div:nth-child(odd):not(.eg-grid-row-selected) {
+  background-color: rgb(248, 248, 248);
+}
+
+.eg-grid-row {
+  width: 100%;
+  display: flex;
+  border: 1px solid #ccc;
+}
+
+.eg-grid-row:not(.eg-grid-header-row):not(.eg-grid-conf-row) {
+  height: 1.8em;
+}
+
+.eg-grid-action-row {
+  border: none;
+  /* margin should not have to be this large; something's up */
+  margin-bottom: 12px;
+}
+
+.eg-grid-header-row { 
+  font-weight: bold; 
+}
+
+.eg-grid-header-row > .eg-grid-cell {
+  border-right: 1px solid #CCC;
+  text-align: center;
+
+  /* vertically align header cell text by treating 
+     each header cell as a vertical flex container */
+  display:flex;
+  flex-direction:column;
+  justify-content:flex-end;
+}
+
+.eg-grid-cell {
+  /* avoid text flowing into adjacent cells */
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+/* in config display, make cells more obvious */
+.eg-grid-as-conf .eg-grid-row {
+  border: 1px solid #777;
+}
+.eg-grid-as-conf .eg-grid-cell {
+  border-right: 1px solid #777;
+}
+
+/* stock columns need fixed-width controls */
+.eg-grid-cell-stock {
+  flex: 1;
+  text-align: center;
+}
+
+/* the conf header must be twice the stock flex */
+.eg-grid-cell-conf-header {
+  flex: 2;
+  font-weight: bold;
+}
+
+.eg-grid-row-selected {
+  color: rgb(51, 51, 51);
+  background-color: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+
+/* Improve ::selection styling by only allowing selection on text
+ * content cells within the main body of the grid.  Otherwise, the browser 
+ * styles row background and text (all dark blue?) when shift-click or 
+ * click-drag is used.
+ */
+.eg-grid-content-body .eg-grid-row {
+  user-select:none;
+  -moz-user-select: none;
+  -webkit-user-select: none;
+}
+.eg-grid-content-body .eg-grid-cell-content {
+  user-select:text;
+  -moz-user-select: text;
+  -webkit-user-select: text;
+}
+.eg-grid-cell-content::-moz-selection {
+  color: rgb(51, 51, 51);
+  background: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+.eg-grid-cell-content::selection {
+  color: rgb(51, 51, 51);
+  background: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+
+.eg-grid-conf-cell-entry {
+  width:98%;
+  text-align:center;
+  padding: 3px;
+}
+
+.eg-grid-conf-cell-entry:not(:first-child) {
+  border-top:1px solid #ccc;
+}
+
+.eg-grid-conf-row {
+  background-color: #dff0d8;
+  border-color: #d6e9c6;
+}
+
+.eg-grid-conf-row:first-child {
+  /* alignment fix; account for one missing border */
+  padding-right: 1px;
+}
+
+.eg-grid-col-drag, .eg-grid-col-drag:active {
+  /* similar to label-primary, sans padding */
+  background-color: rgb(66, 139, 202);
+  color: #fff;
+}
+
+.eg-grid-col-hover {
+  /* similar to label-success, sans padding */
+  background-color: rgb(92, 184, 92);
+  color: #fff;
+}
+
+.eg-grid-column-drag-handle {
+  width: 1px;
+  margin-left: 2px;
+  height: 100%;
+}
+.eg-grid-column-drag-handle:hover {
+  cursor: col-resize;
+  width: 3px;
+  border: 1px dashed rgb(66, 139, 202);
+}
+
+.eg-grid-column-drag-handle-west {
+  cursor: w-resize;
+}
+.eg-grid-column-drag-handle-east {
+  cursor: e-resize;
+}
+
+
+/* hack to make the header columns line up with the content columns
+   when the scroll bar is visible along the right side of the content
+   columns. TODO: if this varies enough by browser, we'll need to
+   calculate the width instead. */
+/*
+.eg-grid-scroll > .eg-grid-header-row, 
+.eg-grid-scroll > .eg-grid-conf-row { 
+  padding-right: 15px;
+}
+.eg-grid-scroll > .eg-grid-content-body {
+  overflow-y:scroll; 
+  height: 600px; 
+}
+*/
+
+
+/* ----------------------------------------------------------------------
+ * /Grid
+ * ---------------------------------------------------------------------- */
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/parts/t_autogrid.tt2 b/Open-ILS/src/templates/staff/parts/t_autogrid.tt2
new file mode 100644 (file)
index 0000000..f5e4868
--- /dev/null
@@ -0,0 +1,220 @@
+
+<!-- 
+  Actions row.
+  This sits above the grid and contains the column picker, etc.
+-->
+
+<div class="eg-grid-row eg-grid-action-row">
+
+  <div style="flex:1">
+    <div class="eg-grid-primary-label">{{grid.mainLabel}}</div>
+  </div>
+  
+  <!-- column picker -->
+  <div class="btn-group column-picker">
+
+    <!-- first page -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : grid.onFirstPage()}" 
+      ng-click="grid.offset = 0;grid.collect()"
+      title="[% l('Start') %]">
+        <span class="glyphicon glyphicon-fast-backward"></span>
+    </button>
+
+    <!-- previous page -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : grid.onFirstPage()}"
+      ng-click="grid.decrementPage()"
+      title="[% l('Previous Page') %]">
+        <span class="glyphicon glyphicon-backward"></span>
+    </button>
+
+    <!-- next page -->
+    <!-- todo: paging needs a total count value to be fully functional -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : !grid.hasNextPage()}"
+      ng-click="grid.incrementPage()"
+      title="[% l('Next Page') %]">
+        <span class="glyphicon glyphicon-forward"></span>
+    </button>
+
+    <div class="btn-group">
+      <button type="button" title="[% ('Select Row Count') %]"
+        class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+        [% l('Rows [_1]', '{{grid.limit}}') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu">
+        <li ng-repeat="t in [5,10,25,50,100]">
+          <a href='' ng-click='grid.offset=0;grid.limit=t;grid.collect()'>
+            {{t}}
+          </a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group">
+      <button type="button" title="[% ('Select Page') %]"
+        class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+        [% l('Page [_1]', '{{grid.page()}}') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu">
+        <li>
+          <div class="input-group">
+            <input type="text" class="form-control"
+              ng-model="pageFromUI"
+              ng-click="$event.stopPropagation()"/>
+            <span class="input-group-btn">
+              <button class="btn btn-default" type="button"
+                ng-click="grid.goToPage(pageFromUI);pageFromUI=''">
+                [% l('Go To...') %]
+              </button>
+            </span>
+          </div>
+        </li>
+        <li role="presentation" class="divider"></li>
+        <li ng-repeat="t in [1,2,3,4,5,10,25,50,100]">
+          <a href='' ng-click='grid.goToPage(t)'>{{t}}</a>
+        </li>
+      </ul>
+    </div>
+
+    <button type="button" 
+      class="btn btn-default dropdown-toggle" 
+      data-toggle="dropdown"><span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu pull-right">
+      <li><a href='' ng-click="grid.toggleConfDisplay()">
+        <span class="glyphicon glyphicon-wrench"></span>
+        [% l('Configure Columns') %]
+      </a></li>
+      <li><a href='' ng-click="grid.columnsProvider.showAllColumns()">
+        <span class="glyphicon glyphicon-resize-full"></span>
+        [% l('Show All Columns') %]
+      </a></li>
+      <li><a href='' ng-click="grid.columnsProvider.hideAllColumns()">
+        <span class="glyphicon glyphicon-resize-small"></span>
+        [% l('Hide All Columns') %]
+      </a></li>
+      <li><a ng-click="grid.generateCSVExportURL()" 
+        download="{{grid.csvExportFileName}}.csv" ng-href="{{grid.csvExportURL}}">
+        <span class="glyphicon glyphicon-download"></span>
+        [% l('Download CSV') %]
+      </a></li>
+      <li role="presentation" class="divider"></li>
+      <li ng-repeat="col in grid.columnsProvider.columns">
+        <a href='' ng-click="grid.columnsProvider.visible[col.name] = 
+            !grid.columnsProvider.visible[col.name]">
+            <span ng-if="grid.columnsProvider.visible[col.name]" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!grid.columnsProvider.visible[col.name]" 
+              class="label label-warning">&#x2717;</span>
+            <span>{{col.label}}</span>
+        </a>
+      </li>
+    </ul>
+  </div>
+</div>
+
+<!-- Grid -->
+<div class="eg-grid" ng-class="{'eg-grid-as-conf' : grid.showGridConf}">
+
+  <!-- import our eg-grid-field defs -->
+  <div ng-transclude></div>
+
+  <div class="eg-grid-row eg-grid-header-row">
+    <div class="eg-grid-cell eg-grid-cell-stock" style="flex:{{grid.indexFlex}}">
+      <div>[% l('#') %]</div>
+    </div>
+    <div class="eg-grid-cell eg-grid-cell-stock" style="flex:{{grid.selectorFlex}}">
+      <div>
+        <input type='checkbox' ng-click="grid.toggleSelectAllItems()"/>
+      </div>
+    </div>
+    <div class="eg-grid-cell"
+        eg-grid-column-drag-dest
+        column="{{col.name}}"
+        eg-right-click="grid.onContextMenu($event)"
+        ng-repeat="col in grid.columnsProvider.columns"
+        style="flex:{{col.flex}}"
+        ng-show="grid.columnsProvider.visible[col.name]">
+
+        <div style="display:flex">
+          <div style="flex:1">
+            <a column="{{col.name}}" href='' 
+              eg-grid-column-drag-source
+              ng-click="grid.quickSort(col.name)">{{col.label}}</a>
+          </div>
+          <div eg-grid-column-drag-source 
+            drag-type="resize" column="{{col.name}}" 
+            class="eg-grid-column-drag-handle">&nbsp;</div>
+        </div>
+    </div>
+  </div>
+
+  <!-- Inline grid configuration row -->
+  <div class="eg-grid-row eg-grid-conf-row" ng-show="grid.showGridConf">
+    <div class="eg-grid-cell eg-grid-cell-conf-header" 
+        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>
+    <div class="eg-grid-cell"
+      ng-repeat="col in grid.columnsProvider.columns"
+      style="flex:{{col.flex}}"
+      ng-show="grid.columnsProvider.visible[col.name]">
+      <div class="eg-grid-conf-cell-entry">
+        <a href="" title="[% l('Make column wider') %]"
+          ng-click="grid.modifyColumnFlex(col,1)">
+          <span class="glyphicon glyphicon-fast-forward"></span>
+        </a>
+      </div>
+      <div class="eg-grid-conf-cell-entry">
+        <a href="" title="[% l('Make column narrower') %]"
+          ng-click="grid.modifyColumnFlex(col,-1)">
+          <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>
+    </div>
+  </div>
+
+  <div class="eg-grid-content-body">
+    <div ng-show="grid.count() == 0" 
+      class="alert alert-info">[% l('No Items To Display') %]</div>
+
+    <div class="eg-grid-row" 
+        id="eg-grid-row-{{$index + 1}}"
+        ng-repeat="item in grid.items"
+        ng-show="grid.count() > 0"
+        ng-class="{'eg-grid-row-selected' : grid.selected[grid.indexValue(item)]}">
+      <div class="eg-grid-cell eg-grid-cell-stock" style="flex:{{grid.indexFlex}}"
+        ng-click="grid.handleRowClick($event, item)">
+        {{$index + grid.offset + 1}}
+      </div>
+      <div class="eg-grid-cell eg-grid-cell-stock" style="flex:{{grid.selectorFlex}}">
+        <!-- ng-click=handleRowClick here has unintended 
+             consequences and is unnecessary, avoid it -->
+        <div>
+          <input type='checkbox'  
+            ng-model="grid.selected[grid.indexValue(item)]"/>
+        </div>
+      </div>
+      <div class="eg-grid-cell eg-grid-cell-content"
+          ng-click="grid.handleRowClick($event, item)"
+          ng-repeat="col in grid.columnsProvider.columns"
+          style="flex:{{col.flex}}"
+          ng-show="grid.columnsProvider.visible[col.name]">
+        {{grid.dataProvider.itemFieldValue(item, col) | egGridValueFilter:col}}
+      </div>
+    </div>
+  </div>
+
+
+</div>
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/grid.js b/Open-ILS/web/js/ui/default/staff/services/grid.js
new file mode 100644 (file)
index 0000000..c9ef91b
--- /dev/null
@@ -0,0 +1,779 @@
+angular.module('egGridMod', 
+    ['egCoreMod', 'egUiMod', 'ui.bootstrap'])
+
+.directive('egGrid', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        scope : {
+
+            // IDL class hint (e.g. "aou")
+            idlClass : '@',
+
+            // points to a structure in the calling scope which defines
+            // a PCRUD-compliant query.
+            query : '=',
+
+            // if true, grid columns are derived from all non-virtual
+            // fields on the base idlClass
+            autoFields : '@',
+
+            // grid preferences will be stored / retrieved with this key
+            persistKey : '@',
+
+            // field whose value is unique and may be used for item
+            // reference / lookup.  This will usually be someting like
+            // "id".  This is not needed when using autoFields, since we
+            // can determine the primary key directly from the IDL.
+            idField : '@',
+
+            // egList containting our tabular data is provided for us
+            // and managed externally.
+            dataProvider : '=',
+            
+            // if true, hide the sortPriority options in the
+            // grid configuration UI.  This is primarily used by
+            // UIs where the data is ephemeral and can only be
+            // single-display-column sorted.
+            disableSortPriority : '@',
+
+            // optional primary grid label
+            mainLabel : '@'
+        },
+
+        // TODO: avoid hard-coded url
+        templateUrl : '/eg/staff/parts/t_autogrid', 
+
+        link : function(scope, element, attrs) {     
+            // link() is called after page compilation, which means our
+            // eg-grid-field's have been parsed and loaded.  Now it's 
+            // safe to perform our initial page load.
+            scope.grid.collect();
+        },
+
+        controller : [
+                    '$scope','egIDL','egAuth','egNet',
+                    'egGridFlatDataProvider','egGridColumnsProvider',
+                    '$filter','$window',
+            function($scope,  egIDL,  egAuth,  egNet,  
+                    egGridFlatDataProvider,  egGridColumnsProvider,
+                    $filter,  $window) {
+
+            var grid = this;
+
+            grid.init = function() {
+                grid.offset = 0;
+                grid.limit = 25;
+                grid.items = [];
+                grid.selected = {}; // idField-based
+                grid.totalCount = -1;
+                grid.dataProvider = $scope.dataProvider;
+                grid.idlClass = $scope.idlClass;
+                grid.mainLabel = $scope.mainLabel;
+                grid.indexField = $scope.idField;
+                grid.showGridConf = false;
+
+                // default flex values for the index and selector columns
+                grid.indexFlex = 1;
+                grid.selectorFlex = 1;
+
+                grid.columnsProvider = egGridColumnsProvider.instance({
+                    idlClass : grid.idlClass
+                });
+
+                if ($scope.autoFields) {
+                    grid.indexField = egIDL.classes[grid.idlClass].pkey;
+                    if (!grid.mainLabel)
+                        grid.mainLabel = egIDL.classes[grid.idlClass].label;
+                    grid.columnsProvider.compileAutoColumns();
+                }
+
+                if (!grid.dataProvider) {
+                    grid.selfManagedData = true;
+                    grid.dataProvider = egGridFlatDataProvider.instance({
+                        idlClass : grid.idlClass,
+                        columnsProvider : grid.columnsProvider,
+                        query : $scope.query
+                    });
+                }
+
+                grid.compileSort();
+                $scope.grid = grid;
+            }
+
+            grid.onContextMenu = function($event) {
+                var col = angular.element($event.target).attr('column');
+            }
+
+            grid.page = function() {
+                return (grid.offset / grid.limit) + 1;
+            }
+
+            grid.goToPage = function(page) {
+                page = Number(page);
+                if (angular.isNumber(page) && page > 0) {
+                    grid.offset = (page - 1) * grid.limit;
+                    grid.collect();
+                }
+            }
+
+            grid.onFirstPage = function() {
+                return grid.offset == 0;
+            }
+
+            grid.hasNextPage = function() {
+                // we have less data than requested, there must
+                // not be any more pages
+                if (grid.count() < grid.limit) return false;
+
+                // if the total count is not known, assume that a full
+                // page of data implies more pages are available.
+                if (grid.totalCount == -1) return true;
+
+                // we have a full page of data, but is there more?
+                return grid.totalCount > (grid.offset + grid.count());
+            }
+
+            grid.incrementPage = function() {
+                grid.offset += grid.limit;
+                grid.collect();
+            }
+
+            grid.decrementPage = function() {
+                if (grid.offset < grid.limit) {
+                    grid.offset = 0;
+                } else {
+                    grid.offset -= grid.limit;
+                }
+                grid.collect();
+            }
+
+            // number of items loaded for the current page of results
+            grid.count = function() {
+                return grid.items.length;
+            }
+
+            // returns the unique identifier value for the provided item
+            grid.indexValue = function(item) {
+                if (angular.isObject(item)) {
+                    if (item !== null) {
+                        if (grid.indexFieldAsFunction) 
+                            return item[grid.indexField]();
+                        return item[grid.indexField];
+                    }
+                }
+                // passed a non-object; assume it's an index
+                return item; 
+            }
+
+            // selects one row after deselecting all of the others
+            grid.selectOneItem = function(index) {
+                grid.selected = {};
+                grid.selected[index] = true;
+            }
+
+            // selects or deselects an item, without affecting the others.
+            // returns true if the item is selected; false if de-selected.
+            grid.toggleSelectOneItem = function(index) {
+                if (grid.selected[index]) {
+                    delete grid.selected[index];
+                    return false;
+                } else {
+                    return grid.selected[index] = true;
+                }
+            }
+
+            grid.selectAllItems = function() {
+                angular.forEach(grid.items, function(item) {
+                    grid.selected[grid.indexValue(item)] = true
+                });
+            }
+
+            // if all are selected, deselect all, otherwise select all
+            grid.toggleSelectAllItems = function() {
+                if (Object.keys(grid.selected).length == grid.items.length) {
+                    grid.selected = {};
+                } else {
+                    grid.selectAllItems();
+                }
+            }
+
+            // returns true if item1 appears in the list before item2;
+            // false otherwise.  this is slightly more efficient that
+            // finding the position of each then comparing them.
+            // item1 / item2 may be an item or an item index
+            grid.itemComesBefore = function(itemOrIndex1, itemOrIndex2) {
+                var idx1 = grid.indexValue(itemOrIndex1);
+                var idx2 = grid.indexValue(itemOrIndex2);
+
+                // use for() for early exit
+                for (var i = 0; i < grid.items.length; i++) {
+                    var idx = grid.indexValue(grid.items[i]);
+                    if (idx == idx1) return true;
+                    if (idx == idx2) return false;
+                }
+                return false;
+            }
+
+            // 0-based position of item in the current data set
+            grid.indexOf = function(item) {
+                var idx = grid.indexValue(item);
+                for (var i = 0; i < grid.items.length; i++) {
+                    if (grid.indexValue(grid.items[i]) == idx)
+                        return i;
+                }
+                return -1;
+            }
+
+            grid.modifyColumnFlex = function(column, val) {
+                column.flex += val;
+                // prevent flex:0;  use hiding instead
+                if (column.flex < 1)
+                    column.flex = 1;
+            }
+
+            // handles click, control-click, and shift-click
+            grid.handleRowClick = function($event, item) {
+                var index = grid.indexValue(item);
+
+                if ($event.ctrlKey || $event.metaKey /* mac command */) {
+                    // control-click
+                    if (grid.toggleSelectOneItem(index)) 
+                        grid.lastSelectedItemIndex = index;
+
+                } else if ($event.shiftKey) { 
+                    // shift-click
+                    if (!grid.lastSelectedItemIndex || 
+                            index == grid.lastSelectedItemIndex) {
+                        // no source row, just do a simple select
+                        grid.selectOneItem(index);
+                        grid.lastSelectedItemIndex = index;
+                        return;
+                    }
+
+                    var selecting = false;
+                    var ascending = 
+                        grid.itemComesBefore(grid.lastSelectedItemIndex, item);
+                    var startPos = 
+                        grid.indexOf(grid.lastSelectedItemIndex);
+
+                    // update to new last-selected
+                    grid.lastSelectedItemIndex = index;
+
+                    // select each row between the last selected and 
+                    // currently selected items
+                    while (true) {
+                        startPos += ascending ? 1 : -1;
+                        var curItem = grid.items[startPos];
+                        if (!curItem) break;
+                        var curIdx = grid.indexValue(curItem);
+                        grid.selected[curIdx] = true;
+                        if (curIdx == index) break; // all done
+                    }
+                        
+                } else {
+                    grid.selectOneItem(index);
+                    grid.lastSelectedItemIndex = index;
+                }
+            }
+
+            // Builds a sort expression from column sort priorities.
+            // called on page load and any time the priorities are modified.
+            grid.compileSort = function() {
+                var sortList = grid.columnsProvider.columns.filter(
+                    function(col) { return Number(col.sort) != 0 }
+                ).sort( 
+                    function(a, b) { 
+                        if (Math.abs(a.sort) < Math.abs(b.sort))
+                            return -1;
+                        return 1;
+                    }
+                );
+
+                if (sortList.length) {
+                    grid.dataProvider.sort = sortList.map(function(col) {
+                        var blob = {};
+                        blob[col.name] = col.sort < 0 ? 'desc' : 'asc';
+                        return blob;
+                    });
+                }
+            }
+
+            // builds a sort expression using a single column, 
+            // toggling between ascending and descending sort.
+            grid.quickSort = function(col_name) {
+                var sort = grid.dataProvider.sort;
+                if (sort && sort.length &&
+                    sort[0] == col_name) {
+                    var blob = {};
+                    blob[col_name] = 'desc';
+                    grid.dataProvider.sort = [blob];
+                } else {
+                    grid.dataProvider.sort = [col_name];
+                }
+
+                grid.collect();
+            }
+
+            // show / hide the grid configuration row
+            grid.toggleConfDisplay = function() {
+                if (grid.showGridConf) {
+                    grid.showGridConf = false;
+                    grid.compileSort();
+                    grid.collect();
+                } else {
+                    grid.showGridConf = true;
+                }
+            }
+
+            // called when a dragged column is dropped onto itself
+            // or any other column
+            grid.onColumnDrop = function(target) {
+                if (angular.isUndefined(target)) return;
+                if (target == grid.dragColumn) return;
+                var srcIdx, targetIdx, srcCol;
+                angular.forEach(grid.columnsProvider.columns,
+                    function(col, idx) {
+                        if (col.name == grid.dragColumn) {
+                            srcIdx = idx;
+                            srcCol = col;
+                        } else if (col.name == target) {
+                            targetIdx = idx;
+                        }
+                    }
+                );
+
+                if (srcIdx < targetIdx) targetIdx--;
+
+                // move src column from old location to new location in 
+                // the columns array, then force a page refresh
+                grid.columnsProvider.columns.splice(srcIdx, 1);
+                grid.columnsProvider.columns.splice(targetIdx, 0, srcCol);
+                $scope.$apply(); 
+            }
+
+            // prepares a string for inclusion within a CSV document
+            // by escaping commas and quotes and removing newlines.
+            grid.csvDatum = function(str) {
+                str = ''+str;
+                if (!str) return '';
+                str = str.replace(/\n/g, '');
+                if (str.match(/\,/) || str.match(/"/)) {                                     
+                    str = str.replace(/"/g, '""');
+                    str = '"' + str + '"';                                           
+                } 
+                return str;
+            }
+
+            // sets the download file name and inserts the current CSV
+            // into a Blob URL for browser download.
+            grid.generateCSVExportURL = function() {
+
+                // let the file name describe the grid
+                grid.csvExportFileName = 
+                    (grid.mainLabel || grid.persistKey || 'eg_grid_data')
+                    .replace(/\s+/g, '_') + '_' + grid.page();
+
+                // toss the CSV into a Blob and update the export URL
+                var csv = grid.generateCSV();
+                var blob = new Blob([csv], {type : 'text/plain'});
+                grid.csvExportURL = 
+                    ($window.URL || $window.webkitURL).createObjectURL(blob);
+            }
+
+            // generates CSV for the currently visible grid contents
+            grid.generateCSV = function() {
+                var csvStr = '';
+                var colCount = grid.columnsProvider.columns.length;
+
+                // columns
+                angular.forEach(grid.columnsProvider.columns,
+                    function(col, idx) {
+                        csvStr += grid.csvDatum(col.name);
+                        if (idx < colCount -1) csvStr += ',';
+                    }
+                );
+
+                csvStr += "\n";
+
+                // items
+                angular.forEach(grid.items, function(item) {
+                    angular.forEach(grid.columnsProvider.columns, 
+                        function(col, idx) {
+                            // bare value
+                            var val = grid.dataProvider.itemFieldValue(item, col);
+                            // filtered value (dates, etc.)
+                            val = $filter('egGridValueFilter')(val, col);
+                            csvStr += grid.csvDatum(val);
+                            if (idx < colCount -1) csvStr += ',';
+                        }
+                    );
+                    csvStr += "\n";
+                });
+
+                return csvStr;
+            }
+
+            // asks the dataProvider for a page of data
+            grid.collect = function() {
+                grid.items = [];
+                grid.selectedItems = {};
+                grid.dataProvider.get(
+                    grid.offset, 
+                    grid.limit, 
+                    function(item) {
+                        if (item) grid.items.push(item)
+                    }
+                );
+            }
+
+            grid.init();
+        }]
+    };
+})
+
+/**
+ * eg-grid-field : used for collecting custom field data from the templates.
+ * This directive does not direct display, it just passes data up to the 
+ * parent grid.
+ */
+.directive('egGridField', function() {
+    return {
+        require : '^egGrid',
+        restrict : 'AE',
+        transclude : true,
+        scope : {
+            name  : '@', // required; unique name
+            path  : '@', // optional; flesh path
+            label : '@', // optional; display label
+            flex  : '@', // optoinal; default flex width
+        },
+        template : '<div></div>', // NOOP template
+        link : function(scope, element, attrs, egGridCtrl) {
+            egGridCtrl.addColumn(scope);
+        }
+    };
+})
+
+.factory('egGridColumnsProvider', ['egIDL', function(egIDL) {
+
+    function ColumnsProvider(args) {
+        var cols = this;
+        cols.columns = [];
+        cols.visible = {};
+        cols.idlClass = args.idlClass;
+
+        cols.showAllColumns = function() {
+            angular.forEach(cols.columns, function(column) {
+                cols.visible[column.name] = true;
+            });
+        }
+
+        cols.hideAllColumns = function() {
+            cols.visible = {};
+        }
+
+        cols.indexOf = function(name) {
+            for (var i = 0; i < cols.columns.length; i++) {
+                if (cols.columns[i].name == name) 
+                    return i;
+            }
+            return -1;
+        }
+
+        cols.findColumn = function(name) {
+            return cols.columns[cols.indexOf(name)];
+        }
+
+        cols.compileAutoColumns = function() {
+
+            var idl_class = egIDL.classes[cols.idlClass];
+
+            angular.forEach(
+                idl_class.fields.sort(
+                    function(a, b) { return a.name < b.name ? -1 : 1 }),
+                function(field) {
+                    if (field.virtual) return;
+                    if (field.datatype == 'link' || field.datatype == 'org_unit') {
+                        // if the field is a link and the linked class has a
+                        // "selector" field specified, use the selector field
+                        // as the display field for the columns.
+                        // flattener will take care of the fleshing.
+                        if (field['class']) {
+                            var selector_field = egIDL.classes[field['class']].fields
+                                .filter(function(f) { return Boolean(f.selector) })[0];
+                            if (selector_field) {
+                                field.path = field.name + '.' + selector_field.selector;
+                            }
+                        }
+                    }
+                    cols.add(field, true);
+                }
+            );
+        }
+
+        // Add a column to the columns collection.
+        // Columns may come from a slim eg-columns-field or 
+        // directly from the IDL.
+        cols.add = function(colSpec, fromIDL) {
+
+            var column = {
+                name  : colSpec.name,
+                label : colSpec.label,
+                path  : colSpec.path,
+                flex  : Number(colSpec.flex) || 2,
+                sort  : Number(colSpec.sort) || 0,
+                datatype : colSpec.datatype,
+            };
+
+            if (!column.name) column.name = column.path;
+            if (!column.path) column.path = column.name;
+
+            if (colSpec.display !== false)
+                cols.visible[column.name] = true;
+
+            cols.columns.push(column);
+
+            if (fromIDL) return;
+
+            // lookup the matching IDL field
+            var idl_field = cols.idlFieldFromPath(column.path);
+
+            if (!idl_field) return; // ad-hoc field
+
+            column.datatype = idl_field.datatype;
+            
+            if (!column.label) {
+                column.label = idl_field.label || column.name;
+            }
+        },
+
+        // finds the IDL field from the dotpath, using the columns
+        // idlClass as the base.
+        cols.idlFieldFromPath = function(dotpath) {
+            var class_obj = egIDL.classes[cols.idlClass];
+            var path_parts = dotpath.split(/\./);
+
+            // for() == early exit
+            var idl_field;
+            for (var path_idx in path_parts) {
+                var part = path_parts[path_idx];
+
+                // find the field object matching the path component
+                for (var field_idx in class_obj.fields) {
+                    if (class_obj.fields[field_idx].name == part) {
+                        idl_field = class_obj.fields[field_idx];
+                        break;
+                    }
+                }
+
+                // unless we're at the end of the list, this field should
+                // link to another class.
+
+                if (idl_field && idl_field['class'] && (
+                    idl_field.datatype == 'link' || 
+                    idl_field.datatype == 'org_unit')) {
+                    class_obj = egIDL.classes[idl_field['class']];
+                } else {
+                    if (path_idx < (path_parts.length - 1)) {
+                        // we ran out of classes to hop through before
+                        // we ran out of path components
+                        console.error("egGrid: invalid IDL path: " + path);
+                    }
+                }
+            }
+
+            return idl_field;
+        }
+    }
+
+    return {
+        instance : function(args) { return new ColumnsProvider(args) }
+    }
+}])
+
+// Factory service for egGridDataManager instances, which are
+// responsible for collecting flattened grid data.
+.factory('egGridFlatDataProvider', 
+           ['egNet','egAuth',
+    function(egNet,  egAuth) {
+
+        function FlatDataProvider(args) {
+            var gridData = this;
+
+            gridData.idlClass = args.idlClass;
+            gridData.query = args.query;
+            gridData.columnsProvider = args.columnsProvider;
+            gridData.sort = [];
+
+            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;
+                });
+
+                egNet.request(
+                    'open-ils.fielder',
+                    'open-ils.fielder.flattened_search',
+                    egAuth.token(), gridData.idlClass, queryFields,
+                    gridData.query,
+                    {   sort : gridData.sort,
+                        limit : count,
+                        offset : index
+                    }
+                ).then( 
+                    null, null, 
+                    function(item) { onresponse(item) }
+                );
+            }
+
+            gridData.itemFieldValue = function(item, column) {
+                // all of our data is flattened
+                return item[column.name];
+            }
+        }
+
+        return {
+            instance : function(args) {
+                return new FlatDataProvider(args);
+            }
+        };
+    }
+])
+
+.directive('egGridColumnDragSource', function() {
+    return {
+        restrict : 'A',
+        require : '^egGrid',
+        link : function(scope, element, attrs, egGridCtrl) {
+            angular.element(element).attr('draggable', 'true');
+
+            element.bind('dragstart', function(e) {
+                egGridCtrl.dragColumn = attrs.column;
+                egGridCtrl.dragType = attrs.dragType || 'move'; // or resize
+                egGridCtrl.colResizeDir = 0;
+                angular.element(e.target).addClass('eg-grid-col-drag');
+            });
+
+            element.bind('dragend', function(e) {
+                if (egGridCtrl.dragType == 'move')
+                    angular.element(e.target).removeClass('eg-grid-col-drag');
+            });
+        }
+    };
+})
+
+.directive('egGridColumnDragDest', function() {
+    return {
+        restrict : 'A',
+        require : '^egGrid',
+        link : function(scope, element, attrs, egGridCtrl) {
+
+            element.bind('dragover', function(e) { // required for drop
+                e.stopPropagation();
+                e.preventDefault();
+                e.dataTransfer.dropEffect = 'move';
+
+                if (egGridCtrl.colResizeDir == 0) return; // move
+
+                var cols = egGridCtrl.columnsProvider;
+                var srcCol = egGridCtrl.dragColumn;
+                var srcColIdx = cols.indexOf(srcCol);
+
+                if (egGridCtrl.colResizeDir == -1) {
+                    if (cols.indexOf(attrs.column) <= srcColIdx) {
+                        egGridCtrl.modifyColumnFlex(
+                            egGridCtrl.columnsProvider.findColumn(
+                                egGridCtrl.dragColumn), -1);
+                        if (cols.columns[srcColIdx+1]) {
+                            // source column shrinks by one, column to the
+                            // right grows by one.
+                            egGridCtrl.modifyColumnFlex(
+                                cols.columns[srcColIdx+1], 1);
+                        }
+                        scope.$apply();
+                    }
+                } else {
+                    if (cols.indexOf(attrs.column) > srcColIdx) {
+                        egGridCtrl.modifyColumnFlex( 
+                            egGridCtrl.columnsProvider.findColumn(
+                                egGridCtrl.dragColumn), 1);
+                        if (cols.columns[srcColIdx+1]) {
+                            // source column grows by one, column to the 
+                            // right grows by one.
+                            egGridCtrl.modifyColumnFlex(
+                                cols.columns[srcColIdx+1], -1);
+                        }
+
+                        scope.$apply();
+                    }
+                }
+            });
+
+            element.bind('dragenter', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).addClass('eg-grid-col-hover');
+                } else {
+                    // resize grips are on the right side of each column.
+                    // dragenter will either occur on the source column 
+                    // (dragging left) or the column to the right.
+                    if (egGridCtrl.colResizeDir == 0) {
+                        if (egGridCtrl.dragColumn == attrs.column) {
+                            egGridCtrl.colResizeDir = -1; // west
+                        } else {
+                            egGridCtrl.colResizeDir = 1; // east
+                        }
+                    }
+                }
+            });
+
+            element.bind('dragleave', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass('eg-grid-col-hover');
+                }
+            });
+
+            element.bind('drop', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                egGridCtrl.colResizeDir = 0;
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass('eg-grid-col-hover');
+                    egGridCtrl.onColumnDrop(attrs.column); // move the column
+                }
+            });
+        }
+    };
+})
+
+
+
+/**
+ * Translates bare IDL object values into display values.
+ * 1. Passes dates through the angular date filter
+ * 2. Translates bools to Booleans so the browser can display translated 
+ *    value.  (Though we could manually translate instead..)
+ * Others likely to follow...
+ */
+.filter('egGridValueFilter', ['$filter', function($filter) {                         
+    return function(value, column) {                                             
+        switch(column.datatype) {                                                
+            case 'bool':                                                       
+                // Browser will translate true/false for us                    
+                return Boolean(value == 't');                                  
+            case 'timestamp':                                                  
+                // canned angular date filter FTW                              
+                return $filter('date')(value);                                 
+            default:                                                           
+                return value;                                                  
+        }                                                                      
+    }                                                                          
+}]);
+