LP#1777207: teach egGrid how to prepend rows more efficiently
[evergreen-equinox.git] / Open-ILS / web / js / ui / default / staff / services / grid.js
index f5fbc4e..3998abb 100644 (file)
@@ -29,13 +29,39 @@ angular.module('egGridMod',
             // 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 : '@',
 
@@ -45,14 +71,27 @@ angular.module('egGridMod',
             // 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() {}
             //
+            //  ---
+            //  If defined, the grid will watch the return value from
+            //  the function defined at watchQuery on each digest and 
+            //  re-draw the grid when query changes occur.
+            //
+            //  watchQuery : function() { /* return grid query */ }
+            //
             //  ---------------
             //  These functions are defined by the grid and thus
             //  replace any values defined for these attributes from the
@@ -71,25 +110,35 @@ angular.module('egGridMod',
         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;
 
@@ -99,9 +148,27 @@ angular.module('egGridMod',
                 $scope.showGridConf = false;
                 grid.totalCount = -1;
                 $scope.selected = {};
-                $scope.actions = []; // actions for selected items
+                $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;
@@ -111,11 +178,20 @@ angular.module('egGridMod',
                 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;
@@ -123,19 +199,21 @@ angular.module('egGridMod',
 
                 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) {
@@ -161,6 +239,12 @@ angular.module('egGridMod',
                     });
                 }
 
+                // 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)
@@ -168,7 +252,7 @@ angular.module('egGridMod',
 
                 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;
@@ -185,11 +269,27 @@ angular.module('egGridMod',
                 // 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;
                 }
@@ -214,6 +314,17 @@ angular.module('egGridMod',
                     controls.refresh();
                 }
 
+                if (controls.watchQuery) {
+                    // capture the initial query value
+                    grid.dataProvider.query = controls.watchQuery();
+
+                    // watch for changes
+                    $scope.gridWatchQuery = controls.watchQuery;
+                    $scope.$watch('gridWatchQuery()', function(newv) {
+                        controls.setQuery(newv);
+                    }, true);
+                }
+
                 // if the caller provided a functional setSort
                 // extract the value before replacing it
                 grid.dataProvider.sort = 
@@ -228,10 +339,15 @@ angular.module('egGridMod',
                     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;
@@ -243,10 +359,25 @@ angular.module('egGridMod',
                     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);
@@ -262,7 +393,19 @@ angular.module('egGridMod',
 
             // add a selected-items action
             grid.addAction = function(act) {
-                $scope.actions.push(act);
+                var done = false;
+                $scope.actionGroups.forEach(function(g){
+                    if (g.label === act.group) {
+                        g.actions.push(act);
+                        done = true;
+                    }
+                });
+                if (!done) {
+                    $scope.actionGroups.push({
+                        label : act.group,
+                        actions : [ act ]
+                    });
+                }
             }
 
             // remove the stored column configuration preferenc, then recover 
@@ -310,28 +453,41 @@ angular.module('egGridMod',
                 }
 
                 // 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
+                    if (col.align != 'left') c.align = col.align;
                     if (col.flex != 2) c.flex = col.flex;
                     if (Number(col.sort)) c.sort = Number(c.sort);
                     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
@@ -345,7 +501,20 @@ angular.module('egGridMod',
                     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];
 
@@ -355,18 +524,24 @@ angular.module('egGridMod',
                             return;
                         }
 
+                        grid_col.align = col.align || 'left';
                         grid_col.flex = col.flex || 2;
                         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;
@@ -376,6 +551,7 @@ angular.module('egGridMod',
 
                     grid.columnsProvider.columns = new_cols;
                     grid.compileSort();
+
                 });
             }
 
@@ -404,9 +580,10 @@ angular.module('egGridMod',
 
             $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 
             }
@@ -464,10 +641,22 @@ angular.module('egGridMod',
 
             // fires the hide handler function for a context action
             $scope.actionHide = function(action) {
-                if (!action.hide) {
+                if (typeof action.hide == 'undefined') {
                     return false;
                 }
-                return action.hide(action);
+                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;
+                }
+                if (angular.isFunction(action.disabled))
+                    return action.disabled(action);
+                return action.disabled;
             }
 
             // fires the action handler function for a context action
@@ -484,36 +673,56 @@ angular.module('egGridMod',
                             + action.label + '" => ' + E + "\n" + E.stack);
                     }
 
-                    if ($scope.action_context_y || $scope.action_context_x)
-                        $scope.hideActionContextMenu();
+                    if ($scope.action_context_showing) $scope.hideActionContextMenu();
                 }
 
             }
 
             $scope.hideActionContextMenu = function () {
-                var menu_dom = $($scope.grid_element).find('.grid-action-dropdown')[0];
-                $(menu_dom).css({
+                $($scope.menu_dom).css({
                     display: '',
                     width: $scope.action_context_width,
                     top: $scope.action_context_y,
                     left: $scope.action_context_x
                 });
-                $($scope.action_context_parent).append(menu_dom);
+                $($scope.action_context_parent).append($scope.menu_dom);
                 $scope.action_context_oldy = $scope.action_context_oldx = 0;
-                $('body').unbind('click.remove_context_menu');
+                $('body').unbind('click.remove_context_menu_'+$scope.action_context_index);
+                $scope.action_context_showing = false;
             }
 
+            $scope.action_context_showing = false;
             $scope.showActionContextMenu = function ($event) {
-                var menu_dom = $($scope.grid_element).find('.grid-action-dropdown')[0];
-                $scope.action_context_width = $(menu_dom).css('width');
-                $scope.action_context_y = $(menu_dom).css('top');
-                $scope.action_context_x = $(menu_dom).css('left');
-                $scope.action_context_parent = $(menu_dom).parent();
 
-                $($scope.grid_element).append($(menu_dom));
-                $('body').bind('click.remove_context_menu', $scope.hideActionContextMenu);
+                // Have to gather these here, instead of inside link()
+                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();
+
+                // 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.action_context_y = $($scope.menu_dom).css('top');
+                    $scope.action_context_x = $($scope.menu_dom).css('left');
+                    $scope.action_context_showing = true;
+                    $scope.action_context_index = Math.floor((Math.random() * 1000) + 1);
+
+                    $('body').append($($scope.menu_dom));
+                    $('body').bind('click.remove_context_menu_'+$scope.action_context_index, $scope.hideActionContextMenu);
+                }
 
-                $(menu_dom).css({
+                $($scope.menu_dom).css({
                     display: 'block',
                     width: $scope.action_context_width,
                     top: $event.pageY,
@@ -546,21 +755,45 @@ angular.module('egGridMod',
                 $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) {
@@ -571,6 +804,13 @@ angular.module('egGridMod',
                 }
             });
 
+            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.
@@ -605,15 +845,65 @@ angular.module('egGridMod',
                     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)) 
@@ -648,6 +938,7 @@ angular.module('egGridMod',
                             $scope.selected[curIdx] = true;
                             if (curIdx == index) break; // all done
                         }
+                        $scope.selected = angular.copy($scope.selected);
                     }
                         
                 } else {
@@ -710,6 +1001,7 @@ angular.module('egGridMod',
                     $scope.showGridConf = true;
                 }
 
+                delete $scope.lastModColumn;
                 $scope.gridColumnPickerIsOpen = false;
             }
 
@@ -752,9 +1044,35 @@ angular.module('egGridMod',
                 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
@@ -763,12 +1081,21 @@ angular.module('egGridMod',
                     .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({
@@ -777,40 +1104,146 @@ angular.module('egGridMod',
                     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
@@ -831,6 +1264,18 @@ angular.module('egGridMod',
 
             $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() {
 
@@ -852,6 +1297,15 @@ angular.module('egGridMod',
 
                 $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)
@@ -863,10 +1317,79 @@ angular.module('egGridMod',
                         $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();
+                    }
                 });
             }
 
@@ -885,12 +1408,18 @@ angular.module('egGridMod',
         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
             label : '@', // optional; display label
             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
@@ -902,7 +1431,16 @@ angular.module('egGridMod',
             // 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) {
 
@@ -910,6 +1448,7 @@ angular.module('egGridMod',
             angular.forEach(
                 [
                     'visible', 
+                    'compiled', 
                     'hidden', 
                     'sortable', 
                     'nonsortable',
@@ -923,6 +1462,16 @@ angular.module('egGridMod',
                 }
             );
 
+            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*$/))
@@ -944,17 +1493,21 @@ angular.module('egGridMod',
         restrict : 'AE',
         transclude : true,
         scope : {
+            group   : '@', // Action group, ungrouped if not set
             label   : '@', // Action label
             handler : '=',  // Action function handler
             hide    : '=',
+            disabled : '=', // function
             divider : '='
         },
         link : function(scope, element, attrs, egGridCtrl) {
             egGridCtrl.addAction({
                 hide  : scope.hide,
+                group : scope.group,
                 label : scope.label,
                 divider : scope.divider,
-                handler : scope.handler
+                handler : scope.handler,
+                disabled : scope.disabled,
             });
             scope.$destroy();
         }
@@ -968,15 +1521,19 @@ angular.module('egGridMod',
         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 
         // template (i.e. the original egGridField values).
         cols.reset = function() {
             angular.forEach(cols.columns, function(col) {
+                col.align = 'left';
                 col.flex = 2;
                 col.sort = 0;
                 if (cols.stockVisible.indexOf(col.name) > -1) {
@@ -1024,8 +1581,7 @@ angular.module('egGridMod',
             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') {
@@ -1064,17 +1620,22 @@ angular.module('egGridMod',
             } 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];
 
@@ -1083,7 +1644,10 @@ angular.module('egGridMod',
                     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
@@ -1106,26 +1670,35 @@ angular.module('egGridMod',
                         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;
                     }
                 );
 
@@ -1141,15 +1714,20 @@ angular.module('egGridMod',
         // 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,
+                align  : colSpec.align || 'left',
                 flex  : Number(colSpec.flex) || 2,
                 sort  : Number(colSpec.sort) || 0,
                 required : colSpec.required,
                 linkpath : colSpec.linkpath,
                 template : colSpec.template,
                 visible  : colSpec.visible,
+                compiled : colSpec.compiled,
+                handlers : colSpec.handlers,
                 hidden   : colSpec.hidden,
                 datatype : colSpec.datatype,
                 sortable : colSpec.sortable,
@@ -1157,7 +1735,11 @@ angular.module('egGridMod',
                 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
             };
         }
 
@@ -1196,6 +1778,22 @@ angular.module('egGridMod',
                 (!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
@@ -1223,8 +1821,8 @@ angular.module('egGridMod',
 
             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;
                 }
@@ -1235,6 +1833,8 @@ angular.module('egGridMod',
         // 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;
@@ -1244,16 +1844,17 @@ angular.module('egGridMod',
                 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,
@@ -1293,6 +1894,81 @@ angular.module('egGridMod',
             // 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
@@ -1307,6 +1983,7 @@ angular.module('egGridMod',
             // 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
@@ -1320,15 +1997,18 @@ angular.module('egGridMod',
             // 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
@@ -1344,6 +2024,8 @@ angular.module('egGridMod',
             // 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;
 
@@ -1352,11 +2034,17 @@ angular.module('egGridMod',
 
                 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];
@@ -1411,7 +2099,7 @@ angular.module('egGridMod',
                     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;
                         }
                     );
@@ -1556,6 +2244,9 @@ angular.module('egGridMod',
         require : '^egGrid',
         scope : {
             label : '@',  
+            checkbox : '@',  
+            checked : '=',  
+            standalone : '=',  
             handler : '=', // onclick handler function
             divider : '=', // if true, show a divider only
             handlerData : '=', // if set, passed as second argument to handler
@@ -1564,7 +2255,10 @@ angular.module('egGridMod',
         },
         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,
                 divider : scope.divider,
                 disabled : scope.disabled,
@@ -1576,42 +2270,91 @@ angular.module('egGridMod',
     };
 })
 
+/* 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;
 }]);