diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index 10ac42b77f..6d8a1bf280 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -1,19 +1,14 @@ -import React, { Fragment } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; - import { TeamsAPI, UsersAPI } from '../../api'; import AddResourceRole from '../AddRole/AddResourceRole'; import AlertModal from '../AlertModal'; import DataListToolbar from '../DataListToolbar'; import PaginatedDataList, { ToolbarAddButton } from '../PaginatedDataList'; -import { - getQSConfig, - encodeQueryString, - parseQueryString, -} from '../../util/qs'; - +import { getQSConfig, parseQueryString } from '../../util/qs'; +import useRequest, { useDeleteItems } from '../../util/useRequest'; import DeleteRoleConfirmationModal from './DeleteRoleConfirmationModal'; import ResourceAccessListItem from './ResourceAccessListItem'; @@ -23,227 +18,160 @@ const QS_CONFIG = getQSConfig('access', { order_by: 'first_name', }); -class ResourceAccessList extends React.Component { - constructor(props) { - super(props); - this.state = { +function ResourceAccessList({ i18n, apiModel, resource }) { + const [deletionRecord, setDeletionRecord] = useState(null); + const [deletionRole, setDeletionRole] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const location = useLocation(); + + const { + result: { accessRecords, itemCount }, + error: contentError, + isLoading, + request: fetchAccessRecords, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const response = await apiModel.readAccessList(resource.id, params); + return { + accessRecords: response.data.results, + itemCount: response.data.count, + }; + }, [apiModel, location, resource.id]), + { accessRecords: [], - contentError: null, - hasContentLoading: true, - hasDeletionError: false, - deletionRecord: null, - deletionRole: null, - isAddModalOpen: false, itemCount: 0, - }; - this.loadAccessList = this.loadAccessList.bind(this); - this.handleAddClose = this.handleAddClose.bind(this); - this.handleAddOpen = this.handleAddOpen.bind(this); - this.handleAddSuccess = this.handleAddSuccess.bind(this); - this.handleDeleteCancel = this.handleDeleteCancel.bind(this); - this.handleDeleteConfirm = this.handleDeleteConfirm.bind(this); - this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); - this.handleDeleteOpen = this.handleDeleteOpen.bind(this); - } - - componentDidMount() { - this.loadAccessList(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - - const prevParams = parseQueryString(QS_CONFIG, prevProps.location.search); - const currentParams = parseQueryString(QS_CONFIG, location.search); - - if (encodeQueryString(currentParams) !== encodeQueryString(prevParams)) { - this.loadAccessList(); } - } + ); - async loadAccessList() { - const { apiModel, resource, location } = this.props; - const params = parseQueryString(QS_CONFIG, location.search); + useEffect(() => { + fetchAccessRecords(); + }, [fetchAccessRecords]); - this.setState({ contentError: null, hasContentLoading: true }); - try { - const { - data: { results: accessRecords = [], count: itemCount = 0 }, - } = await apiModel.readAccessList(resource.id, params); - this.setState({ itemCount, accessRecords }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); + const { + isLoading: isDeleteLoading, + deleteItems: deleteRole, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + if (typeof deletionRole.team_id !== 'undefined') { + return TeamsAPI.disassociateRole(deletionRole.team_id, deletionRole.id); + } + return UsersAPI.disassociateRole(deletionRecord.id, deletionRole.id); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [deletionRole]), + { + qsConfig: QS_CONFIG, + fetchItems: fetchAccessRecords, } - } + ); - handleDeleteOpen(deletionRole, deletionRecord) { - this.setState({ deletionRole, deletionRecord }); - } - - handleDeleteCancel() { - this.setState({ deletionRole: null, deletionRecord: null }); - } - - handleDeleteErrorClose() { - this.setState({ - hasDeletionError: false, - deletionRecord: null, - deletionRole: null, - }); - } - - async handleDeleteConfirm() { - const { deletionRole, deletionRecord } = this.state; - - if (!deletionRole || !deletionRecord) { - return; - } - - let promise; - if (typeof deletionRole.team_id !== 'undefined') { - promise = TeamsAPI.disassociateRole( - deletionRole.team_id, - deletionRole.id - ); - } else { - promise = UsersAPI.disassociateRole(deletionRecord.id, deletionRole.id); - } - - this.setState({ hasContentLoading: true }); - try { - await promise.then(this.loadAccessList); - this.setState({ - deletionRole: null, - deletionRecord: null, - }); - } catch (error) { - this.setState({ - hasContentLoading: false, - hasDeletionError: true, - }); - } - } - - handleAddClose() { - this.setState({ isAddModalOpen: false }); - } - - handleAddOpen() { - this.setState({ isAddModalOpen: true }); - } - - handleAddSuccess() { - this.setState({ isAddModalOpen: false }); - this.loadAccessList(); - } - - render() { - const { resource, i18n } = this.props; - const { - accessRecords, - contentError, - hasContentLoading, - deletionRole, - deletionRecord, - hasDeletionError, - itemCount, - isAddModalOpen, - } = this.state; - const canEdit = resource.summary_fields.user_capabilities.edit; - const isDeleteModalOpen = - !hasContentLoading && !hasDeletionError && deletionRole; - - return ( - - ( - , - ] - : [] - } - /> - )} - renderItem={accessRecord => ( - - )} + return ( + <> + ( + setShowAddModal(true)} + />, + ] + : [] + } + /> + )} + renderItem={accessRecord => ( + { + setDeletionRecord(record); + setDeletionRole(role); + setShowDeleteModal(true); + }} + /> + )} + /> + {showAddModal && ( + setShowAddModal(false)} + onSave={() => { + setShowAddModal(false); + fetchAccessRecords(); + }} + roles={resource.summary_fields.object_roles} /> - {isAddModalOpen && ( - - )} - {isDeleteModalOpen && ( - - )} + )} + {showDeleteModal && ( + { + setDeletionRecord(null); + setDeletionRole(null); + setShowDeleteModal(false); + }} + onConfirm={async () => { + await deleteRole(); + setShowDeleteModal(false); + setDeletionRecord(null); + setDeletionRole(null); + }} + /> + )} + {deletionError && ( {i18n._(t`Failed to delete role`)} - - ); - } + )} + + ); } - -export { ResourceAccessList as _ResourceAccessList }; -export default withI18n()(withRouter(ResourceAccessList)); +export default withI18n()(ResourceAccessList); diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx index c4f88e825a..4bdb9c08f0 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.test.jsx @@ -1,6 +1,5 @@ import React from 'react'; - -import { sleep } from '../../../testUtils/testUtils'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement, @@ -13,6 +12,7 @@ import ResourceAccessList from './ResourceAccessList'; jest.mock('../../api'); describe('', () => { + let wrapper; const organization = { id: 1, name: 'Default', @@ -74,108 +74,68 @@ describe('', () => { ], }; - beforeEach(() => { + beforeEach(async () => { OrganizationsAPI.readAccessList.mockResolvedValue({ data }); TeamsAPI.disassociateRole.mockResolvedValue({}); UsersAPI.disassociateRole.mockResolvedValue({}); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); }); afterEach(() => { + wrapper.unmount(); jest.clearAllMocks(); }); test('initially renders succesfully', () => { - const wrapper = mountWithContexts( - - ); expect(wrapper.find('PaginatedDataList')).toHaveLength(1); }); test('should fetch and display access records on mount', async done => { - const wrapper = mountWithContexts( - - ); - await waitForElement( - wrapper, - 'ResourceAccessListItem', - el => el.length === 2 - ); - expect(wrapper.find('PaginatedDataList').prop('items')).toEqual( - data.results - ); - expect(wrapper.find('ResourceAccessList').state('hasContentLoading')).toBe( - false - ); - expect(wrapper.find('ResourceAccessList').state('contentError')).toBe(null); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(OrganizationsAPI.readAccessList).toHaveBeenCalled(); + expect(wrapper.find('ResourceAccessListItem').length).toBe(2); done(); }); - test('should open confirmation dialog when deleting role', async done => { - const wrapper = mountWithContexts( - - ); - await sleep(0); - wrapper.update(); - + test('should open and close confirmation dialog when deleting role', async done => { + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); const button = wrapper.find('Chip Button').at(0); - button.prop('onClick')(); + await act(async () => { + button.prop('onClick')(); + }); wrapper.update(); - - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toEqual( - data.results[0].summary_fields.direct_access[0].role - ); - expect(component.state('deletionRecord')).toEqual(data.results[0]); - expect(component.find('DeleteRoleConfirmationModal')).toHaveLength(1); - done(); - }); - - it('should close dialog when cancel button clicked', async done => { - const wrapper = mountWithContexts( - - ); - await sleep(0); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(1); + await act(async () => { + wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')(); + }); wrapper.update(); - const button = wrapper.find('Chip Button').at(0); - button.prop('onClick')(); - wrapper.update(); - - wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')(); - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toBeNull(); - expect(component.state('deletionRecord')).toBeNull(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); done(); }); it('should delete user role', async done => { - const wrapper = mountWithContexts( - - ); - const button = await waitForElement( - wrapper, - 'Chip Button', - el => el.length === 2 - ); - button.at(0).prop('onClick')(); - - const confirmation = await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal' - ); - confirmation.prop('onConfirm')(); - await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal', - el => el.length === 0 - ); - - await sleep(0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const button = wrapper.find('Chip Button').at(0); + await act(async () => { + button.prop('onClick')(); + }); wrapper.update(); - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toBeNull(); - expect(component.state('deletionRecord')).toBeNull(); + await act(async () => { + wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')(); + }); + wrapper.update(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); expect(UsersAPI.disassociateRole).toHaveBeenCalledWith(1, 1); expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); @@ -183,32 +143,17 @@ describe('', () => { }); it('should delete team role', async done => { - const wrapper = mountWithContexts( - - ); - const button = await waitForElement( - wrapper, - 'Chip Button', - el => el.length === 2 - ); - button.at(1).prop('onClick')(); - - const confirmation = await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal' - ); - confirmation.prop('onConfirm')(); - await waitForElement( - wrapper, - 'DeleteRoleConfirmationModal', - el => el.length === 0 - ); - - await sleep(0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const button = wrapper.find('Chip Button').at(1); + await act(async () => { + button.prop('onClick')(); + }); wrapper.update(); - const component = wrapper.find('ResourceAccessList'); - expect(component.state('deletionRole')).toBeNull(); - expect(component.state('deletionRecord')).toBeNull(); + await act(async () => { + wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')(); + }); + wrapper.update(); + expect(wrapper.find('DeleteRoleConfirmationModal')).toHaveLength(0); expect(TeamsAPI.disassociateRole).toHaveBeenCalledWith(5, 3); expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2);