diff --git a/app/registries/foreman/settings/general.rb b/app/registries/foreman/settings/general.rb index a30a0b7eb30d..464c1649093c 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_pages', + type: :boolean, + description: N_("Whether or not to show the new hosts page for All Hosts (requires reload of page)"), + default: false, + full_name: N_('Show New Hosts Pages')) setting('display_fqdn_for_hosts', type: :boolean, description: N_('Display names of hosts as FQDNs. If disabled, only display names of hosts as hostnames.'), diff --git a/app/registries/menu/loader.rb b/app/registries/menu/loader.rb index 0a27a6b5e83b..8530fa4d99f5 100644 --- a/app/registries/menu/loader.rb +++ b/app/registries/menu/loader.rb @@ -52,7 +52,14 @@ 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_pages]} + menu.item :newhosts, :caption => N_('All Hosts'), + :if => proc {Setting[:new_hosts_pages]}, + :caption => N_('All Hosts'), + :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/config/routes.rb b/config/routes.rb index b5a5f8df5941..483d3234488e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -554,6 +554,8 @@ 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..e318b2c4cda9 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -0,0 +1,42 @@ +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: ({ id, 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..0238acf399fc --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/PF4/TableIndexPage/ActionKebab.js @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { 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..bcc9911eb454 --- /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 '../../../../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 -> 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, + 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/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..2b511c6b4efa 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,9 @@ export const Table = ({ url, isPending, isEmbedded, + displaySelectAllCheckbox, + isSelected, + selectOne, }) => { const columnsToSortParams = {}; Object.keys(columns).forEach(key => { @@ -69,6 +73,7 @@ export const Table = ({ getActions && getActions({ id, name, ...item }), ].filter(Boolean); const columnNamesKeys = Object.keys(columns); + return ( <> + {displaySelectAllCheckbox && } {columnNamesKeys.map(k => ( ( + {displaySelectAllCheckbox && ( + { + selectOne(isSelecting, result.id); + }, + isSelected: isSelected(result.id), + disable: false, + }} + /> + )} {columnNamesKeys.map(k => ( {columns[k].wrapper ? columns[k].wrapper(result) : result[k]} @@ -165,6 +183,9 @@ Table.propTypes = { url: PropTypes.string.isRequired, isPending: PropTypes.bool.isRequired, isEmbedded: PropTypes.bool, + displaySelectAllCheckbox: PropTypes.bool, + isSelected: PropTypes.func, + selectOne: PropTypes.func, }; Table.defaultProps = { @@ -174,4 +195,7 @@ Table.defaultProps = { getActions: null, results: [], isEmbedded: false, + displaySelectAllCheckbox: false, + selectOne: 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..20c2ad30d276 --- /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(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, + }; +}; \ No newline at end of file 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..7a1d7f69613a 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,6 @@ 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,7 +13,10 @@ import { PageSectionVariants, TextContent, Text, + PaginationVariant, } from '@patternfly/react-core'; +import Pagination from '../../Pagination'; + import { createURL, exportURL, @@ -29,9 +31,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 +55,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 +73,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 +107,9 @@ const TableIndexPage = ({ search: apiSearchQuery, can_create: canCreate, results, + total, + per_page: perPage, + page, subtotal, message: errorMessage, }, @@ -115,6 +125,28 @@ 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, + areAllRowsOnPageSelected, + areAllRowsSelected, + } = selectAllOptions; const memoDefaultSearchProps = useMemo( () => getControllerSearchProps(controller), @@ -144,6 +176,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 +220,7 @@ const TableIndexPage = ({ icon: , action: { href: customHelpURL || helpURL() }, }, - ...customActionButtons, + ...additionalActionButtons, ].filter(item => item); return ( @@ -187,6 +246,22 @@ const TableIndexPage = ({ {searchable && ( + {displaySelectAllCheckbox && ( + + + + )} )} {actionButtons.length > 0 && ( - + )} - {cutsomToolbarItems && ( - {cutsomToolbarItems} + + {additionalActionKebabs.length > 0 && ( + + + + + + )} + + {customToolbarItems && ( + {customToolbarItems} + )} + + {total > 0 && ( + )} @@ -234,6 +328,8 @@ const TableIndexPage = ({ status === STATUS.ERROR && errorMessage ? errorMessage : null } isPending={status === STATUS.PENDING} + {...selectAllOptions} + displaySelectAllCheckbox={displaySelectAllCheckbox} /> )} @@ -273,17 +369,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 +393,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,