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 ae7366a74e..420d6832ab 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -1,65 +1,105 @@ import React from 'react'; -import { MemoryRouter } 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 { HashRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; -import Dashboard from '../src/pages/Dashboard'; -import Login from '../src/pages/Login'; +import { mount, shallow } from 'enzyme'; +import { asyncFlush } from '../jest.setup'; + +import App from '../src/App'; + +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); - test('renders login page when not authenticated', () => { - api.isAuthenticated = jest.fn(); - api.isAuthenticated.mockReturnValue(false); + // 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); - 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); + // 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 appWrapper = shallow(); + const { onNavToggle } = appWrapper.instance(); + + [true, false, true, false, true].forEach(expected => { + expect(appWrapper.state().isNavOpen).toBe(expected); + onNavToggle(); + }); }); - 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'); + test('onLogoClick sets selected nav back to defaults', () => { + const appWrapper = shallow(); + appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); - expect(api.get).toHaveBeenCalledWith(API_LOGOUT); + expect(appWrapper.state().activeItem).toBe('bar'); + expect(appWrapper.state().activeGroup).toBe('foo'); + + appWrapper.instance().onLogoClick(); + expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); }); - test('Componenet makes REST call to API_CONFIG endpoint when mounted', () => { - api.get = jest.fn().mockImplementation(() => Promise.resolve({})); - const appWrapper = shallow(); - expect(api.get).toHaveBeenCalledTimes(1); - expect(api.get).toHaveBeenCalledWith(API_CONFIG); + test('onLogout makes expected call to api client', async (done) => { + const logout = jest.fn(() => Promise.resolve()); + const api = { logout }; + + const appWrapper = shallow(); + + appWrapper.instance().onLogout(); + await asyncFlush(); + expect(api.logout).toHaveBeenCalledTimes(1); + + done(); + }); + + test('Component makes expected call to api client when mounted', () => { + const getConfig = jest.fn().mockImplementation(() => Promise.resolve({})); + const api = { getConfig }; + const appWrapper = mount( + + + + + + ); + expect(getConfig).toHaveBeenCalledTimes(1); }); }); 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__/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/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/__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/__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', () => { { { + test('renders the expected content', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('Toolbar')).toHaveLength(1); + }); +}); diff --git a/__tests__/index.test.jsx b/__tests__/index.test.jsx index 654bc5a6d6..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 { mount } from 'enzyme'; +import { main } from '../src/index'; -import api from '../src/api'; - -import indexToRender 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 indexToRender(); + 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 ed9fd164f9..49bcffce5c 100644 --- a/__tests__/pages/Login.jsx +++ b/__tests__/pages/Login.jsx @@ -3,12 +3,12 @@ import { MemoryRouter } from 'react-router-dom'; 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 AWXLogin from '../../src/pages/Login'; +import APIClient from '../../src/api'; describe('', () => { let loginWrapper; - let atLogin; + let awxLogin; let loginPage; let loginForm; let usernameInput; @@ -16,21 +16,23 @@ describe('', () => { let submitButton; let loginHeaderLogo; + const api = new APIClient({}); + const findChildren = () => { - atLogin = loginWrapper.find('AtLogin'); + awxLogin = loginWrapper.find('AWXLogin'); loginPage = loginWrapper.find('LoginPage'); loginForm = loginWrapper.find('LoginForm'); 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(() => { loginWrapper = mount( - + ); @@ -49,7 +51,7 @@ describe('', () => { expect(usernameInput.props().value).toBe(''); expect(passwordInput.length).toBe(1); expect(passwordInput.props().value).toBe(''); - expect(atLogin.state().isValidPassword).toBe(true); + expect(awxLogin.state().isInputValid).toBe(true); expect(submitButton.length).toBe(1); expect(submitButton.props().isDisabled).toBe(false); expect(loginHeaderLogo.length).toBe(1); @@ -59,7 +61,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -73,7 +75,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -84,49 +86,49 @@ describe('', () => { }); test('state maps to un/pw input value props', () => { - atLogin.setState({ username: 'un', password: 'pw' }); - expect(atLogin.state().username).toBe('un'); - expect(atLogin.state().password).toBe('pw'); + awxLogin.setState({ username: 'un', password: 'pw' }); + expect(awxLogin.state().username).toBe('un'); + expect(awxLogin.state().password).toBe('pw'); findChildren(); expect(usernameInput.props().value).toBe('un'); expect(passwordInput.props().value).toBe('pw'); }); test('updating un/pw clears out error', () => { - atLogin.setState({ isValidPassword: false }); + awxLogin.setState({ isInputValid: false }); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(1); usernameInput.instance().value = 'uname'; usernameInput.simulate('change'); - expect(atLogin.state().username).toBe('uname'); - expect(atLogin.state().isValidPassword).toBe(true); + expect(awxLogin.state().username).toBe('uname'); + expect(awxLogin.state().isInputValid).toBe(true); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0); - atLogin.setState({ isValidPassword: false }); + awxLogin.setState({ isInputValid: false }); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(1); passwordInput.instance().value = 'pword'; passwordInput.simulate('change'); - expect(atLogin.state().password).toBe('pword'); - expect(atLogin.state().isValidPassword).toBe(true); + expect(awxLogin.state().password).toBe('pword'); + expect(awxLogin.state().isInputValid).toBe(true); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0); }); test('api.login not called when loading', () => { api.login = jest.fn().mockImplementation(() => Promise.resolve({})); - expect(atLogin.state().loading).toBe(false); - atLogin.setState({ loading: true }); + expect(awxLogin.state().isLoading).toBe(false); + awxLogin.setState({ isLoading: true }); submitButton.simulate('click'); expect(api.login).toHaveBeenCalledTimes(0); }); test('submit calls api.login successfully', async () => { api.login = jest.fn().mockImplementation(() => Promise.resolve({})); - expect(atLogin.state().loading).toBe(false); - atLogin.setState({ username: 'unamee', password: 'pwordd' }); + expect(awxLogin.state().isLoading).toBe(false); + awxLogin.setState({ username: 'unamee', password: 'pwordd' }); submitButton.simulate('click'); expect(api.login).toHaveBeenCalledTimes(1); expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); - expect(atLogin.state().loading).toBe(true); + expect(awxLogin.state().isLoading).toBe(true); await asyncFlush(); - expect(atLogin.state().loading).toBe(false); + expect(awxLogin.state().isLoading).toBe(false); }); test('submit calls api.login handles 401 error', async () => { @@ -135,16 +137,16 @@ describe('', () => { err.response = { status: 401, message: 'problem' }; return Promise.reject(err); }); - expect(atLogin.state().loading).toBe(false); - expect(atLogin.state().isValidPassword).toBe(true); - atLogin.setState({ username: 'unamee', password: 'pwordd' }); + expect(awxLogin.state().isLoading).toBe(false); + expect(awxLogin.state().isInputValid).toBe(true); + awxLogin.setState({ username: 'unamee', password: 'pwordd' }); submitButton.simulate('click'); expect(api.login).toHaveBeenCalledTimes(1); expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); - expect(atLogin.state().loading).toBe(true); + expect(awxLogin.state().isLoading).toBe(true); await asyncFlush(); - expect(atLogin.state().isValidPassword).toBe(false); - expect(atLogin.state().loading).toBe(false); + expect(awxLogin.state().isInputValid).toBe(false); + expect(awxLogin.state().isLoading).toBe(false); }); test('submit calls api.login handles non-401 error', async () => { @@ -153,20 +155,20 @@ describe('', () => { err.response = { status: 500, message: 'problem' }; return Promise.reject(err); }); - expect(atLogin.state().loading).toBe(false); - atLogin.setState({ username: 'unamee', password: 'pwordd' }); + expect(awxLogin.state().isLoading).toBe(false); + awxLogin.setState({ username: 'unamee', password: 'pwordd' }); submitButton.simulate('click'); expect(api.login).toHaveBeenCalledTimes(1); expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); - expect(atLogin.state().loading).toBe(true); + expect(awxLogin.state().isLoading).toBe(true); await asyncFlush(); - expect(atLogin.state().loading).toBe(false); + expect(awxLogin.state().isLoading).toBe(false); }); 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/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", diff --git a/src/App.jsx b/src/App.jsx index d08764acd7..93003c0c8d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,261 +1,158 @@ -import React, { Fragment } from 'react'; -import { ConfigContext } from './context'; - -import { I18nProvider, I18n } from '@lingui/react'; -import { t } from '@lingui/macro'; +import React, { Component, Fragment } from 'react'; +import { global_breakpoint_md } from '@patternfly/react-tokens'; import { - Redirect, - Switch, - withRouter -} from 'react-router-dom'; - -import { - BackgroundImage, - BackgroundImageSrc, Nav, NavList, Page, PageHeader, PageSidebar, - Toolbar, - 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'; - -import HelpDropdown from './components/HelpDropdown'; -import LogoutButton from './components/LogoutButton'; -import TowerLogo from './components/TowerLogo'; -import ConditionalRedirect from './components/ConditionalRedirect'; +import About from './components/About'; import NavExpandableGroup from './components/NavExpandableGroup'; +import TowerLogo from './components/TowerLogo'; +import PageHeaderToolbar from './components/PageHeaderToolbar'; +import { ConfigContext } from './context'; -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 -const language = (navigator.languages && navigator.languages[0]) - || navigator.language - || navigator.userLanguage; - -const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; - - -class App extends React.Component { - constructor(props) { +class App extends Component { + constructor (props) { super(props); - const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10); - this.state = { - isNavOpen, - config: {}, - error: false, - }; - } + // initialize with a closed navbar if window size is small + const isNavOpen = typeof window !== 'undefined' + && window.innerWidth >= parseInt(global_breakpoint_md.value, 10); - onNavToggle = () => { - this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); + this.state = { + ansible_version: null, + custom_virtualenvs: null, + isAboutModalOpen: false, + isNavOpen, + version: null, + + }; + + 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); }; - onLogoClick = () => { - this.setState({ activeGroup: 'views_group' }); + componentDidMount () { + this.fetchConfig(); } - onDevLogout = async () => { - await api.get(API_LOGOUT); - this.setState({ activeGroup: 'views_group', activeItem: 'views_group_dashboard' }); - } + async fetchConfig () { + const { api } = this.props; - 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, 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 }); } } - render() { - const { isNavOpen, config } = this.state; - const { logo, loginInfo, history } = this.props; + async onLogout () { + const { api } = this.props; - const PageToolbar = ( - - - - - - - this.onDevLogout()} /> - - - - ); + await api.logout(); + window.location.replace('/#/login') + } + + onAboutModalOpen () { + this.setState({ isAboutModalOpen: true }); + } + + onAboutModalClose () { + this.setState({ isAboutModalOpen: false }); + } + + onNavToggle () { + this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); + } + + onLogoClick () { + this.setState({ activeGroup: 'views_group' }); + } + + render () { + const { + ansible_version, + custom_virtualenvs, + isAboutModalOpen, + isNavOpen, + version, + } = this.state; + + const { + render, + routeGroups = [], + navLabel = '', + } = this.props; + + const config = { + ansible_version, + custom_virtualenvs, + version, + }; return ( - - - + + + } + toolbar={ + + } + /> + )} + sidebar={ + + + {routeGroups.map(({ groupId, groupTitle, routes }) => ( + + ))} + + + )} + /> + } + > - - api.isAuthenticated()} - redirectPath="/" - path="/login" - component={() => } - /> - - } - toolbar={PageToolbar} - showNavToggle - onNavToggle={this.onNavToggle} - /> - )} - sidebar={( - - {({ i18n }) => ( - - - - - - - - - - )} - - )} - /> - )} - useCondensed - > - !api.isAuthenticated()} redirectPath="/login" exact path="/" component={() => ()} /> - !api.isAuthenticated()} redirectPath="/login" path="/home" component={Dashboard} /> - !api.isAuthenticated()} redirectPath="/login" path="/jobs" component={Jobs} /> - !api.isAuthenticated()} redirectPath="/login" path="/schedules" component={Schedules} /> - !api.isAuthenticated()} redirectPath="/login" path="/portal" component={Portal} /> - !api.isAuthenticated()} redirectPath="/login" path="/templates" component={Templates} /> - !api.isAuthenticated()} redirectPath="/login" path="/credentials" component={Credentials} /> - !api.isAuthenticated()} redirectPath="/login" path="/projects" component={Projects} /> - !api.isAuthenticated()} redirectPath="/login" path="/inventories" component={Inventories} /> - !api.isAuthenticated()} redirectPath="/login" path="/inventory_scripts" component={InventoryScripts} /> - !api.isAuthenticated()} redirectPath="/login" path="/organizations" component={Organizations} /> - !api.isAuthenticated()} redirectPath="/login" path="/users" component={Users} /> - !api.isAuthenticated()} redirectPath="/login" path="/teams" component={Teams} /> - !api.isAuthenticated()} redirectPath="/login" path="/credential_types" component={CredentialTypes} /> - !api.isAuthenticated()} redirectPath="/login" path="/notification_templates" component={NotificationTemplates} /> - !api.isAuthenticated()} redirectPath="/login" path="/management_jobs" component={ManagementJobs} /> - !api.isAuthenticated()} redirectPath="/login" path="/instance_groups" component={InstanceGroups} /> - !api.isAuthenticated()} redirectPath="/login" path="/applications" component={Applications} /> - !api.isAuthenticated()} redirectPath="/login" path="/auth_settings" component={AuthSettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/jobs_settings" component={JobsSettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/system_settings" component={SystemSettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/ui_settings" component={UISettings} /> - !api.isAuthenticated()} redirectPath="/login" path="/license" component={License} /> - - - - + {render && render({ routeGroups })} - - + + + ); } } -export default withRouter(App); +export default App; diff --git a/src/api.js b/src/api.js index 62649fa0e5..902eee6a3c 100644 --- a/src/api.js +++ b/src/api.js @@ -1,29 +1,26 @@ -import axios from 'axios'; - -import * as endpoints from './endpoints'; - -const CSRF_COOKIE_NAME = 'csrftoken'; -const CSRF_HEADER_NAME = 'X-CSRFToken'; +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 LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; class APIClient { - constructor () { - this.http = axios.create({ - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME, - }); - } - - /* eslint class-methods-use-this: ["error", { "exceptMethods": ["getCookie"] }] */ - getCookie () { + static getCookie () { return document.cookie; } - isAuthenticated () { - let authenticated = false; + constructor (httpAdapter) { + 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'; @@ -32,7 +29,7 @@ class APIClient { return authenticated; } - async login (username, password, redirect = endpoints.API_CONFIG) { + async login (username, password, redirect = API_CONFIG) { const un = encodeURIComponent(username); const pw = encodeURIComponent(password); const next = encodeURIComponent(redirect); @@ -40,13 +37,37 @@ 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 }); + 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/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/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/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; diff --git a/src/components/DataListToolbar/DataListToolbar.jsx b/src/components/DataListToolbar/DataListToolbar.jsx index 51bf9f782c..46b8189825 100644 --- a/src/components/DataListToolbar/DataListToolbar.jsx +++ b/src/components/DataListToolbar/DataListToolbar.jsx @@ -43,17 +43,24 @@ class DataListToolbar extends React.Component { searchKey: sortedColumnKey, searchValue: '', }; + + this.handleSearchInputChange = this.handleSearchInputChange.bind(this); + this.onSortDropdownToggle = this.onSortDropdownToggle.bind(this); + this.onSortDropdownSelect = this.onSortDropdownSelect.bind(this); + this.onSearch = this.onSearch.bind(this); + this.onSearchDropdownToggle = this.onSearchDropdownToggle.bind(this); + this.onSearchDropdownSelect = this.onSearchDropdownSelect.bind(this); } - handleSearchInputChange = searchValue => { + handleSearchInputChange (searchValue) { this.setState({ searchValue }); - }; + } - onSortDropdownToggle = isSortDropdownOpen => { + onSortDropdownToggle (isSortDropdownOpen) { this.setState({ isSortDropdownOpen }); - }; + } - onSortDropdownSelect = ({ target }) => { + onSortDropdownSelect ({ target }) { const { columns, onSort, sortOrder } = this.props; const [{ key }] = columns.filter(({ name }) => name === target.innerText); @@ -61,27 +68,33 @@ class DataListToolbar extends React.Component { this.setState({ isSortDropdownOpen: false }); onSort(key, sortOrder); - }; + } - onSearchDropdownToggle = isSearchDropdownOpen => { + onSearchDropdownToggle (isSearchDropdownOpen) { this.setState({ isSearchDropdownOpen }); - }; + } - onSearchDropdownSelect = ({ target }) => { + onSearchDropdownSelect ({ target }) { const { columns } = this.props; const targetName = target.innerText; const [{ key }] = columns.filter(({ name }) => name === targetName); this.setState({ isSearchDropdownOpen: false, searchKey: key }); - }; + } + + onSearch () { + const { searchValue } = this.state; + const { onSearch } = this.props; + + onSearch(searchValue); + } render () { const { up } = DropdownPosition; const { columns, isAllSelected, - onSearch, onSelectAll, onSort, sortedColumnKey, @@ -175,7 +188,7 @@ class DataListToolbar extends React.Component { onSearch(searchValue)} + onClick={this.onSearch} > 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 }) => ( - { - if (event.keyCode === 13) { - onDevLogout(); - } - }} - > - - - )} - -); - -export default LogoutButton; diff --git a/src/components/NavExpandableGroup.jsx b/src/components/NavExpandableGroup.jsx index ba3058b3df..8a5562fcfa 100644 --- a/src/components/NavExpandableGroup.jsx +++ b/src/components/NavExpandableGroup.jsx @@ -14,18 +14,23 @@ class NavExpandableGroup extends Component { // Extract a list of paths from the route params and store them for later. This creates // an array of url paths associated with any NavItem component rendered by this component. this.navItemPaths = routes.map(({ path }) => path); + + this.isActiveGroup = this.isActiveGroup.bind(this); + this.isActivePath = this.isActivePath.bind(this); } - isActiveGroup = () => this.navItemPaths.some(this.isActivePath); + isActiveGroup () { + return this.navItemPaths.some(this.isActivePath); + } - isActivePath = (path) => { + isActivePath (path) { const { history } = this.props; return history.location.pathname.startsWith(path); - }; + } render () { - const { routes, groupId, staticContext, ...rest } = this.props; + const { groupId, groupTitle, routes } = this.props; const isActive = this.isActiveGroup(); return ( @@ -33,7 +38,7 @@ class NavExpandableGroup extends Component { isActive={isActive} isExpanded={isActive} groupId={groupId} - {...rest} + title={groupTitle} > {routes.map(({ path, title }) => ( + {({ i18n }) => ( + + + + + + + )} + dropdownItems={[ + + {i18n._(t`Help`)} + , + + {i18n._(t`About`)} + + ]} + /> + + + + + + )} + dropdownItems={[ + + + {i18n._(t`User Details`)} + + , + + {i18n._(t`Logout`)} + + ]} + /> + + + + )} + + ); + } +} + +export default PageHeaderToolbar; diff --git a/src/components/Pagination/Pagination.jsx b/src/components/Pagination/Pagination.jsx index 001f02f504..98998b4140 100644 --- a/src/components/Pagination/Pagination.jsx +++ b/src/components/Pagination/Pagination.jsx @@ -21,6 +21,15 @@ class Pagination extends Component { const { page } = this.props; this.state = { value: page, isOpen: false }; + + this.onPageChange = this.onPageChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onFirst = this.onFirst.bind(this); + this.onPrevious = this.onPrevious.bind(this); + this.onNext = this.onNext.bind(this); + this.onLast = this.onLast.bind(this); + this.onTogglePageSize = this.onTogglePageSize.bind(this); + this.onSelectPageSize = this.onSelectPageSize.bind(this); } componentDidUpdate (prevProps) { @@ -31,11 +40,11 @@ class Pagination extends Component { } } - onPageChange = value => { + onPageChange (value) { this.setState({ value }); - }; + } - onSubmit = event => { + onSubmit (event) { const { onSetPage, page, pageCount, page_size } = this.props; const { value } = this.state; @@ -49,43 +58,43 @@ class Pagination extends Component { } else { this.setState({ value: page }); } - }; + } - onFirst = () => { + onFirst () { const { onSetPage, page_size } = this.props; onSetPage(1, page_size); - }; + } - onPrevious = () => { + onPrevious () { const { onSetPage, page, page_size } = this.props; const previousPage = page - 1; if (previousPage >= 1) { onSetPage(previousPage, page_size); } - }; + } - onNext = () => { + onNext () { const { onSetPage, page, pageCount, page_size } = this.props; const nextPage = page + 1; if (nextPage <= pageCount) { onSetPage(nextPage, page_size); } - }; + } - onLast = () => { + onLast () { const { onSetPage, pageCount, page_size } = this.props; onSetPage(pageCount, page_size) - }; + } - onTogglePageSize = isOpen => { + onTogglePageSize (isOpen) { this.setState({ isOpen }); - }; + } - onSelectPageSize = ({ target }) => { + onSelectPageSize ({ target }) { const { onSetPage } = this.props; const page = 1; @@ -94,7 +103,7 @@ class Pagination extends Component { this.setState({ isOpen: false }); onSetPage(page, page_size); - }; + } render () { const { up } = DropdownDirection; diff --git a/src/components/TowerLogo/TowerLogo.jsx b/src/components/TowerLogo/TowerLogo.jsx index 6b9d0e3a30..10925a3046 100644 --- a/src/components/TowerLogo/TowerLogo.jsx +++ b/src/components/TowerLogo/TowerLogo.jsx @@ -12,18 +12,26 @@ class TowerLogo extends Component { super(props); this.state = { hover: false }; + + this.onClick = this.onClick.bind(this); + this.onHover = this.onHover.bind(this); } - onClick = () => { - const { history } = this.props; - history.push('/'); - }; + onClick () { + const { history, onClick: handleClick } = this.props; - onHover = () => { + if (!handleClick) return; + + history.push('/'); + + handleClick(); + } + + onHover () { const { hover } = this.state; this.setState({ hover: !hover }); - }; + } render () { const { hover } = this.state; 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 diff --git a/src/index.jsx b/src/index.jsx index 24a5eb8576..8f450e9c4a 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,27 +1,282 @@ +import axios from 'axios'; import React from 'react'; -import { render } from 'react-dom'; - +import ReactDOM from 'react-dom'; import { - HashRouter as Router + HashRouter, + Redirect, + Route, + Switch, } from 'react-router-dom'; -import App from './App'; -import api from './api'; -import { API_ROOT } from './endpoints'; +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'; -const el = document.getElementById('app'); +import APIClient from './api'; -const main = async () => { - const { custom_logo, custom_login_info } = await api.get(API_ROOT); - render(, el); +import App from './App'; +import Background from './components/Background'; +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'; + +// +// Initialize http +// + +const http = axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken' }); + +// +// Derive the language and region from global user agent data. Example: es-US +// 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 }; + +// +// Function Main +// + +export async function main (render, api) { + const el = document.getElementById('app'); + // fetch additional config from server + const { data: { custom_logo, custom_login_info } } = await api.getRoot(); + + const loginRoutes = ( + + ( + + )} + /> + + + ); + + return render( + + + + {({ i18n }) => ( + + {!api.isAuthenticated() ? loginRoutes : ( + + ()} /> + ()} /> + ( + ( + routeGroups + .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) + .map(({ component: PageComponent, path }) => ( + ( + + )} + /> + )) + )} + /> + )} + /> + + )} + + )} + + + , el); }; -main(); - -export default main; +main(ReactDOM.render, new APIClient(http)); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index ee86cecf97..a2353542a9 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -8,53 +8,57 @@ import { } from '@patternfly/react-core'; import towerLogo from '../../images/tower-logo-header.svg'; -import api from '../api'; -class AtLogin extends Component { +class AWXLogin extends Component { constructor (props) { super(props); this.state = { username: '', password: '', - isValidPassword: true, - loading: false + isInputValid: true, + isLoading: false }; + + this.onChangeUsername = this.onChangeUsername.bind(this); + this.onChangePassword = this.onChangePassword.bind(this); + this.onLoginButtonClick = this.onLoginButtonClick.bind(this); } - componentWillUnmount () { - this.unmounting = true; // todo: state management + onChangeUsername (value) { + this.setState({ username: value, isInputValid: true }); } - safeSetState = obj => !this.unmounting && this.setState(obj); + onChangePassword (value) { + this.setState({ password: value, isInputValid: true }); + } - handleUsernameChange = value => this.safeSetState({ username: value, isValidPassword: true }); - - handlePasswordChange = value => this.safeSetState({ password: value, isValidPassword: true }); - - handleSubmit = async event => { - const { username, password, loading } = this.state; + async onLoginButtonClick (event) { + const { username, password, isLoading } = this.state; + const { api } = this.props; event.preventDefault(); - if (!loading) { - this.safeSetState({ loading: true }); + if (isLoading) { + return; + } - try { - await api.login(username, password); - } catch (error) { - if (error.response.status === 401) { - this.safeSetState({ isValidPassword: false }); - } - } finally { - this.safeSetState({ loading: false }); + this.setState({ isLoading: true }); + + try { + await api.login(username, password); + } catch (error) { + if (error.response.status === 401) { + this.setState({ isInputValid: false }); } + } finally { + this.setState({ isLoading: false }); } } render () { - const { username, password, isValidPassword } = this.state; - const { logo, alt } = this.props; + const { username, password, isInputValid } = this.state; + const { api, alt, loginInfo, logo } = this.props; const logoSrc = logo ? `data:image/jpeg;${logo}` : towerLogo; if (api.isAuthenticated()) { @@ -65,20 +69,21 @@ class AtLogin extends Component { {({ i18n }) => ( )} @@ -87,4 +92,4 @@ class AtLogin extends Component { } } -export default AtLogin; +export default AWXLogin; 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.add.jsx b/src/pages/Organizations/views/Organization.add.jsx index 75e2147a0c..d5815a5f00 100644 --- a/src/pages/Organizations/views/Organization.add.jsx +++ b/src/pages/Organizations/views/Organization.add.jsx @@ -1,5 +1,4 @@ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; import { Trans } from '@lingui/macro'; import { @@ -18,10 +17,8 @@ import { CardBody, } from '@patternfly/react-core'; -import { ConfigContext } from '../../../context'; -import { API_ORGANIZATIONS } from '../../../endpoints'; -import api from '../../../api'; -import AnsibleSelect from '../../../components/AnsibleSelect' +import AnsibleSelect from '../../../components/AnsibleSelect'; + const { light } = PageSectionVariants; class OrganizationAdd extends React.Component { @@ -40,6 +37,8 @@ class OrganizationAdd extends React.Component { description: '', instanceGroups: '', custom_virtualenv: '', + custom_virtualenvs: [], + hideAnsibleSelect: true, error:'', }; @@ -61,7 +60,8 @@ class OrganizationAdd extends React.Component { async onSubmit() { const data = Object.assign({}, { ...this.state }); - await api.post(API_ORGANIZATIONS, data); + await api.createOrganization(data); + this.resetForm(); } @@ -69,10 +69,22 @@ class OrganizationAdd extends React.Component { this.props.history.push('/organizations'); } + async componentDidMount() { + try { + const { data } = await api.getConfig(); + this.setState({ custom_virtualenvs: [...data.custom_virtualenvs] }); + if (this.state.custom_virtualenvs.length > 1) { + // Show dropdown if we have more than one ansible environment + this.setState({ hideAnsibleSelect: !this.state.hideAnsibleSelect }); + } + } catch (error) { + this.setState({ error }) + } + } + render() { const { name } = this.state; const enabled = name.length > 0; // TODO: add better form validation - return ( @@ -116,16 +128,13 @@ class OrganizationAdd extends React.Component { onChange={this.handleChange} /> - - {({ custom_virtualenvs }) => - - } - + @@ -146,8 +155,4 @@ class OrganizationAdd extends React.Component { } } -OrganizationAdd.contextTypes = { - custom_virtualenvs: PropTypes.array, -}; - export default withRouter(OrganizationAdd); diff --git a/src/pages/Organizations/views/Organization.view.jsx b/src/pages/Organizations/views/Organization.view.jsx index dbfb42c4b4..608e064662 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); @@ -30,6 +27,8 @@ class OrganizationView extends Component { loading: false, mounted: false }; + + this.fetchOrganization = this.fetchOrganization.bind(this); } componentDidMount () { @@ -47,13 +46,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 +119,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..6c3715d999 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, @@ -56,6 +53,15 @@ class Organizations extends Component { results: [], selected: [], }; + + this.onSearch = this.onSearch.bind(this); + this.getQueryParams = this.getQueryParams.bind(this); + this.onSort = this.onSort.bind(this); + this.onSetPage = this.onSetPage.bind(this); + this.onSelectAll = this.onSelectAll.bind(this); + this.onSelect = this.onSelect.bind(this); + this.updateUrl = this.updateUrl.bind(this); + this.fetchOrganizations = this.fetchOrganizations.bind(this); } componentDidMount () { @@ -78,7 +84,7 @@ class Organizations extends Component { return Object.assign({}, this.defaultParams, searchParams, overrides); } - onSort = (sortedColumnKey, sortOrder) => { + onSort(sortedColumnKey, sortOrder) { const { page_size } = this.state; let order_by = sortedColumnKey; @@ -90,26 +96,26 @@ class Organizations extends Component { const queryParams = this.getQueryParams({ order_by, page_size }); this.fetchOrganizations(queryParams); - }; + } - onSetPage = (pageNumber, pageSize) => { + onSetPage (pageNumber, pageSize) { const page = parseInt(pageNumber, 10); const page_size = parseInt(pageSize, 10); const queryParams = this.getQueryParams({ page, page_size }); this.fetchOrganizations(queryParams); - }; + } - onSelectAll = isSelected => { + onSelectAll (isSelected) { const { results } = this.state; const selected = isSelected ? results.map(o => o.id) : []; this.setState({ selected }); - }; + } - onSelect = id => { + onSelect (id) { const { selected } = this.state; const isSelected = selected.includes(id); @@ -119,7 +125,7 @@ class Organizations extends Component { } else { this.setState({ selected: selected.concat(id) }); } - }; + } updateUrl (queryParams) { const { history, location } = this.props; @@ -132,6 +138,7 @@ class Organizations extends Component { } async fetchOrganizations (queryParams) { + const { api } = this.props; const { page, page_size, order_by } = queryParams; let sortOrder = 'ascending'; @@ -145,7 +152,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); 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',
- {this.createSpeechBubble(version)} - {` + + + { speechBubble } + {` \\ - \\ ^__^ + \\ ^__^ (oo)\\_______ (__) A )\\ ||----w | || || `} - - - - - - Ansible Version - - {ansible_version} - - - - )} -
+ { speechBubble } + {` \\ - \\ ^__^ + \\ ^__^ (oo)\\_______ (__) A )\\ ||----w | || || `} -