// Reference to externally provided egGridDataProvider
itemsProvider : '=',
+ // Reference to externally provided item-selection handler
+ onSelect : '=',
+
+ // Reference to externally provided after-item-selection handler
+ afterSelect : '=',
+
// comma-separated list of supported or disabled grid features
// supported features:
+ // startSelected : init the grid with all rows selected by default
+ // allowAll : add an "All" option to row count (really 10000)
+ // -menu : don't show any menu buttons (or use space for them)
+ // -picker : don't show the column picker
+ // -pagination : don't show any pagination elements, and set
+ // the limit to 10000
+ // -actions : don't show the actions dropdown
+ // -index : don't show the row index column (can't use "index"
+ // as the idField in this case)
// -display : columns are hidden by default
// -sort : columns are unsortable by default
// -multisort : sort priorities config disabled by default
+ // -multiselect : only one row at a time can be selected;
+ // choosing this also disables the checkbox
+ // column
features : '@',
+ // optional: object containing function to conditionally apply
+ // class to each row.
+ rowClass : '=',
+
+ // optional: object that enables status icon field and contains
+ // function to handle what status icons should exist and why.
+ statusColumn : '=',
+
// optional primary grid label
mainLabel : '@',
// optional context menu label
menuLabel : '@',
+ dateformat : '@', // optional: passed down to egGridValueFilter
+ datecontext: '@', // optional: passed down to egGridValueFilter to choose TZ
+ datefilter: '@', // optional: passed down to egGridValueFilter to choose specialized date filters
+ dateonlyinterval: '@', // optional: passed down to egGridValueFilter to choose a "better" format
+
// Hash of control functions.
//
// These functions are defined by the calling scope and
// invoked as-is by the grid w/ the specified parameters.
//
+ // collectStarted : function() {}
// itemRetrieved : function(item) {}
// allItemsRetrieved : function() {}
//
templateUrl : '/eg/staff/share/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.
-
- // load auto fields after eg-grid-field's so they are not clobbered
- scope.handleAutoFields();
- scope.collect();
-
- scope.grid_element = element;
- $(element)
- .find('.eg-grid-content-body')
- .bind('contextmenu', scope.showActionContextMenu);
+
+ // Give the grid config loading steps time to fetch the
+ // workstation setting and apply columns before loading data.
+ var loadPromise = scope.configLoadPromise || $q.when();
+ loadPromise.then(function() {
+
+ // load auto fields after eg-grid-field's so they are not clobbered
+ scope.handleAutoFields();
+ scope.collect();
+
+ scope.grid_element = element;
+
+ if(!attrs.id){
+ $(element).attr('id', attrs.persistKey);
+ }
+
+ $(element)
+ .find('.eg-grid-content-body')
+ .bind('contextmenu', scope.showActionContextMenu);
+ });
},
controller : [
'$scope','$q','egCore','egGridFlatDataProvider','$location',
'egGridColumnsProvider','$filter','$window','$sce','$timeout',
+ 'egProgressDialog','$uibModal','egConfirmDialog','egStrings',
function($scope, $q , egCore, egGridFlatDataProvider , $location,
- egGridColumnsProvider , $filter , $window , $sce , $timeout) {
+ egGridColumnsProvider , $filter , $window , $sce , $timeout,
+ egProgressDialog, $uibModal , egConfirmDialog , egStrings) {
var grid = this;
$scope.actionGroups = [{actions:[]}]; // Grouped actions for selected items
$scope.menuItems = []; // global actions
+ // returns true if any rows are selected.
+ $scope.hasSelected = function() {
+ return grid.getSelectedItems().length > 0 };
+
+ var features = ($scope.features) ?
+ $scope.features.split(',') : [];
+ delete $scope.features;
+
+ $scope.showIndex = (features.indexOf('-index') == -1);
+
+ $scope.allowAll = (features.indexOf('allowAll') > -1);
+ $scope.startSelected = $scope.selectAll = (features.indexOf('startSelected') > -1);
+ $scope.showActions = (features.indexOf('-actions') == -1);
+ $scope.showPagination = (features.indexOf('-pagination') == -1);
+ $scope.showPicker = (features.indexOf('-picker') == -1);
+
+ $scope.showMenu = (features.indexOf('-menu') == -1);
+
// remove some unneeded values from the scope to reduce bloat
grid.idlClass = $scope.idlClass;
delete $scope.persistKey;
var stored_limit = 0;
- if (grid.persistKey) {
- var stored_limit = Number(
- egCore.hatch.getLocalItem('eg.grid.' + grid.persistKey + '.limit')
- );
+ if ($scope.showPagination) {
+ // localStorage of grid limits is deprecated. Limits
+ // are now stored along with the columns configuration.
+ // Values found in localStorage will be migrated upon
+ // config save.
+ if (grid.persistKey) {
+ var stored_limit = Number(
+ egCore.hatch.getLocalItem('eg.grid.' + grid.persistKey + '.limit')
+ );
+ }
+ } else {
+ stored_limit = 10000; // maybe support "Inf"?
}
+
grid.limit = Number(stored_limit) || Number($scope.pageSize) || 25;
grid.indexField = $scope.idField;
grid.dataProvider = $scope.itemsProvider;
- var features = ($scope.features) ?
- $scope.features.split(',') : [];
- delete $scope.features;
-
if (!grid.indexField && grid.idlClass)
grid.indexField = egCore.idl.classes[grid.idlClass].pkey;
grid.columnsProvider = egGridColumnsProvider.instance({
idlClass : grid.idlClass,
+ clientSort : (features.indexOf('clientsort') > -1 && features.indexOf('-clientsort') == -1),
defaultToHidden : (features.indexOf('-display') > -1),
defaultToNoSort : (features.indexOf('-sort') > -1),
- defaultToNoMultiSort : (features.indexOf('-multisort') > -1)
+ defaultToNoMultiSort : (features.indexOf('-multisort') > -1),
+ defaultDateFormat : $scope.dateformat,
+ defaultDateContext : $scope.datecontext,
+ defaultDateFilter : $scope.datefilter,
+ defaultDateOnlyInterval : $scope.dateonlyinterval
});
+ $scope.canMultiSelect = (features.indexOf('-multiselect') == -1);
$scope.handleAutoFields = function() {
if ($scope.autoFields) {
});
}
+ // make grid ref available in get() to set totalCount, if known.
+ // this allows us disable the 'next' paging button correctly
+ grid.dataProvider.grid = grid;
+
+ grid.dataProvider.columnsProvider = grid.columnsProvider;
+
$scope.itemFieldValue = grid.dataProvider.itemFieldValue;
$scope.indexValue = function(item) {
return grid.indexValue(item)
grid.applyControlFunctions();
- grid.loadConfig().then(function() {
+ $scope.configLoadPromise = grid.loadConfig().then(function() {
// link columns to scope after loadConfig(), since it
// replaces the columns array.
$scope.columns = grid.columnsProvider.columns;
// them up even if the caller doesn't request them.
var controls = $scope.gridControls || {};
+ controls.columnMap = function() {
+ var m = {};
+ angular.forEach(grid.columnsProvider.columns, function (c) {
+ m[c.name] = c;
+ });
+ return m;
+ }
+
+ controls.columnsProvider = function() {
+ return grid.columnsProvider;
+ }
+
// link in the control functions
controls.selectedItems = function() {
return grid.getSelectedItems()
}
+ controls.selectItemsByValue = function(c,v) {
+ return grid.selectItemsByValue(c,v)
+ }
+
controls.allItems = function() {
return $scope.items;
}
grid.collect();
}
+ controls.prepend = function(limit) {
+ grid.prepend(limit);
+ }
+
controls.setLimit = function(limit,forget) {
- if (!forget && grid.persistKey)
- egCore.hatch.setLocalItem('eg.grid.' + grid.persistKey + '.limit', limit);
grid.limit = limit;
+ if (!forget && grid.persistKey) {
+ $scope.saveConfig();
+ }
}
controls.getLimit = function() {
return grid.limit;
return grid.offset;
}
+ controls.saveConfig = function () {
+ return $scope.saveConfig();
+ }
+
grid.dataProvider.refresh = controls.refresh;
+ grid.dataProvider.prepend = controls.prepend;
grid.controls = controls;
}
+ // If a menu item provides its own HTML template, translate it,
+ // using the menu item for the template scope.
+ // note: $sce is required to avoid security restrictions and
+ // is OK here, since the template comes directly from a
+ // local HTML template (not user input).
+ $scope.translateMenuItemTemplate = function(item) {
+ var html = egCore.strings.$replace(item.template, {item : item});
+ return $sce.trustAsHtml(html);
+ }
+
// add a new (global) grid menu item
grid.addMenuItem = function(item) {
$scope.menuItems.push(item);
}
// only store information about visible columns.
- var conf = grid.columnsProvider.columns.filter(
+ var cols = grid.columnsProvider.columns.filter(
function(col) {return Boolean(col.visible) });
// now scrunch the data down to just the needed info
- conf = conf.map(function(col) {
+ cols = cols.map(function(col) {
var c = {name : col.name}
// Apart from the name, only store non-default values.
// No need to store col.visible, since that's implicit
return c;
});
+ var conf = {
+ version: 2,
+ limit: grid.limit,
+ columns: cols
+ };
+
egCore.hatch.setItem('eg.grid.' + grid.persistKey, conf)
.then(function() {
// Save operation performed from the grid configuration UI.
// Hide the configuration UI and re-draw w/ sort applied
if ($scope.showGridConf)
$scope.toggleConfDisplay();
+
+ // Once a version-2 grid config is saved (with limit
+ // included) we can remove the local limit pref.
+ egCore.hatch.removeLocalItem(
+ 'eg.grid.' + grid.persistKey + '.limit');
});
}
+
// load the columns configuration (position, sort, width) from
// eg.grid.<persist-key> and apply the loaded settings to the
// columns on our columnsProvider
var columns = grid.columnsProvider.columns;
var new_cols = [];
- angular.forEach(conf, function(col) {
+ if (Array.isArray(conf)) {
+ console.debug(
+ 'upgrading version 1 grid config to version 2');
+ conf = {
+ version : 2,
+ columns : conf
+ };
+ }
+
+ if (conf.limit) {
+ grid.limit = Number(conf.limit);
+ }
+
+ angular.forEach(conf.columns, function(col) {
var grid_col = columns.filter(
function(c) {return c.name == col.name})[0];
grid_col.sort = col.sort || 0;
// all saved columns are assumed to be true
grid_col.visible = true;
- new_cols.push(grid_col);
+ if (new_cols
+ .filter(function (c) {
+ return c.name == grid_col.name;
+ }).length == 0
+ )
+ new_cols.push(grid_col);
});
// columns which are not expressed within the saved
// configuration are marked as non-visible and
// appended to the end of the new list of columns.
angular.forEach(columns, function(col) {
- var found = conf.filter(
+ var found = conf.columns.filter(
function(c) {return (c.name == col.name)})[0];
if (!found) {
col.visible = false;
grid.columnsProvider.columns = new_cols;
grid.compileSort();
+
});
}
$scope.limit = function(l) {
if (angular.isNumber(l)) {
- if (grid.persistKey)
- egCore.hatch.setLocalItem('eg.grid.' + grid.persistKey + '.limit', l);
grid.limit = l;
+ if (grid.persistKey) {
+ $scope.saveConfig();
+ }
}
return grid.limit
}
// fires the hide handler function for a context action
$scope.actionHide = function(action) {
- if (!action.hide) {
+ if (typeof action.hide == 'undefined') {
+ return false;
+ }
+ if (angular.isFunction(action.hide))
+ return action.hide(action);
+ return action.hide;
+ }
+
+ // fires the disable handler function for a context action
+ $scope.actionDisable = function(action) {
+ if (typeof action.disabled == 'undefined') {
return false;
}
- return action.hide(action);
+ if (angular.isFunction(action.disabled))
+ return action.disabled(action);
+ return action.disabled;
}
// fires the action handler function for a context action
if (!$scope.menu_dom) $scope.menu_dom = $($scope.grid_element).find('.grid-action-dropdown')[0];
if (!$scope.action_context_parent) $scope.action_context_parent = $($scope.menu_dom).parent();
- if (!grid.getSelectedItems().length) // Nothing selected, fire the click event
- $event.target.click();
+ // we need the the row that got right-clicked...
+ var e = $event.target; // the DOM element
+ var s = undefined; // the angular scope for that element
+ while(e){ // searching for the row
+ // abort & use the browser default context menu for links (lp1669856):
+ if(e.tagName.toLowerCase() === 'a' && e.href){ return true; }
+ s = angular.element(e).scope();
+ if(s.hasOwnProperty('item')){ break; }
+ e = e.parentElement;
+ }
+ // select the right-clicked row if it is not already selected (lp1776557):
+ if(!$scope.selected[grid.indexValue(s.item)]){ $event.target.click(); }
if (!$scope.action_context_showing) {
$scope.action_context_width = $($scope.menu_dom).css('width');
$scope.selected[index] = true;
}
+ // selects items by a column value, first clearing selected list.
+ // we overwrite the object so that we can watch $scope.selected
+ grid.selectItemsByValue = function(column, value) {
+ $scope.selected = {};
+ angular.forEach($scope.items, function(item) {
+ var col_value;
+ if (angular.isFunction(item[column]))
+ col_value = item[column]();
+ else
+ col_value = item[column];
+
+ if (value == col_value) $scope.selected[grid.indexValue(item)] = true
+ });
+ }
+
// selects or deselects an item, without affecting the others.
// returns true if the item is selected; false if de-selected.
+ // we overwrite the object so that we can watch $scope.selected
grid.toggleSelectOneItem = function(index) {
if ($scope.selected[index]) {
delete $scope.selected[index];
+ $scope.selected = angular.copy($scope.selected);
return false;
} else {
- return $scope.selected[index] = true;
+ $scope.selected[index] = true;
+ $scope.selected = angular.copy($scope.selected);
+ return true;
}
}
+ $scope.updateSelected = function () {
+ return $scope.selected = angular.copy($scope.selected);
+ };
+
grid.selectAllItems = function() {
angular.forEach($scope.items, function(item) {
$scope.selected[grid.indexValue(item)] = true
- });
+ });
+ $scope.selected = angular.copy($scope.selected);
}
$scope.$watch('selectAll', function(newVal) {
}
});
+ if ($scope.onSelect) {
+ $scope.$watch('selected', function(newVal) {
+ $scope.onSelect(grid.getSelectedItems());
+ if ($scope.afterSelect) $scope.afterSelect();
+ });
+ }
+
// 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.
column.flex = 1;
}
$scope.modifyColumnFlex = function(col, val) {
+ $scope.lastModColumn = col;
grid.modifyColumnFlex(col, val);
}
+ $scope.isLastModifiedColumn = function(col) {
+ if ($scope.lastModColumn)
+ return $scope.lastModColumn === col;
+ return false;
+ }
+
+ grid.modifyColumnPos = function(col, diff) {
+ var srcIdx, targetIdx;
+ angular.forEach(grid.columnsProvider.columns,
+ function(c, i) { if (c.name == col.name) srcIdx = i });
+
+ targetIdx = srcIdx + diff;
+ if (targetIdx < 0) {
+ targetIdx = 0;
+ } else if (targetIdx >= grid.columnsProvider.columns.length) {
+ // Target index follows the last visible column.
+ var lastVisible = 0;
+ angular.forEach(grid.columnsProvider.columns,
+ function(column, idx) {
+ if (column.visible) lastVisible = idx;
+ }
+ );
+
+ // When moving a column (down) causes one or more
+ // visible columns to shuffle forward, our column
+ // moves into the slot of the last visible column.
+ // Otherwise, put it into the slot directly following
+ // the last visible column.
+ targetIdx =
+ srcIdx <= lastVisible ? lastVisible : lastVisible + 1;
+ }
+
+ // Splice column out of old position, insert at new position.
+ grid.columnsProvider.columns.splice(srcIdx, 1);
+ grid.columnsProvider.columns.splice(targetIdx, 0, col);
+ }
+
+ $scope.modifyColumnPos = function(col, diff) {
+ $scope.lastModColumn = col;
+ return grid.modifyColumnPos(col, diff);
+ }
+
+
// handles click, control-click, and shift-click
$scope.handleRowClick = function($event, item) {
var index = grid.indexValue(item);
var origSelected = Object.keys($scope.selected);
+ if (!$scope.canMultiSelect) {
+ grid.selectOneItem(index);
+ grid.lastSelectedItemIndex = index;
+ return;
+ }
+
if ($event.ctrlKey || $event.metaKey /* mac command */) {
// control-click
if (grid.toggleSelectOneItem(index))
$scope.selected[curIdx] = true;
if (curIdx == index) break; // all done
}
+ $scope.selected = angular.copy($scope.selected);
}
} else {
$scope.showGridConf = true;
}
+ delete $scope.lastModColumn;
$scope.gridColumnPickerIsOpen = false;
}
return str;
}
- // sets the download file name and inserts the current CSV
- // into a Blob URL for browser download.
- $scope.generateCSVExportURL = function() {
+ /** Export the full data set as CSV.
+ * Flow of events:
+ * 1. User clicks the 'download csv' link
+ * 2. All grid data is retrieved asychronously
+ * 3. Once all data is all present and CSV-ized, the download
+ * attributes are linked to the href.
+ * 4. The href .click() action is prgrammatically fired again,
+ * telling the browser to download the data, now that the
+ * data is available for download.
+ * 5 Once downloaded, the href attributes are reset.
+ */
+ grid.csvExportInProgress = false;
+ $scope.generateCSVExportURL = function($event) {
+
+ if (grid.csvExportInProgress) {
+ // This is secondary href click handler. Give the
+ // browser a moment to start the download, then reset
+ // the CSV download attributes / state.
+ $timeout(
+ function() {
+ $scope.csvExportURL = '';
+ $scope.csvExportFileName = '';
+ grid.csvExportInProgress = false;
+ }, 500
+ );
+ return;
+ }
+
+ grid.csvExportInProgress = true;
$scope.gridColumnPickerIsOpen = false;
// let the file name describe the grid
.replace(/\s+/g, '_') + '_' + $scope.page();
// toss the CSV into a Blob and update the export URL
- var csv = grid.generateCSV();
- var blob = new Blob([csv], {type : 'text/plain'});
- $scope.csvExportURL =
- ($window.URL || $window.webkitURL).createObjectURL(blob);
+ grid.generateCSV().then(function(csv) {
+ var blob = new Blob([csv], {type : 'text/plain'});
+ $scope.csvExportURL =
+ ($window.URL || $window.webkitURL).createObjectURL(blob);
+
+ // Fire the 2nd click event now that the browser has
+ // information on how to download the CSV file.
+ $timeout(function() {$event.target.click()});
+ });
}
+ /*
+ * TODO: does this serve any purpose given we can
+ * print formatted HTML? If so, generateCSV() now
+ * returns a promise, needs light refactoring...
$scope.printCSV = function() {
$scope.gridColumnPickerIsOpen = false;
egCore.print.print({
content_type : 'text/plain'
});
}
+ */
+
+ // Given a row item and column definition, extract the
+ // text content for printing. Templated columns must be
+ // processed and parsed as HTML, then boiled down to their
+ // text content.
+ grid.getItemTextContent = function(item, col) {
+ var val;
+ if (col.template) {
+ val = $scope.translateCellTemplate(col, item);
+ if (val) {
+ var node = new DOMParser()
+ .parseFromString(val, 'text/html');
+ val = $(node).text();
+ }
+ } else {
+ val = grid.dataProvider.itemFieldValue(item, col);
+ val = $filter('egGridValueFilter')(val, col, item);
+ }
+ return val;
+ }
+
+ $scope.getHtmlTooltip = function(col, item) {
+ return grid.getItemTextContent(item, col);
+ }
+
+ /**
+ * Fetches all grid data and transates each item into a simple
+ * key-value pair of column name => text-value.
+ * Included in the response for convenience is the list of
+ * currently visible column definitions.
+ * TODO: currently fetches a maximum of 10k rows. Does this
+ * need to be configurable?
+ */
+ grid.getAllItemsAsText = function() {
+ var text_items = [];
+
+ // we don't know the total number of rows we're about
+ // to retrieve, but we can indicate the number retrieved
+ // so far as each item arrives.
+ egProgressDialog.open({value : 0});
+
+ var visible_cols = grid.columnsProvider.columns.filter(
+ function(c) { return c.visible });
+
+ return grid.dataProvider.get(0, 10000).then(
+ function() {
+ return {items : text_items, columns : visible_cols};
+ },
+ null,
+ function(item) {
+ egProgressDialog.increment();
+ var text_item = {};
+ angular.forEach(visible_cols, function(col) {
+ text_item[col.name] =
+ grid.getItemTextContent(item, col);
+ });
+ text_items.push(text_item);
+ }
+ ).finally(egProgressDialog.close);
+ }
+
+ // Fetch "all" of the grid data, translate it into print-friendly
+ // text, and send it to the printer service.
+ $scope.printHTML = function() {
+ $scope.gridColumnPickerIsOpen = false;
+ return grid.getAllItemsAsText().then(function(text_items) {
+ return egCore.print.print({
+ template : 'grid_html',
+ scope : text_items
+ });
+ });
+ }
+
+ $scope.showColumnDialog = function() {
+ return $uibModal.open({
+ templateUrl: './share/t_grid_columns',
+ backdrop: 'static',
+ size : 'lg',
+ controller: ['$scope', '$uibModalInstance',
+ function($dialogScope, $uibModalInstance) {
+ $dialogScope.modifyColumnPos = $scope.modifyColumnPos;
+ $dialogScope.disableMultiSort = $scope.disableMultiSort;
+ $dialogScope.columns = $scope.columns;
+
+ // Push visible columns to the top of the list
+ $dialogScope.elevateVisible = function() {
+ var new_cols = [];
+ angular.forEach($dialogScope.columns, function(col) {
+ if (col.visible) new_cols.push(col);
+ });
+ angular.forEach($dialogScope.columns, function(col) {
+ if (!col.visible) new_cols.push(col);
+ });
+
+ // Update all references to the list of columns
+ $dialogScope.columns =
+ $scope.columns =
+ grid.columnsProvider.columns =
+ new_cols;
+ }
+
+ $dialogScope.toggle = function(col) {
+ col.visible = !Boolean(col.visible);
+ }
+ $dialogScope.ok = $dialogScope.cancel = function() {
+ delete $scope.lastModColumn;
+ $uibModalInstance.close()
+ }
+ }
+ ]
+ });
+ },
// generates CSV for the currently visible grid contents
grid.generateCSV = function() {
- var csvStr = '';
- var colCount = grid.columnsProvider.columns.length;
+ return grid.getAllItemsAsText().then(function(text_items) {
+ var columns = text_items.columns;
+ var items = text_items.items;
+ var csvStr = '';
- // columns
- angular.forEach(grid.columnsProvider.columns,
- function(col) {
- if (!col.visible) return;
+ // column headers
+ angular.forEach(columns, function(col) {
csvStr += grid.csvDatum(col.label);
csvStr += ',';
- }
- );
+ });
- csvStr = csvStr.replace(/,$/,'\n');
+ csvStr = csvStr.replace(/,$/,'\n');
- // items
- angular.forEach($scope.items, function(item) {
- angular.forEach(grid.columnsProvider.columns,
- function(col) {
- if (!col.visible) return;
- // bare value
- var val = grid.dataProvider.itemFieldValue(item, col);
- // filtered value (dates, etc.)
- val = $filter('egGridValueFilter')(val, col);
- csvStr += grid.csvDatum(val);
+ // items
+ angular.forEach(items, function(item) {
+ angular.forEach(columns, function(col) {
+ csvStr += grid.csvDatum(item[col.name]);
csvStr += ',';
- }
- );
- csvStr = csvStr.replace(/,$/,'\n');
- });
+ });
+ csvStr = csvStr.replace(/,$/,'\n');
+ });
- return csvStr;
+ return csvStr;
+ });
}
// Interpolate the value for column.linkpath within the context
$scope.collect = function() { grid.collect() }
+
+ $scope.confirmAllowAllAndCollect = function(){
+ egConfirmDialog.open(egStrings.CONFIRM_LONG_RUNNING_ACTION_ALL_ROWS_TITLE,
+ egStrings.CONFIRM_LONG_RUNNING_ACTION_MSG)
+ .result
+ .then(function(){
+ $scope.offset(0);
+ $scope.limit(10000);
+ grid.collect();
+ });
+ }
+
// asks the dataProvider for a page of data
grid.collect = function() {
$scope.items = [];
$scope.selected = {};
+
+ // Inform the caller we've asked the data provider
+ // for data. This is useful for knowing when collection
+ // has started (e.g. to display a progress dialg) when
+ // using the stock (flattener) data provider, where the
+ // user is not directly defining a get() handler.
+ if (grid.controls.collectStarted)
+ grid.controls.collectStarted(grid.offset, grid.limit);
+
grid.dataProvider.get(grid.offset, grid.limit).then(
function() {
if (grid.controls.allItemsRetrieved)
$scope.items.push(item)
if (grid.controls.itemRetrieved)
grid.controls.itemRetrieved(item);
+ if ($scope.selectAll)
+ $scope.selected[grid.indexValue(item)] = true
}
}).finally(function() {
console.debug('egGrid.collect() complete');
grid.collecting = false
+ $scope.selected = angular.copy($scope.selected);
+ });
+ }
+
+ grid.prepend = function(limit) {
+ var ran_into_duplicate = false;
+ var sort = grid.dataProvider.sort;
+ if (sort && sort.length) {
+ // If sorting is in effect, we have no way
+ // of knowing that the new item should be
+ // visible _if the sort order is retained_.
+ // However, since the grids that do prepending in
+ // the first place are ones where we always
+ // want the new row to show up on top, we'll
+ // remove the current sort options.
+ grid.dataProvider.sort = [];
+ }
+ if (grid.offset > 0) {
+ // if we're prepending, we're forcing the
+ // offset back to zero to display the top
+ // of the list
+ grid.offset = 0;
+ grid.collect();
+ return;
+ }
+ if (grid.collecting) return; // avoid parallel collect() or prepend()
+ grid.collecting = true;
+ console.debug('egGrid.prepend() starting');
+ // Note that we can count on the most-recently added
+ // item being at offset 0 in the data provider only
+ // for arrayNotifier data sources that do not have
+ // sort options currently set.
+ grid.dataProvider.get(0, 1).then(
+ null,
+ null,
+ function(item) {
+ if (item) {
+ var newIdx = grid.indexValue(item);
+ angular.forEach($scope.items, function(existing) {
+ if (grid.indexValue(existing) == newIdx) {
+ console.debug('egGrid.prepend(): refusing to add duplicate item ' + newIdx);
+ ran_into_duplicate = true;
+ return;
+ }
+ });
+ $scope.items.unshift(item);
+ if (limit && $scope.items.length > limit) {
+ // this accommodates the checkin grid that
+ // allows the user to set a definite limit
+ // without requiring that entire collect()
+ $scope.items.length = limit;
+ }
+ if ($scope.items.length > grid.limit) {
+ $scope.items.length = grid.limit;
+ }
+ if (grid.controls.itemRetrieved)
+ grid.controls.itemRetrieved(item);
+ if ($scope.selectAll)
+ $scope.selected[grid.indexValue(item)] = true
+ }
+ }).finally(function() {
+ console.debug('egGrid.prepend() complete');
+ grid.collecting = false;
+ $scope.selected = angular.copy($scope.selected);
+ if (ran_into_duplicate) {
+ grid.collect();
+ }
});
}
require : '^egGrid',
restrict : 'AE',
scope : {
+ flesher: '=', // optional; function that can flesh a linked field, given the value
+ comparator: '=', // optional; function that can sort the thing at the end of 'path'
name : '@', // required; unique name
path : '@', // optional; flesh path
ignore: '@', // optional; fields to ignore when path is a wildcard
flex : '@', // optional; default flex width
align : '@', // optional; default alignment, left/center/right
dateformat : '@', // optional: passed down to egGridValueFilter
+ datecontext: '@', // optional: passed down to egGridValueFilter to choose TZ
+ datefilter: '@', // optional: passed down to egGridValueFilter to choose specialized date filters
+ dateonlyinterval: '@', // optional: passed down to egGridValueFilter to choose a "better" format
// if a field is part of an IDL object, but we are unable to
// determine the class, because it's nested within a hash
// optional: for non-IDL columns, specifying a datatype
// lets the caller control which display filter is used.
// datatype should match the standard IDL datatypes.
- datatype : '@'
+ datatype : '@',
+
+ // optional hash of functions that can be imported into
+ // the directive's scope; meant for cases where the "compiled"
+ // attribute is set
+ handlers : '=',
+
+ // optional: CSS class name that we want to have for this field.
+ // Auto generated from path if nothing is passed in via eg-grid-field declaration
+ cssSelector : "@"
},
link : function(scope, element, attrs, egGridCtrl) {
angular.forEach(
[
'visible',
+ 'compiled',
'hidden',
'sortable',
'nonsortable',
}
);
+ scope.cssSelector = attrs['cssSelector'] ? attrs['cssSelector'] : "";
+
+ // auto-generate CSS selector name for field if none declared in tt2 and there's a path
+ if (scope.path && !scope.cssSelector){
+ var cssClass = 'grid' + "." + scope.path;
+ cssClass = cssClass.replace(/\./g,'-');
+ element.addClass(cssClass);
+ scope.cssSelector = cssClass;
+ }
+
// any HTML content within the field is its custom template
var tmpl = element.html();
if (tmpl && !tmpl.match(/^\s*$/))
label : '@', // Action label
handler : '=', // Action function handler
hide : '=',
+ disabled : '=', // function
divider : '='
},
link : function(scope, element, attrs, egGridCtrl) {
group : scope.group,
label : scope.label,
divider : scope.divider,
- handler : scope.handler
+ handler : scope.handler,
+ disabled : scope.disabled,
});
scope.$destroy();
}
cols.columns = [];
cols.stockVisible = [];
cols.idlClass = args.idlClass;
+ cols.clientSort = args.clientSort;
cols.defaultToHidden = args.defaultToHidden;
cols.defaultToNoSort = args.defaultToNoSort;
cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
+ cols.defaultDateFormat = args.defaultDateFormat;
+ cols.defaultDateContext = args.defaultDateContext;
// resets column width, visibility, and sort behavior
// Visibility resets to the visibility settings defined in the
var idl_class = egCore.idl.classes[cols.idlClass];
angular.forEach(
- idl_class.fields.sort(
- function(a, b) { return a.name < b.name ? -1 : 1 }),
+ idl_class.fields,
function(field) {
if (field.virtual) return;
if (field.datatype == 'link' || field.datatype == 'org_unit') {
} else {
class_obj = egCore.idl.classes[cols.idlClass];
}
+ var idl_parent = class_obj;
+ var old_field_label = '';
if (!class_obj) return;
- console.debug('egGrid: auto dotpath is: ' + dotpath);
+ //console.debug('egGrid: auto dotpath is: ' + dotpath);
var path_parts = dotpath.split(/\./);
// find the IDL class definition for the last element in the
// path before the .*
// an empty path_parts means expand the root class
if (path_parts) {
+ var old_field;
for (var path_idx in path_parts) {
+ old_field = idl_field;
+
var part = path_parts[path_idx];
idl_field = class_obj.field_map[part];
if (idl_field && idl_field['class'] && (
idl_field.datatype == 'link' ||
idl_field.datatype == 'org_unit')) {
+ if (old_field_label) old_field_label += ' : ';
+ old_field_label += idl_field.label;
class_obj = egCore.idl.classes[idl_field['class']];
+ if (old_field) idl_parent = old_field;
} else {
if (path_idx < (path_parts.length - 1)) {
// we ran out of classes to hop through before
return;
var col = cols.cloneFromScope(colSpec);
- col.path = dotpath + '.' + field.name;
- console.debug('egGrid: field: ' +field.name + '; parent field: ' + js2JSON(idl_field));
+ col.path = (dotpath ? dotpath + '.' + field.name : field.name);
+
+ // log line below is very chatty. disable until needed.
+ // console.debug('egGrid: field: ' +field.name + '; parent field: ' + js2JSON(idl_parent));
cols.add(col, false, true,
- {idl_parent : idl_field, idl_field : field, idl_class : class_obj});
+ {idl_parent : idl_parent, idl_field : field, idl_class : class_obj, field_parent_label : old_field_label });
});
cols.columns = cols.columns.sort(
function(a, b) {
if (a.explicit) return -1;
if (b.explicit) return 1;
+
if (a.idlclass && b.idlclass) {
- return a.idlclass < b.idlclass ? -1 : 1;
- return a.idlclass > b.idlclass ? 1 : -1;
+ if (a.idlclass < b.idlclass) return -1;
+ if (b.idlclass < a.idlclass) return 1;
+ }
+
+ if (a.path && b.path && a.path.lastIndexOf('.') && b.path.lastIndexOf('.')) {
+ if (a.path.substring(0, a.path.lastIndexOf('.')) < b.path.substring(0, b.path.lastIndexOf('.'))) return -1;
+ if (b.path.substring(0, b.path.lastIndexOf('.')) < a.path.substring(0, a.path.lastIndexOf('.'))) return 1;
}
- if (a.path && b.path) {
- return a.path < b.path ? -1 : 1;
- return a.path > b.path ? 1 : -1;
+
+ if (a.label && b.label) {
+ if (a.label < b.label) return -1;
+ if (b.label < a.label) return 1;
}
- return a.label < b.label ? -1 : 1;
+ return a.name < b.name ? -1 : 1;
}
);
// the fields over that we need (so the scope object can go away).
cols.cloneFromScope = function(colSpec) {
return {
+ flesher : colSpec.flesher,
+ comparator : colSpec.comparator,
name : colSpec.name,
label : colSpec.label,
path : colSpec.path,
linkpath : colSpec.linkpath,
template : colSpec.template,
visible : colSpec.visible,
+ compiled : colSpec.compiled,
+ handlers : colSpec.handlers,
hidden : colSpec.hidden,
datatype : colSpec.datatype,
sortable : colSpec.sortable,
multisortable : colSpec.multisortable,
nonmultisortable : colSpec.nonmultisortable,
dateformat : colSpec.dateformat,
- parentIdlClass : colSpec.parentIdlClass
+ datecontext : colSpec.datecontext,
+ datefilter : colSpec.datefilter,
+ dateonlyinterval : colSpec.dateonlyinterval,
+ parentIdlClass : colSpec.parentIdlClass,
+ cssSelector : colSpec.cssSelector
};
}
(!cols.defaultToNoMultiSort && !column.nonmultisortable))
column.multisortable = true;
+ if (cols.defaultDateFormat && ! column.dateformat) {
+ column.dateformat = cols.defaultDateFormat;
+ }
+
+ if (cols.defaultDateOnlyInterval && ! column.dateonlyinterval) {
+ column.dateonlyinterval = cols.defaultDateOnlyInterval;
+ }
+
+ if (cols.defaultDateContext && ! column.datecontext) {
+ column.datecontext = cols.defaultDateContext;
+ }
+
+ if (cols.defaultDateFilter && ! column.datefilter) {
+ column.datefilter = cols.defaultDateFilter;
+ }
+
cols.columns.push(column);
// Track which columns are visible by default in case we
if (fromExpand && idl_info.idl_class) {
column.idlclass = '';
- if (idl_info.idl_parent) {
- column.idlclass = idl_info.idl_parent.label || idl_info.idl_parent.name;
+ if (idl_info.field_parent_label && idl_info.idl_parent.label != idl_info.idl_class.label) {
+ column.idlclass = (idl_info.field_parent_label || idl_info.idl_parent.label || idl_info.idl_parent.name);
} else {
column.idlclass += idl_info.idl_class.label || idl_info.idl_class.name;
}
// idlClass as the base.
cols.idlFieldFromPath = function(dotpath) {
var class_obj = egCore.idl.classes[cols.idlClass];
+ if (!dotpath) return null;
+
var path_parts = dotpath.split(/\./);
var idl_parent;
idl_parent = idl_field;
idl_field = class_obj.field_map[part];
- if (idl_field && idl_field['class'] && (
- idl_field.datatype == 'link' ||
- idl_field.datatype == 'org_unit')) {
- class_obj = egCore.idl.classes[idl_field['class']];
+ if (idl_field) {
+ if (idl_field['class'] && (
+ idl_field.datatype == 'link' ||
+ idl_field.datatype == 'org_unit')) {
+ class_obj = egCore.idl.classes[idl_field['class']];
+ }
+ } else {
+ return null;
}
- // else, path is not in the IDL, which is fine
}
- if (!idl_field) return null;
-
return {
idl_parent: idl_parent,
idl_field : idl_field,
// the range defined by count and offset
gridData.arrayNotifier = function(arr, offset, count) {
if (!arr || arr.length == 0) return $q.when();
+
+ if (gridData.columnsProvider.clientSort
+ && gridData.sort
+ && gridData.sort.length > 0
+ ) {
+ var sorter_cache = [];
+ arr.sort(function(a,b) {
+ for (var si = 0; si < gridData.sort.length; si++) {
+ if (!sorter_cache[si]) { // Build sort structure on first comparison, reuse thereafter
+ var field = gridData.sort[si];
+ var dir = 'asc';
+
+ if (angular.isObject(field)) {
+ dir = Object.values(field)[0];
+ field = Object.keys(field)[0];
+ }
+
+ var path = gridData.columnsProvider.findColumn(field).path || field;
+ var comparator = gridData.columnsProvider.findColumn(field).comparator ||
+ function (x,y) { if (x < y) return -1; if (x > y) return 1; return 0 };
+
+ sorter_cache[si] = {
+ field : path,
+ dir : dir,
+ comparator : comparator
+ };
+ }
+
+ var sc = sorter_cache[si];
+
+ var af,bf;
+
+ if (a._isfieldmapper || angular.isFunction(a[sc.field])) {
+ try {af = a[sc.field](); bf = b[sc.field]() } catch (e) {};
+ } else {
+ af = a[sc.field]; bf = b[sc.field];
+ }
+ if (af === undefined && sc.field.indexOf('.') > -1) { // assume an object, not flat path
+ var parts = sc.field.split('.');
+ af = a;
+ bf = b;
+ angular.forEach(parts, function (p) {
+ if (af) {
+ if (af._isfieldmapper || angular.isFunction(af[p])) af = af[p]();
+ else af = af[p];
+ }
+ if (bf) {
+ if (bf._isfieldmapper || angular.isFunction(bf[p])) bf = bf[p]();
+ else bf = bf[p];
+ }
+ });
+ }
+
+ if (af === undefined) af = null;
+ if (bf === undefined) bf = null;
+
+ if (af === null && bf !== null) return 1;
+ if (bf === null && af !== null) return -1;
+
+ if (!(bf === null && af === null)) {
+ var partial = sc.comparator(af,bf);
+ if (partial) {
+ if (sc.dir == 'desc') {
+ if (partial > 0) return -1;
+ return 1;
+ }
+ return partial;
+ }
+ }
+ }
+
+ return 0;
+ });
+ }
+
if (count) arr = arr.slice(offset, offset + count);
var def = $q.defer();
// promise notifications are only witnessed when delivered
// Calls the grid refresh function. Once instantiated, the
// grid will replace this function with it's own refresh()
gridData.refresh = function(noReset) { }
+ gridData.prepend = function(limit) { }
if (!gridData.get) {
// returns a promise whose notify() delivers items
// TODO: consider a caching layer to speed up template
// rendering, particularly for nested objects?
gridData.itemFieldValue = function(item, column) {
+ var val;
if (column.name in item) {
if (typeof item[column.name] == 'function') {
- return item[column.name]();
+ val = item[column.name]();
} else {
- return item[column.name];
+ val = item[column.name];
}
} else {
- return gridData.nestedItemFieldValue(item, column);
+ val = gridData.nestedItemFieldValue(item, column);
}
+
+ return val;
}
// TODO: deprecate me
// value is an IDL field, run the value through its
// corresponding output filter.
gridData.nestedItemFieldValue = function(obj, column) {
+ item = obj; // keep a copy around
+
if (obj === null || obj === undefined || obj === '') return '';
if (!column.path) return obj;
angular.forEach(parts, function(step, idx) {
// object is not fleshed to the expected extent
- if (!obj || typeof obj != 'object') {
- obj = '';
- return;
+ if (typeof obj != 'object') {
+ if (typeof obj != 'undefined' && column.flesher) {
+ obj = column.flesher(obj, column, item);
+ } else {
+ obj = '';
+ return;
+ }
}
+ if (!obj) return '';
+
var cls = obj.classname;
if (cls && (class_obj = egCore.idl.classes[cls])) {
idl_field = class_obj.field_map[step];
angular.forEach(provider.columnsProvider.columns,
function(col) {
// only query IDL-tracked columns
- if (!col.adhoc && (col.required || col.visible))
+ if (!col.adhoc && col.name && col.path && (col.required || col.visible))
queryFields[col.name] = col.path;
}
);
require : '^egGrid',
scope : {
label : '@',
+ checkbox : '@',
+ checked : '=',
standalone : '=',
handler : '=', // onclick handler function
divider : '=', // if true, show a divider only
},
link : function(scope, element, attrs, egGridCtrl) {
egGridCtrl.addMenuItem({
+ checkbox : scope.checkbox,
+ checked : scope.checked ? true : false,
label : scope.label,
standalone : scope.standalone ? true : false,
handler : scope.handler,
};
})
+/* https://stackoverflow.com/questions/17343696/adding-an-ng-click-event-inside-a-filter/17344875#17344875 */
+.directive('compile', ['$compile', function ($compile) {
+ return function(scope, element, attrs) {
+ // pass through column defs from grid cell's scope
+ scope.col = scope.$parent.col;
+ scope.$watch(
+ function(scope) {
+ // watch the 'compile' expression for changes
+ return scope.$eval(attrs.compile);
+ },
+ function(value) {
+ // when the 'compile' expression changes
+ // assign it into the current DOM
+ element.html(value);
+
+ // compile the new DOM and link it to the current
+ // scope.
+ // NOTE: we only compile .childNodes so that
+ // we don't get into infinite loop compiling ourselves
+ $compile(element.contents())(scope);
+ }
+ );
+ };
+}])
+
/**
* 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..)
+ * 2. Converts bools to translated Yes/No strings
* Others likely to follow...
*/
-.filter('egGridValueFilter', ['$filter', function($filter) {
- return function(value, column) {
- switch(column.datatype) {
- case 'bool':
+.filter('egGridValueFilter', ['$filter','egCore', 'egStrings', function($filter,egCore,egStrings) {
+ function traversePath(obj,path) {
+ var list = path.split('.');
+ for (var part in path) {
+ if (obj[path]) obj = obj[path]
+ else return null;
+ }
+ return obj;
+ }
+
+ var GVF = function(value, column, item) {
+ switch(column.datatype) {
+ case 'bool':
switch(value) {
- // Browser will translate true/false for us
+ // Browser will translate true/false for us
case 't' :
case '1' : // legacy
case true:
- return ''+true;
+ return egStrings.YES;
case 'f' :
case '0' : // legacy
case false:
- return ''+false;
+ return egStrings.NO;
// value may be null, '', etc.
default : return '';
}
- case 'timestamp':
- // canned angular date filter FTW
- if (!column.dateformat)
- column.dateformat = 'shortDate';
- return $filter('date')(value, column.dateformat);
- case 'money':
+ case 'timestamp':
+ var interval = angular.isFunction(item[column.dateonlyinterval])
+ ? item[column.dateonlyinterval]()
+ : item[column.dateonlyinterval];
+
+ if (column.dateonlyinterval && !interval) // try it as a dotted path
+ interval = traversePath(item, column.dateonlyinterval);
+
+ var context = angular.isFunction(item[column.datecontext])
+ ? item[column.datecontext]()
+ : item[column.datecontext];
+
+ if (column.datecontext && !context) // try it as a dotted path
+ context = traversePath(item, column.datecontext);
+
+ var date_filter = column.datefilter || 'egOrgDateInContext';
+
+ return $filter(date_filter)(value, column.dateformat, context, interval);
+ case 'money':
return $filter('currency')(value);
- default:
- return value;
- }
- }
+ default:
+ return value;
+ }
+ };
+
+ GVF.$stateful = true;
+ return GVF;
}]);