From b9c2dd00bed537b73bbad5ba529130e8717dfabb Mon Sep 17 00:00:00 2001 From: Jeremy Lenz Date: Mon, 15 Apr 2024 09:46:20 -0400 Subject: [PATCH] Fixes #37398 - Extend TableIndexPage and TableHooks with new capabilities for All Hosts page - add the option to use `idColumnName` to `` - Add the option to supply your own pagination component in `` - Add a `hasInteracted` state to `useBulkSelect` - Add a `pushToHistory` option to `useSetParamsAndAPIAndSearch` to skip changing the browser URL - Add `updateParamsByUrl` to `` for the same reason - Add `restrictedSearchQuery` to `` --- package.json | 12 +-- .../components/HostsIndex/RowSelectTd.js | 18 ++++- .../components/PF4/Bookmarks/Bookmarks.js | 4 + .../components/PF4/Bookmarks/index.js | 4 + .../PF4/TableIndexPage/Table/Table.js | 42 +++++++--- .../PF4/TableIndexPage/Table/TableHooks.js | 29 ++++--- .../TableIndexPage/Table/TableIndexHooks.js | 21 +++-- .../PF4/TableIndexPage/TableIndexPage.js | 80 +++++++++++++++---- .../react_app/components/SearchBar/index.js | 24 +++++- 9 files changed, 182 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 976e34c5a98..98a59097288 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@module-federation/utilities": "^1.7.0", - "@theforeman/vendor": "^13.0.1", + "@theforeman/vendor": "^13.1.0", "graphql-tag": "^2.11.0", "intl": "~1.2.5", "jed": "^1.1.1", @@ -30,11 +30,11 @@ }, "devDependencies": { "@babel/core": "^7.7.0", - "@theforeman/builder": "^13.0.1", - "@theforeman/eslint-plugin-foreman": "^13.0.1", - "@theforeman/eslint-plugin-rules": "^13.0.1", - "@theforeman/test": "^13.0.1", - "@theforeman/vendor-dev": "^13.0.1", + "@theforeman/builder": "^13.1.0", + "@theforeman/eslint-plugin-foreman": "^13.1.0", + "@theforeman/eslint-plugin-rules": "^13.1.0", + "@theforeman/test": "^13.1.0", + "@theforeman/vendor-dev": "^13.1.0", "@types/jest": "<27.0.0", "argv-parse": "^1.0.1", "babel-eslint": "^10.0.0", diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js b/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js index 8fff3593e6b..2bd64346dd1 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js @@ -2,14 +2,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Td } from '@patternfly/react-table'; -export const RowSelectTd = ({ rowData, selectOne, isSelected }) => ( +export const RowSelectTd = ({ + rowData, + selectOne, + isSelected, + idColumnName = 'id', +}) => ( - {showCheckboxes && } + {showCheckboxes && ( + + )} {columnNamesKeys.map(k => ( - {results.length > 0 && !errorMessage && ( - - )} + {results.length > 0 && !errorMessage && bottomPagination} ); }; @@ -179,7 +193,11 @@ Table.propTypes = { isPending: PropTypes.bool.isRequired, isEmbedded: PropTypes.bool, rowSelectTd: PropTypes.func, + idColumn: PropTypes.string, + selectOne: PropTypes.func, + isSelected: PropTypes.func, showCheckboxes: PropTypes.bool, + bottomPagination: PropTypes.node, }; Table.defaultProps = { @@ -191,5 +209,9 @@ Table.defaultProps = { results: [], isEmbedded: false, rowSelectTd: noop, + idColumn: 'id', + selectOne: noop, + isSelected: noop, showCheckboxes: false, + bottomPagination: null, }; 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 index 799c2589336..74f0541d94c 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js @@ -179,11 +179,12 @@ export const useBulkSelect = ({ idColumn, isSelectable, }); + const [hasInteracted, setHasInteracted] = useState(false); const exclusionSet = useSet(initialExclusionArry); const [searchQuery, updateSearchQuery] = useState(initialSearchQuery); const [selectAllMode, setSelectAllMode] = useState(initialSelectAllMode); const selectedCount = selectAllMode - ? Number(metadata.selectable || metadata.total) - exclusionSet.size + ? Number(metadata.selectable ?? metadata.total) - exclusionSet.size : selectOptions.selectedCount; const areAllRowsOnPageSelected = () => @@ -209,6 +210,7 @@ export const useBulkSelect = ({ const selectPage = () => { setSelectAllMode(false); selectOptions.selectPage(); + setHasInteracted(true); }; const selectNone = useCallback(() => { @@ -216,6 +218,7 @@ export const useBulkSelect = ({ exclusionSet.clear(); inclusionSet.clear(); selectOptions.clearSelectedResults(); + setHasInteracted(true); }, [exclusionSet, inclusionSet, selectOptions]); const selectOne = (isRowSelected, id, data) => { @@ -228,20 +231,26 @@ export const useBulkSelect = ({ } else { selectOptions.selectOne(isRowSelected, id, data); } + setHasInteracted(true); }; - const selectAll = checked => { - setSelectAllMode(checked); - if (checked) { - exclusionSet.clear(); - } else { - inclusionSet.clear(); - } - }; + const selectAll = useCallback( + checked => { + setSelectAllMode(checked); + if (checked) { + exclusionSet.clear(); + } else { + inclusionSet.clear(); + } + setHasInteracted(true); + }, + [exclusionSet, inclusionSet] + ); const selectDefault = () => { selectNone(); selectOptions.selectDefault(); + setHasInteracted(true); }; const fetchBulkParams = ({ @@ -301,6 +310,8 @@ export const useBulkSelect = ({ areAllRowsSelected, inclusionSet, exclusionSet, + hasInteracted, + setHasInteracted, }; }; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js index 2d03dedf2b9..6fb1075db3f 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js @@ -44,30 +44,39 @@ A hook that stores the 'params' state and returns the setParamsAndAPI and setSea @param {Object}{apiOptions} - options object. Should include { key: HOSTS_API_KEY }; see APIRequest.js for more details @param {Function}{setAPIOptions} - Pass in the setAPIOptions function returned from useAPI. @param {Function}{updateSearchQuery} - Pass in the updateSearchQuery function returned from useBulkSelect. +@param {Boolean}{pushToHistory} - If true, keep the browser url params in sync with search and pagination @return {Object} - returns the setParamsAndAPI and setSearch functions, and current params +@return {Function}{setParamsAndAPI} - function to set the params and API options +@return {Function}{setSearch} - function to set the search query +@return {Object}{params} - current params state */ export const useSetParamsAndApiAndSearch = ({ defaultParams, apiOptions, setAPIOptions, updateSearchQuery, + pushToHistory = true, }) => { const [params, setParams] = useState(defaultParams); const history = useHistory(); const setParamsAndAPI = newParams => { // add url edit params to the new params - const uri = new URI(); - uri.setSearch(newParams); - history.push({ search: uri.search() }); + if (pushToHistory) { + const uri = new URI(); + uri.setSearch(newParams); + history.push({ search: uri.search() }); + } setParams(newParams); setAPIOptions({ ...apiOptions, params: newParams }); }; const setSearch = newSearch => { - const uri = new URI(); - uri.setSearch(newSearch); + if (pushToHistory) { + const uri = new URI(); + uri.setSearch(newSearch); + history.push({ search: uri.search() }); + } updateSearchQuery(newSearch.search); - history.push({ search: uri.search() }); setParamsAndAPI({ ...params, ...newSearch }); }; 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 34df0423b76..0a5c04e3d4f 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js @@ -66,8 +66,14 @@ A page component that displays a table with data fetched from the API. It provid @param {Object} {replacementResponse} - If included, skip the API request and use this response instead @param {boolean} {showCheckboxes} - Not needed when passing children. Whether or not to show selection checkboxes in the first column. @param {function} {rowSelectTd} - Not needed when passing children. A function that takes a single result object and returns a React component to be rendered in the first column. +@param {function} {selectOne} - Not needed when passing children. Pass in the selectOne function from useBulkSelect, to use within rowSelectTd. +@param {function} {isSelected} - Not needed when passing children. Pass in the isSelected function from useBulkSelect, to use within rowSelectTd. +@param {string} {idColumn} - Not needed when passing children. The column name to use for RowSelectTd to pass to its selectOne function @param {function} {rowKebabItems} - Not needed when passing children. A function that takes a single result object and returns an array of kebab items to be displayed in the last column @param {function} {updateSearchQuery} - Pass in the updateSearchQuery function returned from useBulkSelect. +@param {function} {restrictedSearchQuery} - If included, normalize the search query to add this to all search queries to restrict search results without altering the search input value. Useful for limiting results to an initial selection. +@param {boolean} {updateParamsByUrl} - If true, update pagination props from URL params. Default is true. +@param {string} {bookmarksPosition} - The position of the bookmarks dropdown. Default is 'left', which means the menu will take up space to its right. */ const TableIndexPage = ({ @@ -95,8 +101,14 @@ const TableIndexPage = ({ replacementResponse, showCheckboxes, rowSelectTd, + selectOne, + isSelected, + idColumn, rowKebabItems, updateSearchQuery, + restrictedSearchQuery, + updateParamsByUrl, + bookmarksPosition, }) => { const history = useHistory(); const { location: { search: historySearch } = {} } = history || {}; @@ -104,13 +116,15 @@ const TableIndexPage = ({ const urlParamsSearch = urlParams.get('search') || ''; const search = urlParamsSearch || getURIsearch(); const defaultParams = { search: search || '' }; - const urlPage = urlParams.get('page'); - const urlPerPage = urlParams.get('per_page'); - if (urlPage) { - defaultParams.page = parseInt(urlPage, 10); - } - if (urlPerPage) { - defaultParams.per_page = parseInt(urlPerPage, 10); + if (updateParamsByUrl) { + const urlPage = urlParams.get('page'); + const urlPerPage = urlParams.get('per_page'); + if (urlPage) { + defaultParams.page = parseInt(urlPage, 10); + } + if (urlPerPage) { + defaultParams.per_page = parseInt(urlPerPage, 10); + } } const response = useTableIndexAPIResponse({ replacementResponse, @@ -134,10 +148,6 @@ const TableIndexPage = ({ setAPIOptions, } = response; - const onPagination = newPagination => { - setParamsAndAPI({ ...params, ...newPagination }); - }; - const memoDefaultSearchProps = useMemo( () => getControllerSearchProps(controller), [controller] @@ -150,11 +160,19 @@ const TableIndexPage = ({ apiOptions, setAPIOptions, updateSearchQuery, + pushToHistory: updateParamsByUrl, }); + const onPagination = newPagination => { + setParamsAndAPI({ ...params, ...newPagination }); + }; + const onSearch = newSearch => { if (newSearch !== apiSearchQuery) { - setSearch({ search: newSearch, page: 1 }); + setSearch({ + search: newSearch, + page: 1, + }); } }; @@ -209,8 +227,10 @@ const TableIndexPage = ({ {status === STATUS.PENDING && ( @@ -237,6 +257,8 @@ const TableIndexPage = ({ {total > 0 && ( {children || (
{ - selectOne(isSelecting, rowData.id, rowData); + selectOne(isSelecting, rowData[idColumnName], rowData); }, - isSelected: isSelected(rowData.id), + isSelected: isSelected(rowData[idColumnName]), disable: false, }} /> @@ -19,4 +24,9 @@ RowSelectTd.propTypes = { rowData: PropTypes.object.isRequired, selectOne: PropTypes.func.isRequired, isSelected: PropTypes.func.isRequired, + idColumnName: PropTypes.string, +}; + +RowSelectTd.defaultProps = { + idColumnName: 'id', }; diff --git a/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/Bookmarks.js b/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/Bookmarks.js index 19dbff71f15..b720da01563 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/Bookmarks.js +++ b/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/Bookmarks.js @@ -28,6 +28,7 @@ const Bookmarks = ({ setModalOpen, setModalClosed, searchQuery, + bookmarksPosition, }) => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -73,6 +74,7 @@ const Bookmarks = ({ /> setIsDropdownOpen(false)} toggle={ @@ -107,6 +109,7 @@ Bookmarks.propTypes = { setModalOpen: PropTypes.func.isRequired, setModalClosed: PropTypes.func.isRequired, searchQuery: PropTypes.string.isRequired, + bookmarksPosition: PropTypes.string, }; Bookmarks.defaultProps = { @@ -116,6 +119,7 @@ Bookmarks.defaultProps = { status: null, documentationUrl: '', getBookmarks: noop, + bookmarksPosition: 'left', }; export default Bookmarks; diff --git a/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/index.js b/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/index.js index ff3996f54e8..665231708c8 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/index.js +++ b/webpack/assets/javascripts/react_app/components/PF4/Bookmarks/index.js @@ -23,6 +23,7 @@ const ConnectedBookmarks = ({ canCreate, documentationUrl, searchQuery, + bookmarksPosition, }) => { const key = `${BOOKMARKS}_${controller.toUpperCase()}`; const modalID = getBookmarksModalId(id); @@ -53,6 +54,7 @@ const ConnectedBookmarks = ({ setModalClosed={setModalClosed} isModalOpen={isModalOpen} searchQuery={searchQuery} + bookmarksPosition={bookmarksPosition} /> ); }; @@ -65,6 +67,7 @@ ConnectedBookmarks.propTypes = { canCreate: PropTypes.bool, documentationUrl: PropTypes.string, searchQuery: PropTypes.string, + bookmarksPosition: PropTypes.string, }; ConnectedBookmarks.defaultProps = { @@ -72,6 +75,7 @@ ConnectedBookmarks.defaultProps = { canCreate: false, documentationUrl: '', searchQuery: '', + bookmarksPosition: 'left', }; export const reducers = { bookmarksPF4: reducer }; 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 4954550162c..f93621b9808 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 @@ -23,6 +23,8 @@ export const Table = ({ getActions, isDeleteable, itemCount, + selectOne, + isSelected, params, refreshData, results, @@ -32,8 +34,21 @@ export const Table = ({ isEmbedded, showCheckboxes, rowSelectTd, + idColumn, children, + bottomPagination, }) => { + if (!bottomPagination) + bottomPagination = ( + + ); const columnsToSortParams = {}; Object.keys(columns).forEach(key => { if (columns[key].isSorted) { @@ -129,7 +144,14 @@ export const Table = ({ const rowActions = actions(result); return (
{columns[k].wrapper @@ -147,15 +169,7 @@ export const Table = ({ })}
+ } getActions={rowKebabItems} itemCount={subtotal} results={results} @@ -271,8 +308,11 @@ const TableIndexPage = ({ status === STATUS.ERROR && errorMessage ? errorMessage : null } isPending={status === STATUS.PENDING} + selectOne={selectOne} + isSelected={isSelected} showCheckboxes={showCheckboxes} rowSelectTd={rowSelectTd} + idColumn={idColumn} /> )} @@ -326,10 +366,16 @@ TableIndexPage.propTypes = { searchable: PropTypes.bool, children: PropTypes.node, selectionToolbar: PropTypes.node, + idColumn: PropTypes.string, rowSelectTd: PropTypes.func, + selectOne: PropTypes.func, + isSelected: PropTypes.func, showCheckboxes: PropTypes.bool, rowKebabItems: PropTypes.func, updateSearchQuery: PropTypes.func, + restrictedSearchQuery: PropTypes.func, + updateParamsByUrl: PropTypes.bool, + bookmarksPosition: PropTypes.string, }; TableIndexPage.defaultProps = { @@ -354,10 +400,16 @@ TableIndexPage.defaultProps = { searchable: true, selectionToolbar: null, rowSelectTd: noop, + selectOne: noop, + isSelected: noop, showCheckboxes: false, + idColumn: 'id', replacementResponse: null, rowKebabItems: noop, updateSearchQuery: noop, + restrictedSearchQuery: noop, + updateParamsByUrl: true, + bookmarksPosition: 'left', }; export default TableIndexPage; diff --git a/webpack/assets/javascripts/react_app/components/SearchBar/index.js b/webpack/assets/javascripts/react_app/components/SearchBar/index.js index a3e831cdcf1..5de11e4f7c7 100644 --- a/webpack/assets/javascripts/react_app/components/SearchBar/index.js +++ b/webpack/assets/javascripts/react_app/components/SearchBar/index.js @@ -16,9 +16,11 @@ const SearchBar = ({ disabled, }, initialQuery, + restrictedSearchQuery, onSearch, onSearchChange, name, + bookmarksPosition, }) => { const [search, setSearch] = useState(initialQuery || searchQuery || ''); const { response, status, setAPIOptions } = useAPI('get', url, { @@ -28,8 +30,13 @@ const SearchBar = ({ if (searchQuery !== prevSearch) { setPrevSearch(searchQuery); if (searchQuery !== search) { - setSearch(searchQuery || ''); - setAPIOptions({ params: { ...apiParams, search: searchQuery || '' } }); + setSearch(restrictedSearchQuery(searchQuery) ?? (searchQuery || '')); + setAPIOptions({ + params: { + ...apiParams, + search: restrictedSearchQuery(searchQuery) ?? (searchQuery || ''), + }, + }); } } const _onSearchChange = newValue => { @@ -37,6 +44,12 @@ const SearchBar = ({ setSearch(newValue); setAPIOptions({ params: { ...apiParams, search: newValue } }); }; + const _onSearch = searchValue => { + if (restrictedSearchQuery(searchValue)) { + return onSearch(restrictedSearchQuery(searchValue)); + } + return onSearch(searchValue); + }; const error = status === STATUS.ERROR || response?.[0]?.error ? response?.[0]?.error || response.message @@ -49,7 +62,7 @@ const SearchBar = ({ } onSearchChange={_onSearchChange} value={search} - onSearch={onSearch} + onSearch={_onSearch} disabled={disabled} error={error} name={name} @@ -62,6 +75,7 @@ const SearchBar = ({ }} controller={controller} searchQuery={search || ''} + bookmarksPosition={bookmarksPosition} {...bookmarks} /> )} @@ -86,15 +100,19 @@ SearchBar.propTypes = { }).isRequired, initialQuery: PropTypes.string, onSearch: PropTypes.func, + restrictedSearchQuery: PropTypes.func, onSearchChange: PropTypes.func, name: PropTypes.string, + bookmarksPosition: PropTypes.string, }; SearchBar.defaultProps = { initialQuery: '', onSearch: searchQuery => changeQuery({ search: searchQuery.trim(), page: 1 }), onSearchChange: noop, + restrictedSearchQuery: noop, name: null, + bookmarksPosition: 'left', }; export default SearchBar;