diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index e0f42da49cf..6e94d3bfdcd 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -684,6 +684,14 @@ def statuses render :json => statuses end + def hosts_path(*args) + if Setting[:new_hosts_page] + new_hosts_index_page_path(*args) + else + super + end + end + private def preload_reports diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 364622423cc..a90715344b8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -415,10 +415,19 @@ 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(*args) + if Setting[:new_hosts_page] + new_hosts_index_page_path(*args) + else + super + end + end end diff --git a/app/registries/foreman/settings/general.rb b/app/registries/foreman/settings/general.rb index a30a0b7eb30..bb2c86a2c40 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 0a27a6b5e83..6cc122201e1 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 c63d11015d9..2d86c9ea97d 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 b5a5f8df594..dc9ebb97c6c 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, :as => :new_hosts_index_page + 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 c1ffa541bfb..2f46f097822 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 282d20cf801..69449fdedd3 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: { @@ -59,6 +61,7 @@ const HostDetails = ({ HOST_DETAILS_API_OPTIONS ); const isNavCollapsed = useSelector(selectIsCollapsed); + const hostsIndexUrl = useForemanHostsPageUrl(); const tabs = useSelector( state => selectFillsIDs(state, TABS_SLOT_ID), shallowEqual @@ -113,7 +116,7 @@ const HostDetails = ({ switcherItemUrl: '/new/hosts/:name', }} breadcrumbItems={[ - { caption: __('Hosts'), url: foremanUrl('/hosts') }, + { caption: __('Hosts'), url: hostsIndexUrl }, { 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 00000000000..0af1524b467 --- /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 00000000000..69348e06fb1 --- /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 00000000000..0ffa832786c --- /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 00000000000..171af1dab09 --- /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 00000000000..05df24bb7e0 --- /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 527501f0421..1e13d82e667 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 00000000000..f00865dcbf7 --- /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 00000000000..155dd0c9d07 --- /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 00000000000..84d6a6a9cb1 --- /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 7473d942399..888592ab5d3 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} {cutsomToolbarItems} - an array of custom toolbar items to be displayed +@param {Array} {customToolbarItems} - an array of custom toolbar items to be displayed @param {boolean} {exportable} - whether or not to show export button @param {boolean} {hasHelpPage} - whether or not to show documentation button @param {string}{header} - the header text for the page @@ -72,13 +74,17 @@ const TableIndexPage = ({ customExportURL, customHelpURL, customSearchProps, - cutsomToolbarItems, + customToolbarItems, exportable, hasHelpPage, header, isDeleteable, searchable, children, + selectionToolbar, + replacementResponse, + showCheckboxes, + rowSelectTd, }) => { const history = useHistory(); const { location: { search: historySearch } = {} } = history || {}; @@ -95,26 +101,39 @@ const TableIndexPage = ({ defaultParams.per_page = parseInt(urlPerPage, 10); } const [params, setParams] = useState(defaultParams); + let response = useAPI( + replacementResponse ? null : 'get', + apiUrl.includes('include_permissions') + ? apiUrl + : `${apiUrl}?include_permissions=true`, + { + ...apiOptions, + params: defaultParams, + } + ); + + if (replacementResponse) { + response = replacementResponse; + } + const { response: { search: apiSearchQuery, can_create: canCreate, results, + total, + per_page: perPage, + page, subtotal, message: errorMessage, }, status = STATUS.PENDING, setAPIOptions, - } = useAPI( - 'get', - apiUrl.includes('include_permissions') - ? apiUrl - : `${apiUrl}?include_permissions=true`, - { - ...apiOptions, - params: defaultParams, - } - ); + } = response; + + const onPagination = newPagination => { + setParamsAndAPI({ ...params, ...newPagination }); + }; const memoDefaultSearchProps = useMemo( () => getControllerSearchProps(controller), @@ -187,6 +206,7 @@ const TableIndexPage = ({ {searchable && ( + {selectionToolbar} )} {actionButtons.length > 0 && ( - + )} - {cutsomToolbarItems && ( - {cutsomToolbarItems} + + {customToolbarItems && ( + {customToolbarItems} + )} + + {total > 0 && ( + )} @@ -234,6 +265,8 @@ const TableIndexPage = ({ status === STATUS.ERROR && errorMessage ? errorMessage : null } isPending={status === STATUS.PENDING} + showCheckboxes={showCheckboxes} + rowSelectTd={rowSelectTd} /> )} @@ -277,13 +310,17 @@ TableIndexPage.propTypes = { customExportURL: PropTypes.string, customHelpURL: PropTypes.string, customSearchProps: PropTypes.object, - cutsomToolbarItems: PropTypes.node, + customToolbarItems: PropTypes.node, + replacementResponse: PropTypes.object, exportable: PropTypes.bool, hasHelpPage: PropTypes.bool, header: PropTypes.string, isDeleteable: PropTypes.bool, searchable: PropTypes.bool, children: PropTypes.node, + selectionToolbar: PropTypes.node, + rowSelectTd: PropTypes.func, + showCheckboxes: PropTypes.bool, }; TableIndexPage.defaultProps = { @@ -299,12 +336,16 @@ TableIndexPage.defaultProps = { customExportURL: '', customHelpURL: '', customSearchProps: null, - cutsomToolbarItems: null, + customToolbarItems: null, exportable: false, hasHelpPage: false, header: '', isDeleteable: false, searchable: true, + selectionToolbar: null, + rowSelectTd: noop, + showCheckboxes: false, + replacementResponse: null, }; export default TableIndexPage; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.test.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.test.js index ce306c97b3c..e31e5d64478 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.test.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.test.js @@ -37,6 +37,17 @@ const props = { exportable: true, creatable: true, hasHelpPage: true, + response: { + response: { + search: "", + can_create: true, + results: [{item: 1}], + total: 1, + per_page: 20, + page: 1, + subtotal: 1, + }, + }, children:
Content
, customActionButtons: [ { @@ -44,7 +55,7 @@ const props = { action: { href: '/custom' }, }, ], - cutsomToolbarItems: , + customToolbarItems: , }; Object.defineProperty(window, 'location', { value: { href: '/test?search=name=test' }, diff --git a/webpack/assets/javascripts/react_app/routes/Hosts/constants.js b/webpack/assets/javascripts/react_app/routes/Hosts/constants.js new file mode 100644 index 00000000000..7f5390cf035 --- /dev/null +++ b/webpack/assets/javascripts/react_app/routes/Hosts/constants.js @@ -0,0 +1,3 @@ +export const HOSTS_API_PATH = '/api/hosts'; +export const HOSTS_PATH = '/new/hosts'; +export const API_REQUEST_KEY = 'HOSTS'; diff --git a/webpack/assets/javascripts/react_app/routes/Hosts/index.js b/webpack/assets/javascripts/react_app/routes/Hosts/index.js new file mode 100644 index 00000000000..8dc07784979 --- /dev/null +++ b/webpack/assets/javascripts/react_app/routes/Hosts/index.js @@ -0,0 +1,10 @@ +import React from 'react'; + +import HostsIndex from '../../components/HostsIndex'; +import { HOSTS_PATH } from './constants'; + +export default { + path: HOSTS_PATH, + render: props => , + exact: true, +}; diff --git a/webpack/assets/javascripts/react_app/routes/__test__/__snapshots__/Routes.test.js.snap b/webpack/assets/javascripts/react_app/routes/__test__/__snapshots__/Routes.test.js.snap index 3ee9c819bb7..bd406fcaed9 100644 --- a/webpack/assets/javascripts/react_app/routes/__test__/__snapshots__/Routes.test.js.snap +++ b/webpack/assets/javascripts/react_app/routes/__test__/__snapshots__/Routes.test.js.snap @@ -14,6 +14,12 @@ exports[`Routes rendering routes with children renders routes with chidlren 1`] path="/models" render={[Function]} /> +