Utils.js

/**
 * A collection of utility functions that make it easier to work with
 * table data.
 * @module Utils
 */
import stable from 'stable';
import SortDirection from './SortDirection';
import DataType from './DataType';


/**
 * Gets the value of a cell given the row data. If column.value is
 * a function, it gets called, otherwise it is interpreted as a
 * key to rowData. If column.value is not defined, column.id is
 * used as a key to rowData.
 *
 * @param {Object} column The column definition
 * @param {Object} rowData The data for the row
 * @param {Number} rowNumber The number of the row
 * @param {Object[]} tableData The array of data for the whole table
 * @param {Object[]} columns The column definitions for the whole table
 * @return {Any} The value for this cell
 */
export function getCellData(column, rowData, rowNumber, tableData, columns, isBottomData) {
  const { value, id } = column;

  // if it is bottom data, just use the value directly.
  if (isBottomData) {
    return rowData[id];
  }

  // call value as a function
  if (typeof value === 'function') {
    return value(rowData, { rowNumber, tableData, columns });

  // interpret value as a key
  } else if (value != null) {
    return rowData[value];
  }

  // otherwise, use the ID as a key
  return rowData[id];
}

/**
 * Gets the sort value of a cell given the cell data and row data. If
 * no sortValue function is provided on the column, the cellData is
 * returned.
 *
 * @param {Object} cellData The cell data
 * @param {Object} column The column definition
 * @param {Object} rowData The data for the row
 * @return {Any} The sort value for this cell
 */
export function getSortValueFromCellData(cellData, column, rowData) {
  const { sortValue } = column;

  if (sortValue) {
    return sortValue(cellData, rowData);
  }

  return cellData;
}


/**
 * Gets the sort value for a cell by first computing the cell data. If
 * no sortValue function is provided on the column, the cellData is
 * returned.
 *
 * @param {Object} column The column definition
 * @param {Object} rowData The data for the row
 * @param {Number} rowNumber The number of the row
 * @param {Object[]} tableData The array of data for the whole table
 * @param {Object[]} columns The column definitions for the whole table
 * @return {Any} The sort value for this cell
 */
export function getSortValue(column, rowData, rowNumber, tableData, columns) {
  const cellData = getCellData(column, rowData, rowNumber, tableData, columns);

  return getSortValueFromCellData(cellData, column, rowData);
}


/**
 * Gets a column from the column definitions based on its ID
 *
 * @param {Object[]} columns The column definitions for the whole table
 * @param {String} columnId the `id` of the column
 * @return {Object} The column definition
 */
export function getColumnById(columns, columnId) {
  return columns.find(column => column.id === columnId);
}


/**
 * Gets the comparator function to use based on the type of data
 * the column represents. These comparator functions expect the
 * data to be presented as { index, sortValue }. sortValue is used
 * to determine the order.
 *
 * @param {String} type the DataType the column represents
 * @return {Function} the comparator for sort
 */
export function getSortComparator(type) {
  let comparator;
  switch (type) {
    case DataType.Number:
    case DataType.NumberOrdinal:
      comparator = function numberComparator(a, b) {
        const aSortValue = a.sortValue;
        const bSortValue = b.sortValue;

        if (aSortValue == null && bSortValue == null) {
          return 0;
        } else if (aSortValue == null) {
          return 1;
        } else if (bSortValue == null) {
          return -1;
        }

        const difference = parseFloat(aSortValue) - parseFloat(bSortValue);

        return difference;
      };
      break;

    case DataType.String:
      comparator = function stringComparator(a, b) {
        const aSortValue = a.sortValue;
        const bSortValue = b.sortValue;


        if (aSortValue == null && bSortValue == null) {
          return 0;
        } else if (aSortValue == null) {
          return -1;
        } else if (bSortValue == null) {
          return 1;
        }

        const result = String(aSortValue).toLowerCase()
          .localeCompare(String(bSortValue).toLowerCase());

        return result;
      };
      break;

    default:
      comparator = function defaultComparator(a, b) {
        const aSortValue = a.sortValue;
        const bSortValue = b.sortValue;

        if (aSortValue == null && bSortValue == null) {
          return 0;
        } else if (aSortValue == null) {
          return -1;
        } else if (bSortValue == null) {
          return 1;
        }

        if (aSortValue === bSortValue) {
          return 0;
        }

        return aSortValue < bSortValue ? -1 : 1;
      };
      break;
  }

  return comparator;
}

/**
 * Helper function to test if an array is already sorted
 *
 * @param {Boolean} sortDirection The direction to check if it is sorted in
 * @param {Array} data The data to check
 * @param {Function} comparator The comparator to use
 * @return {Boolean} True if already sorted, false otherwise
 */
function alreadySorted(sortDirection, data, comparator) {
  const numRows = data.length;
  if (sortDirection === SortDirection.Ascending) {
    for (let i = 1; i < numRows; i++) {
      if (comparator(data[i - 1], data[i]) > 0) {
        return false;
      }
    }
  // check descending
  } else {
    for (let i = 1; i < numRows; i++) {
      if (comparator(data[i - 1], data[i]) < 0) {
        return false;
      }
    }
  }

  return true;
}

/**
 * Sorts the data based on sort value and column type. Uses a stable sort
 * by keeping track of the original position to break ties unless the data
 * is already sorted, in which case it just reverses the array.
 *
 * @param {Object[]} data the array of data for the whole table
 * @param {String} columnId the column ID of the column to sort by
 * @param {Boolean} sortDirection The direction to sort in
 * @param {Object[]} columns The column definitions for the whole table
 * @return {Object[]} The sorted data
 */
export function sortData(data, columnId, sortDirection, columns) {
  const column = getColumnById(columns, columnId);

  if (!column) {
    if (process.env.NODE_ENV !== 'production') {
      console.warn('No column found by ID', columnId, columns);
    }

    return data;
  }

  // read the type from `sortType` property if defined, otherwise use `type`
  const sortType = column.sortType == null ? column.type : column.sortType;
  const comparator = getSortComparator(sortType);
  const dataToSort = data.map((rowData, index) => ({
    rowData,
    index,
    sortValue: getSortValue(column, rowData, index, data, columns),
  }));

  // check if already sorted, and if so, just reverse
  let sortedData;

  // if already sorted in the opposite order, just reverse it
  if (alreadySorted(!sortDirection, dataToSort, comparator)) {
    sortedData = dataToSort.reverse();

  // if not sorted, stable sort it
  } else {
    sortedData = stable(dataToSort, comparator);
    if (sortDirection === SortDirection.Descending) {
      sortedData.reverse();
    }
  }

  sortedData = sortedData.map(sortItem => sortItem.rowData);

  return sortedData;
}

/**
 * Renders a cell's contents based on the renderer function. If no
 * renderer is provided, it just returns the raw cell data. In such
 * cases, the user should take care that cellData can be rendered
 * directly.
 *
 * @param {Any} cellData The data for the cell
 * @param {Object} column The column definition
 * @param {Object} rowData The data for the row
 * @param {Number} rowNumber The number of the row
 * @param {Object[]} tableData The array of data for the whole table
 * @param {Object[]} columns The column definitions for the whole table
 * @return {Renderable} The contents of the cell
 */
export function renderCell(cellData, column, rowData, rowNumber, tableData, columns, isBottomData, columnSummary) {
  const { renderer, renderOnNull } = column;

  // render if not bottom data-- bottomData's cellData is already rendered.
  if (!isBottomData) {
    // do not render if value is null and `renderOnNull` is not explicitly set to true
    if (cellData == null && renderOnNull !== true) {
      return null;

    // render normally if a renderer is provided
    } else if (renderer != null) {
      return renderer(cellData, { column, rowData, rowNumber, tableData, columns, columnSummary });
    }
  }

  // otherwise, render the raw cell data
  return cellData;
}


/**
 * Checks an array of column definitions to see if there are any issues.
 * Checks if
 *
 *  - multiple columns have the same ID
 *
 * Typically only used in development.
 *
 * @param {Object[]} columns The column definitions for the whole table
 * @returns {void}
 */
export function validateColumns(columns) {
  if (!columns) {
    return;
  }

  // check IDs
  const ids = {};
  columns.forEach((column, i) => {
    const { id } = column;
    if (!ids[id]) {
      ids[id] = [i];
    } else {
      ids[id].push(i);
    }
  });
  Object.keys(ids).forEach(id => {
    if (ids[id].length > 1) {
      console.warn(`Column ID '${id}' used in multiple columns ${ids[id].join(', ')}`, ids[id].map(index => columns[index]));
    }
  });
}