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
73 changes: 30 additions & 43 deletions awx/ui/src/screens/Credential/Credential.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ import { useLingui } from '@lingui/react/macro';

import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import {
Switch,
Routes,
Route,
Navigate,
useParams,
useLocation,
useRouteMatch,
Route,
Redirect,
Link,
} from 'react-router-dom';
} from 'react-router-dom-v5-compat';
import useRequest from 'hooks/useRequest';
import { ResourceAccessList } from 'components/ResourceAccessList';
import ContentError from 'components/ContentError';
Expand Down Expand Up @@ -40,10 +39,6 @@ const unacceptableCredentialTypes = [
function Credential({ setBreadcrumb }) {
const { t } = useLingui();
const { pathname } = useLocation();

const match = useRouteMatch({
path: '/credentials/:id',
});
const { id } = useParams();

const {
Expand Down Expand Up @@ -135,55 +130,47 @@ function Credential({ setBreadcrumb }) {
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
{!hasContentLoading && credential && (
<Switch>
<Redirect
from="/credentials/:id"
to="/credentials/:id/details"
exact
<Routes>
<Route index element={<Navigate to="details" replace />} />
<Route
path="details"
element={<CredentialDetail credential={credential} />}
/>
<Route
path="edit"
element={<CredentialEdit credential={credential} />}
/>
{credential && [
<Route key="details" path="/credentials/:id/details">
<CredentialDetail credential={credential} />
</Route>,
<Route key="edit" path="/credentials/:id/edit">
<CredentialEdit credential={credential} />
</Route>,
<Route key="access" path="/credentials/:id/access">
<Route
path="access"
element={
<ResourceAccessList
resource={credential}
apiModel={CredentialsAPI}
/>
</Route>,
<Route key="job_templates" path="/credentials/:id/job_templates">
}
/>
<Route
path="job_templates"
element={
<RelatedTemplateList
searchParams={{ credentials__id: credential.id }}
resourceName={[credential.name, credential.kind]}
/>
</Route>,
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/credentials/${match.params.id}/details`}>
{t`View Credential Details`}
</Link>
)}
</ContentError>
)}
</Route>,
]}
<Route key="not-found" path="*">
{!hasContentLoading && (
}
/>
<Route
path="*"
element={
<ContentError isNotFound>
{id && (
<Link to={`/credentials/${id}/details`}>
{t`View Credential Details`}
</Link>
)}
</ContentError>
)}
</Route>
</Switch>
}
/>
</Routes>
)}
</Card>
</PageSection>
Expand Down
184 changes: 103 additions & 81 deletions awx/ui/src/screens/Credential/Credential.test.js
Original file line number Diff line number Diff line change
@@ -1,107 +1,129 @@
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 { CredentialsAPI } from 'api';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { renderWithContexts } from '../../../testUtils/rtlContexts';
import mockMachineCredential from './shared/data.machineCredential.json';
import mockSCMCredential from './shared/data.scmCredential.json';
import mockCyberArkCredential from './shared/data.cyberArkCredential.json';
import Credential from './Credential';

jest.mock('../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
url: '/credentials/2',
params: { id: 2 },
}),
}));
jest.mock('../../api/models/Credentials');

// Markers for the routed tab panels, so assertions are about which branch of
// the nested v6 <Routes> tree resolves.
jest.mock('./CredentialDetail', () => {
const ReactLib = require('react');
return {
__esModule: true,
default: () => ReactLib.createElement('div', null, 'CredentialDetail'),
};
});
jest.mock('./CredentialEdit', () => {
const ReactLib = require('react');
return {
__esModule: true,
default: () => ReactLib.createElement('div', null, 'CredentialEdit'),
};
});
jest.mock('components/RelatedTemplateList', () => {
const ReactLib = require('react');
return {
__esModule: true,
default: () => ReactLib.createElement('div', null, 'RelatedTemplateList'),
};
});
jest.mock('components/ResourceAccessList', () => {
const ReactLib = require('react');
return {
ResourceAccessList: () =>
ReactLib.createElement('div', null, 'ResourceAccessList'),
};
});

// Credential uses paths relative to its parent route, so mount it under the
// same /credentials/:id/* route that Credentials.js gives it in the app.
function renderAt(path) {
const history = createMemoryHistory({ initialEntries: [path] });
return renderWithContexts(
<Routes>
<Route
path="/credentials/:id/*"
element={<Credential setBreadcrumb={() => {}} />}
/>
</Routes>,
{ context: { router: { history } } }
);
}

describe('<Credential />', () => {
let wrapper;
beforeEach(() => {
CredentialsAPI.readDetail.mockResolvedValue({ data: mockMachineCredential });
});

afterEach(() => {
jest.clearAllMocks();
});

wrapper.unmount();
test('fetches the credential detail', async () => {
renderAt('/credentials/2/details');
expect(await screen.findByText('CredentialDetail')).toBeInTheDocument();
// real route params are strings (the old enzyme test mocked a number)
expect(CredentialsAPI.readDetail).toHaveBeenCalledWith('2');
});

test('initially renders user-based machine credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockMachineCredential,
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.update();
expect(wrapper.find('Credential').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(4);
test('renders the edit panel at /edit', async () => {
renderAt('/credentials/2/edit');
expect(await screen.findByText('CredentialEdit')).toBeInTheDocument();
});

test('initially renders user-based SCM credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockSCMCredential,
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.update();
expect(wrapper.find('Credential').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(3);
test('renders the access panel at /access', async () => {
renderAt('/credentials/2/access');
expect(await screen.findByText('ResourceAccessList')).toBeInTheDocument();
});

test('should render expected tabs', async () => {
const expectedTabs = [
'Back to Credentials',
'Details',
'Access',
'Job Templates',
];
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
test('renders the job templates panel at /job_templates', async () => {
renderAt('/credentials/2/job_templates');
expect(await screen.findByText('RelatedTemplateList')).toBeInTheDocument();
});

test('redirects the index path to details', async () => {
const { history } = renderAt('/credentials/2');
expect(await screen.findByText('CredentialDetail')).toBeInTheDocument();
await waitFor(() =>
expect(history.location.pathname).toBe('/credentials/2/details')
);
});

test('shows the Job Templates tab for an acceptable credential kind', async () => {
renderAt('/credentials/2/details');
expect(await screen.findByText('CredentialDetail')).toBeInTheDocument();
expect(screen.getByText('Job Templates')).toBeInTheDocument();
});

test('should not render job template tab', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
test('hides the Job Templates tab for a registry credential', async () => {
CredentialsAPI.readDetail.mockResolvedValue({
data: { ...mockCyberArkCredential, kind: 'registry' },
});
const expectedTabs = ['Back to Credentials', 'Details', 'Access'];
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
renderAt('/credentials/2/details');
expect(await screen.findByText('CredentialDetail')).toBeInTheDocument();
expect(screen.queryByText('Job Templates')).not.toBeInTheDocument();
});

test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/credentials/2/foobar'],
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, {
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/credentials/2/foobar',
path: '/credentials/2/foobar',
},
},
},
},
});
});
await waitForElement(wrapper, 'ContentError', (el) => el.length === 1);
test('shows a not-found error on an unknown sub-route', async () => {
renderAt('/credentials/2/foobar');
expect(
await screen.findByText('View Credential Details')
).toBeInTheDocument();
expect(screen.queryByText('CredentialDetail')).not.toBeInTheDocument();
});

test('shows a not-found error when the detail request 404s', async () => {
const err = new Error('not found');
err.response = { status: 404 };
CredentialsAPI.readDetail.mockRejectedValue(err);
renderAt('/credentials/2/details');
expect(await screen.findByText('Credential not found.')).toBeInTheDocument();
expect(screen.queryByText('CredentialDetail')).not.toBeInTheDocument();
});
});
describe('<Credential> should not show job template tab', () => {});
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom-v5-compat';
import { useNavigate, useParams } from 'react-router-dom-v5-compat';
import { CardBody } from 'components/Card';
import {
CredentialsAPI,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
import CredentialEdit from './CredentialEdit';

jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
// The component reads useParams from react-router-dom-v5-compat (the route
// tree is v6); mock it there, keeping the rest of the module real.
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useParams: () => ({
id: 3,
}),
Expand Down
36 changes: 22 additions & 14 deletions awx/ui/src/screens/Credential/Credentials.js
Original file line number Diff line number Diff line change
@@ -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';
import { Config } from 'contexts/Config';
Expand Down Expand Up @@ -41,19 +41,27 @@ function Credentials() {
streamType="credential"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch>
<Route path="/credentials/add">
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
</Route>
<Route path="/credentials/:id">
<Credential setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/credentials">
<PersistentFilters pageKey="credentials">
<CredentialList />
</PersistentFilters>
</Route>
</Switch>
<Routes>
<Route
path="/credentials/add"
element={
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
}
/>
{/* so the nested <Credential> route tree can match the rest */}
<Route
path="/credentials/:id/*"
element={<Credential setBreadcrumb={buildBreadcrumbConfig} />}
/>
<Route
path="/credentials"
element={
<PersistentFilters pageKey="credentials">
<CredentialList />
</PersistentFilters>
}
/>
</Routes>
</>
);
}
Expand Down
Loading