Merge pull request #8483 from AlexSCorey/8261-AddRelatedGroups

Supports Associating Related Inventory Group

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-11-05 21:08:21 +00:00 committed by GitHub
commit dccddfffe6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 352 additions and 17 deletions

View File

@ -37,6 +37,23 @@ class Groups extends Base {
readChildren(id, params) {
return this.http.get(`${this.baseUrl}${id}/children/`, { params });
}
associateChildGroup(id, childId) {
return this.http.post(`${this.baseUrl}${id}/children/`, { id: childId });
}
disassociateChildGroup(id, childId) {
return this.http.post(`${this.baseUrl}${id}/children/`, {
disassociate: id,
id: childId,
});
}
readPotentialGroups(id, params) {
return this.http.get(`${this.baseUrl}${id}/potential_children/`, {
params,
});
}
}
export default Groups;

View File

@ -69,6 +69,12 @@ function Inventories({ i18n }) {
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
t`Create new host`
),
[`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._(
t`Groups`
),
[`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._(
t`Create new group`
),
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),

View File

@ -17,7 +17,7 @@ import ContentLoading from '../../../components/ContentLoading';
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
import InventoryGroupHosts from '../InventoryGroupHosts';
import InventoryGroupsRelatedGroup from '../InventoryRelatedGroups';
import InventoryRelatedGroups from '../InventoryRelatedGroups';
import { GroupsAPI } from '../../../api';
@ -48,7 +48,7 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
{
name: (
<>
<CaretLeftIcon />
<CaretLeftIcon aria-label={i18n._(t`Back to Groups`)} />
{i18n._(t`Back to Groups`)}
</>
),
@ -134,7 +134,7 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
key="relatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
>
<InventoryGroupsRelatedGroup />
<InventoryRelatedGroups />
</Route>,
]}
<Route key="not-found" path="*">

View File

@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { GroupsAPI } from '../../../api';
import InventoryGroupForm from '../shared/InventoryGroupForm';
function InventoryRelatedGroupAdd() {
const [error, setError] = useState(null);
const history = useHistory();
const { id, groupId } = useParams();
const associateInventoryGroup = async values => {
values.inventory = id;
try {
const { data } = await GroupsAPI.create(values);
await GroupsAPI.associateChildGroup(groupId, data.id);
history.push(`/inventories/inventory/${id}/groups/${data.id}/details`, {
prevGroupId: groupId,
});
} catch (err) {
setError(err);
}
};
const handleCancel = () => {
history.push(
`/inventories/inventory/${id}/groups/${groupId}/nested_groups`
);
};
return (
<InventoryGroupForm
handleSubmit={associateInventoryGroup}
handleCancel={handleCancel}
error={error}
/>
);
}
export default InventoryRelatedGroupAdd;

View File

@ -0,0 +1,110 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { GroupsAPI } from '../../../api';
import InventoryRelatedGroupAdd from './InventoryRelatedGroupAdd';
jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
groupId: 2,
}),
useHistory: () => ({ push: jest.fn() }),
}));
describe('<InventoryRelatedGroupAdd/>', () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/2/nested_groups'],
});
beforeEach(() => {
wrapper = mountWithContexts(<InventoryRelatedGroupAdd />);
});
afterEach(() => {
wrapper.unmount();
});
test('should render properly', () => {
expect(wrapper.find('InventoryRelatedGroupAdd').length).toBe(1);
});
test('should call api with proper data', async () => {
GroupsAPI.create.mockResolvedValue({ data: { id: 3 } });
await act(() =>
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'foo',
description: 'bar',
})
);
expect(GroupsAPI.create).toBeCalledWith({
inventory: 1,
name: 'foo',
description: 'bar',
});
expect(GroupsAPI.associateChildGroup).toBeCalledWith(2, 3);
});
test('cancel should navigate user to Inventory Groups List', async () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(history.location.pathname).toEqual(
'/inventories/inventory/1/groups/2/nested_groups'
);
});
test('should throw error on creation of group', async () => {
GroupsAPI.create.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/groups/',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(() =>
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'foo',
description: 'bar',
})
);
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
test('should throw error on association of group', async () => {
GroupsAPI.create.mockResolvedValue({ data: { id: 3 } });
GroupsAPI.associateChildGroup.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/groups/',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(() =>
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'foo',
description: 'bar',
})
);
expect(GroupsAPI.create).toBeCalledWith({
inventory: 1,
name: 'foo',
description: 'bar',
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryRelatedGroupAdd';

View File

@ -5,7 +5,7 @@ import { useParams, useLocation, Link } from 'react-router-dom';
import { DropdownItem } from '@patternfly/react-core';
import { GroupsAPI, InventoriesAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
import useSelected from '../../../util/useSelected';
@ -14,6 +14,8 @@ import PaginatedDataList from '../../../components/PaginatedDataList';
import InventoryGroupRelatedGroupListItem from './InventoryRelatedGroupListItem';
import AddDropDownButton from '../../../components/AddDropDownButton';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton';
import { toTitleCase } from '../../../util/strings';
@ -25,6 +27,8 @@ const QS_CONFIG = getQSConfig('group', {
});
function InventoryRelatedGroupList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [associateError, setAssociateError] = useState(null);
const [disassociateError, setDisassociateError] = useState(null);
const { id: inventoryId, groupId } = useParams();
const location = useLocation();
@ -69,24 +73,58 @@ function InventoryRelatedGroupList({ i18n }) {
const fetchGroupsToAssociate = useCallback(
params => {
return InventoriesAPI.readGroups(
inventoryId,
mergeParams(params, { not__id: inventoryId, not__parents: inventoryId })
return GroupsAPI.readPotentialGroups(
groupId,
mergeParams(params, { not__id: groupId, not__parents: groupId })
);
},
[inventoryId]
[groupId]
);
const fetchGroupsOptions = useCallback(
() => InventoriesAPI.readGroupsOptions(inventoryId),
[inventoryId]
const associateGroup = useCallback(
async selectedGroups => {
try {
await Promise.all(
selectedGroups.map(selected =>
GroupsAPI.associateChildGroup(groupId, selected.id)
)
);
} catch (err) {
setAssociateError(err);
}
fetchRelated();
},
[groupId, fetchRelated]
);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
groups
);
const addFormUrl = `/home`;
const disassociateGroups = useCallback(async () => {
try {
await Promise.all(
selected.map(({ id: childId }) =>
GroupsAPI.disassociateChildGroup(parseInt(groupId, 10), childId)
)
);
} catch (err) {
setDisassociateError(err);
}
fetchRelated();
setSelected([]);
}, [groupId, selected, setSelected, fetchRelated]);
const fetchGroupsOptions = useCallback(
() => InventoriesAPI.readGroupsOptions(inventoryId),
[inventoryId]
);
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_groups/add`;
const addExistingGroup = toTitleCase(i18n._(t`Add Existing Group`));
const addNewGroup = toTitleCase(i18n._(t`Add New Group`));
@ -163,7 +201,7 @@ function InventoryRelatedGroupList({ i18n }) {
/>,
<DisassociateButton
key="disassociate"
onDisassociate={() => {}}
onDisassociate={disassociateGroups}
itemsToDisassociate={selected}
modalTitle={i18n._(t`Disassociate related group(s)?`)}
/>,
@ -188,11 +226,24 @@ function InventoryRelatedGroupList({ i18n }) {
fetchRequest={fetchGroupsToAssociate}
optionsRequest={fetchGroupsOptions}
isModalOpen={isModalOpen}
onAssociate={() => {}}
onAssociate={associateGroup}
onClose={() => setIsModalOpen(false)}
title={i18n._(t`Select Groups`)}
/>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
variant="error"
>
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more groups.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}

View File

@ -21,6 +21,48 @@ jest.mock('react-router-dom', () => ({
}),
}));
const mockGroups = [
{
id: 1,
type: 'group',
name: 'foo',
inventory: 1,
url: '/api/v2/groups/1',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 2,
type: 'group',
name: 'bar',
inventory: 1,
url: '/api/v2/groups/2',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 3,
type: 'group',
name: 'baz',
inventory: 1,
url: '/api/v2/groups/3',
summary_fields: {
user_capabilities: {
delete: false,
edit: false,
},
},
},
];
describe('<InventoryRelatedGroupList />', () => {
let wrapper;
@ -145,4 +187,38 @@ describe('<InventoryRelatedGroupList />', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('AddDropdown').length).toBe(0);
});
test('should associate existing group', async () => {
GroupsAPI.readPotentialGroups.mockResolvedValue({
data: { count: mockGroups.length, results: mockGroups },
});
await act(async () => {
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')());
wrapper.update();
await act(async () =>
wrapper
.find('DropdownItem[aria-label="Add existing group"]')
.prop('onClick')()
);
expect(GroupsAPI.readPotentialGroups).toBeCalledWith(2, {
not__id: 2,
not__parents: 2,
order_by: 'name',
page: 1,
page_size: 5,
});
wrapper.update();
act(() =>
wrapper.find('CheckboxListItem[name="foo"]').prop('onSelect')({ id: 1 })
);
wrapper.update();
await act(() =>
wrapper.find('button[aria-label="Save"]').prop('onClick')()
);
expect(GroupsAPI.associateChildGroup).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,26 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import InventoryRelatedGroupList from './InventoryRelatedGroupList';
import InventoryRelatedGroupAdd from '../InventoryRelatedGroupAdd';
function InventoryRelatedGroups() {
return (
<>
<Switch>
<Route
key="addRelatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups/add"
>
<InventoryRelatedGroupAdd />
</Route>
<Route
key="relatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
>
<InventoryRelatedGroupList />
</Route>
</Switch>
</>
);
}
export default InventoryRelatedGroups;

View File

@ -1 +1 @@
export { default } from './InventoryRelatedGroupList';
export { default } from './InventoryRelatedGroups';

View File

@ -5,7 +5,7 @@ import { Form, Card } from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { CardBody } from '../../../components/Card';
import FormField from '../../../components/FormField';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import { VariablesField } from '../../../components/CodeMirrorInput';
import { required } from '../../../util/validators';
@ -59,7 +59,7 @@ function InventoryGroupForm({
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
{error ? <div>error</div> : null}
{error && <FormSubmitError error={error} />}
</FormColumnLayout>
</Form>
)}

View File

@ -30,4 +30,15 @@ describe('<InventoryGroupForm />', () => {
expect(wrapper.find("FormGroup[label='Description']").length).toBe(1);
expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1);
});
test('should throw error properly', () => {
const newWrapper = mountWithContexts(
<InventoryGroupForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
group={group}
error
/>
);
expect(newWrapper.find('FormSubmitError').length).toBe(1);
});
});