From 177243d685ad7960a1997137601c769016799fa1 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Tue, 1 Apr 2014 12:34:58 -0400 Subject: [PATCH] web staff : grid; rearranging to clean up dupe code and make infin-scroll easier Signed-off-by: Bill Erickson --- Open-ILS/src/templates/staff/parts/t_autogrid2.tt2 | 57 +++ Open-ILS/src/templates/staff/test/index.tt2 | 4 +- Open-ILS/src/templates/staff/test/t_autogrid.tt2 | 10 +- Open-ILS/web/js/ui/default/staff/services/grid2.js | 495 +++++++++++++++++++++ Open-ILS/web/js/ui/default/staff/test/app.js | 26 +- 5 files changed, 561 insertions(+), 31 deletions(-) create mode 100644 Open-ILS/src/templates/staff/parts/t_autogrid2.tt2 create mode 100644 Open-ILS/web/js/ui/default/staff/services/grid2.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 index 0000000000..1ce032fd2a --- /dev/null +++ b/Open-ILS/src/templates/staff/parts/t_autogrid2.tt2 @@ -0,0 +1,57 @@ + + +
+ +
+ +
+
+
[% l('#') %]
+
+
+
+ +
+
+ +
+ +
+ +
[% l('No Items To Display') %]
+ +
+
+ {{$index + grid.offset}} +
+
+ +
+ +
+
+
+ {{grid.dataProvider.itemFieldValue(item, col) | egGridvalueFilter:col}} +
+
+
+ + +
+ diff --git a/Open-ILS/src/templates/staff/test/index.tt2 b/Open-ILS/src/templates/staff/test/index.tt2 index 3ecd3f14c0..7ebbcbd000 100644 --- a/Open-ILS/src/templates/staff/test/index.tt2 +++ b/Open-ILS/src/templates/staff/test/index.tt2 @@ -8,9 +8,7 @@ [% BLOCK APP_JS %] - - - + [% END %] diff --git a/Open-ILS/src/templates/staff/test/t_autogrid.tt2 b/Open-ILS/src/templates/staff/test/t_autogrid.tt2 index dc52faa121..5824c7ce45 100644 --- a/Open-ILS/src/templates/staff/test/t_autogrid.tt2 +++ b/Open-ILS/src/templates/staff/test/t_autogrid.tt2 @@ -1,3 +1,4 @@ + @@ -27,15 +23,13 @@ +--> - 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 index 0000000000..19be36e243 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/grid2.js @@ -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 : '
', // 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; + } + } +}]); + diff --git a/Open-ILS/web/js/ui/default/staff/test/app.js b/Open-ILS/web/js/ui/default/staff/test/app.js index e715b3b1ae..27e6fe1e71 100644 --- a/Open-ILS/web/js/ui/default/staff/test/app.js +++ b/Open-ILS/web/js/ui/default/staff/test/app.js @@ -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 = []; }); -- 2.11.0