diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js
index 2d13b00072..72ee919dae 100644
--- a/awx/ui_next/src/api/models/Hosts.js
+++ b/awx/ui_next/src/api/models/Hosts.js
@@ -1,4 +1,5 @@
import Base from '../Base';
+import { TintSlashIcon } from '@patternfly/react-icons';
class Hosts extends Base {
constructor(http) {
@@ -8,6 +9,8 @@ class Hosts extends Base {
this.readFacts = this.readFacts.bind(this);
this.readGroups = this.readGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this);
+ this.associateGroup = this.associateGroup.bind(this);
+ this.disassociateGroup = this.disassociateGroup.bind(this);
}
readFacts(id) {
@@ -21,6 +24,17 @@ class Hosts extends Base {
readGroupsOptions(id) {
return this.http.options(`${this.baseUrl}${id}/groups/`);
}
+
+ associateGroup(id, groupId) {
+ return this.http.post(`${this.baseUrl}${id}/groups/`, { id: groupId });
+ }
+
+ disassociateGroup(id, group) {
+ return this.http.post(`${this.baseUrl}${id}/groups/`, {
+ id: group.id,
+ disassociate: true,
+ });
+ }
}
export default Hosts;
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx
index 70a51dc51b..dca2320b88 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx
@@ -5,14 +5,16 @@ import { Switch, Route, withRouter } from 'react-router-dom';
import HostGroupsList from './HostGroupsList';
-function HostGroups({ location, match }) {
+function HostGroups({ location, match, host }) {
return (
{
- return ;
+ return (
+
+ );
}}
/>
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx
index 46aadcf637..bc13293622 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx
@@ -12,7 +12,11 @@ describe('', () => {
const history = createMemoryHistory({
initialEntries: ['/hosts/1/groups'],
});
- const host = { id: 1, name: 'Foo' };
+ const host = {
+ id: 1,
+ name: 'Foo',
+ summary_fields: { inventory: { id: 1 } },
+ };
await act(async () => {
wrapper = mountWithContexts(
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx
index 371329aa97..9576f710b6 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx
@@ -2,11 +2,21 @@ import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { getQSConfig, parseQueryString } from '@util/qs';
-import useRequest from '@util/useRequest';
-import { HostsAPI } from '@api';
+import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
+import useRequest, {
+ useDismissableError,
+ useDeleteItems,
+} from '@util/useRequest';
+import useSelected from '@util/useSelected';
+import { HostsAPI, InventoriesAPI } from '@api';
+import AlertModal from '@components/AlertModal';
+import ErrorDetail from '@components/ErrorDetail';
+import PaginatedDataList, {
+ ToolbarAddButton,
+} from '@components/PaginatedDataList';
+import AssociateModal from '@components/AssociateModal';
+import DisassociateButton from '@components/DisassociateButton';
import DataListToolbar from '@components/DataListToolbar';
-import PaginatedDataList from '@components/PaginatedDataList';
import HostGroupItem from './HostGroupItem';
const QS_CONFIG = getQSConfig('group', {
@@ -15,13 +25,14 @@ const QS_CONFIG = getQSConfig('group', {
order_by: 'name',
});
-function HostGroupsList({ i18n, location, match }) {
- const [selected, setSelected] = useState([]);
+function HostGroupsList({ i18n, location, match, host }) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
const hostId = match.params.id;
+ const invId = host.summary_fields.inventory.id;
const {
- result: { groups, itemCount },
+ result: { groups, itemCount, actions },
error: contentError,
isLoading,
request: fetchGroups,
@@ -29,13 +40,20 @@ function HostGroupsList({ i18n, location, match }) {
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
- const {
- data: { count, results },
- } = await HostsAPI.readGroups(hostId, params);
+ const [
+ {
+ data: { count, results },
+ },
+ actionsResponse,
+ ] = await Promise.all([
+ HostsAPI.readGroups(hostId, params),
+ HostsAPI.readGroupsOptions(hostId),
+ ]);
return {
- itemCount: count,
groups: results,
+ itemCount: count,
+ actions: actionsResponse.data.actions,
};
}, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps
{
@@ -48,26 +66,68 @@ function HostGroupsList({ i18n, location, match }) {
fetchGroups();
}, [fetchGroups]);
- const handleSelectAll = isSelected => {
- setSelected(isSelected ? [...groups] : []);
- };
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ groups
+ );
- const handleSelect = row => {
- if (selected.some(s => s.id === row.id)) {
- setSelected(selected.filter(s => s.id !== row.id));
- } else {
- setSelected(selected.concat(row));
+ const {
+ isLoading: isDisassociateLoading,
+ deleteItems: disassociateHosts,
+ deletionError: disassociateError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(group => HostsAPI.disassociateGroup(hostId, group))
+ );
+ }, [hostId, selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchGroups,
}
+ );
+
+ const handleDisassociate = async () => {
+ await disassociateHosts();
+ setSelected([]);
};
- const isAllSelected =
- selected.length > 0 && selected.length === groups.length;
+ const fetchGroupsToAssociate = useCallback(
+ params => {
+ return InventoriesAPI.readGroups(
+ invId,
+ mergeParams(params, { not__hosts: hostId })
+ );
+ },
+ [invId, hostId]
+ );
+
+ const { request: handleAssociate, error: associateError } = useRequest(
+ useCallback(
+ async groupsToAssociate => {
+ await Promise.all(
+ groupsToAssociate.map(group =>
+ HostsAPI.associateGroup(hostId, group.id)
+ )
+ );
+ fetchGroups();
+ },
+ [hostId, fetchGroups]
+ )
+ );
+
+ const { error, dismissError } = useDismissableError(
+ associateError || disassociateError
+ );
+
+ const canAdd =
+ actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
return (
<>
+ setSelected(isSelected ? [...groups] : [])
+ }
qsConfig={QS_CONFIG}
+ additionalControls={[
+ ...(canAdd
+ ? [
+ setIsModalOpen(true)}
+ />,
+ ]
+ : []),
+ ,
+ ]}
/>
)}
+ emptyStateControls={
+ canAdd ? (
+ setIsModalOpen(true)} />
+ ) : null
+ }
/>
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ title={i18n._(t`Select Groups`)}
+ />
+ )}
+ {error && (
+
+ {associateError
+ ? i18n._(t`Failed to associate.`)
+ : i18n._(t`Failed to disassociate one or more groups.`)}
+
+
+ )}
>
);
}
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx
index d472534e20..c748263eaf 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx
@@ -3,11 +3,19 @@ import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-import { HostsAPI } from '@api';
+import { HostsAPI, InventoriesAPI } from '@api';
import HostGroupsList from './HostGroupsList';
jest.mock('@api');
+const host = {
+ summary_fields: {
+ inventory: {
+ id: 1,
+ },
+ },
+};
+
const mockGroups = [
{
id: 1,
@@ -52,7 +60,7 @@ const mockGroups = [
id: 1,
},
user_capabilities: {
- delete: false,
+ delete: true,
edit: false,
},
},
@@ -82,7 +90,10 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- } />,
+ }
+ />,
{
context: {
router: { history, route: { location: history.location } },
@@ -93,6 +104,11 @@ describe('', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
test('initially renders successfully', () => {
expect(wrapper.find('HostGroupsList').length).toBe(1);
});
@@ -151,8 +167,104 @@ describe('', () => {
test('should show content error when api throws error on initial render', async () => {
HostsAPI.readGroups.mockImplementation(() => Promise.reject(new Error()));
await act(async () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts();
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
+
+ test('should show add button according to permissions', async () => {
+ expect(wrapper.find('ToolbarAddButton').length).toBe(1);
+ HostsAPI.readGroupsOptions.mockResolvedValueOnce({
+ data: {
+ actions: {
+ GET: {},
+ },
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ToolbarAddButton').length).toBe(0);
+ });
+
+ test('should show associate group modal when adding an existing group', () => {
+ wrapper.find('ToolbarAddButton').simulate('click');
+ expect(wrapper.find('AssociateModal').length).toBe(1);
+ wrapper.find('ModalBoxCloseButton').simulate('click');
+ expect(wrapper.find('AssociateModal').length).toBe(0);
+ });
+
+ test('should make expected api request when associating groups', async () => {
+ HostsAPI.associateGroup.mockResolvedValue();
+ InventoriesAPI.readGroups.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [{ id: 123, name: 'foo', url: '/api/v2/groups/123/' }],
+ },
+ });
+ await act(async () => {
+ wrapper.find('ToolbarAddButton').simulate('click');
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ wrapper.update();
+ await act(async () => {
+ wrapper
+ .find('CheckboxListItem')
+ .first()
+ .invoke('onSelect')();
+ });
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ await waitForElement(wrapper, 'AssociateModal', el => el.length === 0);
+ expect(InventoriesAPI.readGroups).toHaveBeenCalledTimes(1);
+ expect(HostsAPI.associateGroup).toHaveBeenCalledTimes(1);
+ });
+
+ test('expected api calls are made for multi-disassociation', async () => {
+ expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(0);
+ expect(HostsAPI.readGroups).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('DataListCheck').length).toBe(3);
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(false);
+ });
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(true);
+ });
+ wrapper.update();
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(true);
+ });
+ wrapper.find('button[aria-label="Disassociate"]').simulate('click');
+ expect(wrapper.find('AlertModal Title').text()).toEqual(
+ 'Disassociate group from host?'
+ );
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ });
+ expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(3);
+ expect(HostsAPI.readGroups).toHaveBeenCalledTimes(2);
+ });
+
+ test('should show error modal for failed disassociation', async () => {
+ HostsAPI.disassociateGroup.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(true);
+ });
+ wrapper.update();
+ wrapper.find('button[aria-label="Disassociate"]').simulate('click');
+ expect(wrapper.find('AlertModal Title').text()).toEqual(
+ 'Disassociate group from host?'
+ );
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ });
+ wrapper.update();
+ expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx
index 3f14e3ae58..7d260f7782 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx
@@ -2,11 +2,21 @@ import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { getQSConfig, parseQueryString } from '@util/qs';
-import useRequest from '@util/useRequest';
-import { HostsAPI } from '@api';
+import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
+import useRequest, {
+ useDismissableError,
+ useDeleteItems,
+} from '@util/useRequest';
+import useSelected from '@util/useSelected';
+import { HostsAPI, InventoriesAPI } from '@api';
import DataListToolbar from '@components/DataListToolbar';
-import PaginatedDataList from '@components/PaginatedDataList';
+import AlertModal from '@components/AlertModal';
+import ErrorDetail from '@components/ErrorDetail';
+import PaginatedDataList, {
+ ToolbarAddButton,
+} from '@components/PaginatedDataList';
+import AssociateModal from '@components/AssociateModal';
+import DisassociateButton from '@components/DisassociateButton';
import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', {
@@ -16,12 +26,12 @@ const QS_CONFIG = getQSConfig('group', {
});
function InventoryHostGroupsList({ i18n, location, match }) {
- const [selected, setSelected] = useState([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
- const { hostId } = match.params;
+ const { hostId, id: invId } = match.params;
const {
- result: { groups, itemCount },
+ result: { groups, itemCount, actions },
error: contentError,
isLoading,
request: fetchGroups,
@@ -29,13 +39,20 @@ function InventoryHostGroupsList({ i18n, location, match }) {
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
- const {
- data: { count, results },
- } = await HostsAPI.readGroups(hostId, params);
+ const [
+ {
+ data: { count, results },
+ },
+ actionsResponse,
+ ] = await Promise.all([
+ HostsAPI.readGroups(hostId, params),
+ HostsAPI.readGroupsOptions(hostId),
+ ]);
return {
- itemCount: count,
groups: results,
+ itemCount: count,
+ actions: actionsResponse.data.actions,
};
}, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps
{
@@ -48,26 +65,68 @@ function InventoryHostGroupsList({ i18n, location, match }) {
fetchGroups();
}, [fetchGroups]);
- const handleSelectAll = isSelected => {
- setSelected(isSelected ? [...groups] : []);
- };
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ groups
+ );
- const handleSelect = row => {
- if (selected.some(s => s.id === row.id)) {
- setSelected(selected.filter(s => s.id !== row.id));
- } else {
- setSelected(selected.concat(row));
+ const {
+ isLoading: isDisassociateLoading,
+ deleteItems: disassociateHosts,
+ deletionError: disassociateError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(group => HostsAPI.disassociateGroup(hostId, group))
+ );
+ }, [hostId, selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchGroups,
}
+ );
+
+ const handleDisassociate = async () => {
+ await disassociateHosts();
+ setSelected([]);
};
- const isAllSelected =
- selected.length > 0 && selected.length === groups.length;
+ const fetchGroupsToAssociate = useCallback(
+ params => {
+ return InventoriesAPI.readGroups(
+ invId,
+ mergeParams(params, { not__hosts: hostId })
+ );
+ },
+ [invId, hostId]
+ );
+
+ const { request: handleAssociate, error: associateError } = useRequest(
+ useCallback(
+ async groupsToAssociate => {
+ await Promise.all(
+ groupsToAssociate.map(group =>
+ HostsAPI.associateGroup(hostId, group.id)
+ )
+ );
+ fetchGroups();
+ },
+ [hostId, fetchGroups]
+ )
+ );
+
+ const { error, dismissError } = useDismissableError(
+ associateError || disassociateError
+ );
+
+ const canAdd =
+ actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
return (
<>
+ setSelected(isSelected ? [...groups] : [])
+ }
qsConfig={QS_CONFIG}
+ additionalControls={[
+ ...(canAdd
+ ? [
+ setIsModalOpen(true)}
+ />,
+ ]
+ : []),
+ ,
+ ]}
/>
)}
+ emptyStateControls={
+ canAdd ? (
+ setIsModalOpen(true)} />
+ ) : null
+ }
/>
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ title={i18n._(t`Select Groups`)}
+ />
+ )}
+ {error && (
+
+ {associateError
+ ? i18n._(t`Failed to associate.`)
+ : i18n._(t`Failed to disassociate one or more groups.`)}
+
+
+ )}
>
);
}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx
index 4b4fa7e884..c31f996c10 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx
@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-import { HostsAPI } from '@api';
+import { HostsAPI, InventoriesAPI } from '@api';
import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('@api');
@@ -52,7 +52,7 @@ const mockGroups = [
id: 1,
},
user_capabilities: {
- delete: false,
+ delete: true,
edit: false,
},
},
@@ -96,6 +96,11 @@ describe('', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
test('initially renders successfully', () => {
expect(wrapper.find('InventoryHostGroupsList').length).toBe(1);
});
@@ -158,4 +163,100 @@ describe('', () => {
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
+
+ test('should show add button according to permissions', async () => {
+ expect(wrapper.find('ToolbarAddButton').length).toBe(1);
+ HostsAPI.readGroupsOptions.mockResolvedValueOnce({
+ data: {
+ actions: {
+ GET: {},
+ },
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ToolbarAddButton').length).toBe(0);
+ });
+
+ test('should show associate group modal when adding an existing group', () => {
+ wrapper.find('ToolbarAddButton').simulate('click');
+ expect(wrapper.find('AssociateModal').length).toBe(1);
+ wrapper.find('ModalBoxCloseButton').simulate('click');
+ expect(wrapper.find('AssociateModal').length).toBe(0);
+ });
+
+ test('should make expected api request when associating groups', async () => {
+ HostsAPI.associateGroup.mockResolvedValue();
+ InventoriesAPI.readGroups.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [{ id: 123, name: 'foo', url: '/api/v2/groups/123/' }],
+ },
+ });
+ await act(async () => {
+ wrapper.find('ToolbarAddButton').simulate('click');
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ wrapper.update();
+ await act(async () => {
+ wrapper
+ .find('CheckboxListItem')
+ .first()
+ .invoke('onSelect')();
+ });
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ await waitForElement(wrapper, 'AssociateModal', el => el.length === 0);
+ expect(InventoriesAPI.readGroups).toHaveBeenCalledTimes(1);
+ expect(HostsAPI.associateGroup).toHaveBeenCalledTimes(1);
+ });
+
+ test('expected api calls are made for multi-disassociation', async () => {
+ expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(0);
+ expect(HostsAPI.readGroups).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('DataListCheck').length).toBe(3);
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(false);
+ });
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(true);
+ });
+ wrapper.update();
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(true);
+ });
+ wrapper.find('button[aria-label="Disassociate"]').simulate('click');
+ expect(wrapper.find('AlertModal Title').text()).toEqual(
+ 'Disassociate group from host?'
+ );
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ });
+ expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(3);
+ expect(HostsAPI.readGroups).toHaveBeenCalledTimes(2);
+ });
+
+ test('should show error modal for failed disassociation', async () => {
+ HostsAPI.disassociateGroup.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(true);
+ });
+ wrapper.update();
+ wrapper.find('button[aria-label="Disassociate"]').simulate('click');
+ expect(wrapper.find('AlertModal Title').text()).toEqual(
+ 'Disassociate group from host?'
+ );
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ });
+ wrapper.update();
+ expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
+ });
});