diff --git a/awx/ui_next/src/App.jsx b/awx/ui_next/src/App.jsx index 5348548af5..e74edee190 100644 --- a/awx/ui_next/src/App.jsx +++ b/awx/ui_next/src/App.jsx @@ -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) ? ( + {children} + ) : ( + + ); -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 = ( - } - logoProps={{ href: '/' }} - toolbar={ - - } - /> - ); - - const sidebar = ( - - - {routeConfig.map(({ groupId, groupTitle, routes }) => ( - - ))} - - - } - /> - ); - - return ( - - - - {children} - - - - - {i18n._(t`Failed to retrieve configuration.`)} - - - - ); - } + return ( + + + {({ i18n }) => ( + + + + + + + + + + + + + + + {getRouteConfig(i18n) + .flatMap(({ routes }) => routes) + .map(({ path, screen: Screen }) => ( + + + + )) + .concat( + + + + )} + + + + + + )} + + + ); } -export { App as _App }; -export default withI18n()(withRouter(App)); +export default () => ( + + + +); diff --git a/awx/ui_next/src/App.test.jsx b/awx/ui_next/src/App.test.jsx index 5b6282bee4..c2d667df3b 100644 --- a/awx/ui_next/src/App.test.jsx +++ b/awx/ui_next/src/App.test.jsx @@ -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('', () => { - 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( - - {routeConfig.map(({ groupId }) => ( -
- ))} - - ); - - // 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(); - 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().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().find('App'); - appWrapper.instance().handleLogout(); - await asyncFlush(); - expect(RootAPI.logout).toHaveBeenCalledTimes(1); - done(); + expect(wrapper.length).toBe(1); }); }); diff --git a/awx/ui_next/src/RootProvider.jsx b/awx/ui_next/src/RootProvider.jsx deleted file mode 100644 index d81121492b..0000000000 --- a/awx/ui_next/src/RootProvider.jsx +++ /dev/null @@ -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 ( - - - {children} - - - ); - } -} - -export default RootProvider; diff --git a/awx/ui_next/src/api/models/Config.js b/awx/ui_next/src/api/models/Config.js index f8e89df245..878ddfad70 100644 --- a/awx/ui_next/src/api/models/Config.js +++ b/awx/ui_next/src/api/models/Config.js @@ -4,6 +4,7 @@ class Config extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/config/'; + this.read = this.read.bind(this); } } diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx new file mode 100644 index 0000000000..690d7fae72 --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx @@ -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 = ( + } + logoProps={{ href: '/' }} + toolbar={ + + } + /> + ); + + const sidebar = ( + + + {navRouteConfig.map(({ groupId, groupTitle, routes }) => ( + + ))} + + + } + /> + ); + + return ( + <> + + {children} + + + + {i18n._(t`Failed to retrieve configuration.`)} + + + + ); +} + +export { AppContainer as _AppContainer }; +export default withI18n()(withRouter(AppContainer)); diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx new file mode 100644 index 0000000000..c01a7ee6d4 --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx @@ -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('', () => { + 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( + + {routeConfig.map(({ groupId }) => ( +
+ ))} + + ); + }); + + // 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(); + }); + + // 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(); + }); + + // 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); + }); +}); diff --git a/awx/ui_next/src/components/BrandLogo/BrandLogo.jsx b/awx/ui_next/src/components/AppContainer/BrandLogo.jsx similarity index 100% rename from awx/ui_next/src/components/BrandLogo/BrandLogo.jsx rename to awx/ui_next/src/components/AppContainer/BrandLogo.jsx diff --git a/awx/ui_next/src/components/BrandLogo/BrandLogo.test.jsx b/awx/ui_next/src/components/AppContainer/BrandLogo.test.jsx similarity index 100% rename from awx/ui_next/src/components/BrandLogo/BrandLogo.test.jsx rename to awx/ui_next/src/components/AppContainer/BrandLogo.test.jsx diff --git a/awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.jsx b/awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx similarity index 100% rename from awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.jsx rename to awx/ui_next/src/components/AppContainer/NavExpandableGroup.jsx diff --git a/awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.test.jsx b/awx/ui_next/src/components/AppContainer/NavExpandableGroup.test.jsx similarity index 100% rename from awx/ui_next/src/components/NavExpandableGroup/NavExpandableGroup.test.jsx rename to awx/ui_next/src/components/AppContainer/NavExpandableGroup.test.jsx diff --git a/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx similarity index 100% rename from awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx rename to awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx diff --git a/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.test.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx similarity index 100% rename from awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.test.jsx rename to awx/ui_next/src/components/AppContainer/PageHeaderToolbar.test.jsx diff --git a/awx/ui_next/src/components/AppContainer/index.jsx b/awx/ui_next/src/components/AppContainer/index.jsx new file mode 100644 index 0000000000..4fa9f5e563 --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/index.jsx @@ -0,0 +1,3 @@ +import AppContainer from './AppContainer'; + +export default AppContainer; diff --git a/awx/ui_next/src/components/BrandLogo/index.js b/awx/ui_next/src/components/BrandLogo/index.js deleted file mode 100644 index aa50d3906d..0000000000 --- a/awx/ui_next/src/components/BrandLogo/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './BrandLogo'; diff --git a/awx/ui_next/src/components/NavExpandableGroup/index.js b/awx/ui_next/src/components/NavExpandableGroup/index.js deleted file mode 100644 index be8070049a..0000000000 --- a/awx/ui_next/src/components/NavExpandableGroup/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './NavExpandableGroup'; diff --git a/awx/ui_next/src/components/PageHeaderToolbar/index.js b/awx/ui_next/src/components/PageHeaderToolbar/index.js deleted file mode 100644 index debdb7da16..0000000000 --- a/awx/ui_next/src/components/PageHeaderToolbar/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PageHeaderToolbar'; diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx index 3e19aa6b26..ea4b815a21 100644 --- a/awx/ui_next/src/index.jsx +++ b/awx/ui_next/src/index.jsx @@ -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 = ( - } - /> - ); - - const AppRoute = ({ auth, children, ...rest }) => - // eslint-disable-next-line no-nested-ternary - auth ? ( - isAuthenticated(document.cookie) ? ( - {children} - ) : ( - - ) - ) : isAuthenticated(document.cookie) ? ( - - ) : ( - {children} - ); - - return render( - - - {({ i18n }) => ( - - - {removeTrailingSlash} - - - - - - - - - - {getRouteConfig(i18n) - .flatMap(({ routes }) => routes) - .map(({ path, screen: Screen }) => ( - } - /> - )) - .concat( - - - - )} - - - - - - )} - - , - el || document.createElement('div') - ); -} - -main(ReactDOM.render); +ReactDOM.render( + , + document.getElementById('app') || document.createElement('div') +); diff --git a/awx/ui_next/src/index.test.jsx b/awx/ui_next/src/index.test.jsx index 085e86ac66..82dec86c56 100644 --- a/awx/ui_next/src/index.test.jsx +++ b/awx/ui_next/src/index.test.jsx @@ -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({template}); +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(, div); }); });