diff --git a/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.js b/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.js index 1ade5a556..ca6677e1e 100644 --- a/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.js +++ b/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.js @@ -30,11 +30,15 @@ function UserAndTeamAccessAdd({ apiModel, onClose, onError, + resourceId, }) { const { t } = useLingui(); const [selectedResourceType, setSelectedResourceType] = useState(null); const [stepIdReached, setStepIdReached] = useState(1); - const { id: userId } = useParams(); + const { id: routeId } = useParams(); + // The caller passes the resource id explicitly (works whether the parent + // screen uses react-router v5 or v6); fall back to the v5 route param. + const associationId = resourceId ?? routeId; const teamsRouteMatch = useRouteMatch({ path: '/teams/:id/roles', exact: true, @@ -292,14 +296,16 @@ function UserAndTeamAccessAdd({ rolesSelected.map((role) => resourceRolesTypes.forEach((rolename) => { if (rolename.name === role.name) { - roleRequests.push(apiModel.associateRole(userId, rolename.id)); + roleRequests.push( + apiModel.associateRole(associationId, rolename.id) + ); } }) ); await Promise.all(roleRequests); onFetchData(); - }, [onFetchData, rolesSelected, apiModel, userId, resourcesSelected]), + }, [onFetchData, rolesSelected, apiModel, associationId, resourcesSelected]), {} ); diff --git a/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js b/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js index 377c1cda3..b82e02b33 100644 --- a/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js +++ b/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js @@ -59,6 +59,7 @@ describe('', () => { wrapper = mountWithContexts( {}} onClose={onClose} title="Add user permissions" @@ -171,7 +172,12 @@ describe('', () => { wrapper.find('Button[type="submit"]').prop('onClick')() ); - await expect(UsersAPI.associateRole).toHaveBeenCalled(); + // associate must use the resourceId passed by the parent screen, not a + // route param (which is empty when the parent screen uses react-router v6) + await expect(UsersAPI.associateRole).toHaveBeenCalledWith( + 99, + expect.any(Number) + ); }); test('should close wizard on cancel', async () => { diff --git a/awx/ui/src/screens/Team/Team.js b/awx/ui/src/screens/Team/Team.js index 8ca1efa5f..b63275a01 100644 --- a/awx/ui/src/screens/Team/Team.js +++ b/awx/ui/src/screens/Team/Team.js @@ -1,14 +1,14 @@ import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; import { - Link, - Redirect, + Routes, Route, - Switch, + Navigate, useLocation, useParams, -} from 'react-router-dom'; +} from 'react-router-dom-v5-compat'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; import { Config } from 'contexts/Config'; @@ -87,42 +87,47 @@ function Team({ setBreadcrumb }) { {showCardHeader && } - - + + } /> {team && ( - - - + } /> )} + {team && } />} {team && ( - - - + + } + /> )} {team && ( - - - + + {({ me }) => ( + <>{me && } + )} + + } + /> )} - {team && ( - - - {({ me }) => <>{me && }} - - - )} - - {!hasContentLoading && ( - - {id && ( - - {t`View Team Details`} - - )} - - )} - - + + {id && ( + + {t`View Team Details`} + + )} + + ) : null + } + /> + ); diff --git a/awx/ui/src/screens/Team/Team.test.js b/awx/ui/src/screens/Team/Team.test.js index c634fee1e..212855044 100644 --- a/awx/ui/src/screens/Team/Team.test.js +++ b/awx/ui/src/screens/Team/Team.test.js @@ -1,83 +1,116 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; +import { Routes, Route } from 'react-router-dom-v5-compat'; import { TeamsAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import Team from './Team'; -jest.mock('../../api'); +jest.mock('../../api/models/Teams'); -const mockMe = { - is_super_user: true, - is_system_auditor: false, -}; +// Markers for the routed tab panels, so assertions are about which branch of +// the nested v6 tree resolves. +jest.mock('./TeamDetail', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => ReactLib.createElement('div', null, 'TeamDetail'), + }; +}); +jest.mock('./TeamEdit', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => ReactLib.createElement('div', null, 'TeamEdit'), + }; +}); +jest.mock('./TeamRoles', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => ReactLib.createElement('div', null, 'TeamRoles'), + }; +}); +jest.mock('components/ResourceAccessList', () => { + const ReactLib = require('react'); + return { + ResourceAccessList: () => + ReactLib.createElement('div', null, 'ResourceAccessList'), + }; +}); const mockTeam = { id: 1, name: 'Test Team', summary_fields: { - organization: { - id: 1, - name: 'Default', - }, + organization: { id: 1, name: 'Default' }, + user_capabilities: { edit: true, delete: true }, }, }; -async function getTeams() { - return { - count: 1, - next: null, - previous: null, - data: { - results: [mockTeam], - }, - }; +// Team uses paths relative to its parent route, so mount it under the same +// /teams/:id/* route that Teams.js gives it in the app. +function renderAt(path) { + const history = createMemoryHistory({ initialEntries: [path] }); + return renderWithContexts( + + {}} />} /> + , + { context: { router: { history } } } + ); } describe('', () => { - let wrapper; - beforeEach(() => { TeamsAPI.readDetail.mockResolvedValue({ data: mockTeam }); - TeamsAPI.read.mockImplementation(getTeams); }); - test('initially renders successfully', async () => { - await act(async () => { - wrapper = mountWithContexts( - {}} me={mockMe} /> - ); - }); - expect(wrapper.find('Team').length).toBe(1); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('fetches the team detail', async () => { + renderAt('/teams/1/details'); + expect(await screen.findByText('TeamDetail')).toBeInTheDocument(); + // real route params are strings (the old enzyme test mocked a number) + expect(TeamsAPI.readDetail).toHaveBeenCalledWith('1'); + }); + + test('renders the edit panel at /edit', async () => { + renderAt('/teams/1/edit'); + expect(await screen.findByText('TeamEdit')).toBeInTheDocument(); + }); + + test('renders the access panel at /access', async () => { + renderAt('/teams/1/access'); + expect(await screen.findByText('ResourceAccessList')).toBeInTheDocument(); + }); + + test('renders the roles panel at /roles', async () => { + renderAt('/teams/1/roles'); + expect(await screen.findByText('TeamRoles')).toBeInTheDocument(); + }); + + test('redirects the index path to details', async () => { + const { history } = renderAt('/teams/1'); + expect(await screen.findByText('TeamDetail')).toBeInTheDocument(); + await waitFor(() => + expect(history.location.pathname).toBe('/teams/1/details') + ); + }); + + test('shows a not-found error on an unknown sub-route', async () => { + renderAt('/teams/1/foobar'); + expect(await screen.findByText('View Team Details')).toBeInTheDocument(); + expect(screen.queryByText('TeamDetail')).not.toBeInTheDocument(); }); - test('should show content error when user attempts to navigate to erroneous route', async () => { - const history = createMemoryHistory({ - initialEntries: ['/teams/1/foobar'], - }); - await act(async () => { - wrapper = mountWithContexts( - {}} me={mockMe} />, - { - context: { - router: { - history, - route: { - location: history.location, - match: { - params: { id: 1 }, - url: '/teams/1/foobar', - path: '/teams/1/foobar', - }, - }, - }, - }, - } - ); - }); - await waitForElement(wrapper, 'ContentError', (el) => el.length === 1); + test('shows a not-found error when the detail request 404s', async () => { + const err = new Error('not found'); + err.response = { status: 404 }; + TeamsAPI.readDetail.mockRejectedValue(err); + renderAt('/teams/1/details'); + expect(await screen.findByText('Team not found.')).toBeInTheDocument(); + expect(screen.queryByText('TeamDetail')).not.toBeInTheDocument(); }); }); diff --git a/awx/ui/src/screens/Team/TeamDetail/TeamDetail.js b/awx/ui/src/screens/Team/TeamDetail/TeamDetail.js index 5a15e9ad1..7aca789d2 100644 --- a/awx/ui/src/screens/Team/TeamDetail/TeamDetail.js +++ b/awx/ui/src/screens/Team/TeamDetail/TeamDetail.js @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { useNavigate } from 'react-router-dom-v5-compat'; +import { Link } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom-v5-compat'; import { Button } from '@patternfly/react-core'; import { useLingui } from '@lingui/react/macro'; diff --git a/awx/ui/src/screens/Team/TeamList/TeamList.js b/awx/ui/src/screens/Team/TeamList/TeamList.js index fc9541b7a..915d86849 100644 --- a/awx/ui/src/screens/Team/TeamList/TeamList.js +++ b/awx/ui/src/screens/Team/TeamList/TeamList.js @@ -1,5 +1,5 @@ import React, { useEffect, useCallback } from 'react'; -import { useLocation, useRouteMatch } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { Card, PageSection } from '@patternfly/react-core'; import { useLingui } from '@lingui/react/macro'; @@ -30,7 +30,6 @@ const QS_CONFIG = getQSConfig('team', { function TeamList() { const { t } = useLingui(); const location = useLocation(); - const match = useRouteMatch(); const { result: { @@ -156,7 +155,7 @@ function TeamList() { ? [ , ] : []), @@ -173,7 +172,7 @@ function TeamList() { row.id === team.id)} onSelect={() => handleSelect(team)} rowIndex={index} @@ -181,7 +180,7 @@ function TeamList() { )} emptyStateControls={ canAdd ? ( - + ) : null } /> diff --git a/awx/ui/src/screens/Team/TeamRoles/TeamRolesList.js b/awx/ui/src/screens/Team/TeamRoles/TeamRolesList.js index 8f447d6db..96cacf34c 100644 --- a/awx/ui/src/screens/Team/TeamRoles/TeamRolesList.js +++ b/awx/ui/src/screens/Team/TeamRoles/TeamRolesList.js @@ -196,6 +196,7 @@ function TeamRolesList({ me, team }) { {showAddModal && ( { setShowAddModal(false); fetchRoles(); diff --git a/awx/ui/src/screens/Team/Teams.js b/awx/ui/src/screens/Team/Teams.js index 85ffe24ea..35ebc18cd 100644 --- a/awx/ui/src/screens/Team/Teams.js +++ b/awx/ui/src/screens/Team/Teams.js @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { Route, Switch } from 'react-router-dom'; +import { Routes, Route } from 'react-router-dom-v5-compat'; import { useLingui } from '@lingui/react/macro'; @@ -40,21 +40,24 @@ function Teams() { return ( <> - - - - - - - - - - - {({ me }) => } - - - - + + } /> + {/* so the nested route tree can match the rest */} + } + /> + + + {({ me }) => } + + + } + /> + ); } diff --git a/awx/ui/src/screens/Team/Teams.test.js b/awx/ui/src/screens/Team/Teams.test.js index ccb12bbf8..a22c50c88 100644 --- a/awx/ui/src/screens/Team/Teams.test.js +++ b/awx/ui/src/screens/Team/Teams.test.js @@ -1,19 +1,57 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import Teams from './Teams'; -jest.mock('../../api'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), -})); +jest.mock('../../api/models/Teams'); + +// Replace the routed children with markers so the assertions are purely about +// which branch of the v6 tree resolves for a given URL. +jest.mock('./TeamList', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => ReactLib.createElement('div', null, 'TeamList'), + }; +}); +jest.mock('./TeamAdd', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => ReactLib.createElement('div', null, 'TeamAdd'), + }; +}); +jest.mock('./Team', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => ReactLib.createElement('div', null, 'Team detail'), + }; +}); + +function renderAt(path) { + const history = createMemoryHistory({ initialEntries: [path] }); + return renderWithContexts(, { + context: { router: { history } }, + }); +} describe('', () => { - test('initially renders successfully', () => { - mountWithContexts( - - ); + test('renders the list at /teams', async () => { + renderAt('/teams'); + expect(await screen.findByText('TeamList')).toBeInTheDocument(); + }); + + test('renders the add form at /teams/add', async () => { + renderAt('/teams/add'); + expect(await screen.findByText('TeamAdd')).toBeInTheDocument(); + expect(screen.queryByText('TeamList')).not.toBeInTheDocument(); + }); + + test('renders the detail subtree at /teams/:id', async () => { + renderAt('/teams/1/details'); + expect(await screen.findByText('Team detail')).toBeInTheDocument(); + expect(screen.queryByText('TeamList')).not.toBeInTheDocument(); }); }); diff --git a/awx/ui/src/screens/User/UserRoles/UserRolesList.js b/awx/ui/src/screens/User/UserRoles/UserRolesList.js index 809056452..2768e2917 100644 --- a/awx/ui/src/screens/User/UserRoles/UserRolesList.js +++ b/awx/ui/src/screens/User/UserRoles/UserRolesList.js @@ -193,6 +193,7 @@ function UserRolesList({ user }) { {showAddModal && ( { setShowAddModal(false); fetchRoles();