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 React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import { shallow, mount } from 'enzyme'; import { I18nProvider } from '@lingui/react';
import App from '../src/App';
import api from '../src/api';
import { API_LOGOUT, API_CONFIG } from '../src/endpoints';
import Dashboard from '../src/pages/Dashboard'; import { mount, shallow } from 'enzyme';
import Login from '../src/pages/Login'; import { asyncFlush } from '../jest.setup';
import App from '../src/App';
const DEFAULT_ACTIVE_GROUP = 'views_group';
describe('<App />', () => { describe('<App />', () => {
test('renders without crashing', () => { test('expected content is rendered', () => {
const appWrapper = shallow(<App />); 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.length).toBe(1);
}); expect(appWrapper.find('PageHeader').length).toBe(1);
expect(appWrapper.find('PageSidebar').length).toBe(1);
test('renders login page when not authenticated', () => { // sidebar groups and route links
api.isAuthenticated = jest.fn(); expect(appWrapper.find('NavExpandableGroup').length).toBe(2);
api.isAuthenticated.mockReturnValue(false); 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>); // inline render
expect(appWrapper.find('#group_one').length).toBe(1);
const login = appWrapper.find(Login); expect(appWrapper.find('#group_two').length).toBe(1);
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);
}); });
test('onNavToggle sets state.isNavOpen to opposite', () => { test('onNavToggle sets state.isNavOpen to opposite', () => {
const appWrapper = shallow(<App.WrappedComponent />); const appWrapper = shallow(<App />);
expect(appWrapper.state().isNavOpen).toBe(true); const { onNavToggle } = appWrapper.instance();
appWrapper.instance().onNavToggle();
expect(appWrapper.state().isNavOpen).toBe(false); [true, false, true, false, true].forEach(expected => {
expect(appWrapper.state().isNavOpen).toBe(expected);
onNavToggle();
});
}); });
test('api.logout called from logout button', async () => { test('onLogoClick sets selected nav back to defaults', () => {
const logOutButtonSelector = 'button[aria-label="Logout"]'; const appWrapper = shallow(<App />);
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');
appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); 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', () => { test('onLogout makes expected call to api client', async (done) => {
api.get = jest.fn().mockImplementation(() => Promise.resolve({})); const logout = jest.fn(() => Promise.resolve());
const appWrapper = shallow(<App.WrappedComponent />); const api = { logout };
expect(api.get).toHaveBeenCalledTimes(1);
expect(api.get).toHaveBeenCalledWith(API_CONFIG); 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 APIClient from '../src/api';
import * as endpoints from '../src/endpoints';
const CSRF_COOKIE_NAME = 'csrftoken'; const invalidCookie = 'invalid';
const CSRF_HEADER_NAME = 'X-CSRFToken'; 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';
const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
describe('APIClient (api.js)', () => { describe('APIClient (api.js)', () => {
afterEach(() => { test('isAuthenticated returns false when cookie is invalid', () => {
mockAxios.customClearMocks(); APIClient.getCookie = jest.fn(() => invalidCookie);
const api = new APIClient();
expect(api.isAuthenticated()).toBe(false);
}); });
test('constructor calls axios create', () => { test('isAuthenticated returns false when cookie is unauthenticated', () => {
const csrfObj = { APIClient.getCookie = jest.fn(() => validLoggedOutCookie);
xsrfCookieName: CSRF_COOKIE_NAME,
xsrfHeaderName: CSRF_HEADER_NAME const api = new APIClient();
}; expect(api.isAuthenticated()).toBe(false);
expect(mockAxios.create).toHaveBeenCalledTimes(1);
expect(mockAxios.create).toHaveBeenCalledWith(csrfObj);
expect(APIClient.http).toHaveProperty('get');
}); });
test('isAuthenticated checks authentication and sets cookie from document', () => { test('isAuthenticated returns true when cookie is valid and authenticated', () => {
APIClient.getCookie = jest.fn(); APIClient.getCookie = jest.fn(() => validLoggedInCookie);
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 api = new APIClient();
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'; expect(api.isAuthenticated()).toBe(true);
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('login calls get and post to login route, and sets cookie from document', (done) => { test('login calls get and post with expected content headers', async (done) => {
const un = 'foo'; const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
const pw = 'bar';
const next = 'baz'; const createPromise = () => Promise.resolve();
const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) });
const data = `username=${un}&password=${pw}&next=${next}`;
APIClient.setCookie = jest.fn(); const api = new APIClient(mockHttp);
APIClient.login(un, pw, next).then(() => { await api.login('username', 'password');
expect(mockAxios.get).toHaveBeenCalledTimes(1);
expect(mockAxios.get).toHaveBeenCalledWith(endpoints.API_LOGIN, { headers }); expect(mockHttp.get).toHaveBeenCalledTimes(1);
expect(mockAxios.post).toHaveBeenCalledTimes(1); expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers });
expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers });
done(); 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) => { test('login sends expected data', async (done) => {
const un = '/foo/'; const createPromise = () => Promise.resolve();
const pw = '/bar/'; const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) });
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 redirect defaults to config route when not explicitly passed', (done) => { const api = new APIClient(mockHttp);
const un = 'foo'; await api.login('foo', 'bar');
const pw = 'bar'; await api.login('foo', 'bar', 'baz');
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();
});
});
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 React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { I18nProvider } from '@lingui/react'; import { I18nProvider } from '@lingui/react';
import api from '../../src/api';
import { API_CONFIG } from '../../src/endpoints';
import About from '../../src/components/About'; import About from '../../src/components/About';
describe('<About />', () => { describe('<About />', () => {
@@ -19,16 +17,16 @@ describe('<About />', () => {
aboutWrapper.unmount(); aboutWrapper.unmount();
}); });
test('close button calls onAboutModalClose', () => { test('close button calls onClose handler', () => {
const onAboutModalClose = jest.fn(); const onClose = jest.fn();
aboutWrapper = mount( aboutWrapper = mount(
<I18nProvider> <I18nProvider>
<About isOpen onAboutModalClose={onAboutModalClose} /> <About isOpen onClose={onClose} />
</I18nProvider> </I18nProvider>
); );
closeButton = aboutWrapper.find('AboutModalBoxCloseButton Button'); closeButton = aboutWrapper.find('AboutModalBoxCloseButton Button');
closeButton.simulate('click'); closeButton.simulate('click');
expect(onAboutModalClose).toBeCalled(); expect(onClose).toBeCalled();
aboutWrapper.unmount(); 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"> <Nav aria-label="Test Navigation">
<NavExpandableGroup <NavExpandableGroup
groupId="test" groupId="test"
title="Test" groupTitle="Test"
routes={[ routes={[
{ path: '/foo', title: 'Foo' }, { path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' }, { path: '/bar', title: 'Bar' },
@@ -45,7 +45,7 @@ describe('NavExpandableGroup', () => {
<Nav aria-label="Test Navigation"> <Nav aria-label="Test Navigation">
<NavExpandableGroup <NavExpandableGroup
groupId="test" groupId="test"
title="Test" groupTitle="Test"
routes={[ routes={[
{ path: '/foo', title: 'Foo' }, { path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' }, { 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 { mount } from 'enzyme';
import ReactDOM from 'react-dom'; import { main } from '../src/index';
import api from '../src/api'; const render = template => mount(template);
const data = { custom_logo: 'foo', custom_login_info: '' }
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() }));
describe('index.jsx', () => { describe('index.jsx', () => {
test('renders without crashing', async () => { test('initialization', async (done) => {
api.getRoot = jest.fn().mockImplementation(() => Promise const isAuthenticated = () => false;
.resolve({ data: { custom_logo, custom_login_info } })); 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 { mount, shallow } from 'enzyme';
import { I18nProvider } from '@lingui/react'; import { I18nProvider } from '@lingui/react';
import { asyncFlush } from '../../jest.setup'; import { asyncFlush } from '../../jest.setup';
import AtLogin from '../../src/pages/Login'; import AWXLogin from '../../src/pages/Login';
import api from '../../src/api'; import APIClient from '../../src/api';
describe('<Login />', () => { describe('<Login />', () => {
let loginWrapper; let loginWrapper;
let atLogin; let awxLogin;
let loginPage; let loginPage;
let loginForm; let loginForm;
let usernameInput; let usernameInput;
@@ -16,21 +16,23 @@ describe('<Login />', () => {
let submitButton; let submitButton;
let loginHeaderLogo; let loginHeaderLogo;
const api = new APIClient({});
const findChildren = () => { const findChildren = () => {
atLogin = loginWrapper.find('AtLogin'); awxLogin = loginWrapper.find('AWXLogin');
loginPage = loginWrapper.find('LoginPage'); loginPage = loginWrapper.find('LoginPage');
loginForm = loginWrapper.find('LoginForm'); loginForm = loginWrapper.find('LoginForm');
usernameInput = loginWrapper.find('input#pf-login-username-id'); usernameInput = loginWrapper.find('input#pf-login-username-id');
passwordInput = loginWrapper.find('input#pf-login-password-id'); passwordInput = loginWrapper.find('input#pf-login-password-id');
submitButton = loginWrapper.find('Button[type="submit"]'); submitButton = loginWrapper.find('Button[type="submit"]');
loginHeaderLogo = loginWrapper.find('LoginHeaderBrand Brand'); loginHeaderLogo = loginPage.find('img');
}; };
beforeEach(() => { beforeEach(() => {
loginWrapper = mount( loginWrapper = mount(
<MemoryRouter> <MemoryRouter>
<I18nProvider> <I18nProvider>
<AtLogin /> <AWXLogin api={api} />
</I18nProvider> </I18nProvider>
</MemoryRouter> </MemoryRouter>
); );
@@ -49,7 +51,7 @@ describe('<Login />', () => {
expect(usernameInput.props().value).toBe(''); expect(usernameInput.props().value).toBe('');
expect(passwordInput.length).toBe(1); expect(passwordInput.length).toBe(1);
expect(passwordInput.props().value).toBe(''); expect(passwordInput.props().value).toBe('');
expect(atLogin.state().isValidPassword).toBe(true); expect(awxLogin.state().isInputValid).toBe(true);
expect(submitButton.length).toBe(1); expect(submitButton.length).toBe(1);
expect(submitButton.props().isDisabled).toBe(false); expect(submitButton.props().isDisabled).toBe(false);
expect(loginHeaderLogo.length).toBe(1); expect(loginHeaderLogo.length).toBe(1);
@@ -59,7 +61,7 @@ describe('<Login />', () => {
loginWrapper = mount( loginWrapper = mount(
<MemoryRouter> <MemoryRouter>
<I18nProvider> <I18nProvider>
<AtLogin logo="images/foo.jpg" alt="Foo Application" /> <AWXLogin api={api} logo="images/foo.jpg" alt="Foo Application" />
</I18nProvider> </I18nProvider>
</MemoryRouter> </MemoryRouter>
); );
@@ -73,7 +75,7 @@ describe('<Login />', () => {
loginWrapper = mount( loginWrapper = mount(
<MemoryRouter> <MemoryRouter>
<I18nProvider> <I18nProvider>
<AtLogin /> <AWXLogin api={api} />
</I18nProvider> </I18nProvider>
</MemoryRouter> </MemoryRouter>
); );
@@ -84,49 +86,49 @@ describe('<Login />', () => {
}); });
test('state maps to un/pw input value props', () => { test('state maps to un/pw input value props', () => {
atLogin.setState({ username: 'un', password: 'pw' }); awxLogin.setState({ username: 'un', password: 'pw' });
expect(atLogin.state().username).toBe('un'); expect(awxLogin.state().username).toBe('un');
expect(atLogin.state().password).toBe('pw'); expect(awxLogin.state().password).toBe('pw');
findChildren(); findChildren();
expect(usernameInput.props().value).toBe('un'); expect(usernameInput.props().value).toBe('un');
expect(passwordInput.props().value).toBe('pw'); expect(passwordInput.props().value).toBe('pw');
}); });
test('updating un/pw clears out error', () => { 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); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(1);
usernameInput.instance().value = 'uname'; usernameInput.instance().value = 'uname';
usernameInput.simulate('change'); usernameInput.simulate('change');
expect(atLogin.state().username).toBe('uname'); expect(awxLogin.state().username).toBe('uname');
expect(atLogin.state().isValidPassword).toBe(true); expect(awxLogin.state().isInputValid).toBe(true);
expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0); 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); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(1);
passwordInput.instance().value = 'pword'; passwordInput.instance().value = 'pword';
passwordInput.simulate('change'); passwordInput.simulate('change');
expect(atLogin.state().password).toBe('pword'); expect(awxLogin.state().password).toBe('pword');
expect(atLogin.state().isValidPassword).toBe(true); expect(awxLogin.state().isInputValid).toBe(true);
expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0); expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0);
}); });
test('api.login not called when loading', () => { test('api.login not called when loading', () => {
api.login = jest.fn().mockImplementation(() => Promise.resolve({})); api.login = jest.fn().mockImplementation(() => Promise.resolve({}));
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
atLogin.setState({ loading: true }); awxLogin.setState({ isLoading: true });
submitButton.simulate('click'); submitButton.simulate('click');
expect(api.login).toHaveBeenCalledTimes(0); expect(api.login).toHaveBeenCalledTimes(0);
}); });
test('submit calls api.login successfully', async () => { test('submit calls api.login successfully', async () => {
api.login = jest.fn().mockImplementation(() => Promise.resolve({})); api.login = jest.fn().mockImplementation(() => Promise.resolve({}));
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
atLogin.setState({ username: 'unamee', password: 'pwordd' }); awxLogin.setState({ username: 'unamee', password: 'pwordd' });
submitButton.simulate('click'); submitButton.simulate('click');
expect(api.login).toHaveBeenCalledTimes(1); expect(api.login).toHaveBeenCalledTimes(1);
expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd');
expect(atLogin.state().loading).toBe(true); expect(awxLogin.state().isLoading).toBe(true);
await asyncFlush(); await asyncFlush();
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
}); });
test('submit calls api.login handles 401 error', async () => { test('submit calls api.login handles 401 error', async () => {
@@ -135,16 +137,16 @@ describe('<Login />', () => {
err.response = { status: 401, message: 'problem' }; err.response = { status: 401, message: 'problem' };
return Promise.reject(err); return Promise.reject(err);
}); });
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
expect(atLogin.state().isValidPassword).toBe(true); expect(awxLogin.state().isInputValid).toBe(true);
atLogin.setState({ username: 'unamee', password: 'pwordd' }); awxLogin.setState({ username: 'unamee', password: 'pwordd' });
submitButton.simulate('click'); submitButton.simulate('click');
expect(api.login).toHaveBeenCalledTimes(1); expect(api.login).toHaveBeenCalledTimes(1);
expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd');
expect(atLogin.state().loading).toBe(true); expect(awxLogin.state().isLoading).toBe(true);
await asyncFlush(); await asyncFlush();
expect(atLogin.state().isValidPassword).toBe(false); expect(awxLogin.state().isInputValid).toBe(false);
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
}); });
test('submit calls api.login handles non-401 error', async () => { test('submit calls api.login handles non-401 error', async () => {
@@ -153,20 +155,20 @@ describe('<Login />', () => {
err.response = { status: 500, message: 'problem' }; err.response = { status: 500, message: 'problem' };
return Promise.reject(err); return Promise.reject(err);
}); });
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
atLogin.setState({ username: 'unamee', password: 'pwordd' }); awxLogin.setState({ username: 'unamee', password: 'pwordd' });
submitButton.simulate('click'); submitButton.simulate('click');
expect(api.login).toHaveBeenCalledTimes(1); expect(api.login).toHaveBeenCalledTimes(1);
expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(api.login).toHaveBeenCalledWith('unamee', 'pwordd');
expect(atLogin.state().loading).toBe(true); expect(awxLogin.state().isLoading).toBe(true);
await asyncFlush(); await asyncFlush();
expect(atLogin.state().loading).toBe(false); expect(awxLogin.state().isLoading).toBe(false);
}); });
test('render Redirect to / when already authenticated', () => { test('render Redirect to / when already authenticated', () => {
api.isAuthenticated = jest.fn(); api.isAuthenticated = jest.fn();
api.isAuthenticated.mockReturnValue(true); api.isAuthenticated.mockReturnValue(true);
loginWrapper = shallow(<AtLogin />); loginWrapper = shallow(<AWXLogin api={api} />);
const redirectElem = loginWrapper.find('Redirect'); const redirectElem = loginWrapper.find('Redirect');
expect(redirectElem.length).toBe(1); expect(redirectElem.length).toBe(1);
expect(redirectElem.props().to).toBe('/'); expect(redirectElem.props().to).toBe('/');

37
package-lock.json generated
View File

@@ -1311,21 +1311,22 @@
"integrity": "sha512-sIRfo/tk4NSnaRwHIHLUf4XoqzNNa4MMa8ZWivzzSfdZ5pCbgvZtyEUqKnQAEH6zuaCM9S8HyhxcNXnm/xaYaQ==" "integrity": "sha512-sIRfo/tk4NSnaRwHIHLUf4XoqzNNa4MMa8ZWivzzSfdZ5pCbgvZtyEUqKnQAEH6zuaCM9S8HyhxcNXnm/xaYaQ=="
}, },
"@patternfly/react-core": { "@patternfly/react-core": {
"version": "1.37.2", "version": "1.43.5",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-1.37.2.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-1.43.5.tgz",
"integrity": "sha512-zzHwqGEsRWzw9uRkbrf6PmUpcl6EMxQSbUJ1zmv7Ryc32CcSMgrDL4ZA3x/tf4TAYTMRBKUK3O8S5veRjxpFuw==", "integrity": "sha512-xO37/q5BJEdGvAoPllm/gbcwCtW7t0Ae7mKm5UU7d4i5bycEjd0UwJacYxCA6GFTwxN5kzn61XEpAUPY49U3pA==",
"requires": { "requires": {
"@patternfly/react-icons": "^2.9.1", "@patternfly/react-icons": "^2.9.5",
"@patternfly/react-styles": "^2.3.0", "@patternfly/react-styles": "^2.3.0",
"@patternfly/react-tokens": "^1.0.0", "@patternfly/react-tokens": "^1.0.0",
"@tippy.js/react": "^1.1.1",
"exenv": "^1.2.2", "exenv": "^1.2.2",
"focus-trap-react": "^4.0.1" "focus-trap-react": "^4.0.1"
}, },
"dependencies": { "dependencies": {
"@patternfly/react-icons": { "@patternfly/react-icons": {
"version": "2.9.1", "version": "2.9.5",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-2.9.1.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-2.9.5.tgz",
"integrity": "sha512-CBTpGXvqr91rBpxeb5/l2BimrtRlMkBKnIOTgX7V44MIIq3YE3P6A6CQK0fgIH1HGvCdiNf5sXbQz9xp+pB/3A==" "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", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-1.9.0.tgz",
"integrity": "sha512-wxlxeY5B37FkI9W3x4EQyZ9Q8lra3xBYEUg5CFCmWQZTvdH4vAC19l7mE+AQZqHXD4unvltS0ndi753LeHPyAg==" "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": { "@types/node": {
"version": "10.12.1", "version": "10.12.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.1.tgz",
@@ -11105,6 +11115,11 @@
"integrity": "sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==", "integrity": "sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==",
"dev": true "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": { "portfinder": {
"version": "1.0.19", "version": "1.0.19",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.19.tgz", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.19.tgz",
@@ -13891,6 +13906,14 @@
"setimmediate": "^1.0.4" "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": { "tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@@ -48,7 +48,7 @@
"dependencies": { "dependencies": {
"@lingui/react": "^2.7.2", "@lingui/react": "^2.7.2",
"@patternfly/patternfly-next": "^1.0.84", "@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-icons": "^2.9.1",
"@patternfly/react-styles": "^2.3.0", "@patternfly/react-styles": "^2.3.0",
"@patternfly/react-tokens": "^1.9.0", "@patternfly/react-tokens": "^1.9.0",

View File

@@ -1,261 +1,158 @@
import React, { Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { ConfigContext } from './context'; import { global_breakpoint_md } from '@patternfly/react-tokens';
import { I18nProvider, I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { import {
Redirect,
Switch,
withRouter
} from 'react-router-dom';
import {
BackgroundImage,
BackgroundImageSrc,
Nav, Nav,
NavList, NavList,
Page, Page,
PageHeader, PageHeader,
PageSidebar, PageSidebar,
Toolbar,
ToolbarGroup,
ToolbarItem
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { global_breakpoint_md as breakpointMd } from '@patternfly/react-tokens';
import api from './api'; import About from './components/About';
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 NavExpandableGroup from './components/NavExpandableGroup'; import NavExpandableGroup from './components/NavExpandableGroup';
import TowerLogo from './components/TowerLogo';
import PageHeaderToolbar from './components/PageHeaderToolbar';
import { ConfigContext } from './context';
import Applications from './pages/Applications'; class App extends Component {
import Credentials from './pages/Credentials'; constructor (props) {
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) {
super(props); super(props);
const isNavOpen = typeof window !== 'undefined' && window.innerWidth >= parseInt(breakpointMd.value, 10); // initialize with a closed navbar if window size is small
this.state = { const isNavOpen = typeof window !== 'undefined'
isNavOpen, && window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
config: {},
error: false,
};
}
onNavToggle = () => { this.state = {
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); 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 = () => { componentDidMount () {
this.setState({ activeGroup: 'views_group' }); this.fetchConfig();
} }
onDevLogout = async () => { async fetchConfig () {
await api.get(API_LOGOUT); const { api } = this.props;
this.setState({ activeGroup: 'views_group', activeItem: 'views_group_dashboard' });
}
async componentDidMount() {
// Grab our config data from the API and store in state
try { try {
const { data } = await api.get(API_CONFIG); const { data: { ansible_version, custom_virtualenvs, version } } = await api.getConfig();
this.setState({ config: data }); this.setState({ ansible_version, custom_virtualenvs, version });
} catch (error) { } catch (err) {
this.setState({ error }); this.setState({ ansible_version: null, custom_virtualenvs: null, version: null });
} }
} }
render() { async onLogout () {
const { isNavOpen, config } = this.state; const { api } = this.props;
const { logo, loginInfo, history } = this.props;
const PageToolbar = ( await api.logout();
<Toolbar> window.location.replace('/#/login')
<ToolbarGroup> }
<ToolbarItem>
<HelpDropdown /> onAboutModalOpen () {
</ToolbarItem> this.setState({ isAboutModalOpen: true });
<ToolbarItem> }
<LogoutButton onDevLogout={() => this.onDevLogout()} />
</ToolbarItem> onAboutModalClose () {
</ToolbarGroup> this.setState({ isAboutModalOpen: false });
</Toolbar> }
);
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 ( return (
<I18nProvider language={languageWithoutRegionCode} catalogs={catalogs}> <Fragment>
<Fragment> <Page
<BackgroundImage usecondensed="True"
src={{ header={(
[BackgroundImageSrc.lg]: '/assets/images/pfbg_1200.jpg', <PageHeader
[BackgroundImageSrc.md]: '/assets/images/pfbg_992.jpg', showNavToggle
[BackgroundImageSrc.md2x]: '/assets/images/pfbg_992@2x.jpg', onNavToggle={this.onNavToggle}
[BackgroundImageSrc.sm]: '/assets/images/pfbg_768.jpg', logo={
[BackgroundImageSrc.sm2x]: '/assets/images/pfbg_768@2x.jpg', <TowerLogo
[BackgroundImageSrc.xl]: '/assets/images/pfbg_2000.jpg', onClick={this.onLogoClick}
[BackgroundImageSrc.xs]: '/assets/images/pfbg_576.jpg', />
[BackgroundImageSrc.xs2x]: '/assets/images/pfbg_576@2x.jpg', }
[BackgroundImageSrc.filter]: '/assets/images/background-filter.svg' 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}> <ConfigContext.Provider value={config}>
<Switch> {render && render({ routeGroups })}
<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>
</ConfigContext.Provider> </ConfigContext.Provider>
</Fragment> </Page>
</I18nProvider> <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'; const API_ROOT = '/api/';
const API_LOGIN = `${API_ROOT}login/`;
import * as endpoints from './endpoints'; const API_LOGOUT = `${API_ROOT}logout/`;
const API_V2 = `${API_ROOT}v2/`;
const CSRF_COOKIE_NAME = 'csrftoken'; const API_CONFIG = `${API_V2}config/`;
const CSRF_HEADER_NAME = 'X-CSRFToken'; const API_ORGANIZATIONS = `${API_V2}organizations/`;
const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
class APIClient { class APIClient {
constructor () { static getCookie () {
this.http = axios.create({
xsrfCookieName: CSRF_COOKIE_NAME,
xsrfHeaderName: CSRF_HEADER_NAME,
});
}
/* eslint class-methods-use-this: ["error", { "exceptMethods": ["getCookie"] }] */
getCookie () {
return document.cookie; return document.cookie;
} }
isAuthenticated () { constructor (httpAdapter) {
let authenticated = false; 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) { if (parsed.length === 2) {
authenticated = parsed.pop().split(';').shift() === 'true'; authenticated = parsed.pop().split(';').shift() === 'true';
@@ -32,7 +29,7 @@ class APIClient {
return authenticated; return authenticated;
} }
async login (username, password, redirect = endpoints.API_CONFIG) { async login (username, password, redirect = API_CONFIG) {
const un = encodeURIComponent(username); const un = encodeURIComponent(username);
const pw = encodeURIComponent(password); const pw = encodeURIComponent(password);
const next = encodeURIComponent(redirect); const next = encodeURIComponent(redirect);
@@ -40,13 +37,37 @@ class APIClient {
const data = `username=${un}&password=${pw}&next=${next}`; const data = `username=${un}&password=${pw}&next=${next}`;
const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; const headers = { 'Content-Type': LOGIN_CONTENT_TYPE };
await this.http.get(endpoints.API_LOGIN, { headers }); await this.http.get(API_LOGIN, { headers });
await this.http.post(endpoints.API_LOGIN, data, { headers }); const response = await this.http.post(API_LOGIN, data, { headers });
return response;
} }
get = (endpoint, params = {}) => this.http.get(endpoint, { params }); logout () {
return this.http.get(API_LOGOUT);
}
post = (endpoint, data) => this.http.post(endpoint, data); getRoot () {
return this.http.get(API_ROOT);
}
getConfig () {
return this.http.get(API_CONFIG);
}
getOrganizations (params = {}) {
return this.http.get(API_ORGANIZATIONS, { params });
}
createOrganization (data) {
return this.http.post(API_ORGANIZATIONS, data);
}
getOrganizationDetails (id) {
const endpoint = `${API_ORGANIZATIONS}${id}/`;
return this.http.get(endpoint);
}
} }
export default new APIClient(); export default APIClient;

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { I18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { 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 brandImg from '../../images/tower-logo-white.svg';
import logoImg from '../../images/tower-logo-login.svg'; import logoImg from '../../images/tower-logo-login.svg';
import { ConfigContext } from '../context'; class About extends Component {
static createSpeechBubble (version) {
class About extends React.Component {
createSpeechBubble = (version) => {
let text = `Tower ${version}`; let text = `Tower ${version}`;
let top = ''; let top = '';
let bottom = ''; let bottom = '';
@@ -33,61 +30,60 @@ class About extends React.Component {
return top + text + bottom; return top + text + bottom;
} }
handleModalToggle = () => { constructor (props) {
const { onAboutModalClose } = this.props; super(props);
onAboutModalClose();
}; this.createSpeechBubble = this.constructor.createSpeechBubble.bind(this);
}
render () { render () {
const { isOpen } = this.props; const {
ansible_version,
version,
isOpen,
onClose
} = this.props;
const speechBubble = this.createSpeechBubble(version);
return ( return (
<I18n> <I18n>
{({ i18n }) => ( {({ i18n }) => (
<ConfigContext.Consumer> <AboutModal
{({ ansible_version, version }) => ( isOpen={isOpen}
<AboutModal onClose={onClose}
isOpen={isOpen} productName="Ansible Tower"
onClose={this.handleModalToggle} trademark={i18n._(t`Copyright 2018 Red Hat, Inc.`)}
productName="Ansible Tower" brandImageSrc={brandImg}
trademark={i18n._(t`Copyright 2018 Red Hat, Inc.`)} brandImageAlt={i18n._(t`Brand Image`)}
brandImageSrc={brandImg} logoImageSrc={logoImg}
brandImageAlt={i18n._(t`Brand Image`)} logoImageAlt={i18n._(t`AboutModal Logo`)}
logoImageSrc={logoImg} heroImageSrc={heroImg}
logoImageAlt={i18n._(t`AboutModal Logo`)} >
heroImageSrc={heroImg} <pre>
> { speechBubble }
<pre> {`
{this.createSpeechBubble(version)}
{`
\\ \\
\\ ^__^ \\ ^__^
(oo)\\_______ (oo)\\_______
(__) A )\\ (__) A )\\
||----w | ||----w |
|| || || ||
`} `}
</pre> </pre>
<TextContent>
<TextContent> <TextList component="dl">
<TextList component="dl"> <TextListItem component="dt">
<TextListItem component="dt"> <Trans>Ansible Version</Trans>
<Trans>Ansible Version</Trans> </TextListItem>
</TextListItem> <TextListItem component="dd">{ ansible_version }</TextListItem>
<TextListItem component="dd">{ansible_version}</TextListItem> </TextList>
</TextList> </TextContent>
</TextContent> </AboutModal>
</AboutModal>
)}
</ConfigContext.Consumer>
)} )}
</I18n> </I18n>
); );
} }
} }
About.contextTypes = {
ansible_version: PropTypes.string,
version: PropTypes.string,
};
export default About; 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, searchKey: sortedColumnKey,
searchValue: '', 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 }); this.setState({ searchValue });
}; }
onSortDropdownToggle = isSortDropdownOpen => { onSortDropdownToggle (isSortDropdownOpen) {
this.setState({ isSortDropdownOpen }); this.setState({ isSortDropdownOpen });
}; }
onSortDropdownSelect = ({ target }) => { onSortDropdownSelect ({ target }) {
const { columns, onSort, sortOrder } = this.props; const { columns, onSort, sortOrder } = this.props;
const [{ key }] = columns.filter(({ name }) => name === target.innerText); const [{ key }] = columns.filter(({ name }) => name === target.innerText);
@@ -61,27 +68,33 @@ class DataListToolbar extends React.Component {
this.setState({ isSortDropdownOpen: false }); this.setState({ isSortDropdownOpen: false });
onSort(key, sortOrder); onSort(key, sortOrder);
}; }
onSearchDropdownToggle = isSearchDropdownOpen => { onSearchDropdownToggle (isSearchDropdownOpen) {
this.setState({ isSearchDropdownOpen }); this.setState({ isSearchDropdownOpen });
}; }
onSearchDropdownSelect = ({ target }) => { onSearchDropdownSelect ({ target }) {
const { columns } = this.props; const { columns } = this.props;
const targetName = target.innerText; const targetName = target.innerText;
const [{ key }] = columns.filter(({ name }) => name === targetName); const [{ key }] = columns.filter(({ name }) => name === targetName);
this.setState({ isSearchDropdownOpen: false, searchKey: key }); this.setState({ isSearchDropdownOpen: false, searchKey: key });
}; }
onSearch () {
const { searchValue } = this.state;
const { onSearch } = this.props;
onSearch(searchValue);
}
render () { render () {
const { up } = DropdownPosition; const { up } = DropdownPosition;
const { const {
columns, columns,
isAllSelected, isAllSelected,
onSearch,
onSelectAll, onSelectAll,
onSort, onSort,
sortedColumnKey, sortedColumnKey,
@@ -175,7 +188,7 @@ class DataListToolbar extends React.Component {
<Button <Button
variant="tertiary" variant="tertiary"
aria-label={i18n._(t`Search`)} aria-label={i18n._(t`Search`)}
onClick={() => onSearch(searchValue)} onClick={this.onSearch}
> >
<i className="fas fa-search" aria-hidden="true" /> <i className="fas fa-search" aria-hidden="true" />
</Button> </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 // 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. // an array of url paths associated with any NavItem component rendered by this component.
this.navItemPaths = routes.map(({ path }) => path); 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; const { history } = this.props;
return history.location.pathname.startsWith(path); return history.location.pathname.startsWith(path);
}; }
render () { render () {
const { routes, groupId, staticContext, ...rest } = this.props; const { groupId, groupTitle, routes } = this.props;
const isActive = this.isActiveGroup(); const isActive = this.isActiveGroup();
return ( return (
@@ -33,7 +38,7 @@ class NavExpandableGroup extends Component {
isActive={isActive} isActive={isActive}
isExpanded={isActive} isExpanded={isActive}
groupId={groupId} groupId={groupId}
{...rest} title={groupTitle}
> >
{routes.map(({ path, title }) => ( {routes.map(({ path, title }) => (
<NavItem <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; const { page } = this.props;
this.state = { value: page, isOpen: false }; 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) { componentDidUpdate (prevProps) {
@@ -31,11 +40,11 @@ class Pagination extends Component {
} }
} }
onPageChange = value => { onPageChange (value) {
this.setState({ value }); this.setState({ value });
}; }
onSubmit = event => { onSubmit (event) {
const { onSetPage, page, pageCount, page_size } = this.props; const { onSetPage, page, pageCount, page_size } = this.props;
const { value } = this.state; const { value } = this.state;
@@ -49,43 +58,43 @@ class Pagination extends Component {
} else { } else {
this.setState({ value: page }); this.setState({ value: page });
} }
}; }
onFirst = () => { onFirst () {
const { onSetPage, page_size } = this.props; const { onSetPage, page_size } = this.props;
onSetPage(1, page_size); onSetPage(1, page_size);
}; }
onPrevious = () => { onPrevious () {
const { onSetPage, page, page_size } = this.props; const { onSetPage, page, page_size } = this.props;
const previousPage = page - 1; const previousPage = page - 1;
if (previousPage >= 1) { if (previousPage >= 1) {
onSetPage(previousPage, page_size); onSetPage(previousPage, page_size);
} }
}; }
onNext = () => { onNext () {
const { onSetPage, page, pageCount, page_size } = this.props; const { onSetPage, page, pageCount, page_size } = this.props;
const nextPage = page + 1; const nextPage = page + 1;
if (nextPage <= pageCount) { if (nextPage <= pageCount) {
onSetPage(nextPage, page_size); onSetPage(nextPage, page_size);
} }
}; }
onLast = () => { onLast () {
const { onSetPage, pageCount, page_size } = this.props; const { onSetPage, pageCount, page_size } = this.props;
onSetPage(pageCount, page_size) onSetPage(pageCount, page_size)
}; }
onTogglePageSize = isOpen => { onTogglePageSize (isOpen) {
this.setState({ isOpen }); this.setState({ isOpen });
}; }
onSelectPageSize = ({ target }) => { onSelectPageSize ({ target }) {
const { onSetPage } = this.props; const { onSetPage } = this.props;
const page = 1; const page = 1;
@@ -94,7 +103,7 @@ class Pagination extends Component {
this.setState({ isOpen: false }); this.setState({ isOpen: false });
onSetPage(page, page_size); onSetPage(page, page_size);
}; }
render () { render () {
const { up } = DropdownDirection; const { up } = DropdownDirection;

View File

@@ -12,18 +12,26 @@ class TowerLogo extends Component {
super(props); super(props);
this.state = { hover: false }; this.state = { hover: false };
this.onClick = this.onClick.bind(this);
this.onHover = this.onHover.bind(this);
} }
onClick = () => { onClick () {
const { history } = this.props; const { history, onClick: handleClick } = this.props;
history.push('/');
};
onHover = () => { if (!handleClick) return;
history.push('/');
handleClick();
}
onHover () {
const { hover } = this.state; const { hover } = this.state;
this.setState({ hover: !hover }); this.setState({ hover: !hover });
}; }
render () { render () {
const { hover } = this.state; 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 React from 'react';
import { render } from 'react-dom'; import ReactDOM from 'react-dom';
import { import {
HashRouter as Router HashRouter,
Redirect,
Route,
Switch,
} from 'react-router-dom'; } from 'react-router-dom';
import App from './App'; import {
import api from './api'; I18n,
import { API_ROOT } from './endpoints'; I18nProvider,
} from '@lingui/react';
import { t } from '@lingui/macro';
import '@patternfly/react-core/dist/styles/base.css'; import '@patternfly/react-core/dist/styles/base.css';
import '@patternfly/patternfly-next/patternfly.css'; import '@patternfly/patternfly-next/patternfly.css';
import './app.scss'; import './app.scss';
import './components/Pagination/styles.scss'; import './components/Pagination/styles.scss';
import './components/DataListToolbar/styles.scss'; import './components/DataListToolbar/styles.scss';
const el = document.getElementById('app'); import APIClient from './api';
const main = async () => { import App from './App';
const { custom_logo, custom_login_info } = await api.get(API_ROOT); import Background from './components/Background';
render(<Router><App logo={custom_logo} loginInfo={custom_login_info} /></Router>, el); 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(); main(ReactDOM.render, new APIClient(http));
export default main;

View File

@@ -8,53 +8,57 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import towerLogo from '../../images/tower-logo-header.svg'; import towerLogo from '../../images/tower-logo-header.svg';
import api from '../api';
class AtLogin extends Component { class AWXLogin extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.state = { this.state = {
username: '', username: '',
password: '', password: '',
isValidPassword: true, isInputValid: true,
loading: false isLoading: false
}; };
this.onChangeUsername = this.onChangeUsername.bind(this);
this.onChangePassword = this.onChangePassword.bind(this);
this.onLoginButtonClick = this.onLoginButtonClick.bind(this);
} }
componentWillUnmount () { onChangeUsername (value) {
this.unmounting = true; // todo: state management 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 }); async onLoginButtonClick (event) {
const { username, password, isLoading } = this.state;
handlePasswordChange = value => this.safeSetState({ password: value, isValidPassword: true }); const { api } = this.props;
handleSubmit = async event => {
const { username, password, loading } = this.state;
event.preventDefault(); event.preventDefault();
if (!loading) { if (isLoading) {
this.safeSetState({ loading: true }); return;
}
try { this.setState({ isLoading: true });
await api.login(username, password);
} catch (error) { try {
if (error.response.status === 401) { await api.login(username, password);
this.safeSetState({ isValidPassword: false }); } catch (error) {
} if (error.response.status === 401) {
} finally { this.setState({ isInputValid: false });
this.safeSetState({ loading: false });
} }
} finally {
this.setState({ isLoading: false });
} }
} }
render () { render () {
const { username, password, isValidPassword } = this.state; const { username, password, isInputValid } = this.state;
const { logo, alt } = this.props; const { api, alt, loginInfo, logo } = this.props;
const logoSrc = logo ? `data:image/jpeg;${logo}` : towerLogo; const logoSrc = logo ? `data:image/jpeg;${logo}` : towerLogo;
if (api.isAuthenticated()) { if (api.isAuthenticated()) {
@@ -65,20 +69,21 @@ class AtLogin extends Component {
<I18n> <I18n>
{({ i18n }) => ( {({ i18n }) => (
<LoginPage <LoginPage
mainBrandImgSrc={logoSrc} brandImgSrc={logoSrc}
mainBrandImgAlt={alt || 'Ansible Tower'} brandImgAlt={alt || 'Ansible Tower'}
loginTitle={i18n._(t`Welcome to Ansible Tower! Please Sign In.`)} loginTitle={i18n._(t`Welcome to Ansible Tower! Please Sign In.`)}
textContent={loginInfo}
> >
<LoginForm <LoginForm
usernameLabel={i18n._(t`Username`)} usernameLabel={i18n._(t`Username`)}
usernameValue={username}
onChangeUsername={this.handleUsernameChange}
passwordLabel={i18n._(t`Password`)} passwordLabel={i18n._(t`Password`)}
passwordValue={password}
onChangePassword={this.handlePasswordChange}
isValidPassword={isValidPassword}
passwordHelperTextInvalid={i18n._(t`Invalid username or password. Please try again.`)} 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> </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 OrganizationView from './views/Organization.view';
import OrganizationsList from './views/Organizations.list'; import OrganizationsList from './views/Organizations.list';
const Organizations = ({ match }) => ( export default ({ api, match }) => (
<Switch> <Switch>
<Route path={`${match.path}/add`} component={OrganizationAdd} /> <Route
<Route path={`${match.path}/:id`} component={OrganizationView} /> path={`${match.path}/add`}
<Route path={`${match.path}`} component={OrganizationsList} /> render={() => (
<OrganizationAdd
api={api}
/>
)}
/>
<Route
path={`${match.path}/:id`}
render={() => (
<OrganizationView
api={api}
/>
)}
/>
<Route
path={`${match.path}`}
render={() => (
<OrganizationsList
api={api}
/>
)}
/>
</Switch> </Switch>
); );
export default Organizations;

View File

@@ -1,5 +1,4 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { import {
@@ -18,10 +17,8 @@ import {
CardBody, CardBody,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { ConfigContext } from '../../../context'; import AnsibleSelect from '../../../components/AnsibleSelect';
import { API_ORGANIZATIONS } from '../../../endpoints';
import api from '../../../api';
import AnsibleSelect from '../../../components/AnsibleSelect'
const { light } = PageSectionVariants; const { light } = PageSectionVariants;
class OrganizationAdd extends React.Component { class OrganizationAdd extends React.Component {
@@ -40,6 +37,8 @@ class OrganizationAdd extends React.Component {
description: '', description: '',
instanceGroups: '', instanceGroups: '',
custom_virtualenv: '', custom_virtualenv: '',
custom_virtualenvs: [],
hideAnsibleSelect: true,
error:'', error:'',
}; };
@@ -61,7 +60,8 @@ class OrganizationAdd extends React.Component {
async onSubmit() { async onSubmit() {
const data = Object.assign({}, { ...this.state }); const data = Object.assign({}, { ...this.state });
await api.post(API_ORGANIZATIONS, data); await api.createOrganization(data);
this.resetForm(); this.resetForm();
} }
@@ -69,10 +69,22 @@ class OrganizationAdd extends React.Component {
this.props.history.push('/organizations'); 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() { render() {
const { name } = this.state; const { name } = this.state;
const enabled = name.length > 0; // TODO: add better form validation const enabled = name.length > 0; // TODO: add better form validation
return ( return (
<Fragment> <Fragment>
<PageSection variant={light} className="pf-m-condensed"> <PageSection variant={light} className="pf-m-condensed">
@@ -116,16 +128,13 @@ class OrganizationAdd extends React.Component {
onChange={this.handleChange} onChange={this.handleChange}
/> />
</FormGroup> </FormGroup>
<ConfigContext.Consumer> <AnsibleSelect
{({ custom_virtualenvs }) => labelName="Ansible Environment"
<AnsibleSelect selected={this.state.custom_virtualenv}
labelName="Ansible Environment" selectChange={this.onSelectChange}
selected={this.state.custom_virtualenv} data={this.state.custom_virtualenvs}
selectChange={this.onSelectChange} hidden={this.state.hideAnsibleSelect}
data={custom_virtualenvs} />
/>
}
</ConfigContext.Consumer>
</Gallery> </Gallery>
<ActionGroup className="at-align-right"> <ActionGroup className="at-align-right">
<Toolbar> <Toolbar>
@@ -146,8 +155,4 @@ class OrganizationAdd extends React.Component {
} }
} }
OrganizationAdd.contextTypes = {
custom_virtualenvs: PropTypes.array,
};
export default withRouter(OrganizationAdd); export default withRouter(OrganizationAdd);

View File

@@ -2,16 +2,13 @@ import React, { Component, Fragment } from 'react';
import { i18nMark } from '@lingui/react'; import { i18nMark } from '@lingui/react';
import { import {
Switch, Switch,
Route Route,
withRouter,
} from 'react-router-dom'; } from 'react-router-dom';
import OrganizationBreadcrumb from '../components/OrganizationBreadcrumb'; import OrganizationBreadcrumb from '../components/OrganizationBreadcrumb';
import OrganizationDetail from '../components/OrganizationDetail'; import OrganizationDetail from '../components/OrganizationDetail';
import OrganizationEdit from '../components/OrganizationEdit'; import OrganizationEdit from '../components/OrganizationEdit';
import api from '../../../api';
import { API_ORGANIZATIONS } from '../../../endpoints';
class OrganizationView extends Component { class OrganizationView extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
@@ -30,6 +27,8 @@ class OrganizationView extends Component {
loading: false, loading: false,
mounted: false mounted: false
}; };
this.fetchOrganization = this.fetchOrganization.bind(this);
} }
componentDidMount () { componentDidMount () {
@@ -47,13 +46,15 @@ class OrganizationView extends Component {
async fetchOrganization () { async fetchOrganization () {
const { mounted } = this.state; const { mounted } = this.state;
const { api } = this.props;
if (mounted) { if (mounted) {
this.setState({ error: false, loading: true }); this.setState({ error: false, loading: true });
const { match } = this.props; const { match } = this.props;
const { parentBreadcrumbObj, organization } = this.state; const { parentBreadcrumbObj, organization } = this.state;
try { try {
const { data } = await api.get(`${API_ORGANIZATIONS}${match.params.id}/`); const { data } = await api.getOrganizationDetails(match.params.id);
if (organization === 'loading') { if (organization === 'loading') {
this.setState({ organization: data }); 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 OrganizationListItem from '../components/OrganizationListItem';
import Pagination from '../../../components/Pagination'; import Pagination from '../../../components/Pagination';
import api from '../../../api';
import { API_ORGANIZATIONS } from '../../../endpoints';
import { import {
encodeQueryString, encodeQueryString,
parseQueryString, parseQueryString,
@@ -56,6 +53,15 @@ class Organizations extends Component {
results: [], results: [],
selected: [], 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 () { componentDidMount () {
@@ -78,7 +84,7 @@ class Organizations extends Component {
return Object.assign({}, this.defaultParams, searchParams, overrides); return Object.assign({}, this.defaultParams, searchParams, overrides);
} }
onSort = (sortedColumnKey, sortOrder) => { onSort(sortedColumnKey, sortOrder) {
const { page_size } = this.state; const { page_size } = this.state;
let order_by = sortedColumnKey; let order_by = sortedColumnKey;
@@ -90,26 +96,26 @@ class Organizations extends Component {
const queryParams = this.getQueryParams({ order_by, page_size }); const queryParams = this.getQueryParams({ order_by, page_size });
this.fetchOrganizations(queryParams); this.fetchOrganizations(queryParams);
}; }
onSetPage = (pageNumber, pageSize) => { onSetPage (pageNumber, pageSize) {
const page = parseInt(pageNumber, 10); const page = parseInt(pageNumber, 10);
const page_size = parseInt(pageSize, 10); const page_size = parseInt(pageSize, 10);
const queryParams = this.getQueryParams({ page, page_size }); const queryParams = this.getQueryParams({ page, page_size });
this.fetchOrganizations(queryParams); this.fetchOrganizations(queryParams);
}; }
onSelectAll = isSelected => { onSelectAll (isSelected) {
const { results } = this.state; const { results } = this.state;
const selected = isSelected ? results.map(o => o.id) : []; const selected = isSelected ? results.map(o => o.id) : [];
this.setState({ selected }); this.setState({ selected });
}; }
onSelect = id => { onSelect (id) {
const { selected } = this.state; const { selected } = this.state;
const isSelected = selected.includes(id); const isSelected = selected.includes(id);
@@ -119,7 +125,7 @@ class Organizations extends Component {
} else { } else {
this.setState({ selected: selected.concat(id) }); this.setState({ selected: selected.concat(id) });
} }
}; }
updateUrl (queryParams) { updateUrl (queryParams) {
const { history, location } = this.props; const { history, location } = this.props;
@@ -132,6 +138,7 @@ class Organizations extends Component {
} }
async fetchOrganizations (queryParams) { async fetchOrganizations (queryParams) {
const { api } = this.props;
const { page, page_size, order_by } = queryParams; const { page, page_size, order_by } = queryParams;
let sortOrder = 'ascending'; let sortOrder = 'ascending';
@@ -145,7 +152,7 @@ class Organizations extends Component {
this.setState({ error: false, loading: true }); this.setState({ error: false, loading: true });
try { try {
const { data } = await api.get(API_ORGANIZATIONS, queryParams); const { data } = await api.getOrganizations(queryParams);
const { count, results } = data; const { count, results } = data;
const pageCount = Math.ceil(count / page_size); const pageCount = Math.ceil(count / page_size);

View File

@@ -1,8 +1,9 @@
const path = require('path'); const path = require('path');
const webpack = require('webpack'); const webpack = require('webpack');
const TARGET_PORT = 8043; const TARGET_PORT = process.env.TARGET_PORT || 8043;
const TARGET = `https://localhost:${TARGET_PORT}`; const TARGET_HOST = process.env.TARGET_HOST || 'localhost';
const TARGET = `https://${TARGET_HOST}:${TARGET_PORT}`;
module.exports = { module.exports = {
entry: './src/index.jsx', entry: './src/index.jsx',