+ {displaySelectAllCheckbox && (
+ {
+ console.log({ result, isSelecting });
+ selectOne(isSelecting, result.id);
+ },
+ isSelected: isSelected(result.id),
+ disable: false,
+ }}
+ />
+ )}
{columnNamesKeys.map(k => (
|
{columns[k].wrapper ? columns[k].wrapper(result) : result[k]}
@@ -165,6 +194,18 @@ Table.propTypes = {
url: PropTypes.string.isRequired,
isPending: PropTypes.bool.isRequired,
isEmbedded: PropTypes.bool,
+ displaySelectAllCheckbox: PropTypes.bool,
+ selectedCount: PropTypes.number,
+ selectedResults: PropTypes.arrayOf(PropTypes.shape({})),
+ clearSelectedResults: PropTypes.func,
+ selectAll: PropTypes.func,
+ selectAllMode: PropTypes.bool,
+ selectNone: PropTypes.func,
+ selectPage: PropTypes.func,
+ areAllRowsOnPageSelected: PropTypes.func,
+ areAllRowsSelected: PropTypes.func,
+ isSelected: PropTypes.func,
+ selectOne: PropTypes.func,
};
Table.defaultProps = {
@@ -174,4 +215,16 @@ Table.defaultProps = {
getActions: null,
results: [],
isEmbedded: false,
+ displaySelectAllCheckbox: false,
+ selectedCount: 0,
+ selectedResults: [],
+ clearSelectedResults: noop,
+ selectAll: undefined,
+ selectAllMode: false,
+ selectNone: undefined,
+ selectPage: undefined,
+ selectOne: noop,
+ areAllRowsOnPageSelected: noop,
+ areAllRowsSelected: noop,
+ isSelected: noop,
};
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js
new file mode 100644
index 000000000000..a8e50592cacc
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js
@@ -0,0 +1,358 @@
+import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
+import { isEmpty } from 'lodash';
+import { useLocation } from 'react-router-dom';
+import { translate as __ } from 'foremanReact/common/I18n';
+// import { friendlySearchParam } from '../../utils/helpers';
+
+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(Date.now());
+ const set = useRef(new ReactConnectedSet(initialArry, forceRender));
+ return set.current;
+};
+
+export const useSelectionSet = ({
+ results,
+ metadata,
+ 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);
+ // eslint-disable-next-line no-restricted-syntax
+ for (const result of selectableResults) {
+ 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 selectedCount = selectionSet.size;
+
+ const isSelected = useCallback(id => canSelect(id) && selectionSet.has(id), [
+ canSelect,
+ selectionSet,
+ ]);
+
+ return {
+ selectOne,
+ selectedCount,
+ areAllRowsOnPageSelected,
+ areAllRowsSelected,
+ selectPage,
+ selectNone,
+ 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 = [],
+ initialSearchQuery = '',
+ idColumn = 'id',
+ filtersQuery = '',
+ isSelectable,
+}) => {
+ const { selectionSet: inclusionSet, ...selectOptions } = useSelectionSet({
+ results,
+ metadata,
+ initialArry,
+ idColumn,
+ isSelectable,
+ });
+ const exclusionSet = useSet([]);
+ const [searchQuery, updateSearchQuery] = useState(initialSearchQuery);
+ const [selectAllMode, setSelectAllMode] = useState(false);
+ const selectedCount = selectAllMode
+ ? Number(metadata.selectable) - 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 => {
+ console.log({ checked });
+ setSelectAllMode(checked);
+ if (checked) {
+ exclusionSet.clear();
+ } else {
+ inclusionSet.clear();
+ }
+ };
+
+ const fetchBulkParams = (idColumnName = idColumn) => {
+ console.log('fetchBulkParams');
+ const searchQueryWithExclusionSet = () => {
+ const query = [
+ searchQuery,
+ filtersQuery,
+ !isEmpty(exclusionSet) &&
+ `${idColumnName} !^ (${[...exclusionSet].join(',')})`,
+ ];
+ console.log(query.filter(item => item).join(' and '));
+ 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(',')})`;
+ };
+
+ console.log({ selectAllMode });
+ 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,
+ selectAllMode,
+ isSelected,
+ selectedCount,
+ fetchBulkParams,
+ searchQuery,
+ updateSearchQuery,
+ selectOne,
+ areAllRowsOnPageSelected,
+ areAllRowsSelected,
+ };
+};
+
+// 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) : '';
+ const searchParam = '';
+
+ return {
+ searchParam,
+ ...urlParams,
+ };
+};
+
+export const useTableSort = ({
+ allColumns,
+ columnsToSortParams,
+ initialSortColumnName,
+}) => {
+ const translatedInitialSortColumnName = initialSortColumnName
+ ? __(initialSortColumnName)
+ : allColumns[0];
+ if (
+ !Object.keys(columnsToSortParams).includes(translatedInitialSortColumnName)
+ ) {
+ throw new Error(
+ `translatedInitialSortColumnName '${translatedInitialSortColumnName}' must also be defined in columnsToSortParams`
+ );
+ }
+ const [activeSortColumn, setActiveSortColumn] = useState(
+ translatedInitialSortColumnName
+ );
+ const [activeSortDirection, setActiveSortDirection] = useState('asc');
+
+ if (!allColumns.includes(activeSortColumn)) {
+ setActiveSortColumn(translatedInitialSortColumnName);
+ }
+
+ // Patternfly sort function
+ const onSort = (_event, index, direction) => {
+ setActiveSortColumn(allColumns?.[index]);
+ setActiveSortDirection(direction);
+ };
+
+ // Patternfly sort params to pass to the | component.
+ // (but you should probably just use instead)
+ const pfSortParams = (columnName, newSortColIndex) => ({
+ columnIndex: newSortColIndex ?? allColumns?.indexOf(columnName),
+ sortBy: {
+ defaultDirection: 'asc',
+ direction: activeSortDirection,
+ index: allColumns?.indexOf(activeSortColumn),
+ },
+ onSort,
+ });
+
+ return {
+ pfSortParams,
+ apiSortParams: {
+ // scoped_search params to pass to the Katello API
+ sort_by: columnsToSortParams[activeSortColumn],
+ sort_order: activeSortDirection,
+ },
+ activeSortColumn, // state values to pass as additionalListeners
+ activeSortDirection,
+ };
+};
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js
new file mode 100644
index 000000000000..84d6a6a9cb1e
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js
@@ -0,0 +1,19 @@
+export const getPageStats = ({ total, page, perPage }) => {
+ // logic adapted from patternfly so that we can know the number of items per page
+ const lastPage = Math.ceil(total / perPage) ?? 0;
+ const firstIndex = total <= 0 ? 0 : (page - 1) * perPage + 1;
+ let lastIndex;
+ if (total <= 0) {
+ lastIndex = 0;
+ } else {
+ lastIndex = page === lastPage ? total : page * perPage;
+ }
+ let pageRowCount = lastIndex - firstIndex + 1;
+ if (total <= 0) pageRowCount = 0;
+ return {
+ firstIndex,
+ lastIndex,
+ pageRowCount,
+ lastPage,
+ };
+};
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js
index 7473d9423999..aa9adba756bc 100644
--- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { useHistory } from 'react-router-dom';
import URI from 'urijs';
-
+import { noop } from '../../../common/helpers';
import {
Spinner,
Toolbar,
@@ -14,7 +14,10 @@ import {
PageSectionVariants,
TextContent,
Text,
+ PaginationVariant,
} from '@patternfly/react-core';
+import Pagination from '../../Pagination';
+
import {
createURL,
exportURL,
@@ -29,9 +32,12 @@ import BreadcrumbBar from '../../BreadcrumbBar';
import SearchBar from '../../SearchBar';
import Head from '../../Head';
import { ActionButtons } from './ActionButtons';
+import { ActionKebab } from './ActionKebab';
import './TableIndexPage.scss';
import { Table } from './Table/Table';
-
+import { useBulkSelect } from './Table/TableHooks';
+import SelectAllCheckbox from './Table/SelectAllCheckbox';
+import { getPageStats } from './Table/helpers';
/**
A page component that displays a table with data fetched from an API. It provides search and filtering functionality, and the ability to create new entries and export data.
@@ -50,7 +56,7 @@ A page component that displays a table with data fetched from an API. It provide
@param {string} {customExportURL} - a custom URL for the export button
@param {string} {customHelpURL} - a custom URL for the documentation button
@param {Object} {customSearchProps} custom search props to send to the search bar
-@param {Array |