diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb
index e0f42da49cf1..398dd45e08e0 100644
--- a/app/controllers/hosts_controller.rb
+++ b/app/controllers/hosts_controller.rb
@@ -941,4 +941,9 @@ def redirection_url_on_host_deletion
def current_host_details_path(host)
Setting['host_details_ui'] ? host_details_page_path(host) : host_path(host)
end
+
+ def hosts_path
+ Setting[:new_hosts_page] ? '/new/hosts' : super
+ end
+
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 364622423cc5..9d417823e416 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -415,10 +415,15 @@ def ui_settings
labFeatures: Setting[:lab_features],
safeMode: Setting[:safemode_render],
displayFqdnForHosts: Setting[:display_fqdn_for_hosts],
+ displayNewHostsPage: Setting[:new_hosts_page]
}
end
def current_host_details_path(host)
Setting['host_details_ui'] ? host_details_page_path(host) : host_path(host)
end
+
+ def hosts_path
+ Setting[:new_hosts_page] ? '/new/hosts' : '/hosts'
+ end
end
diff --git a/app/registries/foreman/settings/general.rb b/app/registries/foreman/settings/general.rb
index a30a0b7eb30d..bb2c86a2c409 100644
--- a/app/registries/foreman/settings/general.rb
+++ b/app/registries/foreman/settings/general.rb
@@ -51,6 +51,11 @@
description: N_("Whether or not to show a menu to access experimental lab features (requires reload of page)"),
default: false,
full_name: N_('Show Experimental Labs'))
+ setting('new_hosts_page',
+ type: :boolean,
+ description: N_("Whether or not to show the new overview page for All Hosts (requires reload of page)"),
+ default: false,
+ full_name: N_('Show New Host Overview Page'))
setting('display_fqdn_for_hosts',
type: :boolean,
description: N_('Display names of hosts as FQDNs. If disabled, only display names of hosts as hostnames.'),
diff --git a/app/registries/menu/loader.rb b/app/registries/menu/loader.rb
index 0a27a6b5e83b..6cc122201e13 100644
--- a/app/registries/menu/loader.rb
+++ b/app/registries/menu/loader.rb
@@ -52,7 +52,13 @@ def self.load
end
menu.sub_menu :hosts_menu, :caption => N_('Hosts'), :icon => 'fa fa-server' do
- menu.item :hosts, :caption => N_('All Hosts')
+ menu.item :hosts, :caption => N_('All Hosts'),
+ :if => proc { !Setting[:new_hosts_page] }
+ menu.item :newhosts, :caption => N_('All Hosts'),
+ :if => proc { Setting[:new_hosts_page] },
+ :url => '/new/hosts',
+ :url_hash => { :controller => 'api/v2/hosts', :action => 'index' }
+
menu.item :newhost, :caption => N_('Create Host'),
:url_hash => {:controller => '/hosts', :action => 'new'}
menu.item :register_hosts, :caption => N_('Register Host'),
diff --git a/app/views/api/v2/hosts/main.json.rabl b/app/views/api/v2/hosts/main.json.rabl
index c63d11015d95..2d86c9ea97d1 100644
--- a/app/views/api/v2/hosts/main.json.rabl
+++ b/app/views/api/v2/hosts/main.json.rabl
@@ -2,8 +2,9 @@ object @host
extends "api/v2/hosts/base"
extends "api/v2/smart_proxies/children_nodes"
+extends "api/v2/layouts/permissions"
-# we need to cache results with @last_reports, rabl can't pass custom parameters to attriute methods
+# we need to cache results with @last_reports, rabl can't pass custom parameters to attribute methods
@object.global_status_label(:last_reports => @last_reports)
@object.configuration_status(:last_reports => @last_reports)
@object.configuration_status_label(:last_reports => @last_reports)
diff --git a/config/routes.rb b/config/routes.rb
index b5a5f8df5941..5d28c3fbcfc7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -551,9 +551,12 @@
end
match 'host_statuses' => 'react#index', :via => :get
+ match 'new/hosts/auto_complete_search', :via => :get, :to => 'hosts#auto_complete_search', :as => "auto_complete_search_hosts_new"
constraints(id: /[^\/]+/) do
match 'new/hosts/:id' => 'react#index', :via => :get, :as => :host_details_page
end
+ match 'new/hosts/' => 'react#index', :via => :get
+
get 'page-not-found' => 'react#index'
get 'links/:type(/:section)' => 'links#show', :as => 'external_link', :constraints => { section: %r{.*} }
end
diff --git a/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js b/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js
index c1ffa541bfb7..2f46f0978226 100644
--- a/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js
+++ b/webpack/assets/javascripts/react_app/Root/Context/ForemanContext.js
@@ -13,3 +13,8 @@ export const useForemanDocUrl = () => useForemanMetadata().docUrl;
export const useForemanOrganization = () => useForemanMetadata().organization;
export const useForemanLocation = () => useForemanMetadata().location;
export const useForemanUser = () => useForemanMetadata().user;
+
+export const useForemanHostsPageUrl = () => {
+ const { displayNewHostsPage } = useForemanSettings();
+ return displayNewHostsPage ? '/new/hosts' : '/hosts';
+};
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/index.js
index 282d20cf8012..2bb487f78e8f 100644
--- a/webpack/assets/javascripts/react_app/components/HostDetails/index.js
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/index.js
@@ -40,10 +40,12 @@ import { useAPI } from '../../common/hooks/API/APIHooks';
import TabRouter from './Tabs/TabRouter';
import RedirectToEmptyHostPage from './EmptyState';
import BreadcrumbBar from '../BreadcrumbBar';
-import { foremanUrl } from '../../common/helpers';
import { CardExpansionContextWrapper } from './CardExpansionContext';
import Head from '../Head';
-import { useForemanSettings } from '../../Root/Context/ForemanContext';
+import {
+ useForemanSettings,
+ useForemanHostsPageUrl,
+} from '../../Root/Context/ForemanContext';
const HostDetails = ({
match: {
@@ -113,7 +115,7 @@ const HostDetails = ({
switcherItemUrl: '/new/hosts/:name',
}}
breadcrumbItems={[
- { caption: __('Hosts'), url: foremanUrl('/hosts') },
+ { caption: __('Hosts'), url: useForemanHostsPageUrl() },
{
caption: displayFqdnForHosts
? response.name
diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/ActionKebab.js b/webpack/assets/javascripts/react_app/components/HostsIndex/ActionKebab.js
new file mode 100644
index 000000000000..0af1524b4679
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostsIndex/ActionKebab.js
@@ -0,0 +1,40 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Dropdown, KebabToggle } from '@patternfly/react-core';
+
+/**
+ * Generate a button or a dropdown of buttons
+ * @param {String} title The title of the button for the title and text inside the button
+ * @param {Object} action action to preform when the button is click can be href with data-method or Onclick
+ * @return {Function} button component or splitbutton component
+ */
+export const ActionKebab = ({ items }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ if (!items.length) return null;
+ return (
+ <>
+ {items.length > 0 && (
+
+ }
+ isOpen={isOpen}
+ isPlain
+ dropdownItems={items}
+ />
+ )}
+ >
+ );
+};
+
+ActionKebab.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.node),
+};
+
+ActionKebab.defaultProps = {
+ items: [],
+};
diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/Selectors.js b/webpack/assets/javascripts/react_app/components/HostsIndex/Selectors.js
new file mode 100644
index 000000000000..69348e06fb1e
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostsIndex/Selectors.js
@@ -0,0 +1,4 @@
+import { selectComponentByWeight } from '../common/Slot/SlotSelectors';
+
+export const selectKebabItems = () =>
+ selectComponentByWeight('hosts-index-kebab');
diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js
new file mode 100644
index 000000000000..0ffa832786c2
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js
@@ -0,0 +1,124 @@
+import React, { createContext } from 'react';
+import PropTypes from 'prop-types';
+import { useSelector, shallowEqual } from 'react-redux';
+import { Td } from '@patternfly/react-table';
+import { ToolbarItem } from '@patternfly/react-core';
+import { translate as __ } from '../../common/I18n';
+import TableIndexPage from '../PF4/TableIndexPage/TableIndexPage';
+import { ActionKebab } from './ActionKebab';
+import { HOSTS_API_PATH, API_REQUEST_KEY } from '../../routes/Hosts/constants';
+import { selectKebabItems } from './Selectors';
+import { useAPI } from '../../common/hooks/API/APIHooks';
+import { useBulkSelect } from '../PF4/TableIndexPage/Table/TableHooks';
+import SelectAllCheckbox from '../PF4/TableIndexPage/Table/SelectAllCheckbox';
+import { getPageStats } from '../PF4/TableIndexPage/Table/helpers';
+
+export const ForemanHostsIndexActionsBarContext = createContext({});
+
+const HostsIndex = () => {
+ const columns = {
+ name: {
+ title: __('Name'),
+ wrapper: ({ id, name }) => {name},
+ isSorted: true,
+ },
+ };
+ const defaultParams = { search: '' }; // search ||
+
+ const response = useAPI('get', `${HOSTS_API_PATH}?include_permissions=true`, {
+ key: API_REQUEST_KEY,
+ params: defaultParams,
+ });
+
+ const {
+ response: {
+ search: apiSearchQuery,
+ results,
+ total,
+ per_page: perPage,
+ page,
+ },
+ } = response;
+
+ const { pageRowCount } = getPageStats({ total, page, perPage });
+
+ const { fetchBulkParams, ...selectAllOptions } = useBulkSelect({
+ results,
+ metadata: { total, page },
+ initialSearchQuery: apiSearchQuery || '',
+ });
+
+ const {
+ selectAll,
+ selectPage,
+ selectNone,
+ selectedCount,
+ selectOne,
+ areAllRowsOnPageSelected,
+ areAllRowsSelected,
+ isSelected,
+ } = selectAllOptions;
+
+ const selectionToolbar = (
+
+
+
+ );
+
+ const RowSelectTd = ({ rowData }) => (
+
{
+ selectOne(isSelecting, rowData.id);
+ },
+ isSelected: isSelected(rowData.id),
+ disable: false,
+ }}
+ />
+ );
+
+ RowSelectTd.propTypes = {
+ rowData: PropTypes.object.isRequired,
+ };
+
+ const actionNode = [];
+ const registeredItems = useSelector(selectKebabItems, shallowEqual);
+ const customToolbarItems = (
+
+
+
+ );
+
+ return (
+
+ );
+};
+
+export default HostsIndex;
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox/SelectAllCheckbox.scss b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox/SelectAllCheckbox.scss
new file mode 100644
index 000000000000..171af1dab093
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox/SelectAllCheckbox.scss
@@ -0,0 +1,3 @@
+.table-select-all-checkbox {
+ font-weight: normal;
+}
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox/index.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox/index.js
new file mode 100644
index 000000000000..05df24bb7e0c
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox/index.js
@@ -0,0 +1,156 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownToggleCheckbox,
+ DropdownItem,
+} from '@patternfly/react-core';
+import { translate as __ } from '../../../../../common/I18n';
+import { noop } from '../../../../../common/helpers';
+
+import './SelectAllCheckbox.scss';
+
+const SelectAllCheckbox = ({
+ selectNone,
+ selectPage,
+ selectedCount,
+ pageRowCount,
+ totalCount,
+ areAllRowsOnPageSelected,
+ areAllRowsSelected,
+ selectAll,
+}) => {
+ const [isSelectAllDropdownOpen, setSelectAllDropdownOpen] = useState(false);
+ const [selectionToggle, setSelectionToggle] = useState(false);
+
+ const canSelectAll = selectAll !== noop;
+ // Checkbox states: false = unchecked, null = partially-checked, true = checked
+ // Flow: All are selected -> click -> none are selected
+ // Some are selected -> click -> none are selected
+ // None are selected -> click -> all are selected, or page is selected (depends on canSelectAll)
+ const onSelectAllCheckboxChange = checked => {
+ if (checked && selectionToggle !== null) {
+ if (!canSelectAll) {
+ selectPage();
+ } else {
+ selectAll(true);
+ }
+ } else {
+ selectNone();
+ }
+ };
+
+ const onSelectAllDropdownToggle = () =>
+ setSelectAllDropdownOpen(isOpen => !isOpen);
+
+ const handleSelectAll = () => {
+ setSelectAllDropdownOpen(false);
+ setSelectionToggle(true);
+ selectAll(true);
+ };
+ const handleSelectPage = () => {
+ setSelectAllDropdownOpen(false);
+ setSelectionToggle(true);
+ selectPage();
+ };
+ const handleSelectNone = () => {
+ setSelectAllDropdownOpen(false);
+ setSelectionToggle(false);
+ selectNone();
+ };
+
+ useEffect(() => {
+ let newCheckedState = null; // null is partially-checked state
+
+ if (areAllRowsSelected) {
+ newCheckedState = true;
+ } else if (selectedCount === 0) {
+ newCheckedState = false;
+ }
+ setSelectionToggle(newCheckedState);
+ }, [selectedCount, areAllRowsSelected]);
+
+ const selectAllDropdownItems = [
+
+ {`${__('Select none')} (0)`}
+ ,
+
+ {`${__('Select page')} (${pageRowCount})`}
+ ,
+ ];
+ if (canSelectAll) {
+ selectAllDropdownItems.push(
+
+ {`${__('Select all')} (${totalCount})`}
+
+ );
+ }
+
+ return (
+ onSelectAllCheckboxChange(checked)}
+ isChecked={selectionToggle}
+ isDisabled={totalCount === 0 && selectedCount === 0}
+ >
+ {selectedCount > 0 && `${selectedCount} selected`}
+ ,
+ ]}
+ />
+ }
+ isOpen={isSelectAllDropdownOpen}
+ dropdownItems={selectAllDropdownItems}
+ id="selection-checkbox"
+ ouiaId="selection-checkbox"
+ />
+ );
+};
+
+SelectAllCheckbox.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ selectNone: PropTypes.func.isRequired,
+ selectPage: PropTypes.func.isRequired,
+ selectAll: PropTypes.func,
+ pageRowCount: PropTypes.number,
+ totalCount: PropTypes.number,
+ areAllRowsOnPageSelected: PropTypes.bool.isRequired,
+ areAllRowsSelected: PropTypes.bool.isRequired,
+};
+
+SelectAllCheckbox.defaultProps = {
+ selectAll: noop,
+ pageRowCount: 0,
+ totalCount: 0,
+};
+
+export default SelectAllCheckbox;
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/Table.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/Table.js
index 527501f04216..1e13d82e6679 100644
--- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/Table.js
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/Table.js
@@ -9,6 +9,7 @@ import {
Td,
ActionsColumn,
} from '@patternfly/react-table';
+import { noop } from '../../../../common/helpers';
import { translate as __ } from '../../../../common/I18n';
import { useTableSort } from '../../Helpers/useTableSort';
import Pagination from '../../../Pagination';
@@ -28,6 +29,8 @@ export const Table = ({
url,
isPending,
isEmbedded,
+ showCheckboxes,
+ rowSelectTd,
}) => {
const columnsToSortParams = {};
Object.keys(columns).forEach(key => {
@@ -69,6 +72,7 @@ export const Table = ({
getActions && getActions({ id, name, ...item }),
].filter(Boolean);
const columnNamesKeys = Object.keys(columns);
+ const RowSelectTd = rowSelectTd;
return (
<>
+ {showCheckboxes && | }
{columnNamesKeys.map(k => (
(
|
+ {showCheckboxes && }
{columnNamesKeys.map(k => (
{columns[k].wrapper ? columns[k].wrapper(result) : result[k]}
@@ -165,6 +171,8 @@ Table.propTypes = {
url: PropTypes.string.isRequired,
isPending: PropTypes.bool.isRequired,
isEmbedded: PropTypes.bool,
+ rowSelectTd: PropTypes.func,
+ showCheckboxes: PropTypes.bool,
};
Table.defaultProps = {
@@ -174,4 +182,6 @@ Table.defaultProps = {
getActions: null,
results: [],
isEmbedded: false,
+ rowSelectTd: noop,
+ showCheckboxes: false,
};
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..aea918fa5394
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js
@@ -0,0 +1,302 @@
+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,
+ 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 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 || 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 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,
+ 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,
+ };
+};
diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.test.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.test.js
new file mode 100644
index 000000000000..7271946e4689
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.test.js
@@ -0,0 +1,99 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useBulkSelect } from './TableHooks';
+
+const isSelectable = () => true;
+const idColumn = 'errata_id';
+const metadata = {
+ error: null, selectable: 2, subtotal: 2, total: 2,
+};
+const results = [
+ {
+ errata_id: 'RHSA-2022:2031',
+ id: 311,
+ severity: 'Low',
+ type: 'security',
+ },
+ {
+ errata_id: 'RHSA-2022:2110',
+ id: 17,
+ severity: 'Low',
+ type: 'security',
+ },
+];
+
+it('returns a scoped search string based on inclusionSet', () => {
+ const { result } = renderHook(() => useBulkSelect({
+ results,
+ metadata,
+ idColumn,
+ isSelectable,
+ }));
+
+ act(() => {
+ result.current.selectOne(true, 'RHSA-2022:2031');
+ });
+
+ expect(result.current.fetchBulkParams()).toBe('errata_id ^ (RHSA-2022:2031)');
+});
+
+it('returns a scoped search string based on exclusionSet', () => {
+ const { result } = renderHook(() => useBulkSelect({
+ results,
+ metadata,
+ idColumn,
+ isSelectable,
+ }));
+
+ act(() => {
+ result.current.selectAll(true);
+ });
+
+ act(() => {
+ result.current.selectOne(false, 'RHSA-2022:2031');
+ });
+
+ expect(result.current.fetchBulkParams()).toBe('errata_id !^ (RHSA-2022:2031)');
+});
+
+it('adds search query to scoped search string based on exclusionSet', () => {
+ const { result } = renderHook(() => useBulkSelect({
+ results,
+ metadata,
+ idColumn,
+ isSelectable,
+ }));
+
+ act(() => {
+ result.current.updateSearchQuery('type=security');
+ });
+
+ act(() => {
+ result.current.selectAll(true);
+ });
+
+ act(() => {
+ result.current.selectOne(false, 'RHSA-2022:2031');
+ });
+
+ expect(result.current.fetchBulkParams()).toBe('type=security and errata_id !^ (RHSA-2022:2031)');
+});
+
+it('adds filter dropdown query to scoped search string', () => {
+ const { result } = renderHook(() => useBulkSelect({
+ results,
+ metadata,
+ idColumn,
+ isSelectable,
+ filtersQuery: 'severity=Low',
+ }));
+
+ act(() => {
+ result.current.selectAll(true);
+ });
+
+ act(() => {
+ result.current.selectOne(false, 'RHSA-2022:2031');
+ });
+
+ expect(result.current.fetchBulkParams()).toBe('severity=Low and errata_id !^ (RHSA-2022:2031)');
+});
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..888592ab5d38 100644
--- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js
+++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js
@@ -1,9 +1,9 @@
+/* eslint-disable max-lines */
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { useHistory } from 'react-router-dom';
import URI from 'urijs';
-
import {
Spinner,
Toolbar,
@@ -14,16 +14,19 @@ import {
PageSectionVariants,
TextContent,
Text,
+ PaginationVariant,
} from '@patternfly/react-core';
+
import {
createURL,
exportURL,
helpURL,
getURIsearch,
} from '../../../common/urlHelpers';
-import { translate as __ } from '../../../common/I18n';
-
import { useAPI } from '../../../common/hooks/API/APIHooks';
+import { translate as __ } from '../../../common/I18n';
+import { noop } from '../../../common/helpers';
+import Pagination from '../../Pagination';
import { getControllerSearchProps, STATUS } from '../../../constants';
import BreadcrumbBar from '../../BreadcrumbBar';
import SearchBar from '../../SearchBar';
@@ -31,7 +34,6 @@ import Head from '../../Head';
import { ActionButtons } from './ActionButtons';
import './TableIndexPage.scss';
import { Table } from './Table/Table';
-
/**
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 +52,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 | |