diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js
index 747949eb85..3f9aa928e0 100644
--- a/awx/ui_next/src/api/models/Groups.js
+++ b/awx/ui_next/src/api/models/Groups.js
@@ -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;
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index 30a46b64c3..cf286c05eb 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -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`),
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
index 4b53938373..0221d3afe7 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
@@ -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: (
<>
-
+
{i18n._(t`Back to Groups`)}
>
),
@@ -134,7 +134,7 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
key="relatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
>
-
+
,
]}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/InventoryRelatedGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/InventoryRelatedGroupAdd.jsx
new file mode 100644
index 0000000000..d8549f88b0
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/InventoryRelatedGroupAdd.jsx
@@ -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 (
+
+ );
+}
+
+export default InventoryRelatedGroupAdd;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/InventoryRelatedGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/InventoryRelatedGroupAdd.test.jsx
new file mode 100644
index 0000000000..2b2d0afdf8
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/InventoryRelatedGroupAdd.test.jsx
@@ -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('', () => {
+ let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/inventories/inventory/1/groups/2/nested_groups'],
+ });
+
+ beforeEach(() => {
+ wrapper = mountWithContexts();
+ });
+
+ 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);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/index.js
new file mode 100644
index 0000000000..706faf0764
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroupAdd/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryRelatedGroupAdd';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx
index 8e2e80790f..18e3e2ab3a 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx
@@ -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 }) {
/>,
{}}
+ 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 && (
+
+ {associateError
+ ? i18n._(t`Failed to associate.`)
+ : i18n._(t`Failed to disassociate one or more groups.`)}
+
+
+ )}
>
);
}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx
index 834e0738ec..e3879c560a 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx
@@ -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('', () => {
let wrapper;
@@ -145,4 +187,38 @@ describe('', () => {
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();
+ });
+ 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);
+ });
});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.jsx
new file mode 100644
index 0000000000..da7c4fd20e
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.jsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}
+export default InventoryRelatedGroups;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js
index 09833dbe98..45a43e200a 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js
+++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js
@@ -1 +1 @@
-export { default } from './InventoryRelatedGroupList';
+export { default } from './InventoryRelatedGroups';
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx
index 495cf415f2..5fa41a2921 100644
--- a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx
+++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx
@@ -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 ? error
: null}
+ {error && }
)}
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx
index a44b594af6..37ede7c709 100644
--- a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx
@@ -30,4 +30,15 @@ describe('', () => {
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(
+
+ );
+ expect(newWrapper.find('FormSubmitError').length).toBe(1);
+ });
});