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); + }); });