diff --git a/__mocks__/axios.js b/__mocks__/axios.js index 23f96b475f..aad497303f 100644 --- a/__mocks__/axios.js +++ b/__mocks__/axios.js @@ -1,5 +1,13 @@ -const axios = require('axios'); +import * as endpoints from '../src/endpoints'; +const axios = require('axios'); +const mockAPIConfigData = { + data: { + custom_virtualenvs: ['foo', 'bar'], + ansible_version: "2.7.2", + version: "2.1.1-40-g2758a3848" + } +}; jest.genMockFromModule('axios'); axios.create = jest.fn(() => axios); @@ -9,7 +17,16 @@ axios.create.mockReturnValue({ get: axios.get, post: axios.post }); -axios.get.mockResolvedValue('get results'); +axios.get.mockImplementation((endpoint) => { + if (endpoint === endpoints.API_CONFIG) { + return new Promise((resolve, reject) => { + resolve(mockAPIConfigData); + }); + } + else { + return 'get results'; + } +}); axios.post.mockResolvedValue('post results'); axios.customClearMocks = () => { diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index cbffd97f86..ae7366a74e 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router-dom'; import { shallow, mount } from 'enzyme'; import App from '../src/App'; import api from '../src/api'; -import { API_LOGOUT } from '../src/endpoints'; +import { API_LOGOUT, API_CONFIG } from '../src/endpoints'; import Dashboard from '../src/pages/Dashboard'; import Login from '../src/pages/Login'; @@ -53,7 +53,13 @@ describe('', () => { expect(logOutButton.length).toBe(1); logOutButton.simulate('click'); appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); - expect(api.get).toHaveBeenCalledTimes(1); expect(api.get).toHaveBeenCalledWith(API_LOGOUT); }); + + test('Componenet makes REST call to API_CONFIG endpoint when mounted', () => { + api.get = jest.fn().mockImplementation(() => Promise.resolve({})); + const appWrapper = shallow(); + 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 e2f101d095..e20fe77057 100644 --- a/__tests__/components/About.test.jsx +++ b/__tests__/components/About.test.jsx @@ -31,26 +31,4 @@ describe('', () => { expect(onAboutModalClose).toBeCalled(); aboutWrapper.unmount(); }); - - test('sets error on api request failure', async () => { - api.get = jest.fn().mockImplementation(() => { - const err = new Error('404 error'); - err.response = { status: 404, message: 'problem' }; - return Promise.reject(err); - }); - aboutWrapper = mount( - - - - ); - - const aboutComponentInstance = aboutWrapper.find(About).instance(); - await aboutComponentInstance.componentDidMount(); - expect(aboutComponentInstance.state.error.response.status).toBe(404); - aboutWrapper.unmount(); - }); - - test('API Config endpoint is valid', () => { - expect(API_CONFIG).toBeDefined(); - }); }); diff --git a/__tests__/components/AnsibleSelect.test.jsx b/__tests__/components/AnsibleSelect.test.jsx index 47a068ef4f..d2eacf2129 100644 --- a/__tests__/components/AnsibleSelect.test.jsx +++ b/__tests__/components/AnsibleSelect.test.jsx @@ -2,16 +2,29 @@ import React from 'react'; import { mount } from 'enzyme'; import AnsibleSelect from '../../src/components/AnsibleSelect'; -const mockData = ['foo', 'bar']; +const label = "test select" +const mockData = ["/venv/baz/", "/venv/ansible/"]; describe('', () => { - test('initially renders succesfully', async() => { - const wrapper = mount( {}} />); - wrapper.setState({ isHidden: false }); + test('initially renders succesfully', async () => { + mount( + { }} + labelName={label} + data={mockData} + /> + ); }); test('calls "onSelectChange" on dropdown select change', () => { const spy = jest.spyOn(AnsibleSelect.prototype, 'onSelectChange'); - const wrapper = mount( {}} />); - wrapper.setState({ isHidden: false }); + const wrapper = mount( + { }} + labelName={label} + data={mockData} + /> + ); expect(spy).not.toHaveBeenCalled(); wrapper.find('select').simulate('change'); expect(spy).toHaveBeenCalled(); diff --git a/__tests__/pages/Organizations/views/Organization.add.test.jsx b/__tests__/pages/Organizations/views/Organization.add.test.jsx index 8c25eb1eef..30990942a4 100644 --- a/__tests__/pages/Organizations/views/Organization.add.test.jsx +++ b/__tests__/pages/Organizations/views/Organization.add.test.jsx @@ -1,8 +1,29 @@ import React from 'react'; import { mount } from 'enzyme'; -import OrganizationAdd from '../../../../src/pages/Organizations/views/Organization.add'; import { MemoryRouter } from 'react-router-dom'; +let OrganizationAdd; +const getAppWithConfigContext = (context = { + custom_virtualenvs: ['foo', 'bar'] +}) => { + + // Mock the ConfigContext module being used in our OrganizationAdd component + jest.doMock('../../../../src/context', () => { + return { + ConfigContext: { + Consumer: (props) => props.children(context) + } + } + }); + + // Return the updated OrganizationAdd module with mocked context + return require('../../../../src/pages/Organizations/views/Organization.add').default; +}; + +beforeEach(() => { + OrganizationAdd = getAppWithConfigContext(); +}) + describe('', () => { test('initially renders succesfully', () => { mount( diff --git a/package.json b/package.json index 833f11652c..c2d0e85ac0 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@patternfly/react-styles": "^2.3.0", "@patternfly/react-tokens": "^1.9.0", "axios": "^0.18.0", + "prop-types": "^15.6.2", "react": "^16.4.1", "react-dom": "^16.4.1", "react-router-dom": "^4.3.1" diff --git a/src/App.jsx b/src/App.jsx index 3b03de3382..d08764acd7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,6 @@ import React, { Fragment } from 'react'; +import { ConfigContext } from './context'; + import { I18nProvider, I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { @@ -22,7 +24,7 @@ import { import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens'; import api from './api'; -import { API_LOGOUT } from './endpoints'; +import { API_LOGOUT, API_CONFIG } from './endpoints'; import HelpDropdown from './components/HelpDropdown'; import LogoutButton from './components/LogoutButton'; @@ -61,19 +63,21 @@ const catalogs = { en, ja }; // This spits out the language and the region. Example: es-US const language = (navigator.languages && navigator.languages[0]) -|| navigator.language -|| navigator.userLanguage; + || navigator.language + || navigator.userLanguage; const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; class App extends React.Component { - constructor (props) { + constructor(props) { super(props); const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10); this.state = { - isNavOpen + isNavOpen, + config: {}, + error: false, }; } @@ -81,12 +85,27 @@ class App extends React.Component { this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); }; - onDevLogout = async () => { - await api.get(API_LOGOUT); + onLogoClick = () => { + this.setState({ activeGroup: 'views_group' }); } - render () { - const { isNavOpen } = this.state; + onDevLogout = async () => { + await api.get(API_LOGOUT); + this.setState({ activeGroup: 'views_group', activeItem: 'views_group_dashboard' }); + } + + 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 }); + } + } + + render() { + const { isNavOpen, config } = this.state; const { logo, loginInfo, history } = this.props; const PageToolbar = ( @@ -118,118 +137,121 @@ class App extends React.Component { [BackgroundImageSrc.filter]: '/assets/images/background-filter.svg' }} /> - - api.isAuthenticated()} - redirectPath="/" - path="/login" - component={() => } - /> - - } - toolbar={PageToolbar} - showNavToggle - onNavToggle={this.onNavToggle} - /> - )} - sidebar={( - - {({ i18n }) => ( - - )} - - )} - /> - )} - useCondensed - > - !api.isAuthenticated()} redirectPath="/login" exact path="/" component={() => ()} /> - !api.isAuthenticated()} redirectPath="/login" path="/home" component={Dashboard} /> - !api.isAuthenticated()} redirectPath="/login" path="/jobs" component={Jobs} /> - !api.isAuthenticated()} redirectPath="/login" path="/schedules" component={Schedules} /> - !api.isAuthenticated()} redirectPath="/login" path="/portal" component={Portal} /> - !api.isAuthenticated()} redirectPath="/login" path="/templates" component={Templates} /> - !api.isAuthenticated()} redirectPath="/login" path="/credentials" component={Credentials} /> - !api.isAuthenticated()} redirectPath="/login" path="/projects" component={Projects} /> - !api.isAuthenticated()} redirectPath="/login" path="/inventories" component={Inventories} /> - !api.isAuthenticated()} redirectPath="/login" path="/inventory_scripts" component={InventoryScripts} /> - !api.isAuthenticated()} redirectPath="/login" path="/organizations" component={Organizations} /> - !api.isAuthenticated()} redirectPath="/login" path="/users" component={Users} /> - !api.isAuthenticated()} redirectPath="/login" path="/teams" component={Teams} /> - !api.isAuthenticated()} redirectPath="/login" path="/credential_types" component={CredentialTypes} /> - !api.isAuthenticated()} redirectPath="/login" path="/notification_templates" component={NotificationTemplates} /> - !api.isAuthenticated()} redirectPath="/login" path="/management_jobs" component={ManagementJobs} /> - !api.isAuthenticated()} redirectPath="/login" path="/instance_groups" component={InstanceGroups} /> - !api.isAuthenticated()} redirectPath="/login" path="/applications" component={Applications} /> - !api.isAuthenticated()} redirectPath="/login" path="/auth_settings" component={AuthSettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/jobs_settings" component={JobsSettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/system_settings" component={SystemSettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/ui_settings" component={UISettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/license" component={License} /> - - - + + + api.isAuthenticated()} + redirectPath="/" + path="/login" + component={() => } + /> + + } + toolbar={PageToolbar} + showNavToggle + onNavToggle={this.onNavToggle} + /> + )} + sidebar={( + + {({ i18n }) => ( + + )} + + )} + /> + )} + useCondensed + > + !api.isAuthenticated()} redirectPath="/login" exact path="/" component={() => ()} /> + !api.isAuthenticated()} redirectPath="/login" path="/home" component={Dashboard} /> + !api.isAuthenticated()} redirectPath="/login" path="/jobs" component={Jobs} /> + !api.isAuthenticated()} redirectPath="/login" path="/schedules" component={Schedules} /> + !api.isAuthenticated()} redirectPath="/login" path="/portal" component={Portal} /> + !api.isAuthenticated()} redirectPath="/login" path="/templates" component={Templates} /> + !api.isAuthenticated()} redirectPath="/login" path="/credentials" component={Credentials} /> + !api.isAuthenticated()} redirectPath="/login" path="/projects" component={Projects} /> + !api.isAuthenticated()} redirectPath="/login" path="/inventories" component={Inventories} /> + !api.isAuthenticated()} redirectPath="/login" path="/inventory_scripts" component={InventoryScripts} /> + !api.isAuthenticated()} redirectPath="/login" path="/organizations" component={Organizations} /> + !api.isAuthenticated()} redirectPath="/login" path="/users" component={Users} /> + !api.isAuthenticated()} redirectPath="/login" path="/teams" component={Teams} /> + !api.isAuthenticated()} redirectPath="/login" path="/credential_types" component={CredentialTypes} /> + !api.isAuthenticated()} redirectPath="/login" path="/notification_templates" component={NotificationTemplates} /> + !api.isAuthenticated()} redirectPath="/login" path="/management_jobs" component={ManagementJobs} /> + !api.isAuthenticated()} redirectPath="/login" path="/instance_groups" component={InstanceGroups} /> + !api.isAuthenticated()} redirectPath="/login" path="/applications" component={Applications} /> + !api.isAuthenticated()} redirectPath="/login" path="/auth_settings" component={AuthSettings} /> + !api.isAuthenticated()} redirectPath="/login" path="/jobs_settings" component={JobsSettings} /> + !api.isAuthenticated()} redirectPath="/login" path="/system_settings" component={SystemSettings} /> + !api.isAuthenticated()} redirectPath="/login" path="/ui_settings" component={UISettings} /> + !api.isAuthenticated()} redirectPath="/login" path="/license" component={License} /> + + + + + ); diff --git a/src/components/About.jsx b/src/components/About.jsx index e5e8254e34..18c986fc7d 100644 --- a/src/components/About.jsx +++ b/src/components/About.jsx @@ -1,46 +1,21 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { I18n } from '@lingui/react'; import { Trans, t } from '@lingui/macro'; import { AboutModal, TextContent, TextList, - TextListItem } from '@patternfly/react-core'; + TextListItem +} from '@patternfly/react-core'; 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 api from '../api'; -import { API_CONFIG } from '../endpoints'; +import { ConfigContext } from '../context'; class About extends React.Component { - unmounting = false; - - constructor (props) { - super(props); - - this.state = { - config: {}, - error: false - }; - } - - async componentDidMount () { - try { - const { data } = await api.get(API_CONFIG); - this.safeSetState({ config: data }); - } catch (error) { - this.safeSetState({ error }); - } - } - - componentWillUnmount () { - this.unmounting = true; - } - - safeSetState = obj => !this.unmounting && this.setState(obj); - createSpeechBubble = (version) => { let text = `Tower ${version}`; let top = ''; @@ -65,26 +40,25 @@ class About extends React.Component { render () { const { isOpen } = this.props; - const { config = {}, error } = this.state; - const { ansible_version = 'loading', version = 'loading' } = config; - return ( {({ i18n }) => ( - -
-              { this.createSpeechBubble(version) }
-              {`
+          
+            {({ ansible_version, version }) => (
+              
+                
+                  {this.createSpeechBubble(version)}
+                  {`
               \\
               \\  ^__^
                   (oo)\\_______
@@ -92,22 +66,28 @@ class About extends React.Component {
                       ||----w |
                       ||     ||
                         `}
-            
+
- - - - Ansible Version - - { ansible_version } - - - { error ?
error
: ''} -
+ + + + Ansible Version + + {ansible_version} + + + + )} + )}
); } } +About.contextTypes = { + ansible_version: PropTypes.string, + version: PropTypes.string, +}; + export default About; diff --git a/src/components/AnsibleSelect/AnsibleSelect.jsx b/src/components/AnsibleSelect/AnsibleSelect.jsx index d411c1d17a..f3da0b4595 100644 --- a/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -1,4 +1,5 @@ import React from 'react'; + import { FormGroup, Select, @@ -11,24 +12,37 @@ class AnsibleSelect extends React.Component { this.onSelectChange = this.onSelectChange.bind(this); } + state = { + count: 1, + } + + static getDerivedStateFromProps(nexProps, _) { + if (nexProps.data) { + return { + count: nexProps.data.length, + } + } + return null; + } onSelectChange(val, _) { this.props.selectChange(val); } render() { - const { hidden } = this.props; - if (hidden) { - return null; - } else { + const { count } = this.state; + if (count > 1) { return ( - ); + ) + } + else { + return null; } } } diff --git a/src/context.jsx b/src/context.jsx new file mode 100644 index 0000000000..446f82bd63 --- /dev/null +++ b/src/context.jsx @@ -0,0 +1,3 @@ +import React from "react"; + +export const ConfigContext = React.createContext({}); diff --git a/src/pages/Organizations/views/Organization.add.jsx b/src/pages/Organizations/views/Organization.add.jsx index 023817fe1d..75e2147a0c 100644 --- a/src/pages/Organizations/views/Organization.add.jsx +++ b/src/pages/Organizations/views/Organization.add.jsx @@ -1,4 +1,5 @@ import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; import { Trans } from '@lingui/macro'; import { @@ -17,7 +18,8 @@ import { CardBody, } from '@patternfly/react-core'; -import { API_ORGANIZATIONS, API_CONFIG } from '../../../endpoints'; +import { ConfigContext } from '../../../context'; +import { API_ORGANIZATIONS } from '../../../endpoints'; import api from '../../../api'; import AnsibleSelect from '../../../components/AnsibleSelect' const { light } = PageSectionVariants; @@ -38,8 +40,6 @@ class OrganizationAdd extends React.Component { description: '', instanceGroups: '', custom_virtualenv: '', - custom_virtualenvs: [], - hideAnsibleSelect: true, error:'', }; @@ -69,22 +69,10 @@ class OrganizationAdd extends React.Component { this.props.history.push('/organizations'); } - async componentDidMount() { - try { - const { data } = await api.get(API_CONFIG); - this.setState({ custom_virtualenvs: [...data.custom_virtualenvs] }); - if (this.state.custom_virtualenvs.length > 1) { - // Show dropdown if we have more than one ansible environment - this.setState({ hideAnsibleSelect: !this.state.hideAnsibleSelect }); - } - } catch (error) { - this.setState({ error }) - } - - } render() { const { name } = this.state; const enabled = name.length > 0; // TODO: add better form validation + return ( @@ -128,13 +116,16 @@ class OrganizationAdd extends React.Component { onChange={this.handleChange} /> -