Merge pull request #6221 from marshmalien/6150-disassociate-modal

Add disassociate inventory group host button 

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-03-09 23:47:16 +00:00 committed by GitHub
commit 01d77d5407
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 333 additions and 6 deletions

View File

@ -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;

View File

@ -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 (
<div>
{i18n._(
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
)}
</div>
);
}
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 <div> around the <DeleteButton> below.
// See: https://github.com/patternfly/patternfly-react/issues/1894
return (
<>
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
variant="danger"
aria-label={i18n._(t`Disassociate`)}
onClick={() => setIsOpen(true)}
isDisabled={isDisabled}
>
{i18n._(t`Disassociate`)}
</Button>
</div>
</Tooltip>
{isOpen && (
<AlertModal
isOpen={isOpen}
title={modalTitle}
variant="warning"
onClose={() => setIsOpen(false)}
actions={[
<Button
key="disassociate"
variant="danger"
aria-label={i18n._(t`confirm disassociate`)}
onClick={handleDisassociate}
>
{i18n._(t`Disassociate`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel`)}
onClick={() => setIsOpen(false)}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{modalNote && <ModalNote>{modalNote}</ModalNote>}
<div>{i18n._(t`This action will disassociate the following:`)}</div>
{itemsToDisassociate.map(item => (
<span key={item.id}>
<strong>{item.name}</strong>
<br />
</span>
))}
</AlertModal>
)}
</>
);
}
DisassociateButton.propTypes = {
itemsToDisassociate: arrayOf(object),
modalNote: string,
modalTitle: string,
onDisassociate: func.isRequired,
};
export default withI18n()(DisassociateButton);

View File

@ -0,0 +1,113 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import DisassociateButton from './DisassociateButton';
describe('<DisassociateButton />', () => {
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(
<DisassociateButton
onDisassociate={handleDisassociate}
itemsToDisassociate={mockHosts}
modalNote="custom note"
modalTitle="custom title"
/>
);
});
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(<div>custom note</div>)
).toEqual(true);
expect(
wrapper
.find('AlertModal')
.containsMatchingElement(
<div>This action will disassociate the following:</div>
)
).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(
<DisassociateButton
onDisassociate={() => {}}
itemsToDelete={readOnlyHost}
/>
);
expect(wrapper.find('button[disabled]')).toHaveLength(1);
});
});
});

View File

@ -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 }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={isLoading || isDisassociateLoading}
items={hosts}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
@ -113,12 +139,23 @@ function InventoryGroupHostList({ i18n }) {
...(canAdd
? [
<AddHostDropdown
key="associate"
onAddExisting={() => setIsModalOpen(true)}
onAddNew={() => history.push(addFormUrl)}
/>,
]
: []),
// TODO HOST DISASSOCIATE BUTTON
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={i18n._(t`Disassociate host from group?`)}
modalNote={i18n._(t`
Note that only hosts directly in this group can
be disassociated. Hosts in sub-groups must be disassociated
directly from the sub-group level that they belong.
`)}
/>,
]}
/>
)}
@ -141,9 +178,6 @@ function InventoryGroupHostList({ i18n }) {
)
}
/>
{/* DISASSOCIATE HOST MODAL PLACEHOLDER */}
{isModalOpen && (
<AlertModal
isOpen={isModalOpen}
@ -155,6 +189,17 @@ function InventoryGroupHostList({ i18n }) {
{i18n._(t`Host Select Modal`)}
</AlertModal>
)}
{disassociateError && (
<AlertModal
isOpen={disassociateError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDisassociateError}
>
{i18n._(t`Failed to disassociate one or more hosts.`)}
<ErrorDetail error={disassociateError} />
</AlertModal>
)}
</>
);
}

View File

@ -108,6 +108,45 @@ describe('<InventoryGroupHostList />', () => {
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"]'