diff --git a/awx/ui_next/src/components/AlertModal/AlertModal.jsx b/awx/ui_next/src/components/AlertModal/AlertModal.jsx index f600d77745..8b47a8d3f9 100644 --- a/awx/ui_next/src/components/AlertModal/AlertModal.jsx +++ b/awx/ui_next/src/components/AlertModal/AlertModal.jsx @@ -16,7 +16,13 @@ const Header = styled.div` } `; -export default ({ isOpen = null, title, variant, children, ...props }) => { +export default function AlertModal({ + isOpen = null, + title, + variant, + children, + ...props +}) { const variantIcons = { danger: , error: , @@ -44,4 +50,4 @@ export default ({ isOpen = null, title, variant, children, ...props }) => { {children} ); -}; +} diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap index 9ea0627668..d97266c627 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap @@ -17,7 +17,7 @@ exports[` should render initially 1`] = ` } username="jane" > - <_default + should render initially 1`] = ` - + `; diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx index c6c1814667..7e03abc736 100644 --- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx @@ -3,7 +3,7 @@ import { Link, useHistory, useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Host } from '@types'; -import { Button, Switch } from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; import { CardBody, CardActionsRow } from '@components/Card'; import AlertModal from '@components/AlertModal'; import ErrorDetail from '@components/ErrorDetail'; @@ -12,6 +12,7 @@ import { VariablesDetail } from '@components/CodeMirrorInput'; import Sparkline from '@components/Sparkline'; import DeleteButton from '@components/DeleteButton'; import { HostsAPI } from '@api'; +import HostToggle from '../shared/HostToggle'; function HostDetail({ host, i18n, onUpdateHost }) { const { @@ -20,7 +21,6 @@ function HostDetail({ host, i18n, onUpdateHost }) { id, modified, name, - enabled, summary_fields: { inventory, recent_jobs, @@ -36,25 +36,9 @@ function HostDetail({ host, i18n, onUpdateHost }) { const { id: inventoryId, hostId: inventoryHostId } = useParams(); const [isLoading, setIsloading] = useState(false); const [deletionError, setDeletionError] = useState(false); - const [toggleLoading, setToggleLoading] = useState(false); - const [toggleError, setToggleError] = useState(false); const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' })); - const handleHostToggle = async () => { - setToggleLoading(true); - try { - const { data } = await HostsAPI.update(id, { - enabled: !enabled, - }); - onUpdateHost(data); - } catch (err) { - setToggleError(err); - } finally { - setToggleLoading(false); - } - }; - const handleHostDelete = async () => { setIsloading(true); try { @@ -66,19 +50,6 @@ function HostDetail({ host, i18n, onUpdateHost }) { } }; - if (toggleError && !toggleLoading) { - return ( - setToggleError(false)} - > - {i18n._(t`Failed to toggle host.`)} - - - ); - } if (!isLoading && deletionError) { return ( - + onUpdateHost({ + ...host, + enabled, + }) + } css="padding-bottom: 40px" - id={`host-${id}-toggle`} - label={i18n._(t`On`)} - labelOff={i18n._(t`Off`)} - isChecked={enabled} - isDisabled={!user_capabilities.edit} - onChange={() => handleHostToggle()} - aria-label={i18n._(t`Toggle Host`)} /> diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 0edc224d30..b4138fc26f 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -1,5 +1,5 @@ -import React, { Component, Fragment } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card } from '@patternfly/react-core'; @@ -12,6 +12,7 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '@components/PaginatedDataList'; +import useRequest, { useDeleteItems } from '@util/useRequest'; import { getQSConfig, parseQueryString } from '@util/qs'; import HostListItem from './HostListItem'; @@ -22,263 +23,158 @@ const QS_CONFIG = getQSConfig('host', { order_by: 'name', }); -class HostsList extends Component { - constructor(props) { - super(props); +function HostList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + const [selected, setSelected] = useState([]); - this.state = { - hasContentLoading: true, - contentError: null, - deletionError: null, + const { + result: { hosts, count, actions }, + error: contentError, + isLoading, + request: fetchHosts, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const results = await Promise.all([ + HostsAPI.read(params), + HostsAPI.readOptions(), + ]); + return { + hosts: results[0].data.results, + count: results[0].data.count, + actions: results[1].data.actions, + }; + }, [location]), + { hosts: [], - selected: [], - itemCount: 0, - actions: null, - toggleError: null, - toggleLoading: null, - }; - - this.handleSelectAll = this.handleSelectAll.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleHostDelete = this.handleHostDelete.bind(this); - this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); - this.loadActions = this.loadActions.bind(this); - this.loadHosts = this.loadHosts.bind(this); - this.handleHostToggle = this.handleHostToggle.bind(this); - this.handleHostToggleErrorClose = this.handleHostToggleErrorClose.bind( - this - ); - } - - componentDidMount() { - this.loadHosts(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - this.loadHosts(); + count: 0, + actions: {}, } - } + ); - handleSelectAll(isSelected) { - const { hosts } = this.state; + useEffect(() => { + fetchHosts(); + }, [fetchHosts]); - const selected = isSelected ? [...hosts] : []; - this.setState({ selected }); - } + const isAllSelected = selected.length === hosts.length && selected.length > 0; + const { + isLoading: isDeleteLoading, + deleteItems: deleteHosts, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all(selected.map(host => HostsAPI.destroy(host.id))); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchHosts, + } + ); - handleSelect(row) { - const { selected } = this.state; + const handleHostDelete = async () => { + await deleteHosts(); + setSelected([]); + }; - if (selected.some(s => s.id === row.id)) { - this.setState({ selected: selected.filter(s => s.id !== row.id) }); + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...hosts] : []); + }; + + const handleSelect = host => { + if (selected.some(h => h.id === host.id)) { + setSelected(selected.filter(h => h.id !== host.id)); } else { - this.setState({ selected: selected.concat(row) }); + setSelected(selected.concat(host)); } - } + }; - handleDeleteErrorClose() { - this.setState({ deletionError: null }); - } + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - handleHostToggleErrorClose() { - this.setState({ toggleError: null }); - } - - async handleHostDelete() { - const { selected } = this.state; - - this.setState({ hasContentLoading: true }); - try { - await Promise.all(selected.map(host => HostsAPI.destroy(host.id))); - } catch (err) { - this.setState({ deletionError: err }); - } finally { - await this.loadHosts(); - } - } - - async handleHostToggle(hostToToggle) { - const { hosts } = this.state; - this.setState({ toggleLoading: hostToToggle.id }); - try { - const { data: updatedHost } = await HostsAPI.update(hostToToggle.id, { - enabled: !hostToToggle.enabled, - }); - this.setState({ - hosts: hosts.map(host => - host.id === updatedHost.id ? updatedHost : host - ), - }); - } catch (err) { - this.setState({ toggleError: err }); - } finally { - this.setState({ toggleLoading: null }); - } - } - - async loadActions() { - const { actions: cachedActions } = this.state; - let optionsPromise; - if (cachedActions) { - optionsPromise = Promise.resolve({ data: { actions: cachedActions } }); - } else { - optionsPromise = HostsAPI.readOptions(); - } - - return optionsPromise; - } - - async loadHosts() { - const { location } = this.props; - const params = parseQueryString(QS_CONFIG, location.search); - - const promises = Promise.all([HostsAPI.read(params), this.loadActions()]); - - this.setState({ contentError: null, hasContentLoading: true }); - try { - const [ - { - data: { count, results }, - }, - { - data: { actions }, - }, - ] = await promises; - this.setState({ - actions, - itemCount: count, - hosts: results, - selected: [], - }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); - } - } - - render() { - const { - actions, - itemCount, - contentError, - hasContentLoading, - deletionError, - selected, - hosts, - toggleLoading, - toggleError, - } = this.state; - const { match, i18n } = this.props; - - const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = - selected.length > 0 && selected.length === hosts.length; - - return ( - - - ( - , - ...(canAdd - ? [ - , - ] - : []), - ]} - /> - )} - renderItem={o => ( - row.id === o.id)} - onSelect={() => this.handleSelect(o)} - onToggleHost={this.handleHostToggle} - toggleLoading={toggleLoading === o.id} - /> - )} - emptyStateControls={ - canAdd ? ( - - ) : null - } - /> - - {toggleError && !toggleLoading && ( - - {i18n._(t`Failed to toggle host.`)} - - - )} - {deletionError && ( - - {i18n._(t`Failed to delete one or more hosts.`)} - - - )} - - ); - } + return ( + + + ( + , + ...(canAdd + ? [] + : []), + ]} + /> + )} + renderItem={host => ( + row.id === host.id)} + onSelect={() => handleSelect(host)} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + + {deletionError && ( + + {i18n._(t`Failed to delete one or more hosts.`)} + + + )} + + ); } -export { HostsList as _HostsList }; -export default withI18n()(withRouter(HostsList)); +export default withI18n()(HostList); diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx index 7328b31c9c..dbd6199a92 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx @@ -1,9 +1,9 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { HostsAPI } from '@api'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import { sleep } from '@testUtils/testUtils'; -import HostsList, { _HostsList } from './HostList'; +import HostList from './HostList'; jest.mock('@api'); @@ -68,7 +68,15 @@ const mockHosts = [ }, ]; -describe('', () => { +function waitForLoaded(wrapper) { + return waitForElement( + wrapper, + 'HostList', + el => el.find('ContentLoading').length === 0 + ); +} + +describe('', () => { beforeEach(() => { HostsAPI.read.mockResolvedValue({ data: { @@ -91,114 +99,114 @@ describe('', () => { jest.clearAllMocks(); }); - test('initially renders successfully', () => { - mountWithContexts( - - ); - }); - - test('Hosts are retrieved from the api and the components finishes loading', async done => { - const loadHosts = jest.spyOn(_HostsList.prototype, 'loadHosts'); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === true - ); - expect(loadHosts).toHaveBeenCalled(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === false - ); - done(); - }); - - test('handleSelect is called when a host list item is selected', async done => { - const handleSelect = jest.spyOn(_HostsList.prototype, 'handleSelect'); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === false - ); - await wrapper - .find('input#select-host-1') - .closest('DataListCheck') - .props() - .onChange(); - expect(handleSelect).toBeCalled(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('selected').length === 1 - ); - done(); - }); - - test('handleSelectAll is called when select all checkbox is clicked', async done => { - const handleSelectAll = jest.spyOn(_HostsList.prototype, 'handleSelectAll'); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === false - ); - wrapper - .find('Checkbox#select-all') - .props() - .onChange(true); - expect(handleSelectAll).toBeCalled(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('selected').length === 3 - ); - done(); - }); - - test('delete button is disabled if user does not have delete capabilities on a selected host', async done => { - const wrapper = mountWithContexts(); - wrapper.find('HostsList').setState({ - hosts: mockHosts, - itemCount: 3, - isInitialized: true, - selected: mockHosts.slice(0, 1), + test('initially renders successfully', async () => { + await act(async () => { + mountWithContexts( + + ); }); - await waitForElement( - wrapper, - 'ToolbarDeleteButton * button', - el => el.getDOMNode().disabled === false - ); - wrapper.find('HostsList').setState({ - selected: mockHosts, - }); - await waitForElement( - wrapper, - 'ToolbarDeleteButton * button', - el => el.getDOMNode().disabled === true - ); - done(); }); - test('api is called to delete hosts for each selected host.', () => { + test('Hosts are retrieved from the api and the components finishes loading', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + expect(HostsAPI.read).toHaveBeenCalled(); + expect(wrapper.find('HostListItem')).toHaveLength(3); + }); + + test('should select single item', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + act(() => { + wrapper + .find('input#select-host-1') + .closest('DataListCheck') + .invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper + .find('HostListItem') + .first() + .prop('isSelected') + ).toEqual(true); + }); + + test('should select all items', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + + wrapper.find('HostListItem').forEach(item => { + expect(item.prop('isSelected')).toEqual(true); + }); + }); + + test('delete button is disabled if user does not have delete capabilities on a selected host', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + act(() => { + wrapper + .find('HostListItem') + .at(2) + .invoke('onSelect')(); + }); + expect(wrapper.find('ToolbarDeleteButton button').prop('disabled')).toEqual( + true + ); + }); + + test('api is called to delete hosts for each selected host.', async () => { HostsAPI.destroy = jest.fn(); - const wrapper = mountWithContexts(); - wrapper.find('HostsList').setState({ - hosts: mockHosts, - itemCount: 2, - isInitialized: true, - isModalOpen: true, - selected: mockHosts.slice(0, 2), + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + await act(async () => { + wrapper + .find('HostListItem') + .at(0) + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('HostListItem') + .at(1) + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); expect(HostsAPI.destroy).toHaveBeenCalledTimes(2); }); - test('error is shown when host not successfully deleted from api', async done => { + test('error is shown when host not successfully deleted from api', async () => { HostsAPI.destroy.mockRejectedValue( new Error({ response: { @@ -210,43 +218,40 @@ describe('', () => { }, }) ); - const wrapper = mountWithContexts(); - wrapper.find('HostsList').setState({ - hosts: mockHosts, - itemCount: 1, - isInitialized: true, - isModalOpen: true, - selected: mockHosts.slice(0, 1), + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + + await act(async () => { + wrapper + .find('HostListItem') + .at(0) + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); - await sleep(0); wrapper.update(); - await waitForElement( - wrapper, - 'Modal', - el => el.props().isOpen === true && el.props().title === 'Error!' - ); - done(); + const modal = wrapper.find('Modal'); + expect(modal).toHaveLength(1); + expect(modal.prop('title')).toEqual('Error!'); }); - test('Add button shown for users without ability to POST', async done => { - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === true - ); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === false - ); + test('should show Add button according to permissions', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + expect(wrapper.find('ToolbarAddButton').length).toBe(1); - done(); }); - test('Add button hidden for users without ability to POST', async done => { + test('should hide Add button according to permissions', async () => { HostsAPI.readOptions.mockResolvedValue({ data: { actions: { @@ -254,18 +259,12 @@ describe('', () => { }, }, }); - const wrapper = mountWithContexts(); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === true - ); - await waitForElement( - wrapper, - 'HostsList', - el => el.state('hasContentLoading') === false - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForLoaded(wrapper); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); - done(); }); }); diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx index 2dda211169..3652f2e52b 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx @@ -10,7 +10,6 @@ import { DataListItem, DataListItemRow, DataListItemCells, - Switch, Tooltip, } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; @@ -19,6 +18,7 @@ import { PencilAltIcon } from '@patternfly/react-icons'; import Sparkline from '@components/Sparkline'; import { Host } from '@types'; import styled from 'styled-components'; +import HostToggle from '../shared/HostToggle'; const DataListAction = styled(_DataListAction)` align-items: center; @@ -36,15 +36,7 @@ class HostListItem extends React.Component { }; render() { - const { - host, - isSelected, - onSelect, - detailUrl, - onToggleHost, - toggleLoading, - i18n, - } = this.props; + const { host, isSelected, onSelect, detailUrl, i18n } = this.props; const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({ ...job, @@ -87,6 +79,22 @@ class HostListItem extends React.Component { )} , + + + , + + {host.summary_fields.user_capabilities.edit && ( + + + + )} + , ]} /> - - onToggleHost(host)} - aria-label={i18n._(t`Toggle host`)} - /> - + {host.summary_fields.user_capabilities.edit && (