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
81 changes: 42 additions & 39 deletions awx/ui/src/screens/Host/Host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,18 +26,18 @@ import HostGroups from './HostGroups';
function Host({ setBreadcrumb }) {
const { t } = useLingui();
const location = useLocation();
const match = useRouteMatch('/hosts/:id');
const { id } = useParams();
const {
error,
isLoading,
result: host,
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(() => {
Expand All @@ -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,
},
];
Expand Down Expand Up @@ -115,33 +115,36 @@ function Host({ setBreadcrumb }) {
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
<Switch>
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
{host && [
<Route path="/hosts/:id/details" key="details">
<HostDetail host={host} />
</Route>,
<Route path="/hosts/:id/edit" key="edit">
<HostEdit host={host} />
</Route>,
<Route key="facts" path="/hosts/:id/facts">
<HostFacts host={host} />
</Route>,
<Route path="/hosts/:id/groups" key="groups">
<HostGroups host={host} />
</Route>,
<Route path="/hosts/:id/jobs" key="jobs">
<JobList defaultParams={{ job__hosts: host.id }} />
</Route>,
]}
<Route key="not-found" path="*">
<ContentError isNotFound>
<Link to={`${match.url}/details`}>
{t`View Host Details`}
</Link>
</ContentError>
</Route>
</Switch>
<Routes>
<Route index element={<Navigate to="details" replace />} />
{host && (
<Route path="details" element={<HostDetail host={host} />} />
)}
{host && <Route path="edit" element={<HostEdit host={host} />} />}
{host && (
<Route path="facts" element={<HostFacts host={host} />} />
)}
{/* /* so the nested <HostGroups> route tree can match the rest */}
{host && (
<Route path="groups/*" element={<HostGroups host={host} />} />
)}
{host && (
<Route
path="jobs"
element={<JobList defaultParams={{ job__hosts: host.id }} />}
/>
)}
<Route
path="*"
element={
<ContentError isNotFound>
<Link to={`/hosts/${id}/details`}>
{t`View Host Details`}
</Link>
</ContentError>
}
/>
</Routes>
</Card>
</PageSection>
);
Expand Down
151 changes: 101 additions & 50 deletions awx/ui/src/screens/Host/Host.test.js
Original file line number Diff line number Diff line change
@@ -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 <Routes> 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(
<Routes>
<Route path="/hosts/:id/*" element={<Host setBreadcrumb={() => {}} />} />
</Routes>,
{ context: { router: { history } } }
);
}

describe('<Host />', () => {
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(
<Route path="/hosts/:id/details">
<Host setBreadcrumb={() => {}} />
</Route>
);
});
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(<Host setBreadcrumb={() => {}} />, {
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(<Host setBreadcrumb={() => {}} />, {
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();
});
});
12 changes: 6 additions & 6 deletions awx/ui/src/screens/Host/HostGroups/HostGroups.js
Original file line number Diff line number Diff line change
@@ -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 (
<Switch>
<Route key="list" path="/hosts/:id/groups">
<HostGroupsList host={host} />
</Route>
</Switch>
<Routes>
<Route index element={<HostGroupsList host={host} />} />
<Route path="*" element={<ContentError isNotFound />} />
</Routes>
);
}

Expand Down
59 changes: 33 additions & 26 deletions awx/ui/src/screens/Host/HostGroups/HostGroups.test.js
Original file line number Diff line number Diff line change
@@ -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('<HostGroups />', () => {
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(
<HostGroups setBreadcrumb={() => {}} 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(
<Routes>
<Route
path="/hosts/:id/groups/*"
element={<HostGroups setBreadcrumb={() => {}} host={host} />}
/>
</Routes>,
{ context: { router: { history } } }
);
}

{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('HostGroupsList').length).toBe(1);
describe('<HostGroups />', () => {
test('renders the host groups list at the index path', async () => {
renderAt('/hosts/1/groups');
expect(await screen.findByText('HostGroupsList')).toBeInTheDocument();
});
});
Loading