From 2254bdb0e175afc626dc05e0f656651e7dab5e9f Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 00:31:10 -0500 Subject: [PATCH 01/25] make default dev server target overridable with env vars --- webpack.config.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index 82754cfd38..b8f754d81b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,8 +1,9 @@ const path = require('path'); const webpack = require('webpack'); -const TARGET_PORT = 8043; -const TARGET = `https://localhost:${TARGET_PORT}`; +const TARGET_PORT = process.env.TARGET_PORT || 8043; +const TARGET_HOST = process.env.TARGET_HOST || 'localhost'; +const TARGET = `https://${TARGET_HOST}:${TARGET_PORT}`; module.exports = { entry: './src/index.jsx', From 1c483a42c60c2536cd4a0d4d21c76a0068d00cda Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 00:32:43 -0500 Subject: [PATCH 02/25] update @patternfly/react-core to 1.43.5 --- package-lock.json | 37 ++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 021ebbf790..3e7b157533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1311,21 +1311,22 @@ "integrity": "sha512-sIRfo/tk4NSnaRwHIHLUf4XoqzNNa4MMa8ZWivzzSfdZ5pCbgvZtyEUqKnQAEH6zuaCM9S8HyhxcNXnm/xaYaQ==" }, "@patternfly/react-core": { - "version": "1.37.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-1.37.2.tgz", - "integrity": "sha512-zzHwqGEsRWzw9uRkbrf6PmUpcl6EMxQSbUJ1zmv7Ryc32CcSMgrDL4ZA3x/tf4TAYTMRBKUK3O8S5veRjxpFuw==", + "version": "1.43.5", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-1.43.5.tgz", + "integrity": "sha512-xO37/q5BJEdGvAoPllm/gbcwCtW7t0Ae7mKm5UU7d4i5bycEjd0UwJacYxCA6GFTwxN5kzn61XEpAUPY49U3pA==", "requires": { - "@patternfly/react-icons": "^2.9.1", + "@patternfly/react-icons": "^2.9.5", "@patternfly/react-styles": "^2.3.0", "@patternfly/react-tokens": "^1.0.0", + "@tippy.js/react": "^1.1.1", "exenv": "^1.2.2", "focus-trap-react": "^4.0.1" }, "dependencies": { "@patternfly/react-icons": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-2.9.1.tgz", - "integrity": "sha512-CBTpGXvqr91rBpxeb5/l2BimrtRlMkBKnIOTgX7V44MIIq3YE3P6A6CQK0fgIH1HGvCdiNf5sXbQz9xp+pB/3A==" + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-2.9.5.tgz", + "integrity": "sha512-5e/BD2ER5jifUjUgbIilApOfhVldlAjhQdh7EwH/M3M+qzIb+2qKxV/xQ6hWD3AA71lcYIxvPMMHgdWIAl5oPQ==" } } }, @@ -1358,6 +1359,15 @@ "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-1.9.0.tgz", "integrity": "sha512-wxlxeY5B37FkI9W3x4EQyZ9Q8lra3xBYEUg5CFCmWQZTvdH4vAC19l7mE+AQZqHXD4unvltS0ndi753LeHPyAg==" }, + "@tippy.js/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@tippy.js/react/-/react-1.1.1.tgz", + "integrity": "sha512-TkL1VufxgUvTMouDoBGv2vTdtUxtLUaRpspI4Rv0DsoKe2Ex1E5bl/qISk434mhuAhEnXuemrcgTaPWrfDvmGw==", + "requires": { + "prop-types": "^15.6.2", + "tippy.js": "^3.2.0" + } + }, "@types/node": { "version": "10.12.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.1.tgz", @@ -11105,6 +11115,11 @@ "integrity": "sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==", "dev": true }, + "popper.js": { + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.6.tgz", + "integrity": "sha512-AGwHGQBKumlk/MDfrSOf0JHhJCImdDMcGNoqKmKkU+68GFazv3CQ6q9r7Ja1sKDZmYWTckY/uLyEznheTDycnA==" + }, "portfinder": { "version": "1.0.19", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.19.tgz", @@ -13891,6 +13906,14 @@ "setimmediate": "^1.0.4" } }, + "tippy.js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-3.3.0.tgz", + "integrity": "sha512-2gIQg57EFSCBqE97NZbakSkGBJF0GzdOhx/lneGQGMzJiJyvbpyKgNy4l4qofq0nEbXACl7C/jW/ErsdQa21aQ==", + "requires": { + "popper.js": "^1.14.6" + } + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", diff --git a/package.json b/package.json index c2d0e85ac0..26ced09c3b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "dependencies": { "@lingui/react": "^2.7.2", "@patternfly/patternfly-next": "^1.0.84", - "@patternfly/react-core": "^1.37.2", + "@patternfly/react-core": "^1.43.5", "@patternfly/react-icons": "^2.9.1", "@patternfly/react-styles": "^2.3.0", "@patternfly/react-tokens": "^1.9.0", From 70840841c13ae0208020121489d704fd0e4ce6a4 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 00:51:01 -0500 Subject: [PATCH 03/25] update LoginPage param names for @patternfly/react-core 1.43.5 --- src/pages/Login.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index ee86cecf97..20690940b2 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -65,8 +65,8 @@ class AtLogin extends Component { {({ i18n }) => ( Date: Wed, 2 Jan 2019 00:56:45 -0500 Subject: [PATCH 04/25] fix login page test for @patternfly/react-core 1.43.5 --- __tests__/pages/Login.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/pages/Login.jsx b/__tests__/pages/Login.jsx index ed9fd164f9..b0e3616776 100644 --- a/__tests__/pages/Login.jsx +++ b/__tests__/pages/Login.jsx @@ -23,7 +23,7 @@ describe('', () => { usernameInput = loginWrapper.find('input#pf-login-username-id'); passwordInput = loginWrapper.find('input#pf-login-password-id'); submitButton = loginWrapper.find('Button[type="submit"]'); - loginHeaderLogo = loginWrapper.find('LoginHeaderBrand Brand'); + loginHeaderLogo = loginPage.find('img'); }; beforeEach(() => { From 18505b35b875b70759d4b2728bad7e8670a2d515 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:11:13 -0500 Subject: [PATCH 05/25] add params for component routing --- __tests__/App.test.jsx | 37 +++-- __tests__/index.test.jsx | 4 +- src/App.jsx | 296 +++++++++++++++------------------------ src/api.js | 16 ++- src/index.jsx | 191 +++++++++++++++++++++++-- 5 files changed, 337 insertions(+), 207 deletions(-) diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index ae7366a74e..d69dab8929 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { HashRouter as Router } from 'react-router-dom'; import { shallow, mount } from 'enzyme'; import App from '../src/App'; import api from '../src/api'; @@ -7,6 +7,16 @@ import { API_LOGOUT, API_CONFIG } from '../src/endpoints'; import Dashboard from '../src/pages/Dashboard'; import Login from '../src/pages/Login'; +import { asyncFlush } from '../jest.setup'; + +const DEFAULT_ACTIVE_GROUP = 'views_group'; +const DEFAULT_ACTIVE_ITEM = 'views_group_dashboard'; + +const routeGroups = [{ + groupId: DEFAULT_ACTIVE_GROUP, + title: 'test', + routes: [{ path: '/home', title: 'Dashboard', component: Dashboard }], +}]; describe('', () => { test('renders without crashing', () => { @@ -18,7 +28,7 @@ describe('', () => { api.isAuthenticated = jest.fn(); api.isAuthenticated.mockReturnValue(false); - const appWrapper = mount(); + const appWrapper = mount(); const login = appWrapper.find(Login); expect(login.length).toBe(1); @@ -30,7 +40,7 @@ describe('', () => { api.isAuthenticated = jest.fn(); api.isAuthenticated.mockReturnValue(true); - const appWrapper = mount(); + const appWrapper = mount(); const dashboard = appWrapper.find(Dashboard); expect(dashboard.length).toBe(1); @@ -39,21 +49,30 @@ describe('', () => { }); test('onNavToggle sets state.isNavOpen to opposite', () => { - const appWrapper = shallow(); + const appWrapper = shallow(); expect(appWrapper.state().isNavOpen).toBe(true); appWrapper.instance().onNavToggle(); expect(appWrapper.state().isNavOpen).toBe(false); }); + 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 () => { - const logOutButtonSelector = 'button[aria-label="Logout"]'; api.get = jest.fn().mockImplementation(() => Promise.resolve({})); - const appWrapper = mount(); - const logOutButton = appWrapper.find(logOutButtonSelector); - expect(logOutButton.length).toBe(1); - logOutButton.simulate('click'); + const appWrapper = shallow(); + appWrapper.instance().onDevLogout(); appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); expect(api.get).toHaveBeenCalledWith(API_LOGOUT); + await asyncFlush(); + expect(appWrapper.state().activeItem).toBe(DEFAULT_ACTIVE_ITEM); + expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); }); test('Componenet makes REST call to API_CONFIG endpoint when mounted', () => { diff --git a/__tests__/index.test.jsx b/__tests__/index.test.jsx index 654bc5a6d6..f2b79ba7da 100644 --- a/__tests__/index.test.jsx +++ b/__tests__/index.test.jsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import api from '../src/api'; -import indexToRender from '../src/index'; +import { main } from '../src/index'; const custom_logo = (
logo
); const custom_login_info = 'custom login info'; @@ -15,7 +15,7 @@ describe('index.jsx', () => { api.getRoot = jest.fn().mockImplementation(() => Promise .resolve({ data: { custom_logo, custom_login_info } })); - await indexToRender(); + await main(); expect(ReactDOM.render).toHaveBeenCalled(); }); diff --git a/src/App.jsx b/src/App.jsx index d08764acd7..0fd573d650 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,14 +1,12 @@ import React, { Fragment } from 'react'; import { ConfigContext } from './context'; -import { I18nProvider, I18n } from '@lingui/react'; -import { t } from '@lingui/macro'; import { + HashRouter as Router, Redirect, Switch, withRouter } from 'react-router-dom'; - import { BackgroundImage, BackgroundImageSrc, @@ -22,64 +20,56 @@ import { ToolbarItem } from '@patternfly/react-core'; import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens'; +import { I18nProvider, I18n } from '@lingui/react'; +import { t } from '@lingui/macro'; import api from './api'; import { API_LOGOUT, API_CONFIG } from './endpoints'; +import ja from '../build/locales/ja/messages'; +import en from '../build/locales/en/messages'; + +import Login from './pages/Login'; import HelpDropdown from './components/HelpDropdown'; import LogoutButton from './components/LogoutButton'; import TowerLogo from './components/TowerLogo'; import ConditionalRedirect from './components/ConditionalRedirect'; import NavExpandableGroup from './components/NavExpandableGroup'; -import Applications from './pages/Applications'; -import Credentials from './pages/Credentials'; -import CredentialTypes from './pages/CredentialTypes'; -import Dashboard from './pages/Dashboard'; -import InstanceGroups from './pages/InstanceGroups'; -import Inventories from './pages/Inventories'; -import InventoryScripts from './pages/InventoryScripts'; -import Jobs from './pages/Jobs'; -import Login from './pages/Login'; -import ManagementJobs from './pages/ManagementJobs'; -import NotificationTemplates from './pages/NotificationTemplates'; -import Organizations from './pages/Organizations'; -import Portal from './pages/Portal'; -import Projects from './pages/Projects'; -import Schedules from './pages/Schedules'; -import AuthSettings from './pages/AuthSettings'; -import JobsSettings from './pages/JobsSettings'; -import SystemSettings from './pages/SystemSettings'; -import UISettings from './pages/UISettings'; -import License from './pages/License'; -import Teams from './pages/Teams'; -import Templates from './pages/Templates'; -import Users from './pages/Users'; - -import ja from '../build/locales/ja/messages'; -import en from '../build/locales/en/messages'; - const catalogs = { en, ja }; - -// This spits out the language and the region. Example: es-US +// Derive the language and the region from global user agent data. Example: es-US +// https://developer.mozilla.org/en-US/docs/Web/API/Navigator const language = (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage; - const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; +// define background src image config +const backgroundConfig = { + [BackgroundImageSrc.lg]: '/assets/images/pfbg_1200.jpg', + [BackgroundImageSrc.md]: '/assets/images/pfbg_992.jpg', + [BackgroundImageSrc.md2x]: '/assets/images/pfbg_992@2x.jpg', + [BackgroundImageSrc.sm]: '/assets/images/pfbg_768.jpg', + [BackgroundImageSrc.sm2x]: '/assets/images/pfbg_768@2x.jpg', + [BackgroundImageSrc.xl]: '/assets/images/pfbg_2000.jpg', + [BackgroundImageSrc.xs]: '/assets/images/pfbg_576.jpg', + [BackgroundImageSrc.xs2x]: '/assets/images/pfbg_576@2x.jpg', + [BackgroundImageSrc.filter]: '/assets/images/background-filter.svg' +}; class App extends React.Component { constructor(props) { super(props); - const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10); + const isNavOpen = typeof window !== 'undefined' + && window.innerWidth >= parseInt(breakpointMd.value, 10); + this.state = { isNavOpen, config: {}, error: false, }; - } + }; onNavToggle = () => { this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); @@ -104,158 +94,102 @@ class App extends React.Component { } } - render() { - const { isNavOpen, config } = this.state; - const { logo, loginInfo, history } = this.props; - - const PageToolbar = ( - - - - - - - this.onDevLogout()} /> - - - - ); + render () { + const { isNavOpen } = this.state; + const { logo, loginInfo, history, routeConfig = [] } = this.props; + // extract a flattened array of all routes from the provided route config + const allRoutes = routeConfig.reduce((flattened, { routes }) => flattened.concat(routes), []); return ( - - - - - - api.isAuthenticated()} - redirectPath="/" - path="/login" - component={() => } - /> + + + + {({ i18n }) => ( - } - toolbar={PageToolbar} - showNavToggle - onNavToggle={this.onNavToggle} + + + + api.isAuthenticated()} + redirectPath="/" + path="/login" + component={() => } /> - )} - 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} /> - - + + } + toolbar={( + + + + + + + this.onDevLogout()} /> + + + + )} + showNavToggle + onNavToggle={this.onNavToggle} + /> + )} + sidebar={( + + + { routeConfig.map(({ groupId, routes, title }) => ( + ({ + path: route.path, + component: route.component, + title: i18n._(route.title) + }))} + /> + ))} + + + )} + /> + )} + > + !api.isAuthenticated()} + redirectPath="/login" + exact path="/" + component={() => ()} + /> + { allRoutes.map(({ component, path }) => ( + !api.isAuthenticated()} + component={component} + /> + ))} + + + + - - - - + )} +
+ + ); } } -export default withRouter(App); +export default App; diff --git a/src/api.js b/src/api.js index 62649fa0e5..9e07c3bac5 100644 --- a/src/api.js +++ b/src/api.js @@ -1,6 +1,10 @@ import axios from 'axios'; -import * as endpoints from './endpoints'; +import { + API_CONFIG, + API_LOGIN, + API_ROOT, +} from './endpoints'; const CSRF_COOKIE_NAME = 'csrftoken'; const CSRF_HEADER_NAME = 'X-CSRFToken'; @@ -32,7 +36,11 @@ class APIClient { return authenticated; } - async login (username, password, redirect = endpoints.API_CONFIG) { + getRoot () { + return this.http.get(API_ROOT); + } + + async login (username, password, redirect = API_CONFIG) { const un = encodeURIComponent(username); const pw = encodeURIComponent(password); const next = encodeURIComponent(redirect); @@ -40,8 +48,8 @@ class APIClient { const data = `username=${un}&password=${pw}&next=${next}`; const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - await this.http.get(endpoints.API_LOGIN, { headers }); - await this.http.post(endpoints.API_LOGIN, data, { headers }); + await this.http.get(API_LOGIN, { headers }); + await this.http.post(API_LOGIN, data, { headers }); } get = (endpoint, params = {}) => this.http.get(endpoint, { params }); diff --git a/src/index.jsx b/src/index.jsx index 24a5eb8576..03a4264183 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,12 +1,8 @@ import React from 'react'; import { render } from 'react-dom'; -import { - HashRouter as Router -} from 'react-router-dom'; import App from './App'; import api from './api'; -import { API_ROOT } from './endpoints'; import '@patternfly/react-core/dist/styles/base.css'; import '@patternfly/patternfly-next/patternfly.css'; @@ -15,13 +11,186 @@ import './app.scss'; import './components/Pagination/styles.scss'; import './components/DataListToolbar/styles.scss'; -const el = document.getElementById('app'); +import Applications from './pages/Applications'; +import Credentials from './pages/Credentials'; +import CredentialTypes from './pages/CredentialTypes'; +import Dashboard from './pages/Dashboard'; +import InstanceGroups from './pages/InstanceGroups'; +import Inventories from './pages/Inventories'; +import InventoryScripts from './pages/InventoryScripts'; +import Jobs from './pages/Jobs'; +import Login from './pages/Login'; +import ManagementJobs from './pages/ManagementJobs'; +import NotificationTemplates from './pages/NotificationTemplates'; +import Organizations from './pages/Organizations'; +import Portal from './pages/Portal'; +import Projects from './pages/Projects'; +import Schedules from './pages/Schedules'; +import AuthSettings from './pages/AuthSettings'; +import JobsSettings from './pages/JobsSettings'; +import SystemSettings from './pages/SystemSettings'; +import UISettings from './pages/UISettings'; +import License from './pages/License'; +import Teams from './pages/Teams'; +import Templates from './pages/Templates'; +import Users from './pages/Users'; -const main = async () => { - const { custom_logo, custom_login_info } = await api.get(API_ROOT); - render(, el); +const routeGroups = [ + { + groupId: 'views_group', + title: 'Views', + routes: [ + { + path: '/home', + title: 'Dashboard', + component: Dashboard + }, + { + path: '/jobs', + title: 'Jobs', + component: Jobs + }, + { + path: '/schedules', + title: 'Schedules', + component: Schedules + }, + { + path: '/portal', + title: 'Portal Mode', + component: Portal + }, + ], + }, + { + groupId: 'resources_group', + title: "Resources", + routes: [ + { + path: '/templates', + title: 'Templates', + component: Templates + }, + { + path: '/credentials', + title: 'Credentials', + component: Credentials + }, + { + path: '/projects', + title: 'Projects', + component: Projects + }, + { + path: '/inventories', + title: 'Inventories', + component: Inventories + }, + { + path: '/inventory_scripts', + title: 'Inventory Scripts', + component: InventoryScripts + }, + ], + }, + { + groupId: 'access_group', + title: 'Access', + routes: [ + { + path: '/organizations', + title: 'Organizations', + component: Organizations + }, + { + path: '/users', + title: 'Users', + component: Users + }, + { + path: '/teams', + title: 'Teams', + component: Teams + }, + ], + }, + { + groupId: 'administration_group', + title: 'Administration', + routes: [ + { + path: '/credential_types', + title: 'Credential Types', + component: CredentialTypes + }, + { + path: '/notification_templates', + title: 'Notifications', + component: NotificationTemplates + }, + { + path: '/management_jobs', + title: 'Management Jobs', + component: ManagementJobs + }, + { + path: '/instance_groups', + title: 'Instance Groups', + component: InstanceGroups + }, + { + path: '/applications', + title: 'Integrations', + component: Applications + }, + ], + }, + { + groupId: 'settings_group', + title: 'Settings', + routes: [ + { + path: '/auth_settings', + title: 'Authentication', + component: AuthSettings + }, + { + path: '/jobs_settings', + title: 'Jobs', + component: JobsSettings + }, + { + path: '/system_settings', + title: 'System', + component: SystemSettings + }, + { + path: '/ui_settings', + title: 'User Interface', + component: UISettings + }, + { + path: '/license', + title: 'License', + component: License + }, + ], + }, +]; + + +export async function main () { + const el = document.getElementById('app'); + // fetch additional config from server + const { data } = await api.getRoot(); + const { custom_logo, custom_login_info } = data; + + render( + , el); }; -main(); - -export default main; +export default main(); From a2601d5f674c7b69de1cfc30bfe770a578ecf8c3 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:15:31 -0500 Subject: [PATCH 06/25] remove conditional redirect component --- .../components/ConditionalRedirect.test.jsx | 35 ---- src/App.jsx | 155 +++++++++--------- src/components/ConditionalRedirect.jsx | 23 --- 3 files changed, 76 insertions(+), 137 deletions(-) delete mode 100644 __tests__/components/ConditionalRedirect.test.jsx delete mode 100644 src/components/ConditionalRedirect.jsx diff --git a/__tests__/components/ConditionalRedirect.test.jsx b/__tests__/components/ConditionalRedirect.test.jsx deleted file mode 100644 index c437ae6971..0000000000 --- a/__tests__/components/ConditionalRedirect.test.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { - Route, - Redirect -} from 'react-router-dom'; -import { shallow } from 'enzyme'; -import ConditionalRedirect from '../../src/components/ConditionalRedirect'; - -describe('', () => { - test('renders Redirect when shouldRedirect is passed truthy func', () => { - const truthyFunc = () => true; - const shouldHaveRedirectChild = shallow( - truthyFunc()} - /> - ); - const redirectChild = shouldHaveRedirectChild.find(Redirect); - expect(redirectChild.length).toBe(1); - const routeChild = shouldHaveRedirectChild.find(Route); - expect(routeChild.length).toBe(0); - }); - - test('renders Route when shouldRedirect is passed falsy func', () => { - const falsyFunc = () => false; - const shouldHaveRouteChild = shallow( - falsyFunc()} - /> - ); - const routeChild = shouldHaveRouteChild.find(Route); - expect(routeChild.length).toBe(1); - const redirectChild = shouldHaveRouteChild.find(Redirect); - expect(redirectChild.length).toBe(0); - }); -}); diff --git a/src/App.jsx b/src/App.jsx index 0fd573d650..c03ecbcb06 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,7 +5,8 @@ import { HashRouter as Router, Redirect, Switch, - withRouter + withRouter, + Route, } from 'react-router-dom'; import { BackgroundImage, @@ -28,12 +29,10 @@ import { API_LOGOUT, API_CONFIG } from './endpoints'; import ja from '../build/locales/ja/messages'; import en from '../build/locales/en/messages'; - import Login from './pages/Login'; import HelpDropdown from './components/HelpDropdown'; import LogoutButton from './components/LogoutButton'; import TowerLogo from './components/TowerLogo'; -import ConditionalRedirect from './components/ConditionalRedirect'; import NavExpandableGroup from './components/NavExpandableGroup'; const catalogs = { en, ja }; @@ -61,6 +60,7 @@ class App extends React.Component { constructor(props) { super(props); + // initialize with a closed navbar if window size is small const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10); @@ -95,10 +95,9 @@ class App extends React.Component { } render () { - const { isNavOpen } = this.state; - const { logo, loginInfo, history, routeConfig = [] } = this.props; + const { config, isNavOpen } = this.state; // extract a flattened array of all routes from the provided route config - const allRoutes = routeConfig.reduce((flattened, { routes }) => flattened.concat(routes), []); + const { logo, loginInfo, routeGroups = [] } = this.props; return ( @@ -108,82 +107,80 @@ class App extends React.Component { > {({ i18n }) => ( - - - + api.isAuthenticated () ? ( + - api.isAuthenticated()} - redirectPath="/" - path="/login" - component={() => } - /> - - } - toolbar={( - - - - - - - this.onDevLogout()} /> - - - - )} - showNavToggle - onNavToggle={this.onNavToggle} - /> - )} - sidebar={( - - - { routeConfig.map(({ groupId, routes, title }) => ( - ({ - path: route.path, - component: route.component, - title: i18n._(route.title) - }))} - /> - ))} - - - )} - /> - )} - > - !api.isAuthenticated()} - redirectPath="/login" - exact path="/" - component={() => ()} - /> - { allRoutes.map(({ component, path }) => ( - !api.isAuthenticated()} - component={component} - /> - ))} - - + } /> + } /> + ( + + + this.onNavToggle()} + logo={( + + )} + toolbar={( + + + + + + + this.onDevLogout()} + /> + + + + )} + /> + )} + sidebar={( + + + { + routeGroups.map(params => ) + } + + + )} + /> + )} + > + { + // + // Extract a flattened array of all route params from the provided route groups + // and use it to create the route components. + // + // [{ routes }, { routes }] -> [route, route, route] -> () + // + routeGroups + .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) + .map(({ component: Component, path }) => ( + } /> + )) + } + + + )} /> - + ) : ( + + } /> + + + ) )} diff --git a/src/components/ConditionalRedirect.jsx b/src/components/ConditionalRedirect.jsx deleted file mode 100644 index 7a2112326e..0000000000 --- a/src/components/ConditionalRedirect.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { - Route, - Redirect -} from 'react-router-dom'; - -const ConditionalRedirect = ({ - component: Component, - shouldRedirect, - redirectPath, - location, - ...props -}) => (shouldRedirect() ? ( - -) : ( - ()} /> -)); - -export default ConditionalRedirect; From f975f9fa75e1ba847e10ee32869415a39ea78dd0 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:23:12 -0500 Subject: [PATCH 07/25] add background component --- src/App.jsx | 20 +++--------------- src/components/Background.jsx | 25 +++++++++++++++++++++++ src/pages/Login.jsx | 38 +++++++++++++++++++---------------- 3 files changed, 49 insertions(+), 34 deletions(-) create mode 100644 src/components/Background.jsx diff --git a/src/App.jsx b/src/App.jsx index c03ecbcb06..07e7021ddf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,8 +9,6 @@ import { Route, } from 'react-router-dom'; import { - BackgroundImage, - BackgroundImageSrc, Nav, NavList, Page, @@ -30,6 +28,7 @@ import { API_LOGOUT, API_CONFIG } from './endpoints'; import ja from '../build/locales/ja/messages'; import en from '../build/locales/en/messages'; import Login from './pages/Login'; +import Background from './components/Background'; import HelpDropdown from './components/HelpDropdown'; import LogoutButton from './components/LogoutButton'; import TowerLogo from './components/TowerLogo'; @@ -43,18 +42,6 @@ const language = (navigator.languages && navigator.languages[0]) || navigator.userLanguage; const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; -// define background src image config -const backgroundConfig = { - [BackgroundImageSrc.lg]: '/assets/images/pfbg_1200.jpg', - [BackgroundImageSrc.md]: '/assets/images/pfbg_992.jpg', - [BackgroundImageSrc.md2x]: '/assets/images/pfbg_992@2x.jpg', - [BackgroundImageSrc.sm]: '/assets/images/pfbg_768.jpg', - [BackgroundImageSrc.sm2x]: '/assets/images/pfbg_768@2x.jpg', - [BackgroundImageSrc.xl]: '/assets/images/pfbg_2000.jpg', - [BackgroundImageSrc.xs]: '/assets/images/pfbg_576.jpg', - [BackgroundImageSrc.xs2x]: '/assets/images/pfbg_576@2x.jpg', - [BackgroundImageSrc.filter]: '/assets/images/background-filter.svg' -}; class App extends React.Component { constructor(props) { @@ -113,8 +100,7 @@ class App extends React.Component { } /> } /> ( - - + - + )} /> diff --git a/src/components/Background.jsx b/src/components/Background.jsx new file mode 100644 index 0000000000..b1dce984be --- /dev/null +++ b/src/components/Background.jsx @@ -0,0 +1,25 @@ +import React, { Fragment } from 'react'; + +import { + BackgroundImage, + BackgroundImageSrc, +} from '@patternfly/react-core'; + +const backgroundImageConfig = { + [BackgroundImageSrc.lg]: '/assets/images/pfbg_1200.jpg', + [BackgroundImageSrc.md]: '/assets/images/pfbg_992.jpg', + [BackgroundImageSrc.md2x]: '/assets/images/pfbg_992@2x.jpg', + [BackgroundImageSrc.sm]: '/assets/images/pfbg_768.jpg', + [BackgroundImageSrc.sm2x]: '/assets/images/pfbg_768@2x.jpg', + [BackgroundImageSrc.xl]: '/assets/images/pfbg_2000.jpg', + [BackgroundImageSrc.xs]: '/assets/images/pfbg_576.jpg', + [BackgroundImageSrc.xs2x]: '/assets/images/pfbg_576@2x.jpg', + [BackgroundImageSrc.filter]: '/assets/images/background-filter.svg', +}; + +export default ({ children }) => ( + + + { children } + +); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 20690940b2..ba7917f9a3 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -10,6 +10,8 @@ import { import towerLogo from '../../images/tower-logo-header.svg'; import api from '../api'; +import Background from '../components/Background'; + class AtLogin extends Component { constructor (props) { super(props); @@ -64,23 +66,25 @@ class AtLogin extends Component { return ( {({ i18n }) => ( - - - + + + + + )} ); From 6efd523db24736db371dc129c9d1c9f28f9ff74e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:30:08 -0500 Subject: [PATCH 08/25] move wrapper / shared components out of App component --- src/App.jsx | 168 +++++++++------------- src/index.jsx | 333 ++++++++++++++++++++++++-------------------- src/pages/Login.jsx | 38 +++-- 3 files changed, 267 insertions(+), 272 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 07e7021ddf..c64bbb022e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,10 +2,8 @@ import React, { Fragment } from 'react'; import { ConfigContext } from './context'; import { - HashRouter as Router, Redirect, Switch, - withRouter, Route, } from 'react-router-dom'; import { @@ -19,30 +17,16 @@ import { ToolbarItem } from '@patternfly/react-core'; import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens'; -import { I18nProvider, I18n } from '@lingui/react'; -import { t } from '@lingui/macro'; import api from './api'; import { API_LOGOUT, API_CONFIG } from './endpoints'; -import ja from '../build/locales/ja/messages'; -import en from '../build/locales/en/messages'; import Login from './pages/Login'; -import Background from './components/Background'; import HelpDropdown from './components/HelpDropdown'; import LogoutButton from './components/LogoutButton'; import TowerLogo from './components/TowerLogo'; import NavExpandableGroup from './components/NavExpandableGroup'; -const catalogs = { en, ja }; -// Derive the language and the region from global user agent data. Example: es-US -// https://developer.mozilla.org/en-US/docs/Web/API/Navigator -const language = (navigator.languages && navigator.languages[0]) - || navigator.language - || navigator.userLanguage; -const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; - - class App extends React.Component { constructor(props) { super(props); @@ -83,94 +67,80 @@ class App extends React.Component { render () { const { config, isNavOpen } = this.state; - // extract a flattened array of all routes from the provided route config - const { logo, loginInfo, routeGroups = [] } = this.props; + const { logo, loginInfo, navLabel, routeGroups = [] } = this.props; return ( - - - - {({ i18n }) => ( - api.isAuthenticated () ? ( - - - } /> - } /> - ( - - this.onNavToggle()} - logo={( - - )} - toolbar={( - - - - - - - this.onDevLogout()} - /> - - - - )} + api.isAuthenticated () ? ( + + } /> + } /> + ( + + this.onNavToggle()} + logo={( + + )} + toolbar={( + + + + + + + this.onDevLogout()} /> - )} - sidebar={( - - - { - routeGroups.map(params => ) - } - - - )} - /> - )} - > + + + + )} + /> + )} + sidebar={( + + { - // - // Extract a flattened array of all route params from the provided route groups - // and use it to create the route components. - // - // [{ routes }, { routes }] -> [route, route, route] -> () - // - routeGroups - .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) - .map(({ component: Component, path }) => ( - } /> - )) + routeGroups.map(params => ) } - - - )} /> - - - ) : ( - - } /> - - - ) - )} - - - + + + )} + /> + )} + > + { + // + // Extract a flattened array of all route params from the provided route config + // and use it to render route components. + // + // [{ routes }, { routes }] -> [route, route, route] -> () + // + routeGroups + .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) + .map(({ component: Component, path }) => ( + } /> + )) + } + + + )} /> + + ) : ( + + } /> + + + ) ); } } diff --git a/src/index.jsx b/src/index.jsx index 03a4264183..ed95e8b74e 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,16 +1,24 @@ import React from 'react'; import { render } from 'react-dom'; - -import App from './App'; -import api from './api'; +import { + HashRouter as Router, + Switch, +} from 'react-router-dom'; +import { + I18n, + I18nProvider, +} from '@lingui/react'; +import { t } from '@lingui/macro'; import '@patternfly/react-core/dist/styles/base.css'; import '@patternfly/patternfly-next/patternfly.css'; - import './app.scss'; import './components/Pagination/styles.scss'; import './components/DataListToolbar/styles.scss'; +import api from './api'; +import App from './App'; +import Background from './components/Background'; import Applications from './pages/Applications'; import Credentials from './pages/Credentials'; import CredentialTypes from './pages/CredentialTypes'; @@ -34,150 +42,16 @@ import License from './pages/License'; import Teams from './pages/Teams'; import Templates from './pages/Templates'; import Users from './pages/Users'; +import ja from '../build/locales/ja/messages'; +import en from '../build/locales/en/messages'; -const routeGroups = [ - { - groupId: 'views_group', - title: 'Views', - routes: [ - { - path: '/home', - title: 'Dashboard', - component: Dashboard - }, - { - path: '/jobs', - title: 'Jobs', - component: Jobs - }, - { - path: '/schedules', - title: 'Schedules', - component: Schedules - }, - { - path: '/portal', - title: 'Portal Mode', - component: Portal - }, - ], - }, - { - groupId: 'resources_group', - title: "Resources", - routes: [ - { - path: '/templates', - title: 'Templates', - component: Templates - }, - { - path: '/credentials', - title: 'Credentials', - component: Credentials - }, - { - path: '/projects', - title: 'Projects', - component: Projects - }, - { - path: '/inventories', - title: 'Inventories', - component: Inventories - }, - { - path: '/inventory_scripts', - title: 'Inventory Scripts', - component: InventoryScripts - }, - ], - }, - { - groupId: 'access_group', - title: 'Access', - routes: [ - { - path: '/organizations', - title: 'Organizations', - component: Organizations - }, - { - path: '/users', - title: 'Users', - component: Users - }, - { - path: '/teams', - title: 'Teams', - component: Teams - }, - ], - }, - { - groupId: 'administration_group', - title: 'Administration', - routes: [ - { - path: '/credential_types', - title: 'Credential Types', - component: CredentialTypes - }, - { - path: '/notification_templates', - title: 'Notifications', - component: NotificationTemplates - }, - { - path: '/management_jobs', - title: 'Management Jobs', - component: ManagementJobs - }, - { - path: '/instance_groups', - title: 'Instance Groups', - component: InstanceGroups - }, - { - path: '/applications', - title: 'Integrations', - component: Applications - }, - ], - }, - { - groupId: 'settings_group', - title: 'Settings', - routes: [ - { - path: '/auth_settings', - title: 'Authentication', - component: AuthSettings - }, - { - path: '/jobs_settings', - title: 'Jobs', - component: JobsSettings - }, - { - path: '/system_settings', - title: 'System', - component: SystemSettings - }, - { - path: '/ui_settings', - title: 'User Interface', - component: UISettings - }, - { - path: '/license', - title: 'License', - component: License - }, - ], - }, -]; - +const catalogs = { en, ja }; +// Derive the language and region from global user agent data. Example: es-US +// https://developer.mozilla.org/en-US/docs/Web/API/Navigator +const language = (navigator.languages && navigator.languages[0]) + || navigator.language + || navigator.userLanguage; +const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; export async function main () { const el = document.getElementById('app'); @@ -186,11 +60,166 @@ export async function main () { const { custom_logo, custom_login_info } = data; render( - , el); + + + + {({ i18n }) => ( + + + + )} + + + , el); }; export default main(); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index ba7917f9a3..20690940b2 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -10,8 +10,6 @@ import { import towerLogo from '../../images/tower-logo-header.svg'; import api from '../api'; -import Background from '../components/Background'; - class AtLogin extends Component { constructor (props) { super(props); @@ -66,25 +64,23 @@ class AtLogin extends Component { return ( {({ i18n }) => ( - - - - - + + + )} ); From 9c6df685574164f9fecebe48df04464cc482a65a Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:35:34 -0500 Subject: [PATCH 09/25] decouple App and Login components --- __tests__/App.test.jsx | 26 +--- src/App.jsx | 163 +++++++++++---------- src/index.jsx | 325 ++++++++++++++++++++++------------------- 3 files changed, 259 insertions(+), 255 deletions(-) diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index d69dab8929..5c9bc7d195 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -1,12 +1,12 @@ import React from 'react'; import { HashRouter as Router } from 'react-router-dom'; + import { shallow, mount } from 'enzyme'; import App from '../src/App'; import api from '../src/api'; import { API_LOGOUT, API_CONFIG } from '../src/endpoints'; import Dashboard from '../src/pages/Dashboard'; -import Login from '../src/pages/Login'; import { asyncFlush } from '../jest.setup'; const DEFAULT_ACTIVE_GROUP = 'views_group'; @@ -24,30 +24,6 @@ describe('', () => { expect(appWrapper.length).toBe(1); }); - test('renders login page when not authenticated', () => { - api.isAuthenticated = jest.fn(); - api.isAuthenticated.mockReturnValue(false); - - const appWrapper = mount(); - - const login = appWrapper.find(Login); - expect(login.length).toBe(1); - const dashboard = appWrapper.find(Dashboard); - expect(dashboard.length).toBe(0); - }); - - test('renders dashboard when authenticated', () => { - api.isAuthenticated = jest.fn(); - api.isAuthenticated.mockReturnValue(true); - - const appWrapper = mount(); - - const dashboard = appWrapper.find(Dashboard); - expect(dashboard.length).toBe(1); - const login = appWrapper.find(Login); - expect(login.length).toBe(0); - }); - test('onNavToggle sets state.isNavOpen to opposite', () => { const appWrapper = shallow(); expect(appWrapper.state().isNavOpen).toBe(true); diff --git a/src/App.jsx b/src/App.jsx index c64bbb022e..2ef0aa65f8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,4 @@ -import React, { Fragment } from 'react'; -import { ConfigContext } from './context'; - +import React, { Component } from 'react'; import { Redirect, Switch, @@ -20,15 +18,15 @@ import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens'; import api from './api'; import { API_LOGOUT, API_CONFIG } from './endpoints'; +import { ConfigContext } from './context'; -import Login from './pages/Login'; import HelpDropdown from './components/HelpDropdown'; import LogoutButton from './components/LogoutButton'; import TowerLogo from './components/TowerLogo'; import NavExpandableGroup from './components/NavExpandableGroup'; -class App extends React.Component { - constructor(props) { +class App extends Component { + constructor (props) { super(props); // initialize with a closed navbar if window size is small @@ -47,13 +45,21 @@ class App extends React.Component { }; onLogoClick = () => { - this.setState({ activeGroup: 'views_group' }); + this.setState({ + activeGroup: 'views_group' + }); } onDevLogout = async () => { await api.get(API_LOGOUT); - this.setState({ activeGroup: 'views_group', activeItem: 'views_group_dashboard' }); - } + + 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 @@ -67,80 +73,77 @@ class App extends React.Component { render () { const { config, isNavOpen } = this.state; - const { logo, loginInfo, navLabel, routeGroups = [] } = this.props; + const { navLabel = '', routeGroups = [] } = this.props; + + const header = ( + this.onNavToggle()} + logo={( + + )} + toolbar={( + + + + + + + this.onDevLogout()} + /> + + + + )} + /> + ); + + const sidebar = ( + + + {routeGroups.map(params => ( + + ))} + + + )} + /> + ); return ( - api.isAuthenticated () ? ( - - } /> - } /> - ( - - this.onNavToggle()} - logo={( - - )} - toolbar={( - - - - - - - this.onDevLogout()} - /> - - - - )} - /> + + + { + // + // Extract a flattened array of all route params from the provided route config + // and use it to render route components. + // + // [{ routes }, { routes }] -> [route, route, route] -> () + // + routeGroups + .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) + .map(({ component: Component, path }) => ( + ( + )} - sidebar={( - - - { - routeGroups.map(params => ) - } - - - )} - /> - )} - > - { - // - // Extract a flattened array of all route params from the provided route config - // and use it to render route components. - // - // [{ routes }, { routes }] -> [route, route, route] -> () - // - routeGroups - .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) - .map(({ component: Component, path }) => ( - } /> - )) - } - - - )} /> - - ) : ( - - } /> - - - ) + /> + )) + } + + ); } } diff --git a/src/index.jsx b/src/index.jsx index ed95e8b74e..bfd56e2960 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,7 +1,9 @@ import React from 'react'; import { render } from 'react-dom'; import { - HashRouter as Router, + HashRouter, + Redirect, + Route, Switch, } from 'react-router-dom'; import { @@ -59,8 +61,23 @@ export async function main () { const { data } = await api.getRoot(); const { custom_logo, custom_login_info } = data; + const loginRoutes = ( + + ( + + )} + /> + + + ); + render( - + {({ i18n }) => ( - + {!api.isAuthenticated() ? loginRoutes : ( + + } /> + } /> + ( + + )} + /> + + )} )} - , el); + , el); }; export default main(); From f2760ed91c3ee6efb3e9bbb9075840f536b469e4 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:36:59 -0500 Subject: [PATCH 10/25] use default patternfly breakpoint token name --- src/App.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 2ef0aa65f8..3d8fca5605 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,5 @@ import React, { Component } from 'react'; +import { global_breakpoint_md } from '@patternfly/react-tokens'; import { Redirect, Switch, @@ -14,7 +15,6 @@ import { ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; -import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens'; import api from './api'; import { API_LOGOUT, API_CONFIG } from './endpoints'; @@ -31,7 +31,7 @@ class App extends Component { // initialize with a closed navbar if window size is small const isNavOpen = typeof window !== 'undefined' - && window.innerWidth >= parseInt(breakpointMd.value, 10); + && window.innerWidth >= parseInt(global_breakpoint_md.value, 10); this.state = { isNavOpen, From a023df2c178a708e07c3303cfce2a38be62b6668 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 01:40:44 -0500 Subject: [PATCH 11/25] add inline rendering prop to app component --- src/App.jsx | 108 +++++++++++++++++++++----------------------------- src/index.jsx | 15 +++++++ 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 3d8fca5605..854ab61733 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -73,75 +73,57 @@ class App extends Component { render () { const { config, isNavOpen } = this.state; - const { navLabel = '', routeGroups = [] } = this.props; - - const header = ( - this.onNavToggle()} - logo={( - - )} - toolbar={( - - - - - - - this.onDevLogout()} - /> - - - - )} - /> - ); - - const sidebar = ( - - - {routeGroups.map(params => ( - - ))} - - - )} - /> - ); + const { + render, + routeGroups = [], + navLabel = '', + } = this.props; return ( this.onNavToggle()} + logo={( + + )} + toolbar={( + + + + + + + + + + + )} + /> + )} + sidebar={( + + + {routeGroups.map(params => ( + + ))} + + + )} + /> + )} > - { - // - // Extract a flattened array of all route params from the provided route config - // and use it to render route components. - // - // [{ routes }, { routes }] -> [route, route, route] -> () - // - routeGroups - .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) - .map(({ component: Component, path }) => ( - ( - - )} - /> - )) - } + { render ? render({ routeGroups }) : '' } ); diff --git a/src/index.jsx b/src/index.jsx index bfd56e2960..04cb57b6c8 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -235,6 +235,21 @@ export async function main () { ], }, ]} + render={({ routeGroups }) => ( + routeGroups + .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) + .map(({ component: PageComponent, path }) => ( + ( + + )} + /> + )) + )} /> )} /> From 8f4437e17e443942011af9d70c1cf997a906fcd7 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 02:11:01 -0500 Subject: [PATCH 12/25] initialize and pass api client to subviews --- src/App.jsx | 9 +++ src/api.js | 74 ++++++++++++------- src/index.jsx | 10 ++- src/pages/Login.jsx | 4 +- src/pages/Organizations/index.jsx | 31 ++++++-- .../Organizations/views/Organization.view.jsx | 13 ++-- .../views/Organizations.list.jsx | 6 +- 7 files changed, 99 insertions(+), 48 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 854ab61733..624242384d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -38,8 +38,17 @@ class App extends Component { config: {}, error: false, }; + + this.onLogout = this.onLogout.bind(this); }; + async onLogout () { + const { api } = this.props; + + await api.logout(); + window.location.replace('/#/login') + } + onNavToggle = () => { this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); }; diff --git a/src/api.js b/src/api.js index 9e07c3bac5..007fa37f5c 100644 --- a/src/api.js +++ b/src/api.js @@ -1,33 +1,35 @@ import axios from 'axios'; -import { - API_CONFIG, - API_LOGIN, - API_ROOT, -} from './endpoints'; +const API_ROOT = '/api/'; +const API_LOGIN = `${API_ROOT}login/`; +const API_LOGOUT = `${API_ROOT}logout/`; +const API_V2 = `${API_ROOT}v2/`; +const API_CONFIG = `${API_V2}config/`; +const API_ORGANIZATIONS = `${API_V2}organizations/`; const CSRF_COOKIE_NAME = 'csrftoken'; const CSRF_HEADER_NAME = 'X-CSRFToken'; - const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; -class APIClient { - constructor () { - this.http = axios.create({ - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME, - }); - } +const defaultHttpAdapter = axios.create({ + xsrfCookieName: CSRF_COOKIE_NAME, + xsrfHeaderName: CSRF_HEADER_NAME, +}); - /* eslint class-methods-use-this: ["error", { "exceptMethods": ["getCookie"] }] */ - getCookie () { +class APIClient { + static getCookie () { return document.cookie; } - isAuthenticated () { - let authenticated = false; + constructor (httpAdapter = defaultHttpAdapter) { + this.http = httpAdapter; + } - const parsed = (`; ${this.getCookie()}`).split('; userLoggedIn='); + isAuthenticated () { + const cookie = this.constructor.getCookie(); + const parsed = (`; ${cookie}`).split('; userLoggedIn='); + + let authenticated = false; if (parsed.length === 2) { authenticated = parsed.pop().split(';').shift() === 'true'; @@ -36,10 +38,6 @@ class APIClient { return authenticated; } - getRoot () { - return this.http.get(API_ROOT); - } - async login (username, password, redirect = API_CONFIG) { const un = encodeURIComponent(username); const pw = encodeURIComponent(password); @@ -49,12 +47,36 @@ class APIClient { const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; await this.http.get(API_LOGIN, { headers }); - await this.http.post(API_LOGIN, data, { headers }); + const response = await this.http.post(API_LOGIN, data, { headers }); + + return response; } - get = (endpoint, params = {}) => this.http.get(endpoint, { params }); + logout () { + return this.http.get(API_LOGOUT); + } - post = (endpoint, data) => this.http.post(endpoint, data); + getRoot () { + return this.http.get(API_ROOT); + } + + getConfig () { + return this.http.get(API_CONFIG); + } + + getOrganizations (params = {}) { + return this.http.get(API_ORGANIZATIONS, { params }); + } + + createOrganization (data) { + return this.http.post(API_ORGANIZATIONS, data); + } + + getOrganizationDetails (id) { + const endpoint = `${API_ORGANIZATIONS}${id}/`; + + return this.http.get(endpoint); + } } -export default new APIClient(); +export default APIClient; diff --git a/src/index.jsx b/src/index.jsx index 04cb57b6c8..5c63f5163f 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -18,7 +18,8 @@ import './app.scss'; import './components/Pagination/styles.scss'; import './components/DataListToolbar/styles.scss'; -import api from './api'; +import APIClient from './api'; + import App from './App'; import Background from './components/Background'; import Applications from './pages/Applications'; @@ -55,7 +56,7 @@ const language = (navigator.languages && navigator.languages[0]) || navigator.userLanguage; const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; -export async function main () { +export async function main (api) { const el = document.getElementById('app'); // fetch additional config from server const { data } = await api.getRoot(); @@ -67,6 +68,7 @@ export async function main () { path="/login" render={() => ( @@ -92,6 +94,7 @@ export async function main () { ( ( )} @@ -262,4 +266,4 @@ export async function main () { , el); }; -export default main(); +export default main(new APIClient()); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 20690940b2..207aa1491d 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -8,7 +8,6 @@ import { } from '@patternfly/react-core'; import towerLogo from '../../images/tower-logo-header.svg'; -import api from '../api'; class AtLogin extends Component { constructor (props) { @@ -34,6 +33,7 @@ class AtLogin extends Component { handleSubmit = async event => { const { username, password, loading } = this.state; + const { api } = this.props; event.preventDefault(); @@ -54,7 +54,7 @@ class AtLogin extends Component { render () { const { username, password, isValidPassword } = this.state; - const { logo, alt } = this.props; + const { api, alt, logo } = this.props; const logoSrc = logo ? `data:image/jpeg;${logo}` : towerLogo; if (api.isAuthenticated()) { diff --git a/src/pages/Organizations/index.jsx b/src/pages/Organizations/index.jsx index 09299873df..b3a13a0161 100644 --- a/src/pages/Organizations/index.jsx +++ b/src/pages/Organizations/index.jsx @@ -5,12 +5,31 @@ import OrganizationAdd from './views/Organization.add'; import OrganizationView from './views/Organization.view'; import OrganizationsList from './views/Organizations.list'; -const Organizations = ({ match }) => ( +export default ({ api, match }) => ( - - - + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> ); - -export default Organizations; diff --git a/src/pages/Organizations/views/Organization.view.jsx b/src/pages/Organizations/views/Organization.view.jsx index dbfb42c4b4..18c7066afa 100644 --- a/src/pages/Organizations/views/Organization.view.jsx +++ b/src/pages/Organizations/views/Organization.view.jsx @@ -2,16 +2,13 @@ import React, { Component, Fragment } from 'react'; import { i18nMark } from '@lingui/react'; import { Switch, - Route + Route, + withRouter, } from 'react-router-dom'; - import OrganizationBreadcrumb from '../components/OrganizationBreadcrumb'; import OrganizationDetail from '../components/OrganizationDetail'; import OrganizationEdit from '../components/OrganizationEdit'; -import api from '../../../api'; -import { API_ORGANIZATIONS } from '../../../endpoints'; - class OrganizationView extends Component { constructor (props) { super(props); @@ -47,13 +44,15 @@ class OrganizationView extends Component { async fetchOrganization () { const { mounted } = this.state; + const { api } = this.props; + if (mounted) { this.setState({ error: false, loading: true }); const { match } = this.props; const { parentBreadcrumbObj, organization } = this.state; try { - const { data } = await api.get(`${API_ORGANIZATIONS}${match.params.id}/`); + const { data } = await api.getOrganizationDetails(match.params.id); if (organization === 'loading') { this.setState({ organization: data }); } @@ -118,4 +117,4 @@ class OrganizationView extends Component { } } -export default OrganizationView; +export default withRouter(OrganizationView); diff --git a/src/pages/Organizations/views/Organizations.list.jsx b/src/pages/Organizations/views/Organizations.list.jsx index 5ca2809911..0f14bf21ff 100644 --- a/src/pages/Organizations/views/Organizations.list.jsx +++ b/src/pages/Organizations/views/Organizations.list.jsx @@ -17,9 +17,6 @@ import DataListToolbar from '../../../components/DataListToolbar'; import OrganizationListItem from '../components/OrganizationListItem'; import Pagination from '../../../components/Pagination'; -import api from '../../../api'; -import { API_ORGANIZATIONS } from '../../../endpoints'; - import { encodeQueryString, parseQueryString, @@ -132,6 +129,7 @@ class Organizations extends Component { } async fetchOrganizations (queryParams) { + const { api } = this.props; const { page, page_size, order_by } = queryParams; let sortOrder = 'ascending'; @@ -145,7 +143,7 @@ class Organizations extends Component { this.setState({ error: false, loading: true }); try { - const { data } = await api.get(API_ORGANIZATIONS, queryParams); + const { data } = await api.getOrganizations(queryParams); const { count, results } = data; const pageCount = Math.ceil(count / page_size); From 31d03475536655d6f1e52e99cb8914d22afaea0b Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 02:28:24 -0500 Subject: [PATCH 13/25] test fixup --- __mocks__/axios.js | 38 ------------- __tests__/App.test.jsx | 3 +- __tests__/api.test.js | 111 ++++++++++++++++---------------------- __tests__/index.test.jsx | 44 ++++++++++----- __tests__/pages/Login.jsx | 12 +++-- src/api.js | 11 +--- src/index.jsx | 30 ++++++++--- 7 files changed, 108 insertions(+), 141 deletions(-) delete mode 100644 __mocks__/axios.js diff --git a/__mocks__/axios.js b/__mocks__/axios.js deleted file mode 100644 index aad497303f..0000000000 --- a/__mocks__/axios.js +++ /dev/null @@ -1,38 +0,0 @@ -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); -axios.get = jest.fn(() => axios); -axios.post = jest.fn(() => axios); -axios.create.mockReturnValue({ - get: axios.get, - post: axios.post -}); -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 = () => { - axios.create.mockClear(); - axios.get.mockClear(); - axios.post.mockClear(); -}; - -module.exports = axios; diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index 5c9bc7d195..4d1efe68a4 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -4,7 +4,7 @@ import { HashRouter as Router } from 'react-router-dom'; import { shallow, mount } from 'enzyme'; import App from '../src/App'; import api from '../src/api'; -import { API_LOGOUT, API_CONFIG } from '../src/endpoints'; +import { API_LOGOUT } from '../src/endpoints'; import Dashboard from '../src/pages/Dashboard'; import { asyncFlush } from '../jest.setup'; @@ -45,6 +45,7 @@ describe('', () => { const appWrapper = shallow(); appWrapper.instance().onDevLogout(); appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); + expect(api.get).toHaveBeenCalledTimes(1); expect(api.get).toHaveBeenCalledWith(API_LOGOUT); await asyncFlush(); expect(appWrapper.state().activeItem).toBe(DEFAULT_ACTIVE_ITEM); diff --git a/__tests__/api.test.js b/__tests__/api.test.js index b05e8fdcbd..c4a3ba3542 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -1,80 +1,61 @@ -import mockAxios from 'axios'; import APIClient from '../src/api'; -import * as endpoints from '../src/endpoints'; -const CSRF_COOKIE_NAME = 'csrftoken'; -const CSRF_HEADER_NAME = 'X-CSRFToken'; - -const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; +const invalidCookie = 'invalid'; +const validLoggedOutCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=false; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; +const validLoggedInCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=true; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; describe('APIClient (api.js)', () => { - afterEach(() => { - mockAxios.customClearMocks(); + test('isAuthenticated returns false when cookie is invalid', () => { + APIClient.getCookie = jest.fn(() => invalidCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(false); }); - test('constructor calls axios create', () => { - const csrfObj = { - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME - }; - expect(mockAxios.create).toHaveBeenCalledTimes(1); - expect(mockAxios.create).toHaveBeenCalledWith(csrfObj); - expect(APIClient.http).toHaveProperty('get'); + test('isAuthenticated returns false when cookie is unauthenticated', () => { + APIClient.getCookie = jest.fn(() => validLoggedOutCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(false); }); - test('isAuthenticated checks authentication and sets cookie from document', () => { - APIClient.getCookie = jest.fn(); - const invalidCookie = 'invalid'; - const validLoggedOutCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=false; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; - const validLoggedInCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=true; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; - APIClient.getCookie.mockReturnValue(invalidCookie); - expect(APIClient.isAuthenticated()).toBe(false); - APIClient.getCookie.mockReturnValue(validLoggedOutCookie); - expect(APIClient.isAuthenticated()).toBe(false); - APIClient.getCookie.mockReturnValue(validLoggedInCookie); - expect(APIClient.isAuthenticated()).toBe(true); + test('isAuthenticated returns true when cookie is valid and authenticated', () => { + APIClient.getCookie = jest.fn(() => validLoggedInCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(true); }); - test('login calls get and post to login route, and sets cookie from document', (done) => { - const un = 'foo'; - const pw = 'bar'; - const next = 'baz'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${un}&password=${pw}&next=${next}`; - APIClient.setCookie = jest.fn(); - APIClient.login(un, pw, next).then(() => { - expect(mockAxios.get).toHaveBeenCalledTimes(1); - expect(mockAxios.get).toHaveBeenCalledWith(endpoints.API_LOGIN, { headers }); - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); + test('login calls get and post with expected content headers', async (done) => { + const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.login('username', 'password'); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers }); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual({ headers }); + + done(); }); - test('login encodes uri components for username, password and redirect', (done) => { - const un = '/foo/'; - const pw = '/bar/'; - const next = '/baz/'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${encodeURIComponent(un)}&password=${encodeURIComponent(pw)}&next=${encodeURIComponent(next)}`; - APIClient.login(un, pw, next).then(() => { - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); - }); + test('login sends expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); - test('login redirect defaults to config route when not explicitly passed', (done) => { - const un = 'foo'; - const pw = 'bar'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${un}&password=${pw}&next=${encodeURIComponent(endpoints.API_CONFIG)}`; - APIClient.setCookie = jest.fn(); - APIClient.login(un, pw).then(() => { - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); - }); + const api = new APIClient(mockHttp); + await api.login('foo', 'bar'); + await api.login('foo', 'bar', 'baz'); + expect(mockHttp.post).toHaveBeenCalledTimes(2); + expect(mockHttp.post.mock.calls[0]).toContainEqual('username=foo&password=bar&next=%2Fapi%2Fv2%2Fconfig%2F'); + expect(mockHttp.post.mock.calls[1]).toContainEqual('username=foo&password=bar&next=baz'); + + done(); + }); }); diff --git a/__tests__/index.test.jsx b/__tests__/index.test.jsx index f2b79ba7da..d6789513dc 100644 --- a/__tests__/index.test.jsx +++ b/__tests__/index.test.jsx @@ -1,22 +1,38 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; - -import api from '../src/api'; - +import { mount } from 'enzyme'; import { main } from '../src/index'; -const custom_logo = (
logo
); -const custom_login_info = 'custom login info'; - -jest.mock('react-dom', () => ({ render: jest.fn() })); +const render = template => mount(template); +const data = { custom_logo: 'foo', custom_login_info: '' } describe('index.jsx', () => { - test('renders without crashing', async () => { - api.getRoot = jest.fn().mockImplementation(() => Promise - .resolve({ data: { custom_logo, custom_login_info } })); + test('initialization', async (done) => { + const isAuthenticated = () => false; + const getRoot = jest.fn(() => Promise.resolve({ data })); - await main(); + const api = { getRoot, isAuthenticated }; + const wrapper = await main(render, api); - expect(ReactDOM.render).toHaveBeenCalled(); + expect(api.getRoot).toHaveBeenCalled(); + expect(wrapper.find('Dashboard')).toHaveLength(0); + expect(wrapper.find('Login')).toHaveLength(1); + + const { src } = wrapper.find('Login Brand img').props(); + expect(src).toContain(data.custom_logo); + + done(); + }); + + test('dashboard is loaded when authenticated', async (done) => { + const isAuthenticated = () => true; + const getRoot = jest.fn(() => Promise.resolve({ data })); + + const api = { getRoot, isAuthenticated }; + const wrapper = await main(render, api); + + expect(api.getRoot).toHaveBeenCalled(); + expect(wrapper.find('Dashboard')).toHaveLength(1); + expect(wrapper.find('Login')).toHaveLength(0); + + done(); }); }); diff --git a/__tests__/pages/Login.jsx b/__tests__/pages/Login.jsx index b0e3616776..09eb67b8dc 100644 --- a/__tests__/pages/Login.jsx +++ b/__tests__/pages/Login.jsx @@ -4,7 +4,7 @@ import { mount, shallow } from 'enzyme'; import { I18nProvider } from '@lingui/react'; import { asyncFlush } from '../../jest.setup'; import AtLogin from '../../src/pages/Login'; -import api from '../../src/api'; +import APIClient from '../../src/api'; describe('', () => { let loginWrapper; @@ -16,6 +16,8 @@ describe('', () => { let submitButton; let loginHeaderLogo; + const api = new APIClient({}); + const findChildren = () => { atLogin = loginWrapper.find('AtLogin'); loginPage = loginWrapper.find('LoginPage'); @@ -30,7 +32,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -59,7 +61,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -73,7 +75,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -166,7 +168,7 @@ describe('', () => { test('render Redirect to / when already authenticated', () => { api.isAuthenticated = jest.fn(); api.isAuthenticated.mockReturnValue(true); - loginWrapper = shallow(); + loginWrapper = shallow(); const redirectElem = loginWrapper.find('Redirect'); expect(redirectElem.length).toBe(1); expect(redirectElem.props().to).toBe('/'); diff --git a/src/api.js b/src/api.js index 007fa37f5c..902eee6a3c 100644 --- a/src/api.js +++ b/src/api.js @@ -1,5 +1,3 @@ -import axios from 'axios'; - const API_ROOT = '/api/'; const API_LOGIN = `${API_ROOT}login/`; const API_LOGOUT = `${API_ROOT}logout/`; @@ -7,21 +5,14 @@ const API_V2 = `${API_ROOT}v2/`; const API_CONFIG = `${API_V2}config/`; const API_ORGANIZATIONS = `${API_V2}organizations/`; -const CSRF_COOKIE_NAME = 'csrftoken'; -const CSRF_HEADER_NAME = 'X-CSRFToken'; const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; -const defaultHttpAdapter = axios.create({ - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME, -}); - class APIClient { static getCookie () { return document.cookie; } - constructor (httpAdapter = defaultHttpAdapter) { + constructor (httpAdapter) { this.http = httpAdapter; } diff --git a/src/index.jsx b/src/index.jsx index 5c63f5163f..b5f1ff6168 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,5 +1,6 @@ +import axios from 'axios'; import React from 'react'; -import { render } from 'react-dom'; +import ReactDOM from 'react-dom'; import { HashRouter, Redirect, @@ -48,15 +49,28 @@ import Users from './pages/Users'; import ja from '../build/locales/ja/messages'; import en from '../build/locales/en/messages'; -const catalogs = { en, ja }; +// +// Initialize http +// + +const http = axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken' }); + +// // Derive the language and region from global user agent data. Example: es-US -// https://developer.mozilla.org/en-US/docs/Web/API/Navigator +// see: https://developer.mozilla.org/en-US/docs/Web/API/Navigator +// + const language = (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage; const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; +const catalogs = { en, ja }; -export async function main (api) { +// +// Function Main +// + +export async function main (render, api) { const el = document.getElementById('app'); // fetch additional config from server const { data } = await api.getRoot(); @@ -78,7 +92,7 @@ export async function main (api) { ); - render( + return render( {!api.isAuthenticated() ? loginRoutes : ( - } /> - } /> + ()} /> + ()} /> ( , el); }; -export default main(new APIClient()); +main(ReactDOM.render, new APIClient(http)); From 4ccce4cc9e3c71cd84d42c90c4cc91f39b0394c5 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 2 Jan 2019 19:49:34 -0500 Subject: [PATCH 14/25] 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 From 3e201d3ca01fac1f06f9a4d78b3a729a8ee09866 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 3 Jan 2019 09:37:02 -0500 Subject: [PATCH 15/25] add config pass-through to inline render --- src/App.jsx | 31 ++++++++++++++++++------------- src/index.jsx | 6 +++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 04ca8c740f..e0e162e07b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -24,9 +24,11 @@ class App extends Component { this.state = { ansible_version: null, - version: null, + custom_virtualenvs: null, isAboutModalOpen: false, isNavOpen, + version: null, + }; this.fetchConfig = this.fetchConfig.bind(this); @@ -41,6 +43,17 @@ class App extends Component { this.fetchConfig(); } + async fetchConfig () { + const { api } = this.props; + + try { + const { data: { ansible_version, custom_virtualenvs, version } } = await api.getConfig(); + this.setState({ ansible_version, custom_virtualenvs, version }); + } catch (err) { + this.setState({ ansible_version: null, custom_virtualenvs: null, version: null }); + } + } + async onLogout () { const { api } = this.props; @@ -48,17 +61,6 @@ class App extends Component { window.location.replace('/#/login') } - async fetchConfig () { - const { api } = this.props; - - try { - 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 }); } @@ -78,10 +80,12 @@ class App extends Component { render () { const { ansible_version, + custom_virtualenvs, isAboutModalOpen, isNavOpen, version, } = this.state; + const { render, routeGroups = [], @@ -90,6 +94,7 @@ class App extends Component { const config = { ansible_version, + custom_virtualenvs, version, }; @@ -134,7 +139,7 @@ class App extends Component { } > - { render ? render({ routeGroups }) : '' } + {render && render({ routeGroups, config })} @@ -252,7 +251,7 @@ export async function main (render, api) { ], }, ]} - render={({ routeGroups }) => ( + render={({ routeGroups, config }) => ( routeGroups .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) .map(({ component: PageComponent, path }) => ( @@ -263,6 +262,7 @@ export async function main (render, api) { )} /> From dce50fe18bbc8fc5a42313ed61fb9f67d94f7f27 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 3 Jan 2019 09:40:48 -0500 Subject: [PATCH 16/25] update route group params --- __tests__/App.test.jsx | 18 +++---- .../components/NavExpandableGroup.test.jsx | 4 +- src/App.jsx | 8 ++-- src/components/NavExpandableGroup.jsx | 4 +- src/index.jsx | 10 ++-- .../Organizations/views/Organization.add.jsx | 47 ++++++++++--------- 6 files changed, 46 insertions(+), 45 deletions(-) diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index 9048c3fab5..420d6832ab 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -17,7 +17,7 @@ describe('', () => { ', () => { ], }, { - title: 'Group Two', + groupTitle: 'Group Two', groupId: 'group_two', routes: [ { title: 'Fiz', path: '/fiz' }, @@ -77,19 +77,13 @@ describe('', () => { expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); }); - test('logout button click triggers expected callback', async (done) => { + test('onLogout makes expected call to api client', async (done) => { const logout = jest.fn(() => Promise.resolve()); const api = { logout }; - const appWrapper = mount( - - - - - - ); + const appWrapper = shallow(); - appWrapper.find('button[id="button-logout"]').simulate('click'); + appWrapper.instance().onLogout(); await asyncFlush(); expect(api.logout).toHaveBeenCalledTimes(1); @@ -106,6 +100,6 @@ describe('', () => { ); - expect(api.get).toHaveBeenCalledTimes(1); + expect(getConfig).toHaveBeenCalledTimes(1); }); }); diff --git a/__tests__/components/NavExpandableGroup.test.jsx b/__tests__/components/NavExpandableGroup.test.jsx index 68cf571f7d..7619dcbfcd 100644 --- a/__tests__/components/NavExpandableGroup.test.jsx +++ b/__tests__/components/NavExpandableGroup.test.jsx @@ -12,7 +12,7 @@ describe('NavExpandableGroup', () => {