diff --git a/awx/ui/src/screens/Host/Host.js b/awx/ui/src/screens/Host/Host.js
index 3efb74358..2851807d6 100644
--- a/awx/ui/src/screens/Host/Host.js
+++ b/awx/ui/src/screens/Host/Host.js
@@ -2,14 +2,14 @@ import React, { useCallback, useEffect } from 'react';
import { useLingui } from '@lingui/react/macro';
+import { Link } from 'react-router-dom';
import {
- Switch,
+ Routes,
Route,
- Redirect,
- Link,
- useRouteMatch,
+ Navigate,
+ useParams,
useLocation,
-} from 'react-router-dom';
+} from 'react-router-dom-v5-compat';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core';
import RoutedTabs from 'components/RoutedTabs';
@@ -26,7 +26,7 @@ import HostGroups from './HostGroups';
function Host({ setBreadcrumb }) {
const { t } = useLingui();
const location = useLocation();
- const match = useRouteMatch('/hosts/:id');
+ const { id } = useParams();
const {
error,
isLoading,
@@ -34,10 +34,10 @@ function Host({ setBreadcrumb }) {
request: fetchHost,
} = useRequest(
useCallback(async () => {
- const { data } = await HostsAPI.readDetail(match.params.id);
+ const { data } = await HostsAPI.readDetail(id);
setBreadcrumb(data);
return data;
- }, [match.params.id, setBreadcrumb])
+ }, [id, setBreadcrumb])
);
useEffect(() => {
@@ -58,22 +58,22 @@ function Host({ setBreadcrumb }) {
},
{
name: t`Details`,
- link: `${match.url}/details`,
+ link: `/hosts/${id}/details`,
id: 0,
},
{
name: t`Facts`,
- link: `${match.url}/facts`,
+ link: `/hosts/${id}/facts`,
id: 1,
},
{
name: t`Groups`,
- link: `${match.url}/groups`,
+ link: `/hosts/${id}/groups`,
id: 2,
},
{
name: t`Jobs`,
- link: `${match.url}/jobs`,
+ link: `/hosts/${id}/jobs`,
id: 3,
},
];
@@ -115,33 +115,36 @@ function Host({ setBreadcrumb }) {
{showCardHeader && }
-
-
- {host && [
-
-
- ,
-
-
- ,
-
-
- ,
-
-
- ,
-
-
- ,
- ]}
-
-
-
- {t`View Host Details`}
-
-
-
-
+
+ } />
+ {host && (
+ } />
+ )}
+ {host && } />}
+ {host && (
+ } />
+ )}
+ {/* /* so the nested route tree can match the rest */}
+ {host && (
+ } />
+ )}
+ {host && (
+ }
+ />
+ )}
+
+
+ {t`View Host Details`}
+
+
+ }
+ />
+
);
diff --git a/awx/ui/src/screens/Host/Host.test.js b/awx/ui/src/screens/Host/Host.test.js
index cb013ab14..a46be4a2f 100644
--- a/awx/ui/src/screens/Host/Host.test.js
+++ b/awx/ui/src/screens/Host/Host.test.js
@@ -1,69 +1,120 @@
import React from 'react';
-import { Route } from 'react-router-dom';
-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 { HostsAPI } from 'api';
-import {
- mountWithContexts,
- waitForElement,
-} from '../../../testUtils/enzymeHelpers';
+import { renderWithContexts } from '../../../testUtils/rtlContexts';
import mockHost from './data.host.json';
import Host from './Host';
-jest.mock('../../api');
-jest.mock('react-router-dom', () => ({
- ...jest.requireActual('react-router-dom'),
- useRouteMatch: () => ({
- url: '/hosts/1',
- params: { id: 1 },
- }),
-}));
+jest.mock('../../api/models/Hosts');
-HostsAPI.readDetail.mockResolvedValue({
- data: { ...mockHost },
+// Markers for the routed tab panels, so assertions are about which branch of
+// the nested v6 tree resolves.
+jest.mock('./HostDetail', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostDetail'),
+ };
+});
+jest.mock('./HostEdit', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostEdit'),
+ };
+});
+jest.mock('./HostFacts', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostFacts'),
+ };
+});
+jest.mock('./HostGroups', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostGroups subtree'),
+ };
+});
+jest.mock('components/JobList', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'JobList'),
+ };
});
+// Host uses paths relative to its parent route, so mount it under the same
+// /hosts/:id/* route that Hosts.js gives it in the app.
+function renderAt(path) {
+ const history = createMemoryHistory({ initialEntries: [path] });
+ return renderWithContexts(
+
+ {}} />} />
+ ,
+ { context: { router: { history } } }
+ );
+}
+
describe('', () => {
- let wrapper;
- let history;
+ beforeEach(() => {
+ HostsAPI.readDetail.mockResolvedValue({ data: { ...mockHost } });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('fetches the host detail', async () => {
+ renderAt('/hosts/1/details');
+ expect(await screen.findByText('HostDetail')).toBeInTheDocument();
+ // real route params are strings (the old enzyme test mocked a number)
+ expect(HostsAPI.readDetail).toHaveBeenCalledWith('1');
+ });
+
+ test('renders the edit panel at /edit', async () => {
+ renderAt('/hosts/1/edit');
+ expect(await screen.findByText('HostEdit')).toBeInTheDocument();
+ });
+
+ test('renders the facts panel at /facts', async () => {
+ renderAt('/hosts/1/facts');
+ expect(await screen.findByText('HostFacts')).toBeInTheDocument();
+ });
+
+ test('renders the groups subtree at /groups', async () => {
+ renderAt('/hosts/1/groups');
+ expect(await screen.findByText('HostGroups subtree')).toBeInTheDocument();
+ });
- beforeEach(async () => {
- await act(async () => {
- wrapper = mountWithContexts(
-
- {}} />
-
- );
- });
+ test('renders the jobs panel at /jobs', async () => {
+ renderAt('/hosts/1/jobs');
+ expect(await screen.findByText('JobList')).toBeInTheDocument();
});
- test('should render expected tabs', async () => {
- const expectedTabs = ['Details', 'Facts', 'Groups', 'Completed Jobs'];
- wrapper.find('RoutedTabs li').forEach((tab, index) => {
- expect(tab.text()).toEqual(expectedTabs[index]);
- });
+ test('redirects the index path to details', async () => {
+ const { history } = renderAt('/hosts/1');
+ expect(await screen.findByText('HostDetail')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(history.location.pathname).toBe('/hosts/1/details')
+ );
});
- test('should show content error when api throws error on initial render', async () => {
- HostsAPI.readDetail.mockRejectedValueOnce(new Error());
- await act(async () => {
- wrapper = mountWithContexts( {}} />, {
- context: { router: { history } },
- });
- });
- await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
- await waitForElement(wrapper, 'ContentError', (el) => el.length === 1);
+ test('shows a not-found error on an unknown sub-route', async () => {
+ renderAt('/hosts/1/foobar');
+ expect(await screen.findByText('View Host Details')).toBeInTheDocument();
+ expect(screen.queryByText('HostDetail')).not.toBeInTheDocument();
});
- test('should show content error when user attempts to navigate to erroneous route', async () => {
- history = createMemoryHistory({
- initialEntries: ['/hosts/1/foobar'],
- });
- await act(async () => {
- wrapper = mountWithContexts( {}} />, {
- context: { router: { history } },
- });
- });
- 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 };
+ HostsAPI.readDetail.mockRejectedValue(err);
+ renderAt('/hosts/1/details');
+ expect(await screen.findByText('Host not found.')).toBeInTheDocument();
+ expect(screen.queryByText('HostDetail')).not.toBeInTheDocument();
});
});
diff --git a/awx/ui/src/screens/Host/HostGroups/HostGroups.js b/awx/ui/src/screens/Host/HostGroups/HostGroups.js
index eb8bf05d6..3bce620d6 100644
--- a/awx/ui/src/screens/Host/HostGroups/HostGroups.js
+++ b/awx/ui/src/screens/Host/HostGroups/HostGroups.js
@@ -1,15 +1,15 @@
import React from 'react';
-import { Switch, Route } from 'react-router-dom';
+import { Routes, Route } from 'react-router-dom-v5-compat';
+import ContentError from 'components/ContentError';
import HostGroupsList from './HostGroupsList';
function HostGroups({ host }) {
return (
-
-
-
-
-
+
+ } />
+ } />
+
);
}
diff --git a/awx/ui/src/screens/Host/HostGroups/HostGroups.test.js b/awx/ui/src/screens/Host/HostGroups/HostGroups.test.js
index 850a4965d..d1a3ed662 100644
--- a/awx/ui/src/screens/Host/HostGroups/HostGroups.test.js
+++ b/awx/ui/src/screens/Host/HostGroups/HostGroups.test.js
@@ -1,35 +1,42 @@
import React from 'react';
-import { act } from 'react-dom/test-utils';
+import { screen } from '@testing-library/react';
import { createMemoryHistory } from 'history';
-import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { Routes, Route } from 'react-router-dom-v5-compat';
+import { renderWithContexts } from '../../../../testUtils/rtlContexts';
import HostGroups from './HostGroups';
-jest.mock('../../../api');
+jest.mock('./HostGroupsList', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostGroupsList'),
+ };
+});
-describe('', () => {
- test('initially renders successfully', async () => {
- let wrapper;
- const history = createMemoryHistory({
- initialEntries: ['/hosts/1/groups'],
- });
- const host = {
- id: 1,
- name: 'Foo',
- summary_fields: { inventory: { id: 1 } },
- };
+const host = {
+ id: 1,
+ name: 'Foo',
+ summary_fields: { inventory: { id: 1 } },
+};
- await act(async () => {
- wrapper = mountWithContexts(
- {}} host={host} />,
+// HostGroups uses paths relative to its parent route, so mount it under the
+// same /hosts/:id/groups/* route that Host.js gives it in the app.
+function renderAt(path) {
+ const history = createMemoryHistory({ initialEntries: [path] });
+ return renderWithContexts(
+
+ {}} host={host} />}
+ />
+ ,
+ { context: { router: { history } } }
+ );
+}
- {
- context: {
- router: { history, route: { location: history.location } },
- },
- }
- );
- });
- expect(wrapper.length).toBe(1);
- expect(wrapper.find('HostGroupsList').length).toBe(1);
+describe('', () => {
+ test('renders the host groups list at the index path', async () => {
+ renderAt('/hosts/1/groups');
+ expect(await screen.findByText('HostGroupsList')).toBeInTheDocument();
});
});
diff --git a/awx/ui/src/screens/Host/HostGroups/HostGroupsList.js b/awx/ui/src/screens/Host/HostGroups/HostGroupsList.js
index aeb450f00..4568d00af 100644
--- a/awx/ui/src/screens/Host/HostGroups/HostGroupsList.js
+++ b/awx/ui/src/screens/Host/HostGroups/HostGroupsList.js
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
-import { useParams, useLocation } from 'react-router-dom';
+import { useLocation } from 'react-router-dom';
+import { useParams } from 'react-router-dom-v5-compat';
import { useLingui } from '@lingui/react/macro';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
diff --git a/awx/ui/src/screens/Host/HostList/HostList.js b/awx/ui/src/screens/Host/HostList/HostList.js
index 68baccd3e..3e1239f98 100644
--- a/awx/ui/src/screens/Host/HostList/HostList.js
+++ b/awx/ui/src/screens/Host/HostList/HostList.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 { useNavigate } from 'react-router-dom-v5-compat';
import { useLingui } from '@lingui/react/macro';
@@ -33,7 +33,6 @@ function HostList() {
const { t } = useLingui();
const navigate = useNavigate();
const location = useLocation();
- const match = useRouteMatch();
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
const nonDefaultSearchParams = {};
@@ -186,7 +185,7 @@ function HostList() {
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
- ? []
+ ? []
: []),
row.id === host.id)}
onSelect={() => handleSelect(host)}
rowIndex={index}
@@ -223,7 +222,7 @@ function HostList() {
)}
emptyStateControls={
canAdd ? (
-
+
) : null
}
/>
diff --git a/awx/ui/src/screens/Host/Hosts.js b/awx/ui/src/screens/Host/Hosts.js
index 3c9fbf18d..bfa64e1ae 100644
--- a/awx/ui/src/screens/Host/Hosts.js
+++ b/awx/ui/src/screens/Host/Hosts.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';
@@ -39,23 +39,28 @@ function Hosts() {
return (
<>
-
-
-
-
-
-
- {({ me }) => (
-
- )}
-
-
-
-
-
-
-
-
+
+ } />
+ {/* /* so the nested route tree can match the rest */}
+
+ {({ me }) => (
+
+ )}
+
+ }
+ />
+
+
+
+ }
+ />
+
>
);
}
diff --git a/awx/ui/src/screens/Host/Hosts.test.js b/awx/ui/src/screens/Host/Hosts.test.js
index ea34a7cb6..c86656c99 100644
--- a/awx/ui/src/screens/Host/Hosts.test.js
+++ b/awx/ui/src/screens/Host/Hosts.test.js
@@ -1,44 +1,57 @@
import React from 'react';
+import { screen } from '@testing-library/react';
import { createMemoryHistory } from 'history';
-import { shallow } from 'enzyme';
-import { act } from 'react-dom/test-utils';
-
-import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
-
+import { renderWithContexts } from '../../../testUtils/rtlContexts';
import Hosts from './Hosts';
-jest.mock('../../api');
+jest.mock('../../api/models/Hosts');
-describe('', () => {
- test('should display a breadcrumb heading', () => {
- const wrapper = mountWithContexts();
+// 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('./HostList', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostList'),
+ };
+});
+jest.mock('./HostAdd', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'HostAdd'),
+ };
+});
+jest.mock('./Host', () => {
+ const ReactLib = require('react');
+ return {
+ __esModule: true,
+ default: () => ReactLib.createElement('div', null, 'Host detail'),
+ };
+});
- const header = wrapper.find('ScreenHeader');
- expect(header.prop('streamType')).toEqual('host');
- expect(header.prop('breadcrumbConfig')).toEqual({
- '/hosts': 'Hosts',
- '/hosts/add': 'Create New Host',
- });
+function renderAt(path) {
+ const history = createMemoryHistory({ initialEntries: [path] });
+ return renderWithContexts(, {
+ context: { router: { history } },
});
+}
- test('should render Host component', async () => {
- let wrapper;
- const history = createMemoryHistory({
- initialEntries: ['/hosts/1'],
- });
-
- const match = {
- path: '/hosts/:id',
- url: '/hosts/1',
- isExact: true,
- };
+describe('', () => {
+ test('renders the list at /hosts', async () => {
+ renderAt('/hosts');
+ expect(await screen.findByText('HostList')).toBeInTheDocument();
+ });
- await act(async () => {
- wrapper = await mountWithContexts(, {
- context: { router: { history, route: { match } } },
- });
- });
+ test('renders the add form at /hosts/add', async () => {
+ renderAt('/hosts/add');
+ expect(await screen.findByText('HostAdd')).toBeInTheDocument();
+ expect(screen.queryByText('HostList')).not.toBeInTheDocument();
+ });
- expect(wrapper.find('Host').length).toBe(1);
+ test('renders the detail subtree at /hosts/:id', async () => {
+ renderAt('/hosts/1/details');
+ expect(await screen.findByText('Host detail')).toBeInTheDocument();
+ expect(screen.queryByText('HostList')).not.toBeInTheDocument();
});
});