diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js
index 4c19a44572..92acd3af40 100644
--- a/awx/ui_next/src/api/models/Groups.js
+++ b/awx/ui_next/src/api/models/Groups.js
@@ -6,11 +6,19 @@ class Groups extends Base {
this.baseUrl = '/api/v2/groups/';
this.readAllHosts = this.readAllHosts.bind(this);
+ this.disassociateHost = this.disassociateHost.bind(this);
}
readAllHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
}
+
+ disassociateHost(id, host) {
+ return this.http.post(`${this.baseUrl}${id}/hosts/`, {
+ id: host.id,
+ disassociate: true,
+ });
+ }
}
export default Groups;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx
new file mode 100644
index 0000000000..fdcddfb7e3
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx
@@ -0,0 +1,122 @@
+import React, { useState } from 'react';
+import { arrayOf, func, object, string } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Button, Tooltip } from '@patternfly/react-core';
+import AlertModal from '@components/AlertModal';
+import styled from 'styled-components';
+
+const ModalNote = styled.div`
+ margin-bottom: var(--pf-global--spacer--xl);
+`;
+
+function DisassociateButton({
+ i18n,
+ itemsToDisassociate = [],
+ modalNote = '',
+ modalTitle = i18n._(t`Disassociate?`),
+ onDisassociate,
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ function handleDisassociate() {
+ onDisassociate();
+ setIsOpen(false);
+ }
+
+ function cannotDisassociate(item) {
+ return !item.summary_fields.user_capabilities.delete;
+ }
+
+ function renderTooltip() {
+ const itemsUnableToDisassociate = itemsToDisassociate
+ .filter(cannotDisassociate)
+ .map(item => item.name)
+ .join(', ');
+
+ if (itemsToDisassociate.some(cannotDisassociate)) {
+ return (
+
+ {i18n._(
+ t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
+ )}
+
+ );
+ }
+ if (itemsToDisassociate.length) {
+ return i18n._(t`Disassociate`);
+ }
+ return i18n._(t`Select a row to disassociate`);
+ }
+
+ const isDisabled =
+ itemsToDisassociate.length === 0 ||
+ itemsToDisassociate.some(cannotDisassociate);
+
+ // NOTE: Once PF supports tooltips on disabled elements,
+ // we can delete the extra around the
below.
+ // See: https://github.com/patternfly/patternfly-react/issues/1894
+ return (
+ <>
+
+
+
+
+
+
+ {isOpen && (
+ setIsOpen(false)}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+ {modalNote && {modalNote}}
+
+ {i18n._(t`This action will disassociate the following:`)}
+
+ {itemsToDisassociate.map(item => (
+
+ {item.name}
+
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+DisassociateButton.propTypes = {
+ itemsToDisassociate: arrayOf(object),
+ modalNote: string,
+ modalTitle: string,
+ onDisassociate: func.isRequired,
+};
+
+export default withI18n()(DisassociateButton);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx
new file mode 100644
index 0000000000..06e6eedbe7
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import DisassociateButton from './DisassociateButton';
+
+describe('', () => {
+ describe('User has disassociate permissions', () => {
+ let wrapper;
+ const handleDisassociate = jest.fn();
+ const mockHosts = [
+ {
+ id: 1,
+ name: 'foo',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ },
+ },
+ },
+ {
+ id: 2,
+ name: 'bar',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ },
+ },
+ },
+ ];
+
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should render button', () => {
+ expect(wrapper.find('button')).toHaveLength(1);
+ expect(wrapper.find('button').text()).toEqual('Disassociate');
+ });
+
+ test('should open confirmation modal', () => {
+ wrapper.find('button').simulate('click');
+ expect(wrapper.find('AlertModal')).toHaveLength(1);
+ });
+
+ test('cancel button should close confirmation modal', () => {
+ expect(wrapper.find('AlertModal')).toHaveLength(1);
+ wrapper.find('button[aria-label="Cancel"]').simulate('click');
+ expect(wrapper.find('AlertModal')).toHaveLength(0);
+ });
+
+ test('should render expected modal content', () => {
+ wrapper.find('button').simulate('click');
+ expect(
+ wrapper
+ .find('AlertModal')
+ .containsMatchingElement(custom note
)
+ ).toEqual(true);
+ expect(
+ wrapper
+ .find('AlertModal')
+ .containsMatchingElement(
+ This action will disassociate the following:
+ )
+ ).toEqual(true);
+ expect(wrapper.find('Title').text()).toEqual('custom title');
+ wrapper.find('button[aria-label="Close"]').simulate('click');
+ });
+
+ test('disassociate button should call handleDisassociate on click', () => {
+ wrapper.find('button').simulate('click');
+ expect(handleDisassociate).toHaveBeenCalledTimes(0);
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ expect(handleDisassociate).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('User does not have disassociate permissions', () => {
+ const readOnlyHost = [
+ {
+ id: 1,
+ name: 'foo',
+ summary_fields: {
+ user_capabilities: {
+ delete: false,
+ },
+ },
+ },
+ ];
+
+ test('should disable button when no delete permissions', () => {
+ const wrapper = mountWithContexts(
+ {}}
+ itemsToDelete={readOnlyHost}
+ />
+ );
+ expect(wrapper.find('button[disabled]')).toHaveLength(1);
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
index 237c9e5c93..1f4ce9d8b9 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
@@ -7,10 +7,12 @@ import { GroupsAPI, InventoriesAPI } from '@api';
import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar';
+import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList from '@components/PaginatedDataList';
-import useRequest from '@util/useRequest';
+import useRequest, { useDeleteItems } from '@util/useRequest';
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddHostDropdown from './AddHostDropdown';
+import DisassociateButton from './DisassociateButton';
const QS_CONFIG = getQSConfig('host', {
page: 1,
@@ -67,6 +69,30 @@ function InventoryGroupHostList({ i18n }) {
};
const isAllSelected = selected.length > 0 && selected.length === hosts.length;
+
+ const {
+ isLoading: isDisassociateLoading,
+ deleteItems: disassociateHosts,
+ deletionError: disassociateError,
+ clearDeletionError: clearDisassociateError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(host => GroupsAPI.disassociateHost(groupId, host))
+ );
+ }, [groupId, selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchHosts,
+ }
+ );
+
+ const handleDisassociate = async () => {
+ await disassociateHosts();
+ setSelected([]);
+ };
+
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`;
@@ -75,7 +101,7 @@ function InventoryGroupHostList({ i18n }) {
<>
setIsModalOpen(true)}
onAddNew={() => history.push(addFormUrl)}
/>,
]
: []),
- // TODO HOST DISASSOCIATE BUTTON
+ ,
]}
/>
)}
@@ -141,9 +178,6 @@ function InventoryGroupHostList({ i18n }) {
)
}
/>
-
- {/* DISASSOCIATE HOST MODAL PLACEHOLDER */}
-
{isModalOpen && (
)}
+ {disassociateError && (
+
+ {i18n._(t`Failed to disassociate one or more hosts.`)}
+
+
+ )}
>
);
}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx
index 8345964e40..f0a436a9d1 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx
@@ -108,6 +108,45 @@ describe('', () => {
expect(wrapper.find('AddHostDropdown').length).toBe(0);
});
+ test('expected api calls are made for multi-delete', async () => {
+ expect(GroupsAPI.disassociateHost).toHaveBeenCalledTimes(0);
+ expect(GroupsAPI.readAllHosts).toHaveBeenCalledTimes(1);
+ 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 host from group?'
+ );
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ });
+ expect(GroupsAPI.disassociateHost).toHaveBeenCalledTimes(3);
+ expect(GroupsAPI.readAllHosts).toHaveBeenCalledTimes(2);
+ });
+
+ test('should show error modal for failed disassociation', async () => {
+ GroupsAPI.disassociateHost.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 host from group?'
+ );
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="confirm disassociate"]')
+ .simulate('click');
+ });
+ wrapper.update();
+ expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
+ });
+
test('should show associate host modal when adding an existing host', () => {
const dropdownToggle = wrapper.find(
'DropdownToggle button[aria-label="add host"]'