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 && (
', () => {
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
+
test('edit button hidden from users without edit capabilities', () => {
const copyMockHost = Object.assign({}, mockHost);
copyMockHost.summary_fields.user_capabilities.edit = false;
@@ -58,39 +58,4 @@ describe('', () => {
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
- test('handles toggle click when host is enabled', () => {
- const wrapper = mountWithContexts(
- {}}
- host={mockHost}
- onToggleHost={onToggleHost}
- />
- );
- wrapper
- .find('Switch')
- .first()
- .find('input')
- .simulate('change');
- expect(onToggleHost).toHaveBeenCalledWith(mockHost);
- });
-
- test('handles toggle click when host is disabled', () => {
- const wrapper = mountWithContexts(
- {}}
- host={mockHost}
- onToggleHost={onToggleHost}
- />
- );
- wrapper
- .find('Switch')
- .first()
- .find('input')
- .simulate('change');
- expect(onToggleHost).toHaveBeenCalledWith(mockHost);
- });
});
diff --git a/awx/ui_next/src/screens/Host/shared/HostToggle.jsx b/awx/ui_next/src/screens/Host/shared/HostToggle.jsx
new file mode 100644
index 0000000000..eefdb76fe9
--- /dev/null
+++ b/awx/ui_next/src/screens/Host/shared/HostToggle.jsx
@@ -0,0 +1,76 @@
+import React, { Fragment, useState, useEffect, useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Switch, Tooltip } from '@patternfly/react-core';
+import AlertModal from '@components/AlertModal';
+import ErrorDetail from '@components/ErrorDetail';
+import useRequest from '@util/useRequest';
+import { HostsAPI } from '@api';
+
+function HostToggle({ host, onToggle, className, i18n }) {
+ const [isEnabled, setIsEnabled] = useState(host.enabled);
+ const [showError, setShowError] = useState(false);
+
+ const { result, isLoading, error, request: toggleHost } = useRequest(
+ useCallback(async () => {
+ await HostsAPI.update(host.id, {
+ enabled: !isEnabled,
+ });
+ return !isEnabled;
+ }, [host, isEnabled]),
+ host.enabled
+ );
+
+ useEffect(() => {
+ if (result !== isEnabled) {
+ setIsEnabled(result);
+ if (onToggle) {
+ onToggle(result);
+ }
+ }
+ }, [result, isEnabled, onToggle]);
+
+ useEffect(() => {
+ if (error) {
+ setShowError(true);
+ }
+ }, [error]);
+
+ return (
+
+
+
+
+ {showError && error && !isLoading && (
+ setShowError(false)}
+ >
+ {i18n._(t`Failed to toggle host.`)}
+
+
+ )}
+
+ );
+}
+
+export default withI18n()(HostToggle);
diff --git a/awx/ui_next/src/screens/Host/shared/HostToggle.test.jsx b/awx/ui_next/src/screens/Host/shared/HostToggle.test.jsx
new file mode 100644
index 0000000000..cef60fb34e
--- /dev/null
+++ b/awx/ui_next/src/screens/Host/shared/HostToggle.test.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { HostsAPI } from '@api';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import HostToggle from './HostToggle';
+
+jest.mock('@api');
+
+const mockHost = {
+ id: 1,
+ name: 'Host 1',
+ url: '/api/v2/hosts/1',
+ inventory: 1,
+ enabled: true,
+ summary_fields: {
+ inventory: {
+ id: 1,
+ name: 'inv 1',
+ },
+ user_capabilities: {
+ delete: true,
+ update: true,
+ },
+ recent_jobs: [],
+ },
+};
+
+describe('', () => {
+ test('should should toggle off', async () => {
+ const onToggle = jest.fn();
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
+
+ await act(async () => {
+ wrapper.find('Switch').invoke('onChange')();
+ });
+ expect(HostsAPI.update).toHaveBeenCalledWith(1, {
+ enabled: false,
+ });
+ wrapper.update();
+ expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
+ expect(onToggle).toHaveBeenCalledWith(false);
+ });
+
+ test('should should toggle on', async () => {
+ const onToggle = jest.fn();
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
+
+ await act(async () => {
+ wrapper.find('Switch').invoke('onChange')();
+ });
+ expect(HostsAPI.update).toHaveBeenCalledWith(1, {
+ enabled: true,
+ });
+ wrapper.update();
+ expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
+ expect(onToggle).toHaveBeenCalledWith(true);
+ });
+
+ test('should show error modal', async () => {
+ HostsAPI.update.mockImplementation(() => {
+ throw new Error('nope');
+ });
+ const wrapper = mountWithContexts();
+ expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
+
+ await act(async () => {
+ wrapper.find('Switch').invoke('onChange')();
+ });
+ wrapper.update();
+ const modal = wrapper.find('AlertModal');
+ expect(modal).toHaveLength(1);
+ expect(modal.prop('isOpen')).toEqual(true);
+
+ act(() => {
+ modal.invoke('onClose')();
+ });
+ wrapper.update();
+ expect(wrapper.find('AlertModal')).toHaveLength(0);
+ });
+});