From 4ccce4cc9e3c71cd84d42c90c4cc91f39b0394c5 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 19:49:34 -0500 Subject: [PATCH] add header toolbar component and move About modal control to App --- __tests__/App.test.jsx | 114 +++++++++++----- __tests__/components/About.test.jsx | 10 +- __tests__/components/HelpDropdown.test.jsx | 68 ---------- __tests__/components/LogoutButton.test.jsx | 32 ----- src/App.jsx | 145 +++++++++++---------- src/components/About.jsx | 88 ++++++------- src/components/HelpDropdown.jsx | 62 --------- src/components/LogoutButton.jsx | 32 ----- src/components/PageHeaderToolbar.jsx | 140 ++++++++++++++++++++ src/endpoints.jsx | 7 - 10 files changed, 345 insertions(+), 353 deletions(-) delete mode 100644 __tests__/components/HelpDropdown.test.jsx delete mode 100644 __tests__/components/LogoutButton.test.jsx delete mode 100644 src/components/HelpDropdown.jsx delete mode 100644 src/components/LogoutButton.jsx create mode 100644 src/components/PageHeaderToolbar.jsx delete mode 100644 src/endpoints.jsx diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index 4d1efe68a4..9048c3fab5 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -1,61 +1,111 @@ import React from 'react'; -import { HashRouter as Router } from 'react-router-dom'; +import { HashRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; -import { shallow, mount } from 'enzyme'; -import App from '../src/App'; -import api from '../src/api'; -import { API_LOGOUT } from '../src/endpoints'; - -import Dashboard from '../src/pages/Dashboard'; +import { mount, shallow } from 'enzyme'; import { asyncFlush } from '../jest.setup'; -const DEFAULT_ACTIVE_GROUP = 'views_group'; -const DEFAULT_ACTIVE_ITEM = 'views_group_dashboard'; +import App from '../src/App'; -const routeGroups = [{ - groupId: DEFAULT_ACTIVE_GROUP, - title: 'test', - routes: [{ path: '/home', title: 'Dashboard', component: Dashboard }], -}]; +const DEFAULT_ACTIVE_GROUP = 'views_group'; describe('', () => { - test('renders without crashing', () => { - const appWrapper = shallow(); + test('expected content is rendered', () => { + const appWrapper = mount( + + + ( + routeGroups.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); + + // inline render + expect(appWrapper.find('#group_one').length).toBe(1); + expect(appWrapper.find('#group_two').length).toBe(1); }); test('onNavToggle sets state.isNavOpen to opposite', () => { const appWrapper = shallow(); - expect(appWrapper.state().isNavOpen).toBe(true); - appWrapper.instance().onNavToggle(); - expect(appWrapper.state().isNavOpen).toBe(false); + const { onNavToggle } = appWrapper.instance(); + + [true, false, true, false, true].forEach(expected => { + expect(appWrapper.state().isNavOpen).toBe(expected); + onNavToggle(); + }); }); test('onLogoClick sets selected nav back to defaults', () => { const appWrapper = shallow(); + appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); expect(appWrapper.state().activeItem).toBe('bar'); expect(appWrapper.state().activeGroup).toBe('foo'); + appWrapper.instance().onLogoClick(); expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); }); - test('api.logout called from logout button', async () => { - api.get = jest.fn().mockImplementation(() => Promise.resolve({})); - const appWrapper = shallow(); - appWrapper.instance().onDevLogout(); - appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); - expect(api.get).toHaveBeenCalledTimes(1); - expect(api.get).toHaveBeenCalledWith(API_LOGOUT); + test('logout button click triggers expected callback', async (done) => { + const logout = jest.fn(() => Promise.resolve()); + const api = { logout }; + + const appWrapper = mount( + + + + + + ); + + appWrapper.find('button[id="button-logout"]').simulate('click'); await asyncFlush(); - expect(appWrapper.state().activeItem).toBe(DEFAULT_ACTIVE_ITEM); - expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); + expect(api.logout).toHaveBeenCalledTimes(1); + + done(); }); - test('Componenet makes REST call to API_CONFIG endpoint when mounted', () => { - api.get = jest.fn().mockImplementation(() => Promise.resolve({})); - const appWrapper = shallow(); + test('Component makes expected call to api client when mounted', () => { + const getConfig = jest.fn().mockImplementation(() => Promise.resolve({})); + const api = { getConfig }; + const appWrapper = mount( + + + + + + ); expect(api.get).toHaveBeenCalledTimes(1); - expect(api.get).toHaveBeenCalledWith(API_CONFIG); }); }); diff --git a/__tests__/components/About.test.jsx b/__tests__/components/About.test.jsx index e20fe77057..c6d322a55d 100644 --- a/__tests__/components/About.test.jsx +++ b/__tests__/components/About.test.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; import { I18nProvider } from '@lingui/react'; -import api from '../../src/api'; -import { API_CONFIG } from '../../src/endpoints'; import About from '../../src/components/About'; describe('', () => { @@ -19,16 +17,16 @@ describe('', () => { aboutWrapper.unmount(); }); - test('close button calls onAboutModalClose', () => { - const onAboutModalClose = jest.fn(); + test('close button calls onClose handler', () => { + const onClose = jest.fn(); aboutWrapper = mount( - + ); closeButton = aboutWrapper.find('AboutModalBoxCloseButton Button'); closeButton.simulate('click'); - expect(onAboutModalClose).toBeCalled(); + expect(onClose).toBeCalled(); aboutWrapper.unmount(); }); }); diff --git a/__tests__/components/HelpDropdown.test.jsx b/__tests__/components/HelpDropdown.test.jsx deleted file mode 100644 index b2b9da1df1..0000000000 --- a/__tests__/components/HelpDropdown.test.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@lingui/react'; -import HelpDropdown from '../../src/components/HelpDropdown'; - -let questionCircleIcon; -let dropdownWrapper; -let dropdownComponentInstance; -let dropdownToggle; -let dropdownItems; -let dropdownItem; - -beforeEach(() => { - dropdownWrapper = mount( - - - - ); - dropdownComponentInstance = dropdownWrapper.find(HelpDropdown).instance(); -}); - -afterEach(() => { - dropdownWrapper.unmount(); -}); - -describe('', () => { - test('initially renders without crashing', () => { - expect(dropdownWrapper.length).toBe(1); - expect(dropdownComponentInstance.state.isOpen).toEqual(false); - expect(dropdownComponentInstance.state.showAboutModal).toEqual(false); - questionCircleIcon = dropdownWrapper.find('QuestionCircleIcon'); - expect(questionCircleIcon.length).toBe(1); - }); - - test('renders two dropdown items', () => { - dropdownComponentInstance.setState({ isOpen: true }); - dropdownWrapper.update(); - dropdownItems = dropdownWrapper.find('DropdownItem'); - expect(dropdownItems.length).toBe(2); - const dropdownTexts = dropdownItems.map(item => item.text()); - expect(dropdownTexts).toEqual(['Help', 'About']); - }); - - test('onToggle sets state.isOpen to opposite', () => { - dropdownComponentInstance.setState({ isOpen: true }); - dropdownWrapper.update(); - dropdownToggle = dropdownWrapper.find('DropdownToggle > DropdownToggle'); - dropdownToggle.simulate('click'); - expect(dropdownComponentInstance.state.isOpen).toEqual(false); - }); - - test('about dropdown item sets state.showAboutModal to true', () => { - dropdownComponentInstance.setState({ isOpen: true }); - dropdownWrapper.update(); - dropdownItem = dropdownWrapper.find('DropdownItem a').at(1); - dropdownItem.simulate('click'); - expect(dropdownComponentInstance.state.showAboutModal).toEqual(true); - }); - - test('onAboutModalClose sets state.showAboutModal to false', () => { - dropdownComponentInstance.setState({ showAboutModal: true }); - dropdownWrapper.update(); - const aboutModal = dropdownWrapper.find('AboutModal'); - aboutModal.find('AboutModalBoxCloseButton Button').simulate('click'); - expect(dropdownComponentInstance.state.showAboutModal).toEqual(false); - }); -}); - diff --git a/__tests__/components/LogoutButton.test.jsx b/__tests__/components/LogoutButton.test.jsx deleted file mode 100644 index aaded3cd3f..0000000000 --- a/__tests__/components/LogoutButton.test.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@lingui/react'; -import LogoutButton from '../../src/components/LogoutButton'; - -let buttonWrapper; -let buttonElem; -let userIconElem; - -const findChildren = () => { - buttonElem = buttonWrapper.find('Button'); - userIconElem = buttonWrapper.find('UserIcon'); -}; - -describe('', () => { - test('initially renders without crashing', () => { - const onDevLogout = jest.fn(); - buttonWrapper = mount( - - - - ); - findChildren(); - expect(buttonWrapper.length).toBe(1); - expect(buttonElem.length).toBe(1); - expect(userIconElem.length).toBe(1); - buttonElem.simulate('keyDown', { keyCode: 40, which: 40 }); - expect(onDevLogout).toHaveBeenCalledTimes(0); - buttonElem.simulate('keyDown', { keyCode: 13, which: 13 }); - expect(onDevLogout).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/App.jsx b/src/App.jsx index 624242384d..04ca8c740f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,29 +1,18 @@ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { global_breakpoint_md } from '@patternfly/react-tokens'; -import { - Redirect, - Switch, - Route, -} from 'react-router-dom'; import { Nav, NavList, Page, PageHeader, PageSidebar, - Toolbar, - ToolbarGroup, - ToolbarItem } from '@patternfly/react-core'; -import api from './api'; -import { API_LOGOUT, API_CONFIG } from './endpoints'; -import { ConfigContext } from './context'; - -import HelpDropdown from './components/HelpDropdown'; -import LogoutButton from './components/LogoutButton'; -import TowerLogo from './components/TowerLogo'; +import About from './components/About'; import NavExpandableGroup from './components/NavExpandableGroup'; +import TowerLogo from './components/TowerLogo'; +import PageHeaderToolbar from './components/PageHeaderToolbar'; +import { ConfigContext } from './context'; class App extends Component { constructor (props) { @@ -34,14 +23,24 @@ class App extends Component { && window.innerWidth >= parseInt(global_breakpoint_md.value, 10); this.state = { + ansible_version: null, + version: null, + isAboutModalOpen: false, isNavOpen, - config: {}, - error: false, }; + this.fetchConfig = this.fetchConfig.bind(this); this.onLogout = this.onLogout.bind(this); + this.onAboutModalClose = this.onAboutModalClose.bind(this); + this.onAboutModalOpen = this.onAboutModalOpen.bind(this); + this.onLogoClick = this.onLogoClick.bind(this); + this.onNavToggle = this.onNavToggle.bind(this); }; + componentDidMount () { + this.fetchConfig(); + } + async onLogout () { const { api } = this.props; @@ -49,92 +48,102 @@ class App extends Component { window.location.replace('/#/login') } - onNavToggle = () => { - this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); - }; + async fetchConfig () { + const { api } = this.props; - onLogoClick = () => { - this.setState({ - activeGroup: 'views_group' - }); - } - - onDevLogout = async () => { - await api.get(API_LOGOUT); - - this.setState({ - activeGroup: 'views_group', - activeItem: 'views_group_dashboard', - }); - - window.location.replace('/#/login'); - }; - - async componentDidMount() { - // Grab our config data from the API and store in state try { - const { data } = await api.get(API_CONFIG); - this.setState({ config: data }); - } catch (error) { - this.setState({ error }); + const { data: { ansible_version, version } } = await api.getConfig(); + this.setState({ ansible_version, version }); + } catch (err) { + this.setState({ ansible_version: null, version: null }); } } + onAboutModalOpen () { + this.setState({ isAboutModalOpen: true }); + } + + onAboutModalClose () { + this.setState({ isAboutModalOpen: false }); + } + + onNavToggle () { + this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); + } + + onLogoClick () { + this.setState({ activeGroup: 'views_group' }); + } + render () { - const { config, isNavOpen } = this.state; + const { + ansible_version, + isAboutModalOpen, + isNavOpen, + version, + } = this.state; const { render, routeGroups = [], navLabel = '', } = this.props; + const config = { + ansible_version, + version, + }; + return ( - + this.onNavToggle()} - logo={( + onNavToggle={this.onNavToggle} + logo={ - )} - toolbar={( - - - - - - - - - - - )} + } + toolbar={ + + } /> )} - sidebar={( + sidebar={ {routeGroups.map(params => ( - + ))} )} /> - )} + } > - { render ? render({ routeGroups }) : '' } + + { render ? render({ routeGroups }) : '' } + - + + ); } } diff --git a/src/components/About.jsx b/src/components/About.jsx index 18c986fc7d..22c1157b30 100644 --- a/src/components/About.jsx +++ b/src/components/About.jsx @@ -1,5 +1,4 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { Component } from 'react'; import { I18n } from '@lingui/react'; import { Trans, t } from '@lingui/macro'; import { @@ -13,10 +12,8 @@ import heroImg from '@patternfly/patternfly-next/assets/images/pfbg_992.jpg'; import brandImg from '../../images/tower-logo-white.svg'; import logoImg from '../../images/tower-logo-login.svg'; -import { ConfigContext } from '../context'; - -class About extends React.Component { - createSpeechBubble = (version) => { +class About extends Component { + static createSpeechBubble (version) { let text = `Tower ${version}`; let top = ''; let bottom = ''; @@ -33,61 +30,60 @@ class About extends React.Component { return top + text + bottom; } - handleModalToggle = () => { - const { onAboutModalClose } = this.props; - onAboutModalClose(); - }; + constructor (props) { + super(props); + + this.createSpeechBubble = this.constructor.createSpeechBubble.bind(this); + } render () { - const { isOpen } = this.props; + const { + ansible_version, + version, + isOpen, + onClose + } = this.props; + + const speechBubble = this.createSpeechBubble(version); + return ( {({ i18n }) => ( - - {({ ansible_version, version }) => ( - -
-                  {this.createSpeechBubble(version)}
-                  {`
+          
+            
+              { speechBubble }
+              {`
               \\
-              \\  ^__^
+              \\   ^__^
                   (oo)\\_______
                   (__)      A )\\
                       ||----w |
                       ||     ||
                         `}
-                
- - - - - Ansible Version - - {ansible_version} - - -
- )} - +
+ + + + Ansible Version + + { ansible_version } + + +
)}
); } } -About.contextTypes = { - ansible_version: PropTypes.string, - version: PropTypes.string, -}; - export default About; diff --git a/src/components/HelpDropdown.jsx b/src/components/HelpDropdown.jsx deleted file mode 100644 index ebe06e2417..0000000000 --- a/src/components/HelpDropdown.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { Trans } from '@lingui/macro'; -import { - Dropdown, - DropdownItem, - DropdownToggle, - DropdownPosition, -} from '@patternfly/react-core'; -import { QuestionCircleIcon } from '@patternfly/react-icons'; -import AboutModal from './About'; - -class HelpDropdown extends Component { - state = { - isOpen: false, - showAboutModal: false - }; - - render () { - const { isOpen, showAboutModal } = this.state; - const dropdownItems = [ - - Help - , - this.setState({ showAboutModal: true })} - key="about" - > - About - - ]; - - return ( - - this.setState({ isOpen: !isOpen })} - toggle={( - this.setState({ isOpen: isToggleOpen })}> - - - )} - isOpen={isOpen} - dropdownItems={dropdownItems} - position={DropdownPosition.right} - /> - {showAboutModal - ? ( - this.setState({ showAboutModal: !showAboutModal })} - /> - ) - : null } - - ); - } -} - -export default HelpDropdown; diff --git a/src/components/LogoutButton.jsx b/src/components/LogoutButton.jsx deleted file mode 100644 index 4f42813374..0000000000 --- a/src/components/LogoutButton.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { I18n } from '@lingui/react'; -import { t } from '@lingui/macro'; - -import { - Button, - ButtonVariant -} from '@patternfly/react-core'; - -import { UserIcon } from '@patternfly/react-icons'; - -const LogoutButton = ({ onDevLogout }) => ( - - {({ i18n }) => ( - - )} - -); - -export default LogoutButton; diff --git a/src/components/PageHeaderToolbar.jsx b/src/components/PageHeaderToolbar.jsx new file mode 100644 index 0000000000..ee56c4119a --- /dev/null +++ b/src/components/PageHeaderToolbar.jsx @@ -0,0 +1,140 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; + +import { t } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import { + Dropdown, + DropdownItem, + DropdownToggle, + DropdownPosition, + Toolbar, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { + QuestionCircleIcon, + UserIcon, +} from '@patternfly/react-icons'; + +const DOCLINK = 'https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html'; +const KEY_ENTER = 13; + +class PageHeaderToolbar extends Component { + constructor (props) { + super(props); + this.state = { isHelpOpen: false, isUserOpen: false }; + + this.onHelpSelect = this.onHelpSelect.bind(this); + this.onHelpToggle = this.onHelpToggle.bind(this); + this.onLogoutKeyDown = this.onLogoutKeyDown.bind(this); + this.onUserSelect = this.onUserSelect.bind(this); + this.onUserToggle = this.onUserToggle.bind(this); + } + + onLogoutKeyDown ({ keyCode }) { + const { onLogoutClick } = this.props; + + if (keyCode === KEY_ENTER) { + onLogoutClick(); + } + } + + onHelpSelect () { + const { isHelpOpen } = this.state; + + this.setState({ isHelpOpen: !isHelpOpen }); + } + + onUserSelect () { + const { isUserOpen } = this.state; + + this.setState({ isUserOpen: !isUserOpen }); + } + + onHelpToggle (isOpen) { + this.setState({ isHelpOpen: isOpen }); + } + + onUserToggle (isOpen) { + this.setState({ isUserOpen: isOpen }); + } + + render () { + const { isHelpOpen, isUserOpen } = this.state; + const { isAboutDisabled, onAboutClick, onLogoutClick } = this.props; + + return ( + + {({ i18n }) => ( + + + + + + + )} + dropdownItems={[ + + {i18n._(t`Help`)} + , + + {i18n._(t`About`)} + + ]} + /> + + + + + + )} + dropdownItems={[ + + + {i18n._(t`User Details`)} + + , + + {i18n._(t`Logout`)} + + ]} + /> + + + + )} + + ); + } +} + +export default PageHeaderToolbar; diff --git a/src/endpoints.jsx b/src/endpoints.jsx deleted file mode 100644 index d1499b00d6..0000000000 --- a/src/endpoints.jsx +++ /dev/null @@ -1,7 +0,0 @@ -export const API_ROOT = '/api/'; -export const API_LOGIN = `${API_ROOT}login/`; -export const API_LOGOUT = `${API_ROOT}logout/`; -export const API_V2 = `${API_ROOT}v2/`; -export const API_CONFIG = `${API_V2}config/`; -export const API_PROJECTS = `${API_V2}projects/`; -export const API_ORGANIZATIONS = `${API_V2}organizations/`; \ No newline at end of file