1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-27 17:55:10 +03:00

Merge pull request #6329 from marshmalien/6143-inv-group-associate-hosts

Add associate modal to nested inventory host list

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-03-20 21:02:57 +00:00 committed by GitHub
commit 1fce77054a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 414 additions and 35 deletions

View File

@ -5,11 +5,16 @@ class Groups extends Base {
super(http);
this.baseUrl = '/api/v2/groups/';
this.associateHost = this.associateHost.bind(this);
this.createHost = this.createHost.bind(this);
this.readAllHosts = this.readAllHosts.bind(this);
this.disassociateHost = this.disassociateHost.bind(this);
}
associateHost(id, hostId) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, { id: hostId });
}
createHost(id, data) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, data);
}

View File

@ -25,6 +25,7 @@ const ModalList = styled.div`
function OptionsList({
value,
contentError,
options,
optionCount,
searchColumns,
@ -53,6 +54,7 @@ function OptionsList({
/>
)}
<PaginatedDataList
contentError={contentError}
items={options}
itemCount={optionCount}
pluralizedItemName={header}

View File

@ -0,0 +1,141 @@
import React, { Fragment, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button, Modal } from '@patternfly/react-core';
import OptionsList from '@components/Lookup/shared/OptionsList';
import useRequest from '@util/useRequest';
import { getQSConfig, parseQueryString } from '@util/qs';
import useSelected from '@util/useSelected';
const QS_CONFIG = getQSConfig('associate', {
page: 1,
page_size: 5,
order_by: 'name',
});
function AssociateModal({
i18n,
header = i18n._(t`Items`),
title = i18n._(t`Select Items`),
onClose,
onAssociate,
fetchRequest,
isModalOpen = false,
}) {
const history = useHistory();
const { selected, handleSelect } = useSelected([]);
const {
request: fetchItems,
result: { items, itemCount },
error: contentError,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const {
data: { count, results },
} = await fetchRequest(params);
return {
items: results,
itemCount: count,
};
}, [fetchRequest, history.location.search]),
{
items: [],
itemCount: 0,
}
);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const clearQSParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = QS_CONFIG.namespace;
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
};
const handleSave = async () => {
await onAssociate(selected);
clearQSParams();
onClose();
};
const handleClose = () => {
clearQSParams();
onClose();
};
return (
<Fragment>
<Modal
isFooterLeftAligned
isLarge
title={title}
isOpen={isModalOpen}
onClose={handleClose}
actions={[
<Button
aria-label={i18n._(t`Save`)}
key="select"
variant="primary"
onClick={handleSave}
isDisabled={selected.length === 0}
>
{i18n._(t`Save`)}
</Button>,
<Button
aria-label={i18n._(t`Cancel`)}
key="cancel"
variant="secondary"
onClick={handleClose}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<OptionsList
contentError={contentError}
deselectItem={handleSelect}
header={header}
isLoading={isLoading}
multiple
optionCount={itemCount}
options={items}
qsConfig={QS_CONFIG}
readOnly={false}
selectItem={handleSelect}
value={selected}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
/>
</Modal>
</Fragment>
);
}
export default withI18n()(AssociateModal);

View File

@ -0,0 +1,73 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import AssociateModal from './AssociateModal';
import mockHosts from '../shared/data.hosts.json';
jest.mock('@api');
describe('<AssociateModal />', () => {
let wrapper;
const onClose = jest.fn();
const onAssociate = jest.fn().mockResolvedValue();
const fetchRequest = jest.fn().mockReturnValue({ data: { ...mockHosts } });
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<AssociateModal
onClose={onClose}
onAssociate={onAssociate}
fetchRequest={fetchRequest}
isModalOpen
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render successfully', () => {
expect(wrapper.find('AssociateModal').length).toBe(1);
});
test('should fetch and render list items', () => {
expect(fetchRequest).toHaveBeenCalledTimes(1);
expect(wrapper.find('CheckboxListItem').length).toBe(3);
});
test('should update selected list chips when items are selected', () => {
expect(wrapper.find('SelectedList Chip')).toHaveLength(0);
act(() => {
wrapper
.find('CheckboxListItem')
.first()
.invoke('onSelect')();
});
wrapper.update();
expect(wrapper.find('SelectedList Chip')).toHaveLength(1);
wrapper.find('SelectedList Chip button').simulate('click');
expect(wrapper.find('SelectedList Chip')).toHaveLength(0);
});
test('save button should call onAssociate', () => {
act(() => {
wrapper
.find('CheckboxListItem')
.first()
.invoke('onSelect')();
});
wrapper.find('button[aria-label="Save"]').simulate('click');
expect(onAssociate).toHaveBeenCalledTimes(1);
});
test('cancel button should call onClose', () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -2,15 +2,20 @@ import React, { useEffect, useCallback, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { getQSConfig, mergeParams, parseQueryString } from '@util/qs';
import { GroupsAPI, InventoriesAPI } from '@api';
import useRequest, {
useDeleteItems,
useDismissableError,
} from '@util/useRequest';
import useSelected from '@util/useSelected';
import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList from '@components/PaginatedDataList';
import useRequest, { useDeleteItems } from '@util/useRequest';
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AssociateModal from './AssociateModal';
import AddHostDropdown from './AddHostDropdown';
import DisassociateButton from './DisassociateButton';
@ -21,7 +26,6 @@ const QS_CONFIG = getQSConfig('host', {
});
function InventoryGroupHostList({ i18n }) {
const [selected, setSelected] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const { id: inventoryId, groupId } = useParams();
const location = useLocation();
@ -52,29 +56,18 @@ function InventoryGroupHostList({ i18n }) {
}
);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
hosts
);
useEffect(() => {
fetchHosts();
}, [fetchHosts]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []);
};
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 isAllSelected = selected.length > 0 && selected.length === hosts.length;
const {
isLoading: isDisassociateLoading,
deleteItems: disassociateHosts,
deletionError: disassociateError,
clearDeletionError: clearDisassociateError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(
@ -93,6 +86,34 @@ function InventoryGroupHostList({ i18n }) {
setSelected([]);
};
const fetchHostsToAssociate = useCallback(
params => {
return InventoriesAPI.readHosts(
inventoryId,
mergeParams(params, { not__groups: groupId })
);
},
[groupId, inventoryId]
);
const { request: handleAssociate, error: associateError } = useRequest(
useCallback(
async hostsToAssociate => {
await Promise.all(
hostsToAssociate.map(host =>
GroupsAPI.associateHost(groupId, host.id)
)
);
fetchHosts();
},
[groupId, fetchHosts]
)
);
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`;
@ -133,7 +154,9 @@ function InventoryGroupHostList({ i18n }) {
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
onSelectAll={isSelected =>
setSelected(isSelected ? [...hosts] : [])
}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
@ -179,25 +202,26 @@ function InventoryGroupHostList({ i18n }) {
}
/>
{isModalOpen && (
<AlertModal
isOpen={isModalOpen}
variant="info"
title={i18n._(t`Select Hosts`)}
<AssociateModal
header={i18n._(t`Hosts`)}
fetchRequest={fetchHostsToAssociate}
isModalOpen={isModalOpen}
onAssociate={handleAssociate}
onClose={() => setIsModalOpen(false)}
>
{/* ADD/ASSOCIATE HOST MODAL PLACEHOLDER */}
{i18n._(t`Host Select Modal`)}
</AlertModal>
title={i18n._(t`Select Hosts`)}
/>
)}
{disassociateError && (
{error && (
<AlertModal
isOpen={disassociateError}
variant="error"
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
onClose={clearDisassociateError}
variant="error"
>
{i18n._(t`Failed to disassociate one or more hosts.`)}
<ErrorDetail error={disassociateError} />
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more hosts.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>

View File

@ -155,9 +155,41 @@ describe('<InventoryGroupHostList />', () => {
wrapper
.find('DropdownItem[aria-label="add existing host"]')
.simulate('click');
expect(wrapper.find('AlertModal').length).toBe(1);
expect(wrapper.find('AssociateModal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
expect(wrapper.find('AlertModal').length).toBe(0);
expect(wrapper.find('AssociateModal').length).toBe(0);
});
test('should make expected api request when associating hosts', async () => {
GroupsAPI.associateHost.mockResolvedValue();
InventoriesAPI.readHosts.mockResolvedValue({
data: {
count: 1,
results: [{ id: 123, name: 'foo', url: '/api/v2/hosts/123/' }],
},
});
wrapper
.find('DropdownToggle button[aria-label="add host"]')
.simulate('click');
await act(async () => {
wrapper
.find('DropdownItem[aria-label="add existing host"]')
.simulate('click');
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await act(async () => {
wrapper
.find('CheckboxListItem')
.first()
.invoke('onSelect')();
});
wrapper.update();
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
await waitForElement(wrapper, 'AssociateModal', el => el.length === 0);
expect(InventoriesAPI.readHosts).toHaveBeenCalledTimes(1);
expect(GroupsAPI.associateHost).toHaveBeenCalledTimes(1);
});
test('should navigate to host add form when adding a new host', async () => {

View File

@ -0,0 +1,27 @@
import { useState } from 'react';
/**
* useSelected hook provides a way to read and update a selected list
* Param: array of list items
* Returns: {
* selected: array of selected list items
* isAllSelected: boolean that indicates if all items are selected
* handleSelect: function that adds and removes items from selected list
* setSelected: setter function
* }
*/
export default function useSelected(list = []) {
const [selected, setSelected] = useState([]);
const isAllSelected = selected.length > 0 && selected.length === list.length;
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(prevState => [...prevState.filter(i => i.id !== row.id)]);
} else {
setSelected(prevState => [...prevState, row]);
}
};
return { selected, isAllSelected, handleSelect, setSelected };
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import useSelected from './useSelected';
const array = [{ id: '1' }, { id: '2' }, { id: '3' }];
const TestHook = ({ callback }) => {
callback();
return null;
};
const testHook = callback => {
mount(<TestHook callback={callback} />);
};
describe('useSelected hook', () => {
let selected;
let isAllSelected;
let handleSelect;
let setSelected;
test('should return expected initial values', () => {
testHook(() => {
({ selected, isAllSelected, handleSelect, setSelected } = useSelected());
});
expect(selected).toEqual([]);
expect(isAllSelected).toEqual(false);
expect(handleSelect).toBeInstanceOf(Function);
expect(setSelected).toBeInstanceOf(Function);
});
test('handleSelect should update and filter selected items', () => {
testHook(() => {
({ selected, isAllSelected, handleSelect, setSelected } = useSelected());
});
act(() => {
handleSelect(array[0]);
});
expect(selected).toEqual([array[0]]);
act(() => {
handleSelect(array[0]);
});
expect(selected).toEqual([]);
});
test('should return expected isAllSelected value', () => {
testHook(() => {
({ selected, isAllSelected, handleSelect, setSelected } = useSelected(
array
));
});
act(() => {
handleSelect(array[0]);
});
expect(selected).toEqual([array[0]]);
expect(isAllSelected).toEqual(false);
act(() => {
handleSelect(array[1]);
handleSelect(array[2]);
});
expect(selected).toEqual(array);
expect(isAllSelected).toEqual(true);
act(() => {
setSelected([]);
});
expect(selected).toEqual([]);
expect(isAllSelected).toEqual(false);
});
});