mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 11:20:39 -03:30
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:
commit
dccddfffe6
@ -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;
|
||||
|
||||
@ -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`),
|
||||
|
||||
@ -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="*">
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryRelatedGroupAdd';
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -1 +1 @@
|
||||
export { default } from './InventoryRelatedGroupList';
|
||||
export { default } from './InventoryRelatedGroups';
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user