qlik-oss/sn-table

View on GitHub
src/table/utils/selections-utils.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
98%
import { Announce, Cell, Row } from "../../types";
import { KeyCodes, SelectionActions, SelectionStates } from "../constants";
import { SelectPayload, SelectionActionTypes, SelectionState } from "../types";
import { isShiftArrow } from "./keyboard-utils";

/**
 * Gets the selection state of the given cell
 */
export const getCellSelectionState = (cell: Cell, selectionState: SelectionState): SelectionStates => {
  const { colIdx, rows, api } = selectionState;
  let cellState = SelectionStates.INACTIVE;
  if (api?.isModal()) {
    if (colIdx !== cell.selectionColIdx) {
      cellState = SelectionStates.EXCLUDED;
    } else if (rows[cell.qElemNumber] !== undefined) {
      cellState = SelectionStates.SELECTED;
    } else {
      cellState = SelectionStates.POSSIBLE;
    }
  }

  return cellState;
};

/**
 * Announces the change in selections
 */
export const announceSelectionStatus = (
  announce: Announce,
  oldRows: Record<string, number>,
  newRows: Record<string, number>,
) => {
  const rowsLength = Object.keys(newRows).length;
  const isAddition = rowsLength >= Object.keys(oldRows).length;

  if (rowsLength) {
    const changeStatus = isAddition ? "SNTable.SelectionLabel.SelectedValue" : "SNTable.SelectionLabel.DeselectedValue";
    const amountStatus =
      rowsLength === 1
        ? "SNTable.SelectionLabel.OneSelectedValue"
        : ["SNTable.SelectionLabel.SelectedValues", rowsLength.toString()];
    announce({ keys: [changeStatus, amountStatus] });
  } else {
    announce({ keys: ["SNTable.SelectionLabel.ExitedSelectionMode"] });
  }
};

/**
 * Get the updated selected rows for when (de)selecting one row
 */
export const getSelectedRows = (
  selectedRows: Record<string, number>,
  cell: Cell,
  evt: React.KeyboardEvent | React.MouseEvent,
): Record<string, number> => {
  const { qElemNumber, rowIdx } = cell;

  if (evt.ctrlKey || evt.metaKey) {
    // if the ctrl key or the ⌘ Command key (On Macintosh keyboards)
    // or the ⊞ Windows key is pressed, get the last clicked
    // item (single select)
    return { [qElemNumber]: rowIdx };
  }

  if (selectedRows[qElemNumber] !== undefined) {
    // if the selected item is clicked again, that item will be removed
    delete selectedRows[qElemNumber];
  } else {
    // if an unselected item was clicked, add it to the object
    selectedRows[qElemNumber] = rowIdx;
  }

  return { ...selectedRows };
};

/**
 * Updates the selection state and calls the backend when (de)selecting one row
 */
const selectCell = (state: SelectionState, payload: SelectPayload): SelectionState => {
  const { api, rows, colIdx } = state;
  const { cell, announce, evt } = payload;
  let selectedRows: Record<string, number> = {};

  if (colIdx === -1) api?.begin(["/qHyperCubeDef"]);
  else if (colIdx === cell.selectionColIdx) selectedRows = { ...rows };
  else return state;

  selectedRows = getSelectedRows(selectedRows, cell, evt);
  announceSelectionStatus(announce, rows, selectedRows);

  // only send a select call if there are rows selected, otherwise cancel selections
  if (Object.keys(selectedRows).length) {
    api?.select({
      method: "selectHyperCubeCells",
      params: ["/qHyperCubeDef", Object.values(selectedRows), [cell.selectionColIdx]],
    });
    return { ...state, rows: selectedRows, colIdx: cell.selectionColIdx };
  }

  api?.cancel();
  return { ...state, rows: selectedRows, colIdx: -1 };
};

/**
 * Get the updated selected rows for when selecting multiple rows
 */
export const getMultiSelectedRows = (
  pageRows: Row[],
  selectedRows: Record<string, number>,
  cell: Cell,
  evt: React.KeyboardEvent | React.MouseEvent,
  firstCell?: Cell,
): Record<string, number> => {
  const newSelectedRows = { ...selectedRows };

  if ("key" in evt && isShiftArrow(evt)) {
    newSelectedRows[cell.qElemNumber] = cell.rowIdx;
    // also add the next or previous cell to selectedRows, based on which arrow is pressed
    const idxShift = evt.key === KeyCodes.DOWN ? 1 : -1;
    const nextCell = pageRows[cell.pageRowIdx + idxShift][`col-${cell.pageColIdx}`] as Cell;
    newSelectedRows[nextCell.qElemNumber] = cell.rowIdx + idxShift;
    return newSelectedRows;
  }

  if (!firstCell) return selectedRows;
  const lowestRowIdx = Math.min(firstCell.pageRowIdx, cell.pageRowIdx);
  const highestRowIdx = Math.max(firstCell.pageRowIdx, cell.pageRowIdx);

  for (let idx = lowestRowIdx; idx <= highestRowIdx; idx++) {
    const selectedCell = pageRows[idx][`col-${firstCell.pageColIdx}`] as Cell;
    newSelectedRows[selectedCell.qElemNumber] = selectedCell.rowIdx;
  }

  return newSelectedRows;
};

/**
 * Updates the selection state but bot the backend when selecting multiple rows
 */
const selectMultipleCells = (state: SelectionState, payload: SelectPayload): SelectionState => {
  const { api, rows, colIdx, pageRows, firstCell } = state;
  const { cell, announce, evt } = payload;
  let selectedRows: Record<string, number> = {};

  if (!firstCell && !("key" in evt && isShiftArrow(evt))) return state;

  if (colIdx === -1) api?.begin(["/qHyperCubeDef"]);
  else selectedRows = { ...rows };

  selectedRows = getMultiSelectedRows(pageRows, selectedRows, cell, evt, firstCell);
  announceSelectionStatus(announce, rows, selectedRows);

  return {
    ...state,
    rows: selectedRows,
    colIdx: firstCell?.selectionColIdx ?? cell.selectionColIdx,
    isSelectMultiValues: true,
  };
};

/**
 * Initiates selecting multiple rows on mouse down
 */
const selectOnMouseDown = (
  state: SelectionState,
  { cell, mouseupOutsideCallback }: { cell: Cell; mouseupOutsideCallback(): void },
): SelectionState => {
  if (mouseupOutsideCallback && (state.colIdx === -1 || state.colIdx === cell.selectionColIdx)) {
    document.addEventListener("mouseup", mouseupOutsideCallback);
    return {
      ...state,
      firstCell: cell,
      mouseupOutsideCallback,
    };
  }

  return state;
};

/**
 * Ends selecting multiple rows by calling backend, for both keyup (shift) and mouseup
 */
const endSelectMulti = (state: SelectionState): SelectionState => {
  const { api, rows, colIdx, isSelectMultiValues, mouseupOutsideCallback } = state;
  mouseupOutsideCallback && document.removeEventListener("mouseup", mouseupOutsideCallback);

  if (isSelectMultiValues) {
    api?.select({
      method: "selectHyperCubeCells",
      params: ["/qHyperCubeDef", Object.values(rows), [colIdx]],
    });
  }

  return { ...state, isSelectMultiValues: false, firstCell: undefined, mouseupOutsideCallback: undefined };
};

/**
 * Calls endSelectMulti with the state as it is if multiple are selected, otherwise runs selectCell and treats it as a click on single cell
 */
const selectOnMouseUp = (state: SelectionState, payload: SelectPayload) =>
  endSelectMulti(!state.isSelectMultiValues && state.firstCell === payload.cell ? selectCell(state, payload) : state);

export const reducer = (state: SelectionState, action: SelectionActionTypes): SelectionState => {
  switch (action.type) {
    case SelectionActions.SELECT:
      return selectCell(state, action.payload);
    case SelectionActions.SELECT_MOUSE_DOWN:
      return selectOnMouseDown(state, action.payload);
    case SelectionActions.SELECT_MOUSE_UP:
      return selectOnMouseUp(state, action.payload);
    case SelectionActions.SELECT_MULTI_ADD:
      return selectMultipleCells(state, action.payload);
    case SelectionActions.SELECT_MULTI_END:
      return endSelectMulti(state);
    case SelectionActions.RESET:
      return state.api?.isModal() ? state : { ...state, rows: {}, colIdx: -1 };
    case SelectionActions.CLEAR:
      return Object.keys(state.rows).length ? { ...state, rows: {} } : state;
    case SelectionActions.UPDATE_PAGE_ROWS:
      return { ...state, pageRows: action.payload.pageRows };
    default:
      throw new Error("reducer called with invalid action type");
  }
};