Merge pull request #81 from jakemcdermott/update-and-refactor

wip - update to pf-react 1.43 and refactor some things
This commit is contained in:
Jake McDermott
2019-01-03 19:37:16 -05:00
committed by GitHub
33 changed files with 1097 additions and 911 deletions

View File

@@ -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;

View File

@@ -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('<App />', () => {
test('renders without crashing', () => {
const appWrapper = shallow(<App />);
test('expected content is rendered', () => {
const appWrapper = mount(
<HashRouter>
<I18nProvider>
<App
routeGroups={[
{
groupTitle: 'Group One',
groupId: 'group_one',
routes: [
{ title: 'Foo', path: '/foo' },
{ title: 'Bar', path: '/bar' },
],
},
{
groupTitle: 'Group Two',
groupId: 'group_two',
routes: [
{ title: 'Fiz', path: '/fiz' },
]
}
]}
render={({ routeGroups }) => (
routeGroups.map(({ groupId }) => (<div key={groupId} id={groupId} />))
)}
/>
</I18nProvider>
</HashRouter>
);
// 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(<MemoryRouter><App /></MemoryRouter>);
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(<MemoryRouter><App /></MemoryRouter>);
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(<App.WrappedComponent />);
expect(appWrapper.state().isNavOpen).toBe(true);
appWrapper.instance().onNavToggle();
expect(appWrapper.state().isNavOpen).toBe(false);
const appWrapper = shallow(<App />);
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(<MemoryRouter><App /></MemoryRouter>);
const logOutButton = appWrapper.find(logOutButtonSelector);
expect(logOutButton.length).toBe(1);
logOutButton.simulate('click');
test('onLogoClick sets selected nav back to defaults', () => {
const appWrapper = shallow(<App />);
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(<App.WrappedComponent />);
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(<App api={api} />);
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(
<HashRouter>
<I18nProvider>
<App api={api} />
</I18nProvider>
</HashRouter>
);
expect(getConfig).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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();
});
});

View File

@@ -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('<About />', () => {
@@ -19,16 +17,16 @@ describe('<About />', () => {
aboutWrapper.unmount();
});
test('close button calls onAboutModalClose', () => {
const onAboutModalClose = jest.fn();
test('close button calls onClose handler', () => {
const onClose = jest.fn();
aboutWrapper = mount(
<I18nProvider>
<About isOpen onAboutModalClose={onAboutModalClose} />
<About isOpen onClose={onClose} />
</I18nProvider>
);
closeButton = aboutWrapper.find('AboutModalBoxCloseButton Button');
closeButton.simulate('click');
expect(onAboutModalClose).toBeCalled();
expect(onClose).toBeCalled();
aboutWrapper.unmount();
});
});

View File

@@ -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('<ConditionalRedirect />', () => {
test('renders Redirect when shouldRedirect is passed truthy func', () => {
const truthyFunc = () => true;
const shouldHaveRedirectChild = shallow(
<ConditionalRedirect
shouldRedirect={() => 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(
<ConditionalRedirect
shouldRedirect={() => falsyFunc()}
/>
);
const routeChild = shouldHaveRouteChild.find(Route);
expect(routeChild.length).toBe(1);
const redirectChild = shouldHaveRouteChild.find(Redirect);
expect(redirectChild.length).toBe(0);
});
});

View File

@@ -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(
<I18nProvider>
<HelpDropdown />
</I18nProvider>
);
dropdownComponentInstance = dropdownWrapper.find(HelpDropdown).instance();
});
afterEach(() => {
dropdownWrapper.unmount();
});
describe('<HelpDropdown />', () => {
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);
});
});

View File

@@ -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('<LogoutButton />', () => {
test('initially renders without crashing', () => {
const onDevLogout = jest.fn();
buttonWrapper = mount(
<I18nProvider>
<LogoutButton onDevLogout={onDevLogout} />
</I18nProvider>
);
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);
});
});

View File

@@ -12,7 +12,7 @@ describe('NavExpandableGroup', () => {
<Nav aria-label="Test Navigation">
<NavExpandableGroup
groupId="test"
title="Test"
groupTitle="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },
@@ -45,7 +45,7 @@ describe('NavExpandableGroup', () => {
<Nav aria-label="Test Navigation">
<NavExpandableGroup
groupId="test"
title="Test"
groupTitle="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { mount } from 'enzyme';
import { I18nProvider } from '@lingui/react';
import PageHeaderToolbar from '../../src/components/PageHeaderToolbar';
describe('PageHeaderToolbar', () => {
test('renders the expected content', () => {
const wrapper = mount(
<I18nProvider>
<PageHeaderToolbar />
</I18nProvider>
);
expect(wrapper.find('Toolbar')).toHaveLength(1);
});
});

View File

@@ -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 = (<div>logo</div>);
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();
});
});

View File

@@ -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('<Login />', () => {
let loginWrapper;
let atLogin;
let awxLogin;
let loginPage;
let loginForm;
let usernameInput;
@@ -16,21 +16,23 @@ describe('<Login />', () => {
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(
<MemoryRouter>
<I18nProvider>
<AtLogin />
<AWXLogin api={api} />
</I18nProvider>
</MemoryRouter>
);
@@ -49,7 +51,7 @@ describe('<Login />', () => {
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('<Login />', () => {
loginWrapper = mount(
<MemoryRouter>
<I18nProvider>
<AtLogin logo="images/foo.jpg" alt="Foo Application" />
<AWXLogin api={api} logo="images/foo.jpg" alt="Foo Application" />
</I18nProvider>
</MemoryRouter>
);
@@ -73,7 +75,7 @@ describe('<Login />', () => {
loginWrapper = mount(
<MemoryRouter>
<I18nProvider>
<AtLogin />
<AWXLogin api={api} />
</I18nProvider>
</MemoryRouter>
);
@@ -84,49 +86,49 @@ describe('<Login />', () => {
});
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('<Login />', () => {
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('<Login />', () => {
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(<AtLogin />);
loginWrapper = shallow(<AWXLogin api={api} />);
const redirectElem = loginWrapper.find('Redirect');
expect(redirectElem.length).toBe(1);
expect(redirectElem.props().to).toBe('/');

37
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 = (
<Toolbar>
<ToolbarGroup>
<ToolbarItem>
<HelpDropdown />
</ToolbarItem>
<ToolbarItem>
<LogoutButton onDevLogout={() => this.onDevLogout()} />
</ToolbarItem>
</ToolbarGroup>
</Toolbar>
);
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 (
<I18nProvider language={languageWithoutRegionCode} catalogs={catalogs}>
<Fragment>
<BackgroundImage
src={{
[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'
}}
/>
<Fragment>
<Page
usecondensed="True"
header={(
<PageHeader
showNavToggle
onNavToggle={this.onNavToggle}
logo={
<TowerLogo
onClick={this.onLogoClick}
/>
}
toolbar={
<PageHeaderToolbar
isAboutDisabled={!version}
onAboutClick={this.onAboutModalOpen}
onLogoutClick={this.onLogout}
/>
}
/>
)}
sidebar={
<PageSidebar
isNavOpen={isNavOpen}
nav={(
<Nav aria-label={navLabel}>
<NavList>
{routeGroups.map(({ groupId, groupTitle, routes }) => (
<NavExpandableGroup
key={groupId}
groupId={groupId}
groupTitle={groupTitle}
routes={routes}
/>
))}
</NavList>
</Nav>
)}
/>
}
>
<ConfigContext.Provider value={config}>
<Switch>
<ConditionalRedirect
shouldRedirect={() => api.isAuthenticated()}
redirectPath="/"
path="/login"
component={() => <Login logo={logo} loginInfo={loginInfo} />}
/>
<Fragment>
<Page
header={(
<PageHeader
logo={<TowerLogo onClick={this.onLogoClick} />}
toolbar={PageToolbar}
showNavToggle
onNavToggle={this.onNavToggle}
/>
)}
sidebar={(
<PageSidebar
isNavOpen={isNavOpen}
nav={(
<I18n>
{({ i18n }) => (
<Nav aria-label={i18n._(t`Primary Navigation`)}>
<NavList>
<NavExpandableGroup
groupId="views_group"
title={i18n._("Views")}
routes={[
{ path: '/home', title: i18n._('Dashboard') },
{ path: '/jobs', title: i18n._('Jobs') },
{ path: '/schedules', title: i18n._('Schedules') },
{ path: '/portal', title: i18n._('Portal Mode') },
]}
/>
<NavExpandableGroup
groupId="resources_group"
title={i18n._("Resources")}
routes={[
{ path: '/templates', title: i18n._('Templates') },
{ path: '/credentials', title: i18n._('Credentials') },
{ path: '/projects', title: i18n._('Projects') },
{ path: '/inventories', title: i18n._('Inventories') },
{ path: '/inventory_scripts', title: i18n._('Inventory Scripts') }
]}
/>
<NavExpandableGroup
groupId="access_group"
title={i18n._("Access")}
routes={[
{ path: '/organizations', title: i18n._('Organizations') },
{ path: '/users', title: i18n._('Users') },
{ path: '/teams', title: i18n._('Teams') }
]}
/>
<NavExpandableGroup
groupId="administration_group"
title={i18n._("Administration")}
routes={[
{ path: '/credential_types', title: i18n._('Credential Types') },
{ path: '/notification_templates', title: i18n._('Notifications') },
{ path: '/management_jobs', title: i18n._('Management Jobs') },
{ path: '/instance_groups', title: i18n._('Instance Groups') },
{ path: '/applications', title: i18n._('Integrations') }
]}
/>
<NavExpandableGroup
groupId="settings_group"
title={i18n._("Settings")}
routes={[
{ path: '/auth_settings', title: i18n._('Authentication') },
{ path: '/jobs_settings', title: i18n._('Jobs') },
{ path: '/system_settings', title: i18n._('System') },
{ path: '/ui_settings', title: i18n._('User Interface') },
{ path: '/license', title: i18n._('License') }
]}
/>
</NavList>
</Nav>
)}
</I18n>
)}
/>
)}
useCondensed
>
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" exact path="/" component={() => (<Redirect to="/home" />)} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/home" component={Dashboard} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/jobs" component={Jobs} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/schedules" component={Schedules} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/portal" component={Portal} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/templates" component={Templates} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/credentials" component={Credentials} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/projects" component={Projects} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/inventories" component={Inventories} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/inventory_scripts" component={InventoryScripts} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/organizations" component={Organizations} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/users" component={Users} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/teams" component={Teams} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/credential_types" component={CredentialTypes} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/notification_templates" component={NotificationTemplates} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/management_jobs" component={ManagementJobs} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/instance_groups" component={InstanceGroups} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/applications" component={Applications} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/auth_settings" component={AuthSettings} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/jobs_settings" component={JobsSettings} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/system_settings" component={SystemSettings} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/ui_settings" component={UISettings} />
<ConditionalRedirect shouldRedirect={() => !api.isAuthenticated()} redirectPath="/login" path="/license" component={License} />
</Page>
</Fragment>
</Switch>
{render && render({ routeGroups })}
</ConfigContext.Provider>
</Fragment>
</I18nProvider>
</Page>
<About
ansible_version={ansible_version}
version={version}
isOpen={isAboutModalOpen}
onClose={this.onAboutModalClose}
/>
</Fragment>
);
}
}
export default withRouter(App);
export default App;

View File

@@ -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;

View File

@@ -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>
{({ i18n }) => (
<ConfigContext.Consumer>
{({ ansible_version, version }) => (
<AboutModal
isOpen={isOpen}
onClose={this.handleModalToggle}
productName="Ansible Tower"
trademark={i18n._(t`Copyright 2018 Red Hat, Inc.`)}
brandImageSrc={brandImg}
brandImageAlt={i18n._(t`Brand Image`)}
logoImageSrc={logoImg}
logoImageAlt={i18n._(t`AboutModal Logo`)}
heroImageSrc={heroImg}
>
<pre>
{this.createSpeechBubble(version)}
{`
<AboutModal
isOpen={isOpen}
onClose={onClose}
productName="Ansible Tower"
trademark={i18n._(t`Copyright 2018 Red Hat, Inc.`)}
brandImageSrc={brandImg}
brandImageAlt={i18n._(t`Brand Image`)}
logoImageSrc={logoImg}
logoImageAlt={i18n._(t`AboutModal Logo`)}
heroImageSrc={heroImg}
>
<pre>
{ speechBubble }
{`
\\
\\ ^__^
\\ ^__^
(oo)\\_______
(__) A )\\
||----w |
|| ||
`}
</pre>
<TextContent>
<TextList component="dl">
<TextListItem component="dt">
<Trans>Ansible Version</Trans>
</TextListItem>
<TextListItem component="dd">{ansible_version}</TextListItem>
</TextList>
</TextContent>
</AboutModal>
)}
</ConfigContext.Consumer>
</pre>
<TextContent>
<TextList component="dl">
<TextListItem component="dt">
<Trans>Ansible Version</Trans>
</TextListItem>
<TextListItem component="dd">{ ansible_version }</TextListItem>
</TextList>
</TextContent>
</AboutModal>
)}
</I18n>
);
}
}
About.contextTypes = {
ansible_version: PropTypes.string,
version: PropTypes.string,
};
export default About;

View File

@@ -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 }) => (
<Fragment>
<BackgroundImage src={backgroundImageConfig} />
{ children }
</Fragment>
);

View File

@@ -1,23 +0,0 @@
import React from 'react';
import {
Route,
Redirect
} from 'react-router-dom';
const ConditionalRedirect = ({
component: Component,
shouldRedirect,
redirectPath,
location,
...props
}) => (shouldRedirect() ? (
<Redirect to={{
pathname: redirectPath,
state: { from: location }
}}
/>
) : (
<Route {...props} render={rest => (<Component {...rest} />)} />
));
export default ConditionalRedirect;

View File

@@ -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 {
<Button
variant="tertiary"
aria-label={i18n._(t`Search`)}
onClick={() => onSearch(searchValue)}
onClick={this.onSearch}
>
<i className="fas fa-search" aria-hidden="true" />
</Button>

View File

@@ -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 = [
<DropdownItem
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html"
target="_blank"
key="help"
>
<Trans>Help</Trans>
</DropdownItem>,
<DropdownItem
onClick={() => this.setState({ showAboutModal: true })}
key="about"
>
<Trans>About</Trans>
</DropdownItem>
];
return (
<Fragment>
<Dropdown
onSelect={() => this.setState({ isOpen: !isOpen })}
toggle={(
<DropdownToggle onToggle={(isToggleOpen) => this.setState({ isOpen: isToggleOpen })}>
<QuestionCircleIcon />
</DropdownToggle>
)}
isOpen={isOpen}
dropdownItems={dropdownItems}
position={DropdownPosition.right}
/>
{showAboutModal
? (
<AboutModal
isOpen={showAboutModal}
onAboutModalClose={() => this.setState({ showAboutModal: !showAboutModal })}
/>
)
: null }
</Fragment>
);
}
}
export default HelpDropdown;

View File

@@ -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>
{({ i18n }) => (
<Button
id="button-logout"
aria-label={i18n._(t`Logout`)}
variant={ButtonVariant.plain}
onClick={onDevLogout}
onKeyDown={event => {
if (event.keyCode === 13) {
onDevLogout();
}
}}
>
<UserIcon />
</Button>
)}
</I18n>
);
export default LogoutButton;

View File

@@ -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 }) => (
<NavItem

View File

@@ -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>
{({ i18n }) => (
<Toolbar>
<ToolbarGroup>
<ToolbarItem>
<Dropdown
isOpen={isHelpOpen}
position={DropdownPosition.right}
onSelect={this.onHelpSelect}
toggle={(
<DropdownToggle
onToggle={this.onHelpToggle}
>
<QuestionCircleIcon />
</DropdownToggle>
)}
dropdownItems={[
<DropdownItem
key="help"
target="_blank"
href={DOCLINK}
>
{i18n._(t`Help`)}
</DropdownItem>,
<DropdownItem
key="about"
component="button"
isDisabled={isAboutDisabled}
onClick={onAboutClick}
>
{i18n._(t`About`)}
</DropdownItem>
]}
/>
</ToolbarItem>
<ToolbarItem>
<Dropdown
isOpen={isUserOpen}
position={DropdownPosition.right}
onSelect={this.onUserSelect}
toggle={(
<DropdownToggle
onToggle={this.onUserToggle}
>
<UserIcon />
</DropdownToggle>
)}
dropdownItems={[
<DropdownItem key="user">
<Link to="/home">
{i18n._(t`User Details`)}
</Link>
</DropdownItem>,
<DropdownItem
key="logout"
component="button"
onClick={onLogoutClick}
onKeyDown={this.onLogoutKeyDown}
>
{i18n._(t`Logout`)}
</DropdownItem>
]}
/>
</ToolbarItem>
</ToolbarGroup>
</Toolbar>
)}
</I18n>
);
}
}
export default PageHeaderToolbar;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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/`;

View File

@@ -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(<Router><App logo={custom_logo} loginInfo={custom_login_info} /></Router>, 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 = (
<Switch>
<Route
path="/login"
render={() => (
<Login
api={api}
logo={custom_logo}
loginInfo={custom_login_info}
/>
)}
/>
<Redirect to="/login" />
</Switch>
);
return render(
<HashRouter>
<I18nProvider
language={languageWithoutRegionCode}
catalogs={catalogs}
>
<I18n>
{({ i18n }) => (
<Background>
{!api.isAuthenticated() ? loginRoutes : (
<Switch>
<Route path="/login" render={() => (<Redirect to="/home" />)} />
<Route exact path="/" render={() => (<Redirect to="/home" />)} />
<Route
render={() => (
<App
api={api}
navLabel={i18n._(t`Primary Navigation`)}
routeGroups={[
{
groupTitle: i18n._(t`Views`),
groupId: 'views_group',
routes: [
{
title: i18n._(t`Dashboard`),
path: '/home',
component: Dashboard
},
{
title: i18n._(t`Jobs`),
path: '/jobs',
component: Jobs
},
{
title: i18n._(t`Schedules`),
path: '/schedules',
component: Schedules
},
{
title: i18n._(t`Portal Mode`),
path: '/portal',
component: Portal
},
],
},
{
groupTitle: i18n._(t`Resources`),
groupId: 'resources_group',
routes: [
{
title: i18n._(t`Templates`),
path: '/templates',
component: Templates
},
{
title: i18n._(t`Credentials`),
path: '/credentials',
component: Credentials
},
{
title: i18n._(t`Projects`),
path: '/projects',
component: Projects
},
{
title: i18n._(t`Inventories`),
path: '/inventories',
component: Inventories
},
{
title: i18n._(t`Inventory Scripts`),
path: '/inventory_scripts',
component: InventoryScripts
},
],
},
{
groupTitle: i18n._(t`Access`),
groupId: 'access_group',
routes: [
{
title: i18n._(t`Organizations`),
path: '/organizations',
component: Organizations
},
{
title: i18n._(t`Users`),
path: '/users',
component: Users
},
{
title: i18n._(t`Teams`),
path: '/teams',
component: Teams
},
],
},
{
groupTitle: i18n._(t`Administration`),
groupId: 'administration_group',
routes: [
{
title: i18n._(t`Credential Types`),
path: '/credential_types',
component: CredentialTypes
},
{
title: i18n._(t`Notifications`),
path: '/notification_templates',
component: NotificationTemplates
},
{
title: i18n._(t`Management Jobs`),
path: '/management_jobs',
component: ManagementJobs
},
{
title: i18n._(t`Instance Groups`),
path: '/instance_groups',
component: InstanceGroups
},
{
title: i18n._(t`Integrations`),
path: '/applications',
component: Applications
},
],
},
{
groupTitle: i18n._(t`Settings`),
groupId: 'settings_group',
routes: [
{
title: i18n._(t`Authentication`),
path: '/auth_settings',
component: AuthSettings
},
{
title: i18n._(t`Jobs`),
path: '/jobs_settings',
component: JobsSettings
},
{
title: i18n._(t`System`),
path: '/system_settings',
component: SystemSettings
},
{
title: i18n._(t`User Interface`),
path: '/ui_settings',
component: UISettings
},
{
title: i18n._(t`License`),
path: '/license',
component: License
},
],
},
]}
render={({ routeGroups }) => (
routeGroups
.reduce((allRoutes, { routes }) => allRoutes.concat(routes), [])
.map(({ component: PageComponent, path }) => (
<Route
key={path}
path={path}
render={({ match }) => (
<PageComponent
api={api}
match={match}
/>
)}
/>
))
)}
/>
)}
/>
</Switch>
)}
</Background>
)}
</I18n>
</I18nProvider>
</HashRouter>, el);
};
main();
export default main;
main(ReactDOM.render, new APIClient(http));

View File

@@ -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>
{({ i18n }) => (
<LoginPage
mainBrandImgSrc={logoSrc}
mainBrandImgAlt={alt || 'Ansible Tower'}
brandImgSrc={logoSrc}
brandImgAlt={alt || 'Ansible Tower'}
loginTitle={i18n._(t`Welcome to Ansible Tower! Please Sign In.`)}
textContent={loginInfo}
>
<LoginForm
usernameLabel={i18n._(t`Username`)}
usernameValue={username}
onChangeUsername={this.handleUsernameChange}
passwordLabel={i18n._(t`Password`)}
passwordValue={password}
onChangePassword={this.handlePasswordChange}
isValidPassword={isValidPassword}
passwordHelperTextInvalid={i18n._(t`Invalid username or password. Please try again.`)}
onLoginButtonClick={this.handleSubmit}
usernameValue={username}
passwordValue={password}
isValidPassword={isInputValid}
onChangeUsername={this.onChangeUsername}
onChangePassword={this.onChangePassword}
onLoginButtonClick={this.onLoginButtonClick}
/>
</LoginPage>
)}
@@ -87,4 +92,4 @@ class AtLogin extends Component {
}
}
export default AtLogin;
export default AWXLogin;

View File

@@ -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 }) => (
<Switch>
<Route path={`${match.path}/add`} component={OrganizationAdd} />
<Route path={`${match.path}/:id`} component={OrganizationView} />
<Route path={`${match.path}`} component={OrganizationsList} />
<Route
path={`${match.path}/add`}
render={() => (
<OrganizationAdd
api={api}
/>
)}
/>
<Route
path={`${match.path}/:id`}
render={() => (
<OrganizationView
api={api}
/>
)}
/>
<Route
path={`${match.path}`}
render={() => (
<OrganizationsList
api={api}
/>
)}
/>
</Switch>
);
export default Organizations;

View File

@@ -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 (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
@@ -116,16 +128,13 @@ class OrganizationAdd extends React.Component {
onChange={this.handleChange}
/>
</FormGroup>
<ConfigContext.Consumer>
{({ custom_virtualenvs }) =>
<AnsibleSelect
labelName="Ansible Environment"
selected={this.state.custom_virtualenv}
selectChange={this.onSelectChange}
data={custom_virtualenvs}
/>
}
</ConfigContext.Consumer>
<AnsibleSelect
labelName="Ansible Environment"
selected={this.state.custom_virtualenv}
selectChange={this.onSelectChange}
data={this.state.custom_virtualenvs}
hidden={this.state.hideAnsibleSelect}
/>
</Gallery>
<ActionGroup className="at-align-right">
<Toolbar>
@@ -146,8 +155,4 @@ class OrganizationAdd extends React.Component {
}
}
OrganizationAdd.contextTypes = {
custom_virtualenvs: PropTypes.array,
};
export default withRouter(OrganizationAdd);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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',