diff --git a/config/routes.rb b/config/routes.rb index b5a5f8df5941..9c3cd2eed407 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -550,10 +550,13 @@ mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/api/graphql' end + match 'host_statuses' => 'react#index', :via => :get 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/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js new file mode 100644 index 000000000000..e802f3bd8583 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { CubeIcon } from '@patternfly/react-icons'; +import { translate as __ } from '../../common/I18n'; +import TableIndexPage from '../PF4/TableIndexPage/TableIndexPage'; +import { HOSTS_API_PATH, API_REQUEST_KEY } from '../../routes/Hosts/constants'; + +const HostsIndex = () => { + const columns = { + name: { + title: __('Name'), + wrapper: ({ can_edit: canEdit, id, name }) => + canEdit ? ( + {name} + ) : ( + {name} + ), + isSorted: true, + }, + }; + + const computeContentSource = search => + `/change_host_content_source?search=${search}`; + + const customActionKebabs = [ + { + title: __('Change content source'), + icon: , + computeHref: computeContentSource, + }, + ]; + + return ( + + ); +}; + +export default HostsIndex; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionButtons.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionButtons.js index c285d27d042c..fe4c287ca79c 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionButtons.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionButtons.js @@ -1,11 +1,6 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { - Button, - Dropdown, - KebabToggle, - DropdownItem, -} from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; /** * Generate a button or a dropdown of buttons @@ -15,43 +10,20 @@ import { */ export const ActionButtons = ({ buttons: originalButtons }) => { const buttons = [...originalButtons]; - const [isOpen, setIsOpen] = useState(false); if (!buttons.length) return null; - const firstButton = buttons.shift(); - return ( - <> - - {buttons.length > 0 && ( - - } - isOpen={isOpen} - isPlain - dropdownItems={buttons.map(button => ( - - {button.icon} {button.title} - - ))} - /> - )} - - ); + + const pfButtons = buttons.map(button => ( + + )); + + return <>{pfButtons}; }; ActionButtons.propTypes = { @@ -60,6 +32,7 @@ ActionButtons.propTypes = { action: PropTypes.object, title: PropTypes.string, icon: PropTypes.node, + isDisabled: PropTypes.bool, }) ), }; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionKebab.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionKebab.js new file mode 100644 index 000000000000..21da73f68d93 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionKebab.js @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Dropdown, + KebabToggle, + DropdownItem, +} 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: originalItems }) => { + const items = [...originalItems]; + const [isOpen, setIsOpen] = useState(false); + if (!items.length) return null; + return ( + <> + {items.length > 0 && ( + + } + isOpen={isOpen} + isPlain + dropdownItems={items.map(item => ( + + {item.icon} {item.title} + + ))} + /> + )} + + ); +}; + +ActionKebab.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + action: PropTypes.object, + title: PropTypes.string, + icon: PropTypes.node, + isDisabled: PropTypes.bool, + }) + ), +}; + +ActionKebab.defaultProps = { + items: [], +}; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox.js new file mode 100644 index 000000000000..9eff5f397175 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox.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 'foremanReact/common/I18n'; +import { noop } from 'foremanReact/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 -> page is selected + 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.isRequired, + totalCount: PropTypes.number.isRequired, + 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/SelectAllCheckbox.scss b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox.scss new file mode 100644 index 000000000000..1e7e890bf505 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/SelectAllCheckbox.scss @@ -0,0 +1,3 @@ +.tablewrapper-select-all-checkbox { + font-weight: normal; +} 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..f68a120212a0 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,10 +9,12 @@ 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'; import { DeleteModal } from './DeleteModal'; +import SelectAllCheckbox from './SelectAllCheckbox'; import EmptyPage from '../../../../routes/common/EmptyPage'; export const Table = ({ @@ -28,6 +30,18 @@ export const Table = ({ url, isPending, isEmbedded, + displaySelectAllCheckbox, + selectAll, + selectAllMode, + selectNone, + selectPage, + areAllRowsOnPageSelected, + areAllRowsSelected, + selectedCount, + selectedResults, + clearSelectedResults, + isSelected, + selectOne, }) => { const columnsToSortParams = {}; Object.keys(columns).forEach(key => { @@ -69,6 +83,7 @@ export const Table = ({ getActions && getActions({ id, name, ...item }), ].filter(Boolean); const columnNamesKeys = Object.keys(columns); + return ( <> + {displaySelectAllCheckbox && } {columnNamesKeys.map(k => ( ( + {displaySelectAllCheckbox && ( + { + console.log({ result, isSelecting }); + selectOne(isSelecting, result.id); + }, + isSelected: isSelected(result.id), + disable: false, + }} + /> + )} {columnNamesKeys.map(k => ( {columns[k].wrapper ? columns[k].wrapper(result) : result[k]} @@ -165,6 +194,18 @@ Table.propTypes = { url: PropTypes.string.isRequired, isPending: PropTypes.bool.isRequired, isEmbedded: PropTypes.bool, + displaySelectAllCheckbox: PropTypes.bool, + selectedCount: PropTypes.number, + selectedResults: PropTypes.arrayOf(PropTypes.shape({})), + clearSelectedResults: PropTypes.func, + selectAll: PropTypes.func, + selectAllMode: PropTypes.bool, + selectNone: PropTypes.func, + selectPage: PropTypes.func, + areAllRowsOnPageSelected: PropTypes.func, + areAllRowsSelected: PropTypes.func, + isSelected: PropTypes.func, + selectOne: PropTypes.func, }; Table.defaultProps = { @@ -174,4 +215,16 @@ Table.defaultProps = { getActions: null, results: [], isEmbedded: false, + displaySelectAllCheckbox: false, + selectedCount: 0, + selectedResults: [], + clearSelectedResults: noop, + selectAll: undefined, + selectAllMode: false, + selectNone: undefined, + selectPage: undefined, + selectOne: noop, + areAllRowsOnPageSelected: noop, + areAllRowsSelected: noop, + isSelected: noop, }; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js new file mode 100644 index 000000000000..a8e50592cacc --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/TableHooks.js @@ -0,0 +1,358 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { isEmpty } from 'lodash'; +import { useLocation } from 'react-router-dom'; +import { translate as __ } from 'foremanReact/common/I18n'; +// import { friendlySearchParam } from '../../utils/helpers'; + +class ReactConnectedSet extends Set { + constructor(initialValue, forceRender) { + super(); + this.forceRender = forceRender; + // The constructor would normally call add() with the initial value, but since we + // must call super() at the top, this.forceRender() isn't defined yet. + // So, we call super() above with no argument, then call add() manually below + // after forceRender is defined + if (initialValue) { + if (initialValue.constructor.name === 'Array') { + initialValue.forEach(id => super.add(id)); + } else { + super.add(initialValue); + } + } + } + + add(value) { + const result = super.add(value); // ensuring these methods have the same API as the superclass + this.forceRender(); + return result; + } + + clear() { + const result = super.clear(); + this.forceRender(); + return result; + } + + delete(value) { + const result = super.delete(value); + this.forceRender(); + return result; + } + + onToggle(isOpen, id) { + if (isOpen) { + this.add(id); + } else { + this.delete(id); + } + } + + addAll(ids) { + ids.forEach(id => super.add(id)); + this.forceRender(); + } +} + +export const useSet = initialArry => { + const [, setToggle] = useState(Date.now()); + // needed because mutating a Ref won't cause React to rerender + const forceRender = () => setToggle(Date.now()); + const set = useRef(new ReactConnectedSet(initialArry, forceRender)); + return set.current; +}; + +export const useSelectionSet = ({ + results, + metadata, + initialArry = [], + idColumn = 'id', + isSelectable = () => true, +}) => { + const selectionSet = useSet(initialArry); + const pageIds = results?.map(result => result[idColumn]) ?? []; + const selectableResults = useMemo( + () => results?.filter(result => isSelectable(result)) ?? [], + [results, isSelectable] + ); + const selectedResults = useRef({}); // { id: result } + const canSelect = useCallback( + id => { + const selectableIds = new Set( + selectableResults.map(result => result[idColumn]) + ); + return selectableIds.has(id); + }, + [idColumn, selectableResults] + ); + const areAllRowsOnPageSelected = () => + Number(pageIds?.length) > 0 && + pageIds.every(result => selectionSet.has(result) || !canSelect(result)); + + const areAllRowsSelected = () => + Number(selectionSet.size) > 0 && + selectionSet.size === Number(metadata.selectable); + + const selectPage = () => { + const selectablePageIds = pageIds.filter(canSelect); + selectionSet.addAll(selectablePageIds); + // eslint-disable-next-line no-restricted-syntax + for (const result of selectableResults) { + selectedResults.current[result[idColumn]] = result; + } + }; + const clearSelectedResults = () => { + selectedResults.current = {}; + }; + const selectNone = () => { + selectionSet.clear(); + clearSelectedResults(); + }; + const selectOne = (isSelected, id, data) => { + if (canSelect(id)) { + if (isSelected) { + if (data) selectedResults.current[id] = data; + selectionSet.add(id); + } else { + delete selectedResults.current[id]; + selectionSet.delete(id); + } + } + }; + + const selectedCount = selectionSet.size; + + const isSelected = useCallback(id => canSelect(id) && selectionSet.has(id), [ + canSelect, + selectionSet, + ]); + + return { + selectOne, + selectedCount, + areAllRowsOnPageSelected, + areAllRowsSelected, + selectPage, + selectNone, + isSelected, + isSelectable: canSelect, + selectionSet, + selectedResults: Object.values(selectedResults.current), + clearSelectedResults, + }; +}; + +const usePrevious = value => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; + +export const useBulkSelect = ({ + results, + metadata, + initialArry = [], + initialSearchQuery = '', + idColumn = 'id', + filtersQuery = '', + isSelectable, +}) => { + const { selectionSet: inclusionSet, ...selectOptions } = useSelectionSet({ + results, + metadata, + initialArry, + idColumn, + isSelectable, + }); + const exclusionSet = useSet([]); + const [searchQuery, updateSearchQuery] = useState(initialSearchQuery); + const [selectAllMode, setSelectAllMode] = useState(false); + const selectedCount = selectAllMode + ? Number(metadata.selectable) - exclusionSet.size + : selectOptions.selectedCount; + + const areAllRowsOnPageSelected = () => + selectAllMode || selectOptions.areAllRowsOnPageSelected(); + + const areAllRowsSelected = () => + (selectAllMode && exclusionSet.size === 0) || + selectOptions.areAllRowsSelected(); + + const isSelected = useCallback( + id => { + if (!selectOptions.isSelectable(id)) { + return false; + } + if (selectAllMode) { + return !exclusionSet.has(id); + } + return inclusionSet.has(id); + }, + [exclusionSet, inclusionSet, selectAllMode, selectOptions] + ); + + const selectPage = () => { + setSelectAllMode(false); + selectOptions.selectPage(); + }; + + const selectNone = useCallback(() => { + setSelectAllMode(false); + exclusionSet.clear(); + inclusionSet.clear(); + selectOptions.clearSelectedResults(); + }, [exclusionSet, inclusionSet, selectOptions]); + + const selectOne = (isRowSelected, id, data) => { + if (selectAllMode) { + if (isRowSelected) { + exclusionSet.delete(id); + } else { + exclusionSet.add(id); + } + } else { + selectOptions.selectOne(isRowSelected, id, data); + } + }; + + const selectAll = checked => { + console.log({ checked }); + setSelectAllMode(checked); + if (checked) { + exclusionSet.clear(); + } else { + inclusionSet.clear(); + } + }; + + const fetchBulkParams = (idColumnName = idColumn) => { + console.log('fetchBulkParams'); + const searchQueryWithExclusionSet = () => { + const query = [ + searchQuery, + filtersQuery, + !isEmpty(exclusionSet) && + `${idColumnName} !^ (${[...exclusionSet].join(',')})`, + ]; + console.log(query.filter(item => item).join(' and ')); + return query.filter(item => item).join(' and '); + }; + + const searchQueryWithInclusionSet = () => { + if (isEmpty(inclusionSet)) + throw new Error('Cannot build a search query with no items selected'); + return `${idColumnName} ^ (${[...inclusionSet].join(',')})`; + }; + + console.log({ selectAllMode }); + return selectAllMode + ? searchQueryWithExclusionSet() + : searchQueryWithInclusionSet(); + }; + + const prevSearchRef = usePrevious({ searchQuery }); + + useEffect(() => { + // if search value changed and cleared from a string to empty value + // And it was select all -> then reset selections + if ( + prevSearchRef && + !isEmpty(prevSearchRef.searchQuery) && + isEmpty(searchQuery) && + selectAllMode + ) { + selectNone(); + } + }, [searchQuery, selectAllMode, prevSearchRef, selectNone]); + + return { + ...selectOptions, + selectPage, + selectNone, + selectAll, + selectAllMode, + isSelected, + selectedCount, + fetchBulkParams, + searchQuery, + updateSearchQuery, + selectOne, + areAllRowsOnPageSelected, + areAllRowsSelected, + }; +}; + +// takes a url query like ?type=security&search=name+~+foo +// and returns an object +// { +// type: 'security', +// searchParam: 'name ~ foo' +// } +export const useUrlParams = () => { + const location = useLocation(); + const { search: urlSearchParam, ...urlParams } = Object.fromEntries( + new URLSearchParams(location.search).entries() + ); + // const searchParam = urlSearchParam ? friendlySearchParam(urlSearchParam) : ''; + const searchParam = ''; + + return { + searchParam, + ...urlParams, + }; +}; + +export const useTableSort = ({ + allColumns, + columnsToSortParams, + initialSortColumnName, +}) => { + const translatedInitialSortColumnName = initialSortColumnName + ? __(initialSortColumnName) + : allColumns[0]; + if ( + !Object.keys(columnsToSortParams).includes(translatedInitialSortColumnName) + ) { + throw new Error( + `translatedInitialSortColumnName '${translatedInitialSortColumnName}' must also be defined in columnsToSortParams` + ); + } + const [activeSortColumn, setActiveSortColumn] = useState( + translatedInitialSortColumnName + ); + const [activeSortDirection, setActiveSortDirection] = useState('asc'); + + if (!allColumns.includes(activeSortColumn)) { + setActiveSortColumn(translatedInitialSortColumnName); + } + + // Patternfly sort function + const onSort = (_event, index, direction) => { + setActiveSortColumn(allColumns?.[index]); + setActiveSortDirection(direction); + }; + + // Patternfly sort params to pass to the component. + // (but you should probably just use instead) + const pfSortParams = (columnName, newSortColIndex) => ({ + columnIndex: newSortColIndex ?? allColumns?.indexOf(columnName), + sortBy: { + defaultDirection: 'asc', + direction: activeSortDirection, + index: allColumns?.indexOf(activeSortColumn), + }, + onSort, + }); + + return { + pfSortParams, + apiSortParams: { + // scoped_search params to pass to the Katello API + sort_by: columnsToSortParams[activeSortColumn], + sort_order: activeSortDirection, + }, + activeSortColumn, // state values to pass as additionalListeners + activeSortDirection, + }; +}; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js new file mode 100644 index 000000000000..84d6a6a9cb1e --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/Table/helpers.js @@ -0,0 +1,19 @@ +export const getPageStats = ({ total, page, perPage }) => { + // logic adapted from patternfly so that we can know the number of items per page + const lastPage = Math.ceil(total / perPage) ?? 0; + const firstIndex = total <= 0 ? 0 : (page - 1) * perPage + 1; + let lastIndex; + if (total <= 0) { + lastIndex = 0; + } else { + lastIndex = page === lastPage ? total : page * perPage; + } + let pageRowCount = lastIndex - firstIndex + 1; + if (total <= 0) pageRowCount = 0; + return { + firstIndex, + lastIndex, + pageRowCount, + lastPage, + }; +}; diff --git a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js index 7473d9423999..aa9adba756bc 100644 --- a/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/TableIndexPage.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { QuestionCircleIcon } from '@patternfly/react-icons'; import { useHistory } from 'react-router-dom'; import URI from 'urijs'; - +import { noop } from '../../../common/helpers'; import { Spinner, Toolbar, @@ -14,7 +14,10 @@ import { PageSectionVariants, TextContent, Text, + PaginationVariant, } from '@patternfly/react-core'; +import Pagination from '../../Pagination'; + import { createURL, exportURL, @@ -29,9 +32,12 @@ import BreadcrumbBar from '../../BreadcrumbBar'; import SearchBar from '../../SearchBar'; import Head from '../../Head'; import { ActionButtons } from './ActionButtons'; +import { ActionKebab } from './ActionKebab'; import './TableIndexPage.scss'; import { Table } from './Table/Table'; - +import { useBulkSelect } from './Table/TableHooks'; +import SelectAllCheckbox from './Table/SelectAllCheckbox'; +import { getPageStats } from './Table/helpers'; /** A page component that displays a table with data fetched from an API. It provides search and filtering functionality, and the ability to create new entries and export data. @@ -50,7 +56,7 @@ A page component that displays a table with data fetched from an API. It provide @param {string} {customExportURL} - a custom URL for the export button @param {string} {customHelpURL} - a custom URL for the documentation button @param {Object} {customSearchProps} custom search props to send to the search bar -@param {Array} {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 @@ -68,17 +74,19 @@ const TableIndexPage = ({ controller, creatable, customActionButtons, + customActionKebabs, customCreateAction, customExportURL, customHelpURL, customSearchProps, - cutsomToolbarItems, + customToolbarItems, exportable, hasHelpPage, header, isDeleteable, searchable, children, + displaySelectAllCheckbox, }) => { const history = useHistory(); const { location: { search: historySearch } = {} } = history || {}; @@ -100,6 +108,9 @@ const TableIndexPage = ({ search: apiSearchQuery, can_create: canCreate, results, + total, + per_page: perPage, + page, subtotal, message: errorMessage, }, @@ -115,6 +126,29 @@ const TableIndexPage = ({ params: defaultParams, } ); + const { pageRowCount } = getPageStats({ total, page, perPage }); + const { + updateSearchQuery, + fetchBulkParams, + ...selectAllOptions + } = useBulkSelect({ + results, + metadata: {}, + }); + + const onPagination = newPagination => { + setParamsAndAPI({ ...params, ...newPagination }); + }; + + const { + selectAll, + selectPage, + selectNone, + selectedCount, + itemCount, + areAllRowsOnPageSelected, + areAllRowsSelected, + } = selectAllOptions; const memoDefaultSearchProps = useMemo( () => getControllerSearchProps(controller), @@ -144,6 +178,33 @@ const TableIndexPage = ({ } }; + const processCustomElementActions = buttons => + buttons.map(button => { + const responseButton = { ...button }; + + if (selectedCount === 0) { + responseButton.isDisabled = true; + } + + if ( + displaySelectAllCheckbox && + responseButton.computeHref && + selectedCount > 0 + ) { + responseButton.action = { + href: responseButton.computeHref(fetchBulkParams()), + }; + } + return responseButton; + }); + + const additionalActionButtons = processCustomElementActions( + customActionButtons + ); + const additionalActionKebabs = processCustomElementActions( + customActionKebabs + ); + const actionButtons = [ creatable && canCreate && { @@ -161,7 +222,7 @@ const TableIndexPage = ({ icon: , action: { href: customHelpURL || helpURL() }, }, - ...customActionButtons, + ...additionalActionButtons, ].filter(item => item); return ( @@ -187,6 +248,22 @@ const TableIndexPage = ({ {searchable && ( + {displaySelectAllCheckbox && ( + + + + )} )} {actionButtons.length > 0 && ( - + )} - {cutsomToolbarItems && ( - {cutsomToolbarItems} + + {additionalActionKebabs.length > 0 && ( + + + + + + )} + + {customToolbarItems && ( + {customToolbarItems} + )} + + {total > 0 && ( + )} @@ -234,6 +330,8 @@ const TableIndexPage = ({ status === STATUS.ERROR && errorMessage ? errorMessage : null } isPending={status === STATUS.PENDING} + {...selectAllOptions} + displaySelectAllCheckbox={displaySelectAllCheckbox} /> )} @@ -273,17 +371,19 @@ TableIndexPage.propTypes = { controller: PropTypes.string, creatable: PropTypes.bool, customActionButtons: PropTypes.array, + customActionKebabs: PropTypes.array, customCreateAction: PropTypes.func, customExportURL: PropTypes.string, customHelpURL: PropTypes.string, customSearchProps: PropTypes.object, - cutsomToolbarItems: PropTypes.node, + customToolbarItems: PropTypes.node, exportable: PropTypes.bool, hasHelpPage: PropTypes.bool, header: PropTypes.string, isDeleteable: PropTypes.bool, searchable: PropTypes.bool, children: PropTypes.node, + displaySelectAllCheckbox: PropTypes.bool, }; TableIndexPage.defaultProps = { @@ -295,16 +395,18 @@ TableIndexPage.defaultProps = { controller: '', creatable: true, customActionButtons: [], + customActionKebabs: [], customCreateAction: null, customExportURL: '', customHelpURL: '', customSearchProps: null, - cutsomToolbarItems: null, + customToolbarItems: null, exportable: false, hasHelpPage: false, header: '', isDeleteable: false, searchable: true, + displaySelectAllCheckbox: false, }; export default TableIndexPage; 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 000000000000..7f5390cf0355 --- /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 000000000000..8dc07784979c --- /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/routes.js b/webpack/assets/javascripts/react_app/routes/routes.js index 5fe6d18cee92..0729e3d0df22 100644 --- a/webpack/assets/javascripts/react_app/routes/routes.js +++ b/webpack/assets/javascripts/react_app/routes/routes.js @@ -3,12 +3,14 @@ import Models from './Models'; import HostDetails from './HostDetails'; import RegistrationCommands from './RegistrationCommands'; import HostStatuses from './HostStatuses'; +import Hosts from './Hosts'; import EmptyPage from './common/EmptyPage/route'; import FiltersForm from './FiltersForm'; export const routes = [ Audits, Models, + Hosts, HostDetails, RegistrationCommands, HostStatuses,