Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]),
{}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('<UserAndTeamAccessAdd/>', () => {
wrapper = mountWithContexts(
<UserAndTeamAccessAdd
apiModel={UsersAPI}
resourceId={99}
onFetchData={() => {}}
onClose={onClose}
title="Add user permissions"
Expand Down Expand Up @@ -171,7 +172,12 @@ describe('<UserAndTeamAccessAdd/>', () => {
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 () => {
Expand Down
73 changes: 39 additions & 34 deletions awx/ui/src/screens/Team/Team.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -87,42 +87,47 @@ function Team({ setBreadcrumb }) {
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
<Switch>
<Redirect from="/teams/:id" to="/teams/:id/details" exact />
<Routes>
<Route index element={<Navigate to="details" replace />} />
{team && (
<Route path="/teams/:id/details">
<TeamDetail team={team} />
</Route>
<Route path="details" element={<TeamDetail team={team} />} />
)}
{team && <Route path="edit" element={<TeamEdit team={team} />} />}
{team && (
<Route path="/teams/:id/edit">
<TeamEdit team={team} />
</Route>
<Route
path="access"
element={
<ResourceAccessList resource={team} apiModel={TeamsAPI} />
}
/>
)}
{team && (
<Route path="/teams/:id/access">
<ResourceAccessList resource={team} apiModel={TeamsAPI} />
</Route>
<Route
path="roles"
element={
<Config>
{({ me }) => (
<>{me && <TeamRolesList me={me} team={team} />}</>
)}
</Config>
}
/>
)}
{team && (
<Route path="/teams/:id/roles">
<Config>
{({ me }) => <>{me && <TeamRolesList me={me} team={team} />}</>}
</Config>
</Route>
)}
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>
{id && (
<Link to={`/teams/${id}/details`}>
{t`View Team Details`}
</Link>
)}
</ContentError>
)}
</Route>
</Switch>
<Route
path="*"
element={
!hasContentLoading ? (
<ContentError isNotFound>
{id && (
<Link to={`/teams/${id}/details`}>
{t`View Team Details`}
</Link>
)}
</ContentError>
) : null
}
/>
</Routes>
</Card>
</PageSection>
);
Expand Down
149 changes: 91 additions & 58 deletions awx/ui/src/screens/Team/Team.test.js
Original file line number Diff line number Diff line change
@@ -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 <Routes> 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(
<Routes>
<Route path="/teams/:id/*" element={<Team setBreadcrumb={() => {}} />} />
</Routes>,
{ context: { router: { history } } }
);
}

describe('<Team />', () => {
let wrapper;

beforeEach(() => {
TeamsAPI.readDetail.mockResolvedValue({ data: mockTeam });
TeamsAPI.read.mockImplementation(getTeams);
});

test('initially renders successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<Team setBreadcrumb={() => {}} 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(
<Team setBreadcrumb={() => {}} 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();
});
});
4 changes: 2 additions & 2 deletions awx/ui/src/screens/Team/TeamDetail/TeamDetail.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
9 changes: 4 additions & 5 deletions awx/ui/src/screens/Team/TeamList/TeamList.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,7 +30,6 @@ const QS_CONFIG = getQSConfig('team', {
function TeamList() {
const { t } = useLingui();
const location = useLocation();
const match = useRouteMatch();

const {
result: {
Expand Down Expand Up @@ -156,7 +155,7 @@ function TeamList() {
? [
<ToolbarAddButton
key="add"
linkTo={`${match.url}/add`}
linkTo="/teams/add"
/>,
]
: []),
Expand All @@ -173,15 +172,15 @@ function TeamList() {
<TeamListItem
key={team.id}
team={team}
detailUrl={`${match.url}/${team.id}`}
detailUrl={`/teams/${team.id}`}
isSelected={selected.some((row) => row.id === team.id)}
onSelect={() => handleSelect(team)}
rowIndex={index}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
<ToolbarAddButton key="add" linkTo="/teams/add" />
) : null
}
/>
Expand Down
1 change: 1 addition & 0 deletions awx/ui/src/screens/Team/TeamRoles/TeamRolesList.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ function TeamRolesList({ me, team }) {
{showAddModal && (
<UserAndTeamAccessAdd
apiModel={TeamsAPI}
resourceId={team.id}
onFetchData={() => {
setShowAddModal(false);
fetchRoles();
Expand Down
Loading