diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index b1fed9d725..3fbf41679c 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -110,7 +110,7 @@ function DataListToolbar({ )} - {isAdvancedSearchShown && ( + {isAdvancedSearchShown && additionalControls.length > 0 && ( diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx index ad83ce61e9..e8d8742b00 100644 --- a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx @@ -1,8 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useContext } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; +import { Button, DropdownItem } from '@patternfly/react-core'; +import { KebabifiedContext } from '../../contexts/Kebabified'; import useRequest, { useDismissableError } from '../../util/useRequest'; import SelectableCard from '../SelectableCard'; import AlertModal from '../AlertModal'; @@ -20,21 +22,21 @@ const Grid = styled.div` grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); `; -function UserAndTeamAccessAdd({ - i18n, - isOpen, - title, - onSave, - apiModel, - onClose, -}) { +function UserAndTeamAccessAdd({ i18n, title, onFetchData, apiModel }) { const [selectedResourceType, setSelectedResourceType] = useState(null); + const [isWizardOpen, setIsWizardOpen] = useState(false); const [stepIdReached, setStepIdReached] = useState(1); const { id: userId } = useParams(); + const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); const { selected: resourcesSelected, handleSelect: handleResourceSelect, } = useSelected([]); + useEffect(() => { + if (isKebabified) { + onKebabModalChange(isWizardOpen); + } + }, [isKebabified, isWizardOpen, onKebabModalChange]); const { selected: rolesSelected, @@ -57,8 +59,9 @@ function UserAndTeamAccessAdd({ ); await Promise.all(roleRequests); - onSave(); - }, [onSave, rolesSelected, apiModel, userId, resourcesSelected]), + onFetchData(); + setIsWizardOpen(false); + }, [onFetchData, rolesSelected, apiModel, userId, resourcesSelected]), {} ); @@ -141,16 +144,39 @@ function UserAndTeamAccessAdd({ } return ( - - setStepIdReached(stepIdReached < id ? id : stepIdReached) - } - onSave={handleWizardSave} - /> + <> + {isKebabified ? ( + setIsWizardOpen(true)} + > + {i18n._(t`Add`)} + + ) : ( + + )} + {isWizardOpen && ( + setIsWizardOpen(false)} + onNext={({ id }) => + setStepIdReached(stepIdReached < id ? id : stepIdReached) + } + onSave={handleWizardSave} + /> + )} + ); } diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx index 7ad19c9057..c4cea2ff29 100644 --- a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -58,23 +58,26 @@ describe('', () => { wrapper = mountWithContexts( {}} - onClose={() => {}} + onFetchData={() => {}} title="Add user permissions" /> ); }); - await waitForElement(wrapper, 'PFWizard'); + await waitForElement(wrapper, 'Button[aria-label="Add"]'); }); afterEach(() => { wrapper.unmount(); jest.clearAllMocks(); }); test('should mount properly', async () => { + expect(wrapper.find('Button[aria-label="Add"]').length).toBe(1); + act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); + wrapper.update(); expect(wrapper.find('PFWizard').length).toBe(1); }); test('should disable steps', async () => { + act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); + wrapper.update(); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect( wrapper @@ -122,7 +125,8 @@ describe('', () => { JobTemplatesAPI.read.mockResolvedValue(resources); JobTemplatesAPI.readOptions.mockResolvedValue(options); UsersAPI.associateRole.mockResolvedValue({}); - + act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); + wrapper.update(); await act(async () => wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ fetchItems: JobTemplatesAPI.read, @@ -178,6 +182,14 @@ describe('', () => { await expect(UsersAPI.associateRole).toHaveBeenCalled(); }); + test('should close wizard', async () => { + act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); + wrapper.update(); + act(() => wrapper.find('PFWizard').prop('onClose')()); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(0); + }); + test('should throw error', async () => { JobTemplatesAPI.read.mockResolvedValue(resources); JobTemplatesAPI.readOptions.mockResolvedValue(options); @@ -201,6 +213,9 @@ describe('', () => { }), })); + act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); + wrapper.update(); + await act(async () => wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ fetchItems: JobTemplatesAPI.read, diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx index 070e64d0ae..39c1b4dfbf 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -14,7 +14,6 @@ import { UserDateDetail, } from '../../../components/DetailList'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; -import { GroupsAPI, InventoriesAPI } from '../../../api'; function InventoryGroupDetail({ i18n, inventoryGroup }) { const { @@ -26,27 +25,9 @@ function InventoryGroupDetail({ i18n, inventoryGroup }) { variables, } = inventoryGroup; const [error, setError] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const history = useHistory(); const params = useParams(); - const handleDelete = async option => { - const inventoryId = parseInt(params.id, 10); - const groupId = parseInt(params.groupId, 10); - setIsDeleteModalOpen(false); - - try { - if (option === 'delete') { - await GroupsAPI.destroy(groupId); - } else { - await InventoriesAPI.promoteGroup(inventoryId, groupId); - } - history.push(`/inventories/inventory/${inventoryId}/groups`); - } catch (err) { - setError(err); - } - }; - return ( @@ -84,22 +65,14 @@ function InventoryGroupDetail({ i18n, inventoryGroup }) { > {i18n._(t`Edit`)} - - - {isDeleteModalOpen && ( setIsDeleteModalOpen(false)} - isModalOpen={isDeleteModalOpen} - onDelete={handleDelete} + isDisabled={false} + onAfterDelete={() => + history.push(`/inventories/inventory/${params.id}/groups`) + } /> - )} + {error && ( ', () => { }); afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); test('InventoryGroupDetail renders successfully', () => { expect(wrapper.length).toBe(1); }); + test('should open delete modal and then call api to delete the group', async () => { await act(async () => { wrapper.find('button[aria-label="Delete"]').simulate('click'); @@ -73,19 +75,22 @@ describe('', () => { wrapper.find('Radio[id="radio-delete"]').invoke('onChange')(); }); wrapper.update(); - await act(async () => { - wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') - .invoke('onClick')(); - }); + expect( + wrapper.find('Button[aria-label="Confirm Delete"]').prop('isDisabled') + ).toBe(false); + await act(() => + wrapper.find('Button[aria-label="Confirm Delete"]').prop('onClick')() + ); expect(GroupsAPI.destroy).toBeCalledWith(1); }); + test('should navigate user to edit form on edit button click', async () => { wrapper.find('button[aria-label="Edit"]').simulate('click'); expect(history.location.pathname).toEqual( '/inventories/inventory/1/groups/1/edit' ); }); + test('details should render with the proper values', () => { expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo'); expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index 8c8676faf1..ad13e06719 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -1,14 +1,12 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button, Tooltip } from '@patternfly/react-core'; +import { Tooltip } from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useSelected from '../../../util/useSelected'; import useRequest from '../../../util/useRequest'; -import { InventoriesAPI, GroupsAPI } from '../../../api'; -import AlertModal from '../../../components/AlertModal'; -import ErrorDetail from '../../../components/ErrorDetail'; +import { InventoriesAPI } from '../../../api'; import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList, { ToolbarAddButton, @@ -29,25 +27,8 @@ function cannotDelete(item) { return !item.summary_fields.user_capabilities.delete; } -const useModal = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - - function toggleModal() { - setIsModalOpen(!isModalOpen); - } - - return { - isModalOpen, - toggleModal, - }; -}; - function InventoryGroupsList({ i18n }) { - const [deletionError, setDeletionError] = useState(null); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const location = useLocation(); - const { isModalOpen, toggleModal } = useModal(); const { id: inventoryId } = useParams(); const { @@ -119,31 +100,6 @@ function InventoryGroupsList({ i18n }) { return i18n._(t`Select a row to delete`); }; - const handleDelete = async option => { - setIsDeleteLoading(true); - - try { - /* eslint-disable no-await-in-loop */ - /* Delete groups sequentially to avoid api integrity errors */ - /* https://eslint.org/docs/rules/no-await-in-loop#when-not-to-use-it */ - for (let i = 0; i < selected.length; i++) { - const group = selected[i]; - if (option === 'delete') { - await GroupsAPI.destroy(+group.id); - } else if (option === 'promote') { - await InventoriesAPI.promoteGroup(inventoryId, +group.id); - } - } - /* eslint-enable no-await-in-loop */ - } catch (error) { - setDeletionError(error); - } - - toggleModal(); - fetchData(); - setSelected([]); - setIsDeleteLoading(false); - }; const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); @@ -151,7 +107,7 @@ function InventoryGroupsList({ i18n }) { <> 0} />, -
- -
+ { + fetchData(); + setSelected([]); + }} + />
, ]} /> @@ -245,24 +199,6 @@ function InventoryGroupsList({ i18n }) { ) } /> - {deletionError && ( - setDeletionError(null)} - > - {i18n._(t`Failed to delete one or more groups.`)} - - - )} - ); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx index edc87e437d..b57d6c93bc 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx @@ -230,7 +230,7 @@ describe(' error handling', () => { wrapper.update(); await act(async () => { wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') + .find('ModalBoxFooter Button[aria-label="Confirm Delete"]') .invoke('onClick')(); }); await waitForElement( diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx index ef3ca3de18..908d9d36ce 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx @@ -1,11 +1,14 @@ import 'styled-components/macro'; -import React, { useState } from 'react'; -import ReactDOM from 'react-dom'; +import React, { useState, useContext, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; import { func, bool, arrayOf, object } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button, Radio } from '@patternfly/react-core'; +import { Button, Radio, DropdownItem } from '@patternfly/react-core'; import styled from 'styled-components'; +import { KebabifiedContext } from '../../../contexts/Kebabified'; +import { GroupsAPI, InventoriesAPI } from '../../../api'; +import ErrorDetail from '../../../components/ErrorDetail'; import AlertModal from '../../../components/AlertModal'; const ListItem = styled.li` @@ -15,83 +18,151 @@ const ListItem = styled.li` `; const InventoryGroupsDeleteModal = ({ - onClose, - onDelete, - isModalOpen, + onAfterDelete, + isDisabled, groups, i18n, }) => { const [radioOption, setRadioOption] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [deletionError, setDeletionError] = useState(null); + const { id: inventoryId } = useParams(); + const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); - return ReactDOM.createPortal( - 1 ? i18n._(t`Delete Groups?`) : i18n._(t`Delete Group?`) + useEffect(() => { + if (isKebabified) { + onKebabModalChange(isModalOpen); + } + }, [isKebabified, isModalOpen, onKebabModalChange]); + const handleDelete = async option => { + setIsDeleteLoading(true); + + try { + /* eslint-disable no-await-in-loop */ + /* Delete groups sequentially to avoid api integrity errors */ + /* https://eslint.org/docs/rules/no-await-in-loop#when-not-to-use-it */ + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (option === 'delete') { + await GroupsAPI.destroy(+group.id); + } else if (option === 'promote') { + await InventoriesAPI.promoteGroup(inventoryId, +group.id); + } } - onClose={onClose} - actions={[ - , + + ) : ( , - ]} - > - {i18n._( - t`Are you sure you want to delete the ${ - groups.length > 1 ? i18n._(t`groups`) : i18n._(t`group`) - } below?` + {i18n._(t`Delete`)} + )} -
- {groups.map(group => { - return {group.name}; - })} -
-
- setRadioOption('delete')} - /> - setRadioOption('promote')} - /> -
-
, - document.body + {isModalOpen && ( + 1 + ? i18n._(t`Delete Groups?`) + : i18n._(t`Delete Group?`) + } + onClose={() => setIsModalOpen(false)} + actions={[ + , + , + ]} + > + {i18n._( + t`Are you sure you want to delete the ${ + groups.length > 1 ? i18n._(t`groups`) : i18n._(t`group`) + } below?` + )} +
+ {groups.map(group => { + return {group.name}; + })} +
+
+ setRadioOption('delete')} + /> + setRadioOption('promote')} + /> +
+
+ )} + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete one or more groups.`)} + + + )} + ); }; InventoryGroupsDeleteModal.propTypes = { - onClose: func.isRequired, - onDelete: func.isRequired, - isModalOpen: bool, + onAfterDelete: func.isRequired, groups: arrayOf(object), + isDisabled: bool.isRequired, }; InventoryGroupsDeleteModal.defaultProps = { - isModalOpen: false, groups: [], }; diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.test.jsx new file mode 100644 index 0000000000..04e721ad20 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.test.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import { InventoriesAPI } from '../../../api'; +import InventoryGroupsDeleteModal from './InventoryGroupsDeleteModal'; + +jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); +describe('', () => { + let wrapper; + beforeEach(() => { + act(() => { + wrapper = mountWithContexts( + {}} + isDisabled={false} + groups={[ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ]} + /> + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mount properly', async () => { + expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1); + act(() => wrapper.find('Button[aria-label="Delete"]').prop('onClick')()); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + }); + + test('should close modal', () => { + act(() => wrapper.find('Button[aria-label="Delete"]').prop('onClick')()); + wrapper.update(); + act(() => wrapper.find('ModalBoxCloseButton').prop('onClose')()); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(0); + }); + + test('should delete properly', async () => { + act(() => wrapper.find('Button[aria-label="Delete"]').prop('onClick')({})); + wrapper.update(); + act(() => + wrapper + .find('Radio[label="Promote Child Groups and Hosts"]') + .invoke('onChange')() + ); + wrapper.update(); + expect( + wrapper.find('Button[aria-label="Confirm Delete"]').prop('isDisabled') + ).toBe(false); + await act(() => + wrapper.find('Button[aria-label="Confirm Delete"]').prop('onClick')() + ); + expect(InventoriesAPI.promoteGroup).toBeCalledWith(1, 1); + }); + + test('should throw deletion error ', async () => { + InventoriesAPI.promoteGroup.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/inventories/1/groups', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + act(() => wrapper.find('Button[aria-label="Delete"]').prop('onClick')({})); + wrapper.update(); + act(() => + wrapper + .find('Radio[label="Promote Child Groups and Hosts"]') + .invoke('onChange')() + ); + wrapper.update(); + expect( + wrapper.find('Button[aria-label="Confirm Delete"]').prop('isDisabled') + ).toBe(false); + await act(() => + wrapper.find('Button[aria-label="Confirm Delete"]').prop('onClick')() + ); + expect(InventoriesAPI.promoteGroup).toBeCalledWith(1, 1); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx index cb2c16a23f..5caa143c09 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -110,8 +110,16 @@ describe('', () => { project_local_paths: ['foobar', 'qux'], project_base_dir: 'dir/foo/bar', }; - const error = new Error('oops'); - ProjectsAPI.create.mockImplementation(() => Promise.reject(error)); + const error = { + response: { + config: { + method: 'create', + url: '/api/v2/projects/', + }, + data: { detail: 'An error occurred' }, + }, + }; + ProjectsAPI.create.mockRejectedValue(error); await act(async () => { wrapper = mountWithContexts(, { context: { config }, diff --git a/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx index 31db276045..a61e8c6f3d 100644 --- a/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx +++ b/awx/ui_next/src/screens/Team/TeamRoles/TeamRolesList.jsx @@ -29,7 +29,6 @@ const QS_CONFIG = getQSConfig('roles', { }); function TeamRolesList({ i18n, me, team }) { - const [isWizardOpen, setIsWizardOpen] = useState(false); const { search } = useLocation(); const [roleToDisassociate, setRoleToDisassociate] = useState(null); @@ -85,10 +84,6 @@ function TeamRolesList({ i18n, me, team }) { fetchRoles(); }, [fetchRoles]); - const saveRoles = () => { - setIsWizardOpen(false); - fetchRoles(); - }; const { isLoading: isDisassociateLoading, deleteItems: disassociateRole, @@ -170,15 +165,11 @@ function TeamRolesList({ i18n, me, team }) { additionalControls={[ ...(canAdd ? [ - , + , ] : []), ]} @@ -195,15 +186,6 @@ function TeamRolesList({ i18n, me, team }) { /> )} /> - {isWizardOpen && ( - setIsWizardOpen(false)} - title={i18n._(t`Add team permissions`)} - /> - )} {roleToDisassociate && ( { - setIsWizardOpen(false); - fetchRoles(); - }; - const detailUrl = role => { const { resource_id, resource_type } = role.summary_fields; @@ -183,30 +177,17 @@ function UserRolesList({ i18n, user }) { additionalControls={[ ...(canAdd ? [ - , + , ] : []), ]} /> )} /> - {isWizardOpen && ( - setIsWizardOpen(false)} - title={i18n._(t`Add user permissions`)} - /> - )} {roleToDisassociate && ( ', () => { }); wrapper.update(); await act(async () => - wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() + wrapper.find('Button[aria-label="Add"]').prop('onClick')() ); wrapper.update(); expect(wrapper.find('PFWizard').length).toBe(1);