LP#1777207: teach egGrid how to prepend rows more efficiently
[evergreen-equinox.git] / Open-ILS / web / js / ui / default / staff / services / grid.js
index ff438f8..3998abb 100644 (file)
@@ -54,6 +54,14 @@ angular.module('egGridMod',
             //                 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 : '@',
 
@@ -63,11 +71,17 @@ 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() {}
             //
@@ -96,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;
 
@@ -155,6 +179,10 @@ angular.module('egGridMod',
 
                 var stored_limit = 0;
                 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')
@@ -176,9 +204,14 @@ angular.module('egGridMod',
 
                 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);
 
@@ -206,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)
@@ -213,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;
@@ -247,6 +286,10 @@ angular.module('egGridMod',
                     return grid.getSelectedItems()
                 }
 
+                controls.selectItemsByValue = function(c,v) {
+                    return grid.selectItemsByValue(c,v)
+                }
+
                 controls.allItems = function() {
                     return $scope.items;
                 }
@@ -296,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;
@@ -316,6 +364,7 @@ angular.module('egGridMod',
                 }
 
                 grid.dataProvider.refresh = controls.refresh;
+                grid.dataProvider.prepend = controls.prepend;
                 grid.controls = controls;
             }
 
@@ -404,11 +453,11 @@ 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
@@ -418,12 +467,23 @@ angular.module('egGridMod',
                     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');
                 });
             }
 
@@ -441,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];
 
@@ -468,7 +541,7 @@ angular.module('egGridMod',
                     // 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;
@@ -507,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 
             }
@@ -624,8 +698,18 @@ angular.module('egGridMod',
                 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');
@@ -671,6 +755,21 @@ 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
@@ -746,9 +845,53 @@ 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);
@@ -858,6 +1001,7 @@ angular.module('egGridMod',
                     $scope.showGridConf = true;
                 }
 
+                delete $scope.lastModColumn;
                 $scope.gridColumnPickerIsOpen = false;
             }
 
@@ -900,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
@@ -911,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({
@@ -925,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
@@ -979,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() {
 
@@ -1000,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)
@@ -1021,6 +1327,72 @@ angular.module('egGridMod',
                 });
             }
 
+            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();
+                    }
+                });
+            }
+
             grid.init();
         }]
     };
@@ -1037,6 +1409,7 @@ angular.module('egGridMod',
         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
@@ -1044,6 +1417,9 @@ angular.module('egGridMod',
             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
@@ -1055,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) {
 
@@ -1063,6 +1448,7 @@ angular.module('egGridMod',
             angular.forEach(
                 [
                     'visible', 
+                    'compiled', 
                     'hidden', 
                     'sortable', 
                     'nonsortable',
@@ -1076,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*$/))
@@ -1125,9 +1521,12 @@ 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 
@@ -1226,7 +1625,7 @@ angular.module('egGridMod',
 
             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
@@ -1316,6 +1715,7 @@ angular.module('egGridMod',
         cols.cloneFromScope = function(colSpec) {
             return {
                 flesher  : colSpec.flesher,
+                comparator  : colSpec.comparator,
                 name  : colSpec.name,
                 label : colSpec.label,
                 path  : colSpec.path,
@@ -1326,6 +1726,8 @@ angular.module('egGridMod',
                 linkpath : colSpec.linkpath,
                 template : colSpec.template,
                 visible  : colSpec.visible,
+                compiled : colSpec.compiled,
+                handlers : colSpec.handlers,
                 hidden   : colSpec.hidden,
                 datatype : colSpec.datatype,
                 sortable : colSpec.sortable,
@@ -1333,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
             };
         }
 
@@ -1372,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
@@ -1411,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;
@@ -1470,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
@@ -1484,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
@@ -1599,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;
                         }
                     );
@@ -1770,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;
 }]);