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:
commit
1fce77054a
@ -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);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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);
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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 () => {
|
||||
|
27
awx/ui_next/src/util/useSelected.jsx
Normal file
27
awx/ui_next/src/util/useSelected.jsx
Normal 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 };
|
||||
}
|
75
awx/ui_next/src/util/useSelected.test.jsx
Normal file
75
awx/ui_next/src/util/useSelected.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user