sort-table.js

import ReportalBase from "r-reporal-base/src/reportal-base";
import TableColumns from "./table-columns";
import SortOrder from "./sort-order";

/**
 * Event reporting that a table has been sorted
 * @event SortTable~reportal-table-sort
 */

/**
 * @class SortTable
 * @prop {HTMLTableElement} source - source table
 * @prop {Array} data - data array to be sorted
 * @prop {Boolean} multidimensional - if `data` is single-dimensional (contains rows with data to be sorted as immediate array items: `data [rowItem...]`), then it is `false`. If it has blocks of data as items (each block containing an array of rows to be sorted: data [block [rowItem...]...]), then set it to `true`. Currently it supports only a two-level aggregation max (data->block->rowItem).
 * @prop {SortOrder} sortOrder - instance of {@link SortOrder}
 * @prop {TableColumns} columns - instance of {@link TableColumns} with a modified prototype (added `sortable:true` and `.sortable` to sortable columns)
 * */
class SortTable {
  /**
   * Makes a table sortable, gives API for sorting. It sorts `data` array, but doesn't move rows in the `source` table, because of differences in implementation
   * @param {Object} options - options passed to configure the Sorting
   * @param {Boolean} options.enabled=false - enables sorting on a header of a table
   * @param {HTMLTableElement} options.source - source table sorting will be applied to
   * @param {HTMLTableElement} [options.refSource] - the floating header if any, will reflect and trigger sorting on header when scrolled.
   * @param {Number} [options.defaultHeaderRow=-1] - index of the row in `thead` (incremented from 0) that will have sorting enabled for columns. If `-1` then last row.
   * @param {Array} [options.included] - Array of column indices (incremented from 0) that will have sorting enabled. If not specified, all columns will be sortable. Optionally `excluded` can be specified instead as a shorthand to pass only indices of columns to be excluded from sorting, assumning that others will be made sortable. It's important to count the column index in the defaultHeaderRow
   * @param {Array} [options.excluded] - Array of column indices (incremented from 0) that will be excluded from sorting. Can be used as a shorthand instead of `included`.
   * @param {Object} [options.defaultSorting] - an array of objects that specify default sorting
   * @param {Number} options.defaultSorting.column - column index
   * @param {String} options.defaultSorting.direction - sort direction (`asc`|`desc`)
   * @param {Array} options.data - data with information for rows to be sorted
   * @param {Boolean} [options.multidimensional=false] - if `data` is single-dimensional (contains rows with data to be sorted as immediate array items: `data [rowItem...]`), then it is `false`. If it has blocks of data as items (each block containing an array of rows to be sorted: data [block [rowItem...]...]), then set it to `true`. Currently it supports only a two-level aggregation max (data->block->rowItem).
   *
   *  */

  constructor(options){
    let {source,refSource,defaultHeaderRow=-1,included,excluded,defaultSorting=[],data=[],multidimensional=false}=options;
    this._sortEvent = ReportalBase.newEvent('reportal-table-sort');

    //if(enabled){
      if(source){
        this.source=source;
      } else {
        throw new Error('`source` table is not specified for SortTable');
      }
      this.data = data;
      this.multidimensional = multidimensional;

      //let tableColumns= new TableColumns({source, refSource, defaultHeaderRow});
      // setup sort order and do initial default sorting
      let sortableColumns=SortTable.defineSortableColumns(new TableColumns({source, refSource, defaultHeaderRow}), included, excluded);
      this.columns = sortableColumns;
      this.sortOrder = {sortOrder:[]} = new SortOrder({columns:sortableColumns, sortCallback:this.sort, sortCallbackScope:this, defaultSorting});
      [source,refSource].forEach(src=>{if(src){SortTable.listenForSort(TableColumns.getHeader(src),sortableColumns, this.sortOrder)}});// set up listeners for headers
    //}

    //SortTable.sort(); //initial sorting if any
  }


  /**
   * Checks the table columns array against the `included`/`excluded` columns arrays and adds a `sortable:true` property and a `.sortable` class to the sortable ones
   * @param {TableColumns} columns - an instance of {@link TableColumns}
   * @param {Array} [included] - array of included columns indices
   * @param {Array} [excluded] - array of excluded columns indices
   * */
  static defineSortableColumns(columns, included, excluded){
    let sortableColumns = [].slice.call(columns);
    sortableColumns.forEach((column,index)=>{
      let sortable=((!included && !excluded) || (included && included.indexOf(index)!=-1) || (excluded && excluded.indexOf(index)==-1));
      if(sortable){
        column.cell.classList.add('sortable');
        if(column.refCell){column.refCell.classList.add('sortable');}
        column.sortable = true;
      }
    });
    return sortableColumns
  }

  /**
   * sets up listeners for column headers available for click
   * @param {HTMLElement} delegatedTarget - element that will receive clicks and see if they are valid, `thead` is recommended to boil down to header clicks only
   * @param {TableColumns} columns - array of table columns from {@link SortTable#defineSortableColumns}
   * @param {SortOrder} sortOrder - instance of {@link SortOrder}
   * @listens click
   * */
  static listenForSort(delegatedTarget, columns, sortOrder){
    delegatedTarget.addEventListener('click',e=>{
      // if it's a table cell, is in columns array and is sortable
      if((e.target.tagName == 'TD' || e.target.tagName == 'TH') && columns.filter(col=>col.sortable).map(function(col){return col.column;}).indexOf(e.target)>-1){
        sortOrder.replace({column:e.target.cellIndex, direction: e.target.classList.contains('asc')?'desc':'asc'});
      }
    })
  }


  /**
   * Performs channeling of sorting based on whether `this.data` is `multidimensional`
   * @param {SortOrder} sortOrder - instance of {@link SortOrder} passed by the {@link SortOrder#sort} on initial sort
   * @fires SortTable~reportal-table-sort
   * */
  sort(sortOrder){
    let so = sortOrder.sortOrder || this.sortOrder.sortOrder,
      columns = this.columns;
    if(so && so.length>0){
      if(!this.multidimensional){
        SortTable.sortDimension(this.data, columns, so);
      } else { // if array has nested array blocks
        this.data.forEach(dimension=>SortTable.sortDimension(dimension, this.columns, so));
      }
      columns[so[0].column].cell.dispatchEvent(this._sortEvent);
    }
  }
  /**
   * Splits sorting into one-column or two-column. The precedence of columns in `sortOrder` is the factor defining sort priority
   * @param {Array} data - array containing row items to be sorted
   * @param {TableColumns} columns - array of table columns from {@link SortTable#defineSortableColumns}
   * @param {SortOrder} sortOrder - instance of {@link SortOrder}
   * */
  static sortDimension(data,columns,sortOrder){
    var getIndex = (i)=>{return columns[sortOrder[i].column].index};
    var getDirection=(i)=>{return sortOrder[i].direction === 'desc' ? -1 : 1};
    data.sort((a, b)=>{ // sort rows
      if(sortOrder.length==1){ //sort one column only
        return SortTable.sorter( a[getIndex(0)], b[getIndex(0)], getDirection(0) )
      } else { //sort against two columns
        return SortTable.sorter( a[getIndex(0)], b[getIndex(0)], getDirection(0) ) || SortTable.sorter( a[getIndex(1)], b[getIndex(1)], getDirection(1) )
      }
    });
  }

  /**
   * Function that performs case insensitive sorting in the array. It can distinguish between numbers, numbers as strings, HTML and plain strings
   * */
  static sorter(a,b,lesser){
    let regex = /[<>]/g;
    if(regex.test(a) || regex.test(b)){ // if we need to sort elements that have HTML like links
      let tempEl1 = document.createElement('span'); tempEl1.innerHTML = a;
      a=tempEl1.textContent.trim();
      let tempEl2 = document.createElement('span'); tempEl2.innerHTML = b;
      b=tempEl2.textContent.trim();
    }
    if(!isNaN(a) && !isNaN(b)){ //they might be numbers or null
      if(a===null){return 1} else if (b===null){return -1}
      return a <  b ? lesser :  a >  b ? -lesser : 0;
    }
    else if(!isNaN(parseFloat(a)) && !isNaN(parseFloat(b))){ // they might be number strings
      return parseFloat(a) <  parseFloat(b) ? lesser :  parseFloat(a) >  parseFloat(b) ? -lesser : 0;
    } else { //they might be simple strings
      return a.toLowerCase() < b.toLowerCase() ? lesser : a.toLowerCase() > b.toLowerCase() ? -lesser : 0;
    }
  }

}

export default SortTable