theforeman/foreman

View on GitHub
webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js

Summary

Maintainability
C
1 day
Test Coverage
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { isEmpty } from 'lodash';
import { useLocation } from 'react-router-dom';

class ReactConnectedSet extends Set {
  constructor(initialValue, forceRender) {
    super();
    this.forceRender = forceRender;
    // The constructor would normally call add() with the initial value, but since we
    // must call super() at the top, this.forceRender() isn't defined yet.
    // So, we call super() above with no argument, then call add() manually below
    // after forceRender is defined
    if (initialValue) {
      if (initialValue.constructor.name === 'Array') {
        initialValue.forEach(id => super.add(id));
      } else {
        super.add(initialValue);
      }
    }
  }

  add(value) {
    const result = super.add(value); // ensuring these methods have the same API as the superclass
    this.forceRender();
    return result;
  }

  clear() {
    const result = super.clear();
    this.forceRender();
    return result;
  }

  delete(value) {
    const result = super.delete(value);
    this.forceRender();
    return result;
  }

  onToggle(isOpen, id) {
    if (isOpen) {
      this.add(id);
    } else {
      this.delete(id);
    }
  }

  addAll(ids) {
    ids.forEach(id => super.add(id));
    this.forceRender();
  }
}

export const useSet = initialArry => {
  const [, setToggle] = useState(Date.now());
  // needed because mutating a Ref won't cause React to rerender
  const forceRender = () => setToggle(Symbol('useSet'));
  const set = useRef(new ReactConnectedSet(initialArry, forceRender));
  return set.current;
};

export const useSelectionSet = ({
  results,
  metadata,
  defaultArry = [],
  initialArry = [],
  idColumn = 'id',
  isSelectable = () => true,
}) => {
  const selectionSet = useSet(initialArry);
  const pageIds = results?.map(result => result[idColumn]) ?? [];
  const selectableResults = useMemo(
    () => results?.filter(result => isSelectable(result)) ?? [],
    [results, isSelectable]
  );
  const selectedResults = useRef({}); // { id: result }
  const canSelect = useCallback(
    id => {
      const selectableIds = new Set(
        selectableResults.map(result => result[idColumn])
      );
      return selectableIds.has(id);
    },
    [idColumn, selectableResults]
  );
  const areAllRowsOnPageSelected = () =>
    Number(pageIds?.length) > 0 &&
    pageIds.every(result => selectionSet.has(result) || !canSelect(result));

  const areAllRowsSelected = () =>
    Number(selectionSet.size) > 0 &&
    selectionSet.size === Number(metadata.selectable);

  const selectPage = () => {
    const selectablePageIds = pageIds.filter(canSelect);
    selectionSet.addAll(selectablePageIds);
    selectableResults.forEach(result => {
      selectedResults.current[result[idColumn]] = result;
    });
  };

  const clearSelectedResults = () => {
    selectedResults.current = {};
  };

  const selectNone = () => {
    selectionSet.clear();
    clearSelectedResults();
  };
  const selectOne = (isSelected, id, data) => {
    if (canSelect(id)) {
      if (isSelected) {
        if (data) selectedResults.current[id] = data;
        selectionSet.add(id);
      } else {
        delete selectedResults.current[id];
        selectionSet.delete(id);
      }
    }
  };
  const selectDefault = () => {
    selectNone();
    selectionSet.addAll(defaultArry);
    defaultArry.forEach(id => {
      selectedResults.current[id] = results.find(
        result => result[idColumn] === id
      );
    });
  };

  const selectedCount = selectionSet.size;

  const isSelected = useCallback(id => canSelect(id) && selectionSet.has(id), [
    canSelect,
    selectionSet,
  ]);

  return {
    selectOne,
    selectedCount,
    areAllRowsOnPageSelected,
    areAllRowsSelected,
    selectPage,
    selectNone,
    selectDefault,
    isSelected,
    isSelectable: canSelect,
    selectionSet,
    selectedResults: Object.values(selectedResults.current),
    clearSelectedResults,
  };
};

const usePrevious = value => {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
};

export const useBulkSelect = ({
  results,
  metadata,
  initialArry = [],
  initialExclusionArry = [],
  defaultArry = [],
  initialSearchQuery = '',
  idColumn = 'id',
  filtersQuery = '',
  isSelectable,
  initialSelectAllMode = false,
}) => {
  const { selectionSet: inclusionSet, ...selectOptions } = useSelectionSet({
    results,
    metadata,
    initialArry,
    defaultArry,
    idColumn,
    isSelectable,
  });
  const exclusionSet = useSet(initialExclusionArry);
  const [searchQuery, updateSearchQuery] = useState(initialSearchQuery);
  const [selectAllMode, setSelectAllMode] = useState(initialSelectAllMode);
  const selectedCount = selectAllMode
    ? Number(metadata.selectable || metadata.total) - exclusionSet.size
    : selectOptions.selectedCount;

  const areAllRowsOnPageSelected = () =>
    selectAllMode || selectOptions.areAllRowsOnPageSelected();

  const areAllRowsSelected = () =>
    (selectAllMode && exclusionSet.size === 0) ||
    selectOptions.areAllRowsSelected();

  const isSelected = useCallback(
    id => {
      if (!selectOptions.isSelectable(id)) {
        return false;
      }
      if (selectAllMode) {
        return !exclusionSet.has(id);
      }
      return inclusionSet.has(id);
    },
    [exclusionSet, inclusionSet, selectAllMode, selectOptions]
  );

  const selectPage = () => {
    setSelectAllMode(false);
    selectOptions.selectPage();
  };

  const selectNone = useCallback(() => {
    setSelectAllMode(false);
    exclusionSet.clear();
    inclusionSet.clear();
    selectOptions.clearSelectedResults();
  }, [exclusionSet, inclusionSet, selectOptions]);

  const selectOne = (isRowSelected, id, data) => {
    if (selectAllMode) {
      if (isRowSelected) {
        exclusionSet.delete(id);
      } else {
        exclusionSet.add(id);
      }
    } else {
      selectOptions.selectOne(isRowSelected, id, data);
    }
  };

  const selectAll = checked => {
    setSelectAllMode(checked);
    if (checked) {
      exclusionSet.clear();
    } else {
      inclusionSet.clear();
    }
  };

  const selectDefault = () => {
    selectNone();
    selectOptions.selectDefault();
  };

  const fetchBulkParams = ({
    idColumnName = idColumn,
    selectAllQuery = '',
  } = {}) => {
    const searchQueryWithExclusionSet = () => {
      const query = [
        searchQuery,
        filtersQuery,
        !isEmpty(exclusionSet) &&
          `${idColumnName} !^ (${[...exclusionSet].join(',')})`,
        selectAllQuery,
      ];
      return query.filter(item => item).join(' and ');
    };

    const searchQueryWithInclusionSet = () => {
      if (isEmpty(inclusionSet))
        throw new Error('Cannot build a search query with no items selected');
      return `${idColumnName} ^ (${[...inclusionSet].join(',')})`;
    };
    return selectAllMode
      ? searchQueryWithExclusionSet()
      : searchQueryWithInclusionSet();
  };

  const prevSearchRef = usePrevious({ searchQuery });

  useEffect(() => {
    // if search value changed and cleared from a string to empty value
    // And it was select all -> then reset selections
    if (
      prevSearchRef &&
      !isEmpty(prevSearchRef.searchQuery) &&
      isEmpty(searchQuery) &&
      selectAllMode
    ) {
      selectNone();
    }
  }, [searchQuery, selectAllMode, prevSearchRef, selectNone]);

  return {
    ...selectOptions,
    selectPage,
    selectNone,
    selectAll,
    selectDefault,
    selectAllMode,
    isSelected,
    selectedCount,
    fetchBulkParams,
    searchQuery,
    updateSearchQuery,
    selectOne,
    areAllRowsOnPageSelected,
    areAllRowsSelected,
    inclusionSet,
    exclusionSet,
  };
};

export const friendlySearchParam = searchParam =>
  decodeURIComponent(searchParam.replace(/\+/g, ' '));

// takes a url query like ?type=security&search=name+~+foo
// and returns an object
// {
//   type: 'security',
//   searchParam: 'name ~ foo'
// }
export const useUrlParams = () => {
  const location = useLocation();
  const { search: urlSearchParam, ...urlParams } = Object.fromEntries(
    new URLSearchParams(location.search).entries()
  );
  const searchParam = urlSearchParam ? friendlySearchParam(urlSearchParam) : '';

  return {
    searchParam,
    ...urlParams,
  };
};