web staff : grid; rearranging to clean up dupe code and make infin-scroll easier
authorBill Erickson <berick@esilibrary.com>
Tue, 1 Apr 2014 16:34:58 +0000 (12:34 -0400)
committerBill Erickson <berick@esilibrary.com>
Tue, 1 Apr 2014 16:34:58 +0000 (12:34 -0400)
Signed-off-by: Bill Erickson <berick@esilibrary.com>
Open-ILS/src/templates/staff/parts/t_autogrid2.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/test/index.tt2
Open-ILS/src/templates/staff/test/t_autogrid.tt2
Open-ILS/web/js/ui/default/staff/services/grid2.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/app.js

diff --git a/Open-ILS/src/templates/staff/parts/t_autogrid2.tt2 b/Open-ILS/src/templates/staff/parts/t_autogrid2.tt2
new file mode 100644 (file)
index 0000000..1ce032f
--- /dev/null
@@ -0,0 +1,57 @@
+
+<!-- Grid -->
+<div class="eg-grid" ng-class="{'eg-grid-as-conf' : grid.showGridConf}">
+
+  <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"
+        ng-repeat="col in grid.columnsProvider.columns"
+        style="flex:{{col.flex}}"
+        ng-show="grid.columnsProvider.visible[col.name]">
+        <a href='' ng-click="grid.quickSort(col.name)">{{col.label}}</a>
+    </div>
+  </div>
+
+  <div class="eg-grid-content-body" ng-repeat="item in grid.items">
+
+    <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-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}}
+      </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"
+          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>
+
index 3ecd3f1..7ebbcbd 100644 (file)
@@ -8,9 +8,7 @@
 [% BLOCK APP_JS %]
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/list.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
-<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
-<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui-scroll-jqlite.js"></script>
-<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui-scroll.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid2.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/test/app.js"></script>
 [% END %]
 
index dc52faa..5824c7c 100644 (file)
@@ -1,3 +1,4 @@
+<!--
 <h1>AutoGrid Explicit Fields</h1>
 
 <eg-grid 
@@ -6,11 +7,6 @@
   query="testGridQuery"
   persist-key="eg.staff.test.grid.explicit-fields"
   id-field="id">
-  <!-- 
-  eg-list="testEgList"
-    eg-grid-field's require closing tags; 
-    you can also do <div eg-grid-tag attrs..></div>
-  -->
   <eg-grid-field path="shortname" label="[% l('Shortname') %]"></eg-grid-field>
   <eg-grid-field path="name" flex="5"></eg-grid-field>
   <eg-grid-field path="id"></eg-grid-field>
   <eg-grid-field path="parent_ou.ou_type.depth" label="[% l('Parent Depth') %]"></eg-grid-field>
   <eg-grid-field path="parent_ou.shortname" label="[% l('Parent SN') %]"></eg-grid-field>
 </eg-grid>
+-->
 
-<!--
 <h1>AutoGrid w/ Auto Fields</h1>
 
 <eg-grid
   persist-key="eg.staff.test.grid.auto-fields"
   idl-class="rmsr"
-  is-scroll="true"
   sort="testGridSort"
   query="testGridQuery"
   auto-fields="true"/>
--->
diff --git a/Open-ILS/web/js/ui/default/staff/services/grid2.js b/Open-ILS/web/js/ui/default/staff/services/grid2.js
new file mode 100644 (file)
index 0000000..19be36e
--- /dev/null
@@ -0,0 +1,495 @@
+angular.module('egGridMod', 
+    ['egCoreMod', '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_autogrid2', 
+
+        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',
+            function($scope,  egIDL,  egAuth,  egNet,  
+                    egGridFlatDataProvider,  egGridColumnsProvider) {
+
+            var grid = this;
+
+            grid.init = function() {
+                grid.offset = 0;
+                grid.limit = 25;
+                grid.items = [];
+                grid.selected = {}; // idField-based
+                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;
+                    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.count = function() {
+                return grid.items.length;
+            }
+
+            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.comesBefore = 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.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.comesBefore(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();
+            }
+
+            // 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) {
+                col.visible[column.name] = true;
+            });
+        }
+
+        cols.hideAllColumns = function() {
+            cols.visible = {};
+        }
+
+        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);
+            }
+        };
+    }
+])
+
+/**
+ * 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, item) {                                             
+        switch(item.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;                                                  
+        }                                                                      
+    }                                                                          
+}]);
+
index e715b3b..27e6fe1 100644 (file)
@@ -1,5 +1,5 @@
 angular.module('egTestApp', ['ngRoute', 'ui.bootstrap', 
-    'egCoreMod', 'egUiMod', 'egListMod', 'egGridMod'])
+    'egCoreMod', 'egUiMod', 'egGridMod'])
 
 .config(function($routeProvider, $locationProvider) {
     $locationProvider.html5Mode(true);
@@ -18,7 +18,6 @@ angular.module('egTestApp', ['ngRoute', 'ui.bootstrap',
         resolve : resolver
     });
 
-
     //$routeProvider.otherwise({redirectTo : '/circ/patron/search'});
 })
 
@@ -26,31 +25,18 @@ angular.module('egTestApp', ['ngRoute', 'ui.bootstrap',
     function($scope, $rootScope, $timeout) {
 }])
 
-.controller('TestGridCtrl', function($scope, $timeout, egList) {
-    var self = this;
+.controller('TestGridCtrl', function($scope, $timeout) {
     console.log('TestGridCtrl');
-
     $scope.testGridQuery = {id : {'<>' : null}};
-    $scope.testGridSort = ['depth', 'parent_ou_id', 'name']
-    $scope.testEgList = egList.create();
-    $timeout(function() { 
-        $scope.testEgList.items.push({
-            name : 'foo',
-            id : '1'
-        })
-        $scope.testEgList.items.push({
-            name : 'bar',
-            id : '2'
-        })
-
-    });
+    //$scope.testGridSort = ['depth', 'parent_ou_id', 'name']
+    $scope.testGridSort = [];
 })
 
-.controller('TestGridCtrl2', function($scope, $timeout, egList) {
+.controller('TestGridCtrl2', function($scope, $timeout) {
     var self = this;
     console.log('TestGridCtrl2');
     $scope.testGridQuery = {id : {'<>' : null}};
-    $scope.testGridSort = []
+    $scope.testGridSort = [];
 });