diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js b/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js new file mode 100644 index 00000000000..8fff3593e6b --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/RowSelectTd.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Td } from '@patternfly/react-table'; + +export const RowSelectTd = ({ rowData, selectOne, isSelected }) => ( + { + selectOne(isSelecting, rowData.id, rowData); + }, + isSelected: isSelected(rowData.id), + disable: false, + }} + /> +); + +RowSelectTd.propTypes = { + rowData: PropTypes.object.isRequired, + selectOne: PropTypes.func.isRequired, + isSelected: PropTypes.func.isRequired, +}; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index dbf072568af..e36441ff417 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -1,8 +1,7 @@ -import React, { createContext } from 'react'; +import React, { createContext, useState } from 'react'; import { useHistory, Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; import { useSelector, useDispatch, shallowEqual } from 'react-redux'; -import { Td } from '@patternfly/react-table'; +import { Tr, Td, ActionsColumn } from '@patternfly/react-table'; import { ToolbarItem, Dropdown, @@ -15,15 +14,18 @@ import { SplitItem, } from '@patternfly/react-core'; import { UndoIcon } from '@patternfly/react-icons'; +import { Table } from '../PF4/TableIndexPage/Table/Table'; 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'; +import { + getColumnHelpers, + getPageStats, +} from '../PF4/TableIndexPage/Table/helpers'; import { deleteHost } from '../HostDetails/ActionsBar/actions'; import { useForemanSettings } from '../../Root/Context/ForemanContext'; import { getURIsearch } from '../../common/urlHelpers'; @@ -32,6 +34,12 @@ import { foremanUrl } from '../../common/helpers'; import Slot from '../common/Slot'; import forceSingleton from '../../common/forceSingleton'; import './index.scss'; +import { STATUS } from '../../constants'; +import { RowSelectTd } from './RowSelectTd'; +import { + useSetParamsAndApiAndSearch, + useTableIndexAPIResponse, +} from '../PF4/TableIndexPage/Table/TableIndexHooks'; export const ForemanHostsIndexActionsBarContext = forceSingleton( 'ForemanHostsIndexActionsBarContext', @@ -46,34 +54,42 @@ const HostsIndex = () => { isSorted: true, }, }; - + const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); const history = useHistory(); const { location: { search: historySearch } = {} } = history || {}; const urlParams = new URLSearchParams(historySearch); const urlParamsSearch = urlParams.get('search') || ''; const searchFromUrl = urlParamsSearch || getURIsearch(); const initialSearchQuery = apiSearchQuery || searchFromUrl || ''; - const defaultParams = { search: initialSearchQuery }; + const apiOptions = { key: API_REQUEST_KEY }; + const response = useTableIndexAPIResponse({ + apiUrl: HOSTS_API_PATH, + apiOptions, + defaultParams, + }); - const response = useAPI('get', `${HOSTS_API_PATH}?include_permissions=true`, { - key: API_REQUEST_KEY, - params: defaultParams, + const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions: response.setAPIOptions, }); const { response: { search: apiSearchQuery, results, - subtotal, total, per_page: perPage, page, + subtotal, + message: errorMessage, }, + status = STATUS.PENDING, + setAPIOptions, } = response; const { pageRowCount } = getPageStats({ total, page, perPage }); - const { fetchBulkParams, updateSearchQuery, @@ -112,22 +128,6 @@ const HostsIndex = () => { ); - const RowSelectTd = ({ rowData }) => ( - { - selectOne(isSelecting, rowData.id, rowData); - }, - isSelected: isSelected(rowData.id), - disable: false, - }} - /> - ); - - RowSelectTd.propTypes = { - rowData: PropTypes.object.isRequired, - }; const dispatch = useDispatch(); const { destroyVmOnHostDelete } = useForemanSettings(); const deleteHostHandler = ({ hostName, computeId }) => @@ -153,8 +153,9 @@ const HostsIndex = () => { {__('Delete')} , ]; + const registeredItems = useSelector(selectKebabItems, shallowEqual); - const customToolbarItems = ( + const pluginToolbarItems = ( @@ -166,7 +167,7 @@ const HostsIndex = () => { id, name: hostName, compute_id: computeId, - canDelete, + can_delete: canDelete, }) => [ { title: __('Delete'), @@ -175,7 +176,7 @@ const HostsIndex = () => { }, ]; - const [legacyUIKebabOpen, setLegacyUIKebabOpen] = React.useState(false); + const [legacyUIKebabOpen, setLegacyUIKebabOpen] = useState(false); const legacyUIKebab = ( { return ( + > + + setAPIOptions({ + ...apiOptions, + params: { search: searchFromUrl }, + }) + } + columns={columns} + errorMessage={ + status === STATUS.ERROR && errorMessage ? errorMessage : null + } + isPending={status === STATUS.PENDING} + > + {results?.map((result, rowIndex) => { + const rowActions = rowKebabItems(result); + return ( + + {} + {columnNamesKeys.map(k => ( + + ))} + + + ); + })} +
+ {columns[k].wrapper ? columns[k].wrapper(result) : result[k]} + + {rowActions.length ? ( + + ) : null} +
+
); }; 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 69bb76da6a6..4954550162c 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 @@ -15,6 +15,7 @@ import { useTableSort } from '../../Helpers/useTableSort'; import Pagination from '../../../Pagination'; import { DeleteModal } from './DeleteModal'; import EmptyPage from '../../../../routes/common/EmptyPage'; +import { getColumnHelpers } from './helpers'; export const Table = ({ columns, @@ -31,6 +32,7 @@ export const Table = ({ isEmbedded, showCheckboxes, rowSelectTd, + children, }) => { const columnsToSortParams = {}; Object.keys(columns).forEach(key => { @@ -38,10 +40,7 @@ export const Table = ({ columnsToSortParams[columns[key].title] = key; } }); - const columnNames = {}; - Object.keys(columns).forEach(key => { - columnNames[key] = columns[key].title; - }); + const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); const onSort = (_event, index, direction) => { setParams({ ...params, @@ -71,7 +70,6 @@ export const Table = ({ }, ...((getActions && getActions({ id, name, canDelete, ...item })) ?? []), ].filter(Boolean); - const columnNamesKeys = Object.keys(columns); const RowSelectTd = rowSelectTd; return ( <> @@ -91,10 +89,10 @@ export const Table = ({ key={k} sort={ Object.values(columnsToSortParams).includes(k) && - pfSortParams(columnNames[k]) + pfSortParams(keysToColumnNames[k]) } > - {columnNames[k]} + {keysToColumnNames[k]} ))} @@ -126,26 +124,27 @@ export const Table = ({ )} - {results.map((result, rowIndex) => { - const rowActions = actions(result); - return ( - - {showCheckboxes && } - {columnNamesKeys.map(k => ( - - {columns[k].wrapper - ? columns[k].wrapper(result) - : result[k]} + {children || + results.map((result, rowIndex) => { + const rowActions = actions(result); + return ( + + {showCheckboxes && } + {columnNamesKeys.map(k => ( + + {columns[k].wrapper + ? columns[k].wrapper(result) + : result[k]} + + ))} + + {rowActions.length ? ( + + ) : null} - ))} - - {rowActions.length ? ( - - ) : null} - - - ); - })} + + ); + })} {results.length > 0 && !errorMessage && ( @@ -162,6 +161,7 @@ export const Table = ({ }; Table.propTypes = { + children: PropTypes.node, columns: PropTypes.object.isRequired, params: PropTypes.shape({ page: PropTypes.number, @@ -183,6 +183,7 @@ Table.propTypes = { }; Table.defaultProps = { + children: null, errorMessage: null, isDeleteable: false, itemCount: 0, 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 new file mode 100644 index 00000000000..dbab54c1c66 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableIndexHooks.js @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import URI from 'urijs'; +import { useHistory } from 'react-router-dom'; +import { useAPI } from '../../../../common/hooks/API/APIHooks'; + +/** + +A hook that encapsulates the logic for fetching the API response for TableIndexPage and HostsIndexPage +@param {Object}{replacementResponse} - If included, skip the API request and use this response instead +@param {string}{apiUrl} - url for the API to make requests to +@param {Object}{apiOptions} - options object. Should include { key: HOSTS_API_KEY }; see APIRequest.js for more details +@param {Object}{defaultParams} - 'params' object to send to useAPI +@return {Object} - returns the API response + +*/ + +export const useTableIndexAPIResponse = ({ + replacementResponse, + apiUrl, + apiOptions = {}, + defaultParams = {}, +}) => { + let response = useAPI( + replacementResponse ? null : 'get', + apiUrl.includes('include_permissions') + ? apiUrl + : `${apiUrl}?include_permissions=true`, + { + ...apiOptions, + params: defaultParams, + } + ); + + if (replacementResponse) { + response = replacementResponse; + } + + return response; +}; + +/** +A hook that stores the 'params' state and returns the setParamsAndAPI and setSearch functions for TableIndexPage and HostsIndexPage +@param {Object}{defaultParams} - initial state value for params +@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. +@return {Object} - returns the setParamsAndAPI and setSearch functions, and current params +*/ +export const useSetParamsAndApiAndSearch = ({ + defaultParams, + apiOptions, + setAPIOptions, + updateSearchQuery, +}) => { + 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() }); + setParams(newParams); + setAPIOptions({ ...apiOptions, params: newParams }); + }; + + const setSearch = newSearch => { + const uri = new URI(); + uri.setSearch(newSearch); + updateSearchQuery(newSearch.search); + history.push({ search: uri.search() }); + setParamsAndAPI({ ...params, ...newSearch }); + }; + + return { + setParamsAndAPI, + setSearch, + params, + }; +}; 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 index 84d6a6a9cb1..8d7ef805e7a 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js @@ -17,3 +17,17 @@ export const getPageStats = ({ total, page, perPage }) => { lastPage, }; }; + +/** + * Assembles column data into various forms needed + * @param {Object} columns - Object with column sort params as keys and column objects as values. Column objects must have a title key + * @returns {Array} - an array of column sort params and a map of keys to column names + */ +export const getColumnHelpers = columns => { + const columnNamesKeys = Object.keys(columns); + const keysToColumnNames = {}; + columnNamesKeys.forEach(key => { + keysToColumnNames[key] = columns[key].title; + }); + return [columnNamesKeys, keysToColumnNames]; +}; 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 a8b6e02a8b9..34df0423b76 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,8 @@ /* eslint-disable max-lines */ -import React, { useState, useMemo } from 'react'; +import React, { 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, @@ -23,7 +22,6 @@ import { helpURL, getURIsearch, } from '../../../common/urlHelpers'; -import { useAPI } from '../../../common/hooks/API/APIHooks'; import { translate as __ } from '../../../common/I18n'; import { noop } from '../../../common/helpers'; import Pagination from '../../Pagination'; @@ -34,14 +32,19 @@ import Head from '../../Head'; import { ActionButtons } from './ActionButtons'; import './TableIndexPage.scss'; import { Table } from './Table/Table'; +import { + useSetParamsAndApiAndSearch, + useTableIndexAPIResponse, +} from './Table/TableIndexHooks'; /** -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. -@param {Object}{apiOptions} - options object for API requests +A page component that displays a table with data fetched from the API. It provides search and filtering functionality, and the ability to create new entries and export data. +@param {Object}{apiOptions} - options object for API requests. See APIRequest.js for more details @param {string}{apiUrl} - url for the API to make requests to @param {React.Component} {beforeToolbarComponent} - a component to be rendered before the toolbar @param {Object} {breadcrumbOptions} - props to send to the breadcrumb bar -@param {Object}{columns} - an object of objects representing the columns to be displayed in the table, keys should be the same as in the api response +@param {React.ReactNode} {children} - optional children to be rendered inside the page instead of the table +@param {Object}{columns} - Not needed when passing children. An object of objects representing the columns to be displayed in the table, keys should be the same as in the api response @param {string} columns[].title - the title of the column, translated @param {function} columns[].wrapper - a function that returns a React component to be rendered in the column @param {boolean} columns[].isSorted - whether or not the column is sorted @@ -59,7 +62,12 @@ A page component that displays a table with data fetched from an API. It provide @param {string}{header} - header node; default is {headerText} @param {boolean} {isDeleteable} - whether or not entries can be deleted @param {boolean} {searchable} - whether or not the table can be searched -@param {React.ReactNode} {children} - optional children to be rendered inside the page instead of the table +@param {React.ReactNode} {selectionToolbar} - Pass in the SelectAll toolbar, if desired +@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} {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. */ const TableIndexPage = ({ @@ -67,6 +75,7 @@ const TableIndexPage = ({ apiUrl, beforeToolbarComponent, breadcrumbOptions, + children, columns, controller, creatable, @@ -82,7 +91,6 @@ const TableIndexPage = ({ header, isDeleteable, searchable, - children, selectionToolbar, replacementResponse, showCheckboxes, @@ -104,21 +112,12 @@ const TableIndexPage = ({ if (urlPerPage) { 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 = useTableIndexAPIResponse({ + replacementResponse, + apiUrl, + apiOptions, + defaultParams, + }); const { response: { @@ -145,22 +144,13 @@ const TableIndexPage = ({ ); const searchProps = customSearchProps || memoDefaultSearchProps; searchProps.autocomplete.searchQuery = search; - const setParamsAndAPI = newParams => { - // add url edit params to the new params - 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); - updateSearchQuery(newSearch.search); - history.push({ search: uri.search() }); - setParamsAndAPI({ ...params, ...newSearch }); - }; + const { setParamsAndAPI, setSearch, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions, + updateSearchQuery, + }); const onSearch = newSearch => { if (newSearch !== apiSearchQuery) {