Add AppContainer and move bootstrapping to App component

This commit is contained in:
Jake McDermott 2020-06-02 14:51:10 -04:00
parent 990eead3ac
commit cb453de6a4
No known key found for this signature in database
GPG Key ID: 0E56ED990CDFCB4F
18 changed files with 363 additions and 439 deletions

View File

@ -1,216 +1,82 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { global_breakpoint_md } from '@patternfly/react-tokens';
import React from 'react';
import {
Nav,
NavList,
Page,
PageHeader as PFPageHeader,
PageSidebar,
} from '@patternfly/react-core';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
useRouteMatch,
useLocation,
HashRouter,
Route,
Switch,
Redirect,
} from 'react-router-dom';
import { I18n, I18nProvider } from '@lingui/react';
import { ConfigAPI, MeAPI, RootAPI } from './api';
import About from './components/About';
import AlertModal from './components/AlertModal';
import NavExpandableGroup from './components/NavExpandableGroup';
import BrandLogo from './components/BrandLogo';
import PageHeaderToolbar from './components/PageHeaderToolbar';
import ErrorDetail from './components/ErrorDetail';
import { ConfigProvider } from './contexts/Config';
import AppContainer from './components/AppContainer';
import Background from './components/Background';
import NotFound from './screens/NotFound';
import Login from './screens/Login';
const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
import ja from './locales/ja/messages';
import en from './locales/en/messages';
import { isAuthenticated } from './util/auth';
import { getLanguageWithoutRegionCode } from './util/language';
&:hover {
color: inherit;
}
import getRouteConfig from './routeConfig';
& svg {
height: 76px;
}
}
`;
const ProtectedRoute = ({ children, ...rest }) =>
isAuthenticated(document.cookie) ? (
<Route {...rest}>{children}</Route>
) : (
<Redirect to="/login" />
);
class App extends Component {
constructor(props) {
super(props);
function App() {
const catalogs = { en, ja };
const language = getLanguageWithoutRegionCode(navigator);
const match = useRouteMatch();
const { hash, search, pathname } = useLocation();
// initialize with a closed navbar if window size is small
const isNavOpen =
typeof window !== 'undefined' &&
window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
this.state = {
ansible_version: null,
custom_virtualenvs: null,
me: null,
version: null,
isAboutModalOpen: false,
isNavOpen,
configError: null,
};
this.handleLogout = this.handleLogout.bind(this);
this.handleAboutClose = this.handleAboutClose.bind(this);
this.handleAboutOpen = this.handleAboutOpen.bind(this);
this.handleNavToggle = this.handleNavToggle.bind(this);
this.handleConfigErrorClose = this.handleConfigErrorClose.bind(this);
}
async componentDidMount() {
await this.loadConfig();
}
// eslint-disable-next-line class-methods-use-this
async handleLogout() {
const { history } = this.props;
await RootAPI.logout();
history.replace('/login');
}
handleAboutOpen() {
this.setState({ isAboutModalOpen: true });
}
handleAboutClose() {
this.setState({ isAboutModalOpen: false });
}
handleNavToggle() {
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
}
handleConfigErrorClose() {
this.setState({
configError: null,
});
}
async loadConfig() {
try {
const [configRes, meRes] = await Promise.all([
ConfigAPI.read(),
MeAPI.read(),
]);
const {
data: {
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
version,
},
} = configRes;
const {
data: {
results: [me],
},
} = meRes;
this.setState({
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
version,
me,
});
} catch (err) {
this.setState({ configError: err });
}
}
render() {
const {
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
isAboutModalOpen,
isNavOpen,
me,
version,
configError,
} = this.state;
const { i18n, routeConfig = [], navLabel = '', children } = this.props;
const header = (
<PageHeader
showNavToggle
onNavToggle={this.handleNavToggle}
logo={<BrandLogo />}
logoProps={{ href: '/' }}
toolbar={
<PageHeaderToolbar
loggedInUser={me}
isAboutDisabled={!version}
onAboutClick={this.handleAboutOpen}
onLogoutClick={this.handleLogout}
/>
}
/>
);
const sidebar = (
<PageSidebar
isNavOpen={isNavOpen}
theme="dark"
nav={
<Nav aria-label={navLabel} theme="dark">
<NavList>
{routeConfig.map(({ groupId, groupTitle, routes }) => (
<NavExpandableGroup
key={groupId}
groupId={groupId}
groupTitle={groupTitle}
routes={routes}
/>
))}
</NavList>
</Nav>
}
/>
);
return (
<Fragment>
<Page usecondensed="True" header={header} sidebar={sidebar}>
<ConfigProvider
value={{
ansible_version,
custom_virtualenvs,
project_base_dir,
project_local_paths,
me,
version,
}}
>
{children}
</ConfigProvider>
</Page>
<About
ansible_version={ansible_version}
version={version}
isOpen={isAboutModalOpen}
onClose={this.handleAboutClose}
/>
<AlertModal
isOpen={configError}
variant="error"
title={i18n._(t`Error!`)}
onClose={this.handleConfigErrorClose}
>
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal>
</Fragment>
);
}
return (
<I18nProvider language={language} catalogs={catalogs}>
<I18n>
{({ i18n }) => (
<Background>
<Switch>
<Route exact strict path="/*/">
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
</Route>
<Route path="/login">
<Login isAuthenticated={isAuthenticated} />
</Route>
<Route exact path="/">
<Redirect to="/home" />
</Route>
<ProtectedRoute>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<Switch>
{getRouteConfig(i18n)
.flatMap(({ routes }) => routes)
.map(({ path, screen: Screen }) => (
<ProtectedRoute auth key={path} path={path}>
<Screen match={match} />
</ProtectedRoute>
))
.concat(
<ProtectedRoute auth key="not-found" path="*">
<NotFound />
</ProtectedRoute>
)}
</Switch>
</AppContainer>
</ProtectedRoute>
</Switch>
</Background>
)}
</I18n>
</I18nProvider>
);
}
export { App as _App };
export default withI18n()(withRouter(App));
export default () => (
<HashRouter>
<App />
</HashRouter>
);

View File

@ -1,120 +1,14 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '../testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from './api';
import { asyncFlush } from './setupTests';
import { mountWithContexts } from '../testUtils/enzymeHelpers';
import App from './App';
jest.mock('./api');
describe('<App />', () => {
const ansible_version = '111';
const custom_virtualenvs = [];
const version = '222';
beforeEach(() => {
ConfigAPI.read = () =>
Promise.resolve({
data: {
ansible_version,
custom_virtualenvs,
version,
},
});
MeAPI.read = () => Promise.resolve({ data: { results: [{}] } });
});
afterEach(() => {
jest.clearAllMocks();
});
test('expected content is rendered', () => {
const routeConfig = [
{
groupTitle: 'Group One',
groupId: 'group_one',
routes: [
{ title: 'Foo', path: '/foo' },
{ title: 'Bar', path: '/bar' },
],
},
{
groupTitle: 'Group Two',
groupId: 'group_two',
routes: [{ title: 'Fiz', path: '/fiz' }],
},
];
const appWrapper = mountWithContexts(
<App routeConfig={routeConfig}>
{routeConfig.map(({ groupId }) => (
<div key={groupId} id={groupId} />
))}
</App>
);
// page components
expect(appWrapper.length).toBe(1);
expect(appWrapper.find('PageHeader').length).toBe(1);
expect(appWrapper.find('PageSidebar').length).toBe(1);
// sidebar groups and route links
expect(appWrapper.find('NavExpandableGroup').length).toBe(2);
expect(appWrapper.find('a[href="/#/foo"]').length).toBe(1);
expect(appWrapper.find('a[href="/#/bar"]').length).toBe(1);
expect(appWrapper.find('a[href="/#/fiz"]').length).toBe(1);
expect(appWrapper.find('#group_one').length).toBe(1);
expect(appWrapper.find('#group_two').length).toBe(1);
});
test('opening the about modal renders prefetched config data', async done => {
test('renders ok', () => {
const wrapper = mountWithContexts(<App />);
wrapper.update();
// open about modal
const aboutDropdown = 'Dropdown QuestionCircleIcon';
const aboutButton = 'DropdownItem li button';
const aboutModalContent = 'AboutModalBoxContent';
const aboutModalClose = 'button[aria-label="Close Dialog"]';
await waitForElement(wrapper, aboutDropdown);
wrapper.find(aboutDropdown).simulate('click');
const button = await waitForElement(
wrapper,
aboutButton,
el => !el.props().disabled
);
button.simulate('click');
// check about modal content
const content = await waitForElement(wrapper, aboutModalContent);
expect(content.find('dd').text()).toContain(ansible_version);
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
// close about modal
wrapper.find(aboutModalClose).simulate('click');
expect(wrapper.find(aboutModalContent)).toHaveLength(0);
done();
});
test('handleNavToggle sets state.isNavOpen to opposite', () => {
const appWrapper = mountWithContexts(<App />).find('App');
const { handleNavToggle } = appWrapper.instance();
[true, false, true, false, true].forEach(expected => {
expect(appWrapper.state().isNavOpen).toBe(expected);
handleNavToggle();
});
});
test('onLogout makes expected call to api client', async done => {
const appWrapper = mountWithContexts(<App />).find('App');
appWrapper.instance().handleLogout();
await asyncFlush();
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
done();
expect(wrapper.length).toBe(1);
});
});

View File

@ -1,27 +0,0 @@
import React, { Component } from 'react';
import { I18nProvider } from '@lingui/react';
import { HashRouter } from 'react-router-dom';
import { getLanguageWithoutRegionCode } from './util/language';
import ja from './locales/ja/messages';
import en from './locales/en/messages';
class RootProvider extends Component {
render() {
const { children } = this.props;
const catalogs = { en, ja };
const language = getLanguageWithoutRegionCode(navigator);
return (
<HashRouter>
<I18nProvider language={language} catalogs={catalogs}>
{children}
</I18nProvider>
</HashRouter>
);
}
}
export default RootProvider;

View File

@ -4,6 +4,7 @@ class Config extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/config/';
this.read = this.read.bind(this);
}
}

View File

@ -0,0 +1,142 @@
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation, withRouter } from 'react-router-dom';
import { global_breakpoint_md } from '@patternfly/react-tokens';
import {
Nav,
NavList,
Page,
PageHeader as PFPageHeader,
PageSidebar,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import styled from 'styled-components';
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import { ConfigProvider } from '../../contexts/Config';
import About from '../About';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import BrandLogo from './BrandLogo';
import NavExpandableGroup from './NavExpandableGroup';
import PageHeaderToolbar from './PageHeaderToolbar';
const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
&:hover {
color: inherit;
}
& svg {
height: 76px;
}
}
`;
function AppContainer({ i18n, navRouteConfig = [], children }) {
const history = useHistory();
const { pathname } = useLocation();
const [config, setConfig] = useState({});
const [configError, setConfigError] = useState(null);
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
const [isNavOpen, setIsNavOpen] = useState(
typeof window !== 'undefined' &&
window.innerWidth >= parseInt(global_breakpoint_md.value, 10)
);
const handleAboutModalOpen = () => setIsAboutModalOpen(true);
const handleAboutModalClose = () => setIsAboutModalOpen(false);
const handleConfigErrorClose = () => setConfigError(null);
const handleNavToggle = () => setIsNavOpen(!isNavOpen);
const handleLogout = async () => {
await RootAPI.logout();
history.replace('/login');
};
useEffect(() => {
const loadConfig = async () => {
if (config?.version) return;
try {
const [
{ data },
{
data: {
results: [me],
},
},
] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
setConfig({ ...data, me });
} catch (err) {
setConfigError(err);
}
};
loadConfig();
}, [config, pathname]);
const header = (
<PageHeader
showNavToggle
onNavToggle={handleNavToggle}
logo={<BrandLogo />}
logoProps={{ href: '/' }}
toolbar={
<PageHeaderToolbar
loggedInUser={config?.me}
isAboutDisabled={!config?.version}
onAboutClick={handleAboutModalOpen}
onLogoutClick={handleLogout}
/>
}
/>
);
const sidebar = (
<PageSidebar
isNavOpen={isNavOpen}
theme="dark"
nav={
<Nav aria-label={i18n._(t`Navigation`)} theme="dark">
<NavList>
{navRouteConfig.map(({ groupId, groupTitle, routes }) => (
<NavExpandableGroup
key={groupId}
groupId={groupId}
groupTitle={groupTitle}
routes={routes}
/>
))}
</NavList>
</Nav>
}
/>
);
return (
<>
<Page usecondensed="True" header={header} sidebar={sidebar}>
<ConfigProvider value={config}>{children}</ConfigProvider>
</Page>
<About
ansible_version={config?.ansible_version}
version={config?.version}
isOpen={isAboutModalOpen}
onClose={handleAboutModalClose}
/>
<AlertModal
isOpen={configError}
variant="error"
title={i18n._(t`Error!`)}
onClose={handleConfigErrorClose}
>
{i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal>
</>
);
}
export { AppContainer as _AppContainer };
export default withI18n()(withRouter(AppContainer));

View File

@ -0,0 +1,125 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import AppContainer from './AppContainer';
jest.mock('../../api');
describe('<AppContainer />', () => {
const ansible_version = '111';
const custom_virtualenvs = [];
const version = '222';
beforeEach(() => {
ConfigAPI.read.mockResolvedValue({
data: {
ansible_version,
custom_virtualenvs,
version,
},
});
MeAPI.read.mockResolvedValue({ data: { results: [{}] } });
});
afterEach(() => {
jest.clearAllMocks();
});
test('expected content is rendered', async () => {
const routeConfig = [
{
groupTitle: 'Group One',
groupId: 'group_one',
routes: [
{ title: 'Foo', path: '/foo' },
{ title: 'Bar', path: '/bar' },
],
},
{
groupTitle: 'Group Two',
groupId: 'group_two',
routes: [{ title: 'Fiz', path: '/fiz' }],
},
];
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<AppContainer navRouteConfig={routeConfig}>
{routeConfig.map(({ groupId }) => (
<div key={groupId} id={groupId} />
))}
</AppContainer>
);
});
// page components
expect(wrapper.length).toBe(1);
expect(wrapper.find('PageHeader').length).toBe(1);
expect(wrapper.find('PageSidebar').length).toBe(1);
// sidebar groups and route links
expect(wrapper.find('NavExpandableGroup').length).toBe(2);
expect(wrapper.find('a[href="/#/foo"]').length).toBe(1);
expect(wrapper.find('a[href="/#/bar"]').length).toBe(1);
expect(wrapper.find('a[href="/#/fiz"]').length).toBe(1);
expect(wrapper.find('#group_one').length).toBe(1);
expect(wrapper.find('#group_two').length).toBe(1);
});
test('opening the about modal renders prefetched config data', async () => {
const aboutDropdown = 'Dropdown QuestionCircleIcon';
const aboutButton = 'DropdownItem li button';
const aboutModalContent = 'AboutModalBoxContent';
const aboutModalClose = 'button[aria-label="Close Dialog"]';
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AppContainer />);
});
// open about dropdown menu
await waitForElement(wrapper, aboutDropdown);
wrapper.find(aboutDropdown).simulate('click');
// open about modal
(
await waitForElement(wrapper, aboutButton, el => !el.props().disabled)
).simulate('click');
// check about modal content
const content = await waitForElement(wrapper, aboutModalContent);
expect(content.find('dd').text()).toContain(ansible_version);
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
// close about modal
wrapper.find(aboutModalClose).simulate('click');
expect(wrapper.find(aboutModalContent)).toHaveLength(0);
});
test('logout makes expected call to api client', async () => {
const userMenuButton = 'UserIcon';
const logoutButton = '#logout-button button';
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AppContainer />);
});
// open the user menu
expect(wrapper.find(logoutButton)).toHaveLength(0);
wrapper.find(userMenuButton).simulate('click');
expect(wrapper.find(logoutButton)).toHaveLength(1);
// logout
wrapper.find(logoutButton).simulate('click');
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,3 @@
import AppContainer from './AppContainer';
export default AppContainer;

View File

@ -1 +0,0 @@
export { default } from './BrandLogo';

View File

@ -1 +0,0 @@
export { default } from './NavExpandableGroup';

View File

@ -1 +0,0 @@
export { default } from './PageHeaderToolbar';

View File

@ -1,93 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Switch, Redirect } from 'react-router-dom';
import { I18n } from '@lingui/react';
import '@patternfly/react-core/dist/styles/base.css';
import { isAuthenticated } from './util/auth';
import Background from './components/Background';
import NotFound from './screens/NotFound';
import Login from './screens/Login';
import App from './App';
import RootProvider from './RootProvider';
import { BrandName } from './variables';
import getRouteConfig from './routeConfig';
// eslint-disable-next-line import/prefer-default-export
export function main(render) {
const el = document.getElementById('app');
document.title = `Ansible ${BrandName}`;
document.title = `Ansible ${BrandName}`;
const removeTrailingSlash = (
<Route
exact
strict
path="/*/"
render={({
history: {
location: { pathname, search, hash },
},
}) => <Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />}
/>
);
const AppRoute = ({ auth, children, ...rest }) =>
// eslint-disable-next-line no-nested-ternary
auth ? (
isAuthenticated(document.cookie) ? (
<Route {...rest}>{children}</Route>
) : (
<Redirect to="/login" />
)
) : isAuthenticated(document.cookie) ? (
<Redirect to="/home" />
) : (
<Route {...rest}>{children}</Route>
);
return render(
<RootProvider>
<I18n>
{({ i18n }) => (
<Background>
<Switch>
{removeTrailingSlash}
<AppRoute path="/login">
<Login isAuthenticated={isAuthenticated} />
</AppRoute>
<AppRoute exact path="/">
<Login isAuthenticated={isAuthenticated} />
</AppRoute>
<AppRoute auth>
<App routeConfig={getRouteConfig(i18n)}>
<Switch>
{getRouteConfig(i18n)
.flatMap(({ routes }) => routes)
.map(({ path, screen: Screen }) => (
<AppRoute
auth
key={path}
path={path}
render={({ match }) => <Screen match={match} />}
/>
))
.concat(
<AppRoute auth key="not-found" path="*">
<NotFound />
</AppRoute>
)}
</Switch>
</App>
</AppRoute>
</Switch>
</Background>
)}
</I18n>
</RootProvider>,
el || document.createElement('div')
);
}
main(ReactDOM.render);
ReactDOM.render(
<App />,
document.getElementById('app') || document.createElement('div')
);

View File

@ -1,13 +1,17 @@
import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
import { main } from './index';
import ReactDOM from 'react-dom';
import App from './App';
const render = template => mount(<MemoryRouter>{template}</MemoryRouter>);
jest.mock('react-dom', () => ({ render: jest.fn() }));
const div = document.createElement('div');
div.setAttribute('id', 'app');
document.body.appendChild(div);
require('./index.jsx');
describe('index.jsx', () => {
test('index.jsx loads without issue', () => {
const wrapper = main(render);
expect(wrapper.find('RootProvider')).toHaveLength(1);
it('renders ok', () => {
expect(ReactDOM.render).toHaveBeenCalledWith(<App />, div);
});
});