6pac / SlickGrid

A lightning fast JavaScript grid/spreadsheet
https://stackblitz.com/github/6pac/SlickGrid/tree/master/vite-demo
MIT License
1.84k stars 423 forks source link

Toward Custom Row support #668

Open arthur-clifford opened 2 years ago

arthur-clifford commented 2 years ago

Sorry this is a bit long guys. Hopefully its a good bathroom break read ;)

Code samples in this issue have -> arrows to indicate beginning and end of what I believe I added to relevant scripts.

A while back I did some customizations that I would eventually like to be able to share, especially along the lines of supporting custom rows. There are relatively simple tweaks to allow the genral capability. What I'm sharing here is to give an idea of the changes that are made and toward the end looking to make sure the approach is okay within your standars or if you have any suggestions for better approaches.

What I was attempting at the time was to be able to have custom rows in order to support a better summary tech where a summary (say sum or avg) end up as a row across all columns and the summary value for a column that summary has been enabled for has a value and those that don't are empty. I ended up allowing for sum, avg, median, min, max, and std dev. And I allowed for summaries + grouping. (The focus of this issue is custom row support not the aggregators per se, that's its own topic.

Here is an example of it working where summaries are shown for data with two groupings and avg applied to two columns and sum applied to 1. image

Note: This requires a group summary title row (they have the calculator icon) as well as a row for each aggregation. Note also that summary values only show up for the appropriate columns and that summary name appears at the left.

image Note: At the end of the grid here as you can see above each grouping has its own nested summary title and group summary rows, and at the end there is a Grand Summary Title Row and Grand Summary summary rows.

Though not depicted I also provide grid menu options for toggling on/off group- or grand- summary sections.

The changes to data view to support this were: (at the beginning of DataView)

    var defaults = {
      groupItemMetadataProvider: null,
->  customItemMetadataProvider: null,
      customRows: {
        getPreRows: function () { return []; }, // NonDataRow item instances
        showPreRows: false,
        getPostRows: function () { return []; }, // NonDataRow item instances
        showPostRows: true
-> },
      inlineFilters: false
    };

Note, that alone if added would not break anything, but what it allows for is a section of grid parameters called customRows to be defined where an array of preRows and postRows may be returned from appropriate functions.

Then a change to the recalc function in DataView

function recalc(_items) {
      rowsById = null;

      if (refreshHints.isFilterNarrowing != prevRefreshHints.isFilterNarrowing ||
        refreshHints.isFilterExpanding != prevRefreshHints.isFilterExpanding) {
        filterCache = [];
      }

      var filteredItems = getFilteredAndPagedItems(_items);
      totalRows = filteredItems.totalRows;
      var newRows = filteredItems.rows;

->  // Add custom rows ahead of new rows if custom.preRows is defined
      if (!!options.customRows && options.customRows.showPreRows === true) {
        if (options.customRows.getPreRows && options.customRows.getPreRows instanceof Function) {
          var preRows = options.customRows.getPreRows();
          preRows.reverse();
          for (var preRow of preRows) {
            newRows.unshift(preRow);
          }
        }
->  }
      groups = [];
      if (groupingInfos.length) {
        groups = extractGroups(newRows);
        if (groups.length) {
          newRows = flattenGroupedRows(groups);
        }
      }
->  // Add custom rows at end of table if custom.postRows is defined
      if (!!options.customRows && options.customRows.showPostRows === true) {
        if (options.customRows.getPostRows && options.customRows.getPostRows instanceof Function) {
          const postRows = options.customRows.getPostRows();
          for (const postRow of postRows) {
            newRows.push(postRow);
          }
        }
 -> }
      var diff = getRowDiffs(rows, newRows);

      rows = newRows;

      return diff;
    }

Note: Again if the customizations aren't present this will have no effect other than a couple condition checks per item. What it is doing is adding custom rows before and after the normal row generation.

Changes to flattenGroupedRows

function flattenGroupedRows(groups, level) {
      level = level || 0;
      var gi = groupingInfos[level];
      var groupedRows = [], rows, gl = 0, g;
      for (var i = 0, l = groups.length; i < l; i++) {
        g = groups[i];
        groupedRows[gl++] = g;

->    // If group info  has custom pre rows defined add
        // them ahead of the grouped rows but after the group heading 
        var cRs = (gi.customGroupRows);
        if (cRs && (!g.collapsed || gi.aggregateCollapsed)) {
          if (cRs.getPreRows && cRs.getPreRows instanceof Function) {

            var preRows = cRs.getPreRows(level, g, gi);
            preRows.reverse();
            for (var preRow of preRows) {
              groupRows[gl++] = preRow;
            }
          }
->   }

        if (!g.collapsed) {
          rows = g.groups ? flattenGroupedRows(g.groups, level + 1) : g.rows;
          for (var j = 0, jj = rows.length; j < jj; j++) {
            groupedRows[gl++] = rows[j];
          }
        }

->    // If group info  has custom post rows defined add
        // them after of the grouped rows within this group 
        if (cRs && ( !gi.aggregateCollapsed)) {
          if (cRs.getPostRows && cRs.getPostRows instanceof Function) {
            var postRows = cRs.getPostRows(level, g, gi);
            for (var postRow of postRows) {
              groupedRows[gl++] = postRow;
            }
          }
->    }
// Note: I can't remember if the next check is mine or not
        if (g.totals && gi.displayTotalsRow && (!g.collapsed || gi.aggregateCollapsed)) {
          groupedRows[gl++] = g.totals;
        }
      }
      return groupedRows;
    }

Changes to getItemMetadata

    function getItemMetadata(i) {
      var item = rows[i];
      if (item === undefined) {
        return null;
      }

->   // override for custom rows
      if (item.__custom && options.customItemMetadataProvider) {
        return options.customItemMetadataProvider(item).getRowMetadata(item);
->     }
      // overrides for grouping rows
      if (item.__group) {
        return options.groupItemMetadataProvider.getGroupRowMetadata(item);
      }

 // overrides for totals rows
      if (item.__groupTotals) {
        return options.groupItemMetadataProvider.getTotalsRowMetadata(item);
      }
      return null;
}

Note: grid uses the getItemMetadata to get groupItemMetadata info when valid. So this expand getItemMetatdata to allow for a more generic getRowMetadataProvider defined in grid dataView parameters.

When the pre and post rows are retrieved from the various customRow functions in the dataView or group options one of a few NonDataRow extension classes is used for the rows returned. These classes have an additional custom property and its value is a unique name for the row type. So providing a getItemMetadataProvider function custom rows can be handled by detecting if they have custom and then switching on the __custom property at which point the getItemMetadataProvider function provides an instance of ItemRowMetadata with the information needed by the grid or a plugin like the Excel exporter.

While one can argue this could be a different data view, there is very little that actually has to be done to the data view class to support custom rows as it already has support for non data rows and group rows. It is more of an issue of registering custom rows and, providng pre and post row functions for returning arrays of custom row instances, and providing an itemMetadataProvider which provides info; all of which can be done outside the grid in an application context.

Toward supporing this I did create a custom script I load for my project which is an adaptation of the slick.groupitemmetadataprovider.js. In fact it may have some residual commenting in it.

slick.custom.itemmetadataprovider.js

(function ($) {

    /***
     * Provides item metadata for group (Slick.Group) and totals (Slick.Totals) rows produced by the DataView.
     * This metadata overrides the default behavior and formatting of those rows so that they appear and function
     * correctly when processed by the grid.
     *
     * This class also acts as a grid plugin providing event handlers to expand & collapse groups.
     * If "grid.registerPlugin(...)" is not called, expand & collapse will not work.
     *
     * @class ItemMetadataProvider
     * @module Data
     * @namespace Slick.Custom.Data
     * @constructor
     * @param inputOptions
     */
    function ItemMetadataProvider(inputOptions) {
        var _grid;
        var _defaults = {
            cellCssClass: "slick-custom-item-cell",
            rowCssClass: "slick-custom-item-row",
            cellFormatter: defaultCellFormatter,
            rowFocusable: false,
            focusable: false
        };

        var options = $.extend(true, {}, _defaults, inputOptions);

        function getOptions() {
            return options;
        }

        function setOptions(inputOptions) {
            $.extend(true, options, inputOptions);
        }

        function defaultCellFormatter(row, cell, value, columnDef, item, grid) {
            return '';
        }

        function init(grid) {
            _grid = grid;
            _grid.onClick.subscribe(handleGridClick);
            _grid.onKeyDown.subscribe(handleGridKeyDown);

        }

        function destroy() {
            if (_grid) {
                _grid.onClick.unsubscribe(handleGridClick);
                _grid.onKeyDown.unsubscribe(handleGridKeyDown);
            }
        }

        function handleGridClick(e, args) {

        }

        // TODO:  add -/+ handling
        function handleGridKeyDown(e, args) {

        }

        function getRowMetadata(item) {
            var groupLevel = item && item.group && item.group.level;

            return {
                selectable: options.rowSelectable,
                focusable: options.rowFocusable,
                cssClasses: options.rowCssClass + ' slick-group-level-' + groupLevel,
                formatter: options.cellFormatter,
                columns: !!options.columns ? options.columns : null,
                editor: null
            };
        }

        return {
            "init": init,
            "destroy": destroy,
            "getRowMetadata": getRowMetadata,
            "getOptions": getOptions,
            "setOptions": setOptions
        };
    }
    $.extend(true, window, {
        Slick: {
            Custom: {
                Data: {
                    ItemMetadata: ItemMetadataProvider
                }
            }
        }
    });
})(jQuery);

I defined my new custom row classes in a script: slick.custom.rows.js

(function ($) {
  var initialized = false;
  function init() {
   // Wait for slickgrid to register stuff in window.Slick
    if(window.Slick === undefined) {
      window.requestAnimationFrame(init.bind(this));
      return;
    }
    // Wait until the Slick.NonDataRow definition is present
    if (!Slick.NonDataRow) {
      window.requestAnimationFrame(init.bind(this));
      return;
    }
    // NonDataRow will be extended with a selectedItems property and some functions for manipulating it.
    Slick.NonDataRow.prototype.selectedItems;
    Slick.NonDataRow.prototype.ClearSelected = function() {
      if(!this.selectedItems) {
        this.selectedItems = [];
      } else {
        this.selectedItems = [];
      }
    }
    Slick.NonDataRow.prototype.AllSelected = function() {
      return this.selectedItems.length === this.rows.length;
    }
    Slick.NonDataRow.prototype.SetSelectedFeature = function(row) {
      this.selectedItems.push(row);
    }
    Slick.NonDataRow.prototype.IsCustom = function() {
      return this.hasOwnProperty('__custom');
    }
    // only coninue if  not initalized.
    if (initialized === true) {
      return;
    }
  // Register the custom rows as Slick.Custom.Rows entries, and 
    $.extend(true, window, {
      Slick: {
        Custom: {
          Rows: {
            AggTotalsRow: AggTotalsRow,
            AggGroupTotalsRow: AggGroupTotalsRow,
            AggGroupTitleRow: AggGroupTitleRow,
            GrandTotalsTitleRow: GrandTotalsTitleRow
          }
        }
      }
    });

   // Define the rows registered above as extensions of Slick.NonDataRow
    function AggGroupTotalsRow(aggName, aggLabel, group, groupInfo, level) {
      this.__custom = 'agg-group-totals-row';
      this.aggName = aggName;
      this.aggLabel = aggLabel;
      this.group = group;
      this.groupInfo = groupInfo;
      this.level = level;
      this.isSelectable = function() { return false };
    }
    AggGroupTotalsRow.prototype = new Slick.NonDataRow();

    function AggTotalsRow(aggInfo, totals) {
      this.__custom = 'agg-totals-row';
      this.aggName = aggInfo.aggName;
      this.aggLabel = aggInfo.aggLabel;
      this.totals = totals;
      this.isSelectable = function() { return false };
    }
    AggTotalsRow.prototype = new Slick.NonDataRow();

    function AggGroupTitleRow(group, groupInfo, level) {
      this.__custom = 'agg-group-title-row';
      this.group = group;
      this.groupInfo = groupInfo;
      this.level = level;
      this.isSelectable = function() { return false };
    }
    AggGroupTitleRow.prototype = new Slick.NonDataRow();

    function GrandTotalsTitleRow() {
      this.__custom = 'grand-totals-title-row';
      this.isSelectable = function() { return false };
    }
    GrandTotalsTitleRow.prototype = new Slick.NonDataRow();
    initialized = true;
  }

  init();

})(jQuery);`

So, I made this work, but I feel like the requestAnimationFrame trick at the beginning to wait for SlickGrid and NonDataRow to be defined before initializing them is a bit hacky. I am not sure if there are application context where the Slick.Custom stuff would not be registered in time for data to be displayed. In my application data displayed in the grid isn't retrieved until well after everything has been loaded and the user has interacted with a search UI or interacted with a map. It is different than example plugins you've provided because it is dependent on Slick and Slick.NonDataRow where as examples provided don't have such dependencies.

I'm wondering then if custom row support as I'm attempting it here is something a) you would like to see in SlickGrid, b) you feel there is a better approach to regarding allowing the Slick.Custom functionality? If you are open to these customizations I can fork a more recent version of the repo make the necessary tweaks and do a proper pull request. (I'm not sure when, but I'll do it.) And if I do .. which folder would you prefer I add the slick.custom.itemmetadataprovider.js script?

6pac commented 2 years ago

Hi @arthur-clifford, I checked this out briefly. Are you aware of this example? http://6pac.github.io/SlickGrid/examples/example16-row-detail.html

There was some work done on integrating several rows to provide a space where a custom HTML layout could be rendered. Because the row height is fundamental to the grid, it would be very hard to allow custom row heights, but the aggregation of more than one row was tricky, but possible.

What you're doing looks good, but I'd need to check it out a bit more carefully before giving the green light.