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(); }); });