1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 23:51:09 +03:00

update content loading and error handling

unwind error handling

use auth cookie as source of truth, fetch config only when authenticated
This commit is contained in:
Jake McDermott 2019-05-09 15:59:43 -04:00
parent 534418c81a
commit e72f0bcfd4
No known key found for this signature in database
GPG Key ID: 9A6F084352C3A0B7
50 changed files with 4721 additions and 4724 deletions

View File

@ -13,6 +13,7 @@ Have questions about this document or anything not covered here? Feel free to re
* [Build the user interface](#build-the-user-interface) * [Build the user interface](#build-the-user-interface)
* [Accessing the AWX web interface](#accessing-the-awx-web-interface) * [Accessing the AWX web interface](#accessing-the-awx-web-interface)
* [AWX REST API Interaction](#awx-rest-api-interaction) * [AWX REST API Interaction](#awx-rest-api-interaction)
* [Handling API Errors](#handling-api-errors)
* [Working with React](#working-with-react) * [Working with React](#working-with-react)
* [App structure](#app-structure) * [App structure](#app-structure)
* [Naming files](#naming-files) * [Naming files](#naming-files)
@ -110,6 +111,15 @@ afterEach(() => {
... ...
``` ```
## Handling API Errors
API requests can and will fail occasionally so they should include explicit error handling. The three _main_ categories of errors from our perspective are: content loading errors, form submission errors, and other errors. The patterns currently in place for these are described below:
- **content loading errors** - These are any errors that occur when fetching data to initialize a page or populate a list. For these, we conditionally render a _content error component_ in place of the unresolved content.
- **form submission errors** - If an error is encountered when submitting a form, we display the error message on the form. For field-specific validation errors, we display the error message beneath the specific field(s). For general errors, we display the error message at the bottom of the form near the action buttons. An error that happens when requesting data to populate a form is not a form submission error, it is still a content error and is handled as such (see above).
- **other errors** - Most errors will fall into the first two categories, but for miscellaneous actions like toggling notifications, deleting a list item, etc. we display an alert modal to notify the user that their requested action couldn't be performed.
## Working with React ## Working with React
### App structure ### App structure

View File

@ -24,6 +24,6 @@ To run the unit tests on files that you've changed:
* `npm test` * `npm test`
To run a single test (in this case the login page test): To run a single test (in this case the login page test):
* `npm test -- __tests__/pages/Login.jsx` * `npm test -- __tests__/pages/Login.test.jsx`
**note:** Once the test watcher is up and running you can hit `a` to run all the tests **note:** Once the test watcher is up and running you can hit `a` to run all the tests

View File

@ -1,16 +1,33 @@
import React from 'react'; import React from 'react';
import { mountWithContexts, waitForElement } from './enzymeHelpers'; import { mountWithContexts, waitForElement } from './enzymeHelpers';
import { asyncFlush } from '../jest.setup'; import { asyncFlush } from '../jest.setup';
import App from '../src/App'; import App from '../src/App';
import { ConfigAPI, MeAPI, RootAPI } from '../src/api';
import { RootAPI } from '../src/api';
jest.mock('../src/api'); jest.mock('../src/api');
describe('<App />', () => { describe('<App />', () => {
const ansible_version = '111';
const custom_virtualenvs = [];
const version = '222';
beforeEach(() => {
ConfigAPI.read = () => Promise.resolve({
data: {
ansible_version,
custom_virtualenvs,
version
}
});
MeAPI.read = () => Promise.resolve({ data: { results: [{}] } });
});
afterEach(() => {
jest.clearAllMocks();
});
test('expected content is rendered', () => { test('expected content is rendered', () => {
const appWrapper = mountWithContexts( const appWrapper = mountWithContexts(
<App <App
@ -34,7 +51,7 @@ describe('<App />', () => {
render={({ routeGroups }) => ( render={({ routeGroups }) => (
routeGroups.map(({ groupId }) => (<div key={groupId} id={groupId} />)) routeGroups.map(({ groupId }) => (<div key={groupId} id={groupId} />))
)} )}
/> />,
); );
// page components // page components
@ -54,12 +71,8 @@ describe('<App />', () => {
}); });
test('opening the about modal renders prefetched config data', async (done) => { test('opening the about modal renders prefetched config data', async (done) => {
const ansible_version = '111'; const wrapper = mountWithContexts(<App />);
const version = '222'; wrapper.update();
const config = { ansible_version, version };
const wrapper = mountWithContexts(<App />, { context: { config } });
// open about modal // open about modal
const aboutDropdown = 'Dropdown QuestionCircleIcon'; const aboutDropdown = 'Dropdown QuestionCircleIcon';
@ -67,9 +80,11 @@ describe('<App />', () => {
const aboutModalContent = 'AboutModalBoxContent'; const aboutModalContent = 'AboutModalBoxContent';
const aboutModalClose = 'button[aria-label="Close Dialog"]'; const aboutModalClose = 'button[aria-label="Close Dialog"]';
expect(wrapper.find(aboutModalContent)).toHaveLength(0); await waitForElement(wrapper, aboutDropdown);
wrapper.find(aboutDropdown).simulate('click'); wrapper.find(aboutDropdown).simulate('click');
wrapper.find(aboutButton).simulate('click');
const button = await waitForElement(wrapper, aboutButton, el => !el.props().disabled);
button.simulate('click');
// check about modal content // check about modal content
const content = await waitForElement(wrapper, aboutModalContent); const content = await waitForElement(wrapper, aboutModalContent);
@ -83,24 +98,21 @@ describe('<App />', () => {
done(); done();
}); });
test('onNavToggle sets state.isNavOpen to opposite', () => { test('handleNavToggle sets state.isNavOpen to opposite', () => {
const appWrapper = mountWithContexts(<App />).find('App'); const appWrapper = mountWithContexts(<App />).find('App');
const { onNavToggle } = appWrapper.instance();
const { handleNavToggle } = appWrapper.instance();
[true, false, true, false, true].forEach(expected => { [true, false, true, false, true].forEach(expected => {
expect(appWrapper.state().isNavOpen).toBe(expected); expect(appWrapper.state().isNavOpen).toBe(expected);
onNavToggle(); handleNavToggle();
}); });
}); });
test('onLogout makes expected call to api client', async (done) => { test('onLogout makes expected call to api client', async (done) => {
const appWrapper = mountWithContexts(<App />, { const appWrapper = mountWithContexts(<App />).find('App');
context: { network: { handleHttpError: () => {} } } appWrapper.instance().handleLogout();
}).find('App');
appWrapper.instance().onLogout();
await asyncFlush(); await asyncFlush();
expect(RootAPI.logout).toHaveBeenCalledTimes(1); expect(RootAPI.logout).toHaveBeenCalledTimes(1);
done(); done();
}); });
}); });

View File

@ -2,20 +2,16 @@
exports[`mountWithContexts injected ConfigProvider should mount and render with custom Config value 1`] = ` exports[`mountWithContexts injected ConfigProvider should mount and render with custom Config value 1`] = `
<Foo> <Foo>
<Config> <div>
<div> Fizz
Fizz 1.1
1.1 </div>
</div>
</Config>
</Foo> </Foo>
`; `;
exports[`mountWithContexts injected ConfigProvider should mount and render with default values 1`] = ` exports[`mountWithContexts injected ConfigProvider should mount and render with default values 1`] = `
<Foo> <Foo>
<Config> <div />
<div />
</Config>
</Foo> </Foo>
`; `;

View File

@ -19,7 +19,6 @@ describe('<Lookup />', () => {
getItems={() => { }} getItems={() => { }}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
handleHttpError={() => {}}
/> />
); );
}); });
@ -34,7 +33,6 @@ describe('<Lookup />', () => {
getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })} getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
handleHttpError={() => {}}
/> />
).find('Lookup'); ).find('Lookup');
@ -57,7 +55,6 @@ describe('<Lookup />', () => {
getItems={() => { }} getItems={() => { }}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
handleHttpError={() => {}}
/> />
).find('Lookup'); ).find('Lookup');
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();

View File

@ -1,19 +0,0 @@
import React from 'react';
import { mountWithContexts } from '../enzymeHelpers';
import { _NotifyAndRedirect } from '../../src/components/NotifyAndRedirect';
describe('<NotifyAndRedirect />', () => {
test('initially renders succesfully and calls setRootDialogMessage', () => {
const setRootDialogMessage = jest.fn();
mountWithContexts(
<_NotifyAndRedirect
to="foo"
setRootDialogMessage={setRootDialogMessage}
location={{ pathname: 'foo' }}
i18n={{ _: val => val.toString() }}
/>
);
expect(setRootDialogMessage).toHaveBeenCalled();
});
});

View File

@ -3,12 +3,10 @@
* derived from https://lingui.js.org/guides/testing.html * derived from https://lingui.js.org/guides/testing.html
*/ */
import React from 'react'; import React from 'react';
import { shape, object, string, arrayOf, func } from 'prop-types'; import { shape, object, string, arrayOf } from 'prop-types';
import { mount, shallow } from 'enzyme'; import { mount, shallow } from 'enzyme';
import { I18nProvider } from '@lingui/react'; import { I18nProvider } from '@lingui/react';
import { ConfigProvider } from '../src/contexts/Config'; import { ConfigProvider } from '../src/contexts/Config';
import { _NetworkProvider } from '../src/contexts/Network';
import { RootDialogProvider } from '../src/contexts/RootDialog';
const language = 'en-US'; const language = 'en-US';
const intlProvider = new I18nProvider( const intlProvider = new I18nProvider(
@ -36,8 +34,6 @@ const defaultContexts = {
ansible_version: null, ansible_version: null,
custom_virtualenvs: [], custom_virtualenvs: [],
version: null, version: null,
custom_logo: null,
custom_login_info: null,
toJSON: () => '/config/' toJSON: () => '/config/'
}, },
router: { router: {
@ -69,30 +65,19 @@ const defaultContexts = {
}, },
toJSON: () => '/router/', toJSON: () => '/router/',
}, },
network: {
handleHttpError: () => {},
},
dialog: {}
}; };
function wrapContexts (node, context) { function wrapContexts (node, context) {
const { config, network, dialog } = context; const { config } = context;
class Wrap extends React.Component { class Wrap extends React.Component {
render () { render () {
// eslint-disable-next-line react/no-this-in-sfc // eslint-disable-next-line react/no-this-in-sfc
const { children, ...props } = this.props; const { children, ...props } = this.props;
const component = React.cloneElement(children, props); const component = React.cloneElement(children, props);
return ( return (
<RootDialogProvider value={dialog}> <ConfigProvider value={config}>
<_NetworkProvider value={network}> {component}
<ConfigProvider </ConfigProvider>
value={config}
i18n={defaultContexts.linguiPublisher.i18n}
>
{component}
</ConfigProvider>
</_NetworkProvider>
</RootDialogProvider>
); );
} }
} }
@ -131,8 +116,6 @@ export function mountWithContexts (node, options = {}) {
ansible_version: string, ansible_version: string,
custom_virtualenvs: arrayOf(string), custom_virtualenvs: arrayOf(string),
version: string, version: string,
custom_logo: string,
custom_login_info: string,
}), }),
router: shape({ router: shape({
route: shape({ route: shape({
@ -141,36 +124,31 @@ export function mountWithContexts (node, options = {}) {
}).isRequired, }).isRequired,
history: shape({}).isRequired, history: shape({}).isRequired,
}), }),
network: shape({
handleHttpError: func.isRequired,
}),
dialog: shape({
title: string,
setRootDialogMessage: func,
clearRootDialogMessage: func,
}),
...options.childContextTypes ...options.childContextTypes
}; };
return mount(wrapContexts(node, context), { context, childContextTypes }); return mount(wrapContexts(node, context), { context, childContextTypes });
} }
/** /**
* Wait for element to exist. * Wait for element(s) to achieve a desired state.
* *
* @param[wrapper] - A ReactWrapper instance * @param[wrapper] - A ReactWrapper instance
* @param[selector] - The selector of the element to wait for. * @param[selector] - The selector of the element(s) to wait for.
* @param[callback] - Callback to poll - by default this checks for a node count of 1.
*/ */
export function waitForElement (wrapper, selector) { export function waitForElement (wrapper, selector, callback = el => el.length === 1) {
const interval = 100; const interval = 100;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let attempts = 30; let attempts = 30;
(function pollElement () { (function pollElement () {
wrapper.update(); wrapper.update();
if (wrapper.exists(selector)) { const el = wrapper.find(selector);
return resolve(wrapper.find(selector)); if (callback(el)) {
return resolve(el);
} }
if (--attempts <= 0) { if (--attempts <= 0) {
return reject(new Error(`Element not found using ${selector}`)); const message = `Expected condition for <${selector}> not met: ${callback.toString()}`;
return reject(new Error(message));
} }
return setTimeout(pollElement, interval); return setTimeout(pollElement, interval);
}()); }());

View File

@ -4,7 +4,6 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { mountWithContexts, waitForElement } from './enzymeHelpers'; import { mountWithContexts, waitForElement } from './enzymeHelpers';
import { Config } from '../src/contexts/Config'; import { Config } from '../src/contexts/Config';
import { withRootDialog } from '../src/contexts/RootDialog';
describe('mountWithContexts', () => { describe('mountWithContexts', () => {
describe('injected I18nProvider', () => { describe('injected I18nProvider', () => {
@ -109,68 +108,6 @@ describe('mountWithContexts', () => {
expect(wrapper.find('Foo')).toMatchSnapshot(); expect(wrapper.find('Foo')).toMatchSnapshot();
}); });
}); });
describe('injected root dialog', () => {
it('should mount and render', () => {
const Foo = ({ title, setRootDialogMessage }) => (
<div>
<span>{title}</span>
<button
type="button"
onClick={() => setRootDialogMessage({ title: 'error' })}
>
click
</button>
</div>
);
const Bar = withRootDialog(Foo);
const wrapper = mountWithContexts(<Bar />);
expect(wrapper.find('span').text()).toEqual('');
wrapper.find('button').simulate('click');
wrapper.update();
expect(wrapper.find('span').text()).toEqual('error');
});
it('should mount and render with stubbed value', () => {
const dialog = {
title: 'this be the title',
setRootDialogMessage: jest.fn(),
};
const Foo = ({ title, setRootDialogMessage }) => (
<div>
<span>{title}</span>
<button
type="button"
onClick={() => setRootDialogMessage('error')}
>
click
</button>
</div>
);
const Bar = withRootDialog(Foo);
const wrapper = mountWithContexts(<Bar />, { context: { dialog } });
expect(wrapper.find('span').text()).toEqual('this be the title');
wrapper.find('button').simulate('click');
expect(dialog.setRootDialogMessage).toHaveBeenCalledWith('error');
});
});
it('should set props on wrapped component', () => {
function TestComponent ({ text }) {
return (<div>{text}</div>);
}
const wrapper = mountWithContexts(
<TestComponent text="foo" />
);
expect(wrapper.find('div').text()).toEqual('foo');
wrapper.setProps({
text: 'bar'
});
expect(wrapper.find('div').text()).toEqual('bar');
});
}); });
/** /**
@ -184,9 +121,7 @@ class TestAsyncComponent extends Component {
} }
componentDidMount () { componentDidMount () {
setTimeout(() => { setTimeout(() => this.setState({ displayElement: true }), 500);
this.setState({ displayElement: true });
}, 1000);
} }
render () { render () {
@ -211,16 +146,15 @@ describe('waitForElement', () => {
}); });
it('eventually throws an error for elements that don\'t exist', async (done) => { it('eventually throws an error for elements that don\'t exist', async (done) => {
const selector = '#does-not-exist';
const wrapper = mountWithContexts(<div />); const wrapper = mountWithContexts(<div />);
let error; let error;
try { try {
await waitForElement(wrapper, selector); await waitForElement(wrapper, '#does-not-exist');
} catch (err) { } catch (err) {
error = err; error = err;
} finally { } finally {
expect(error).toEqual(new Error(`Element not found using ${selector}`)); expect(error).toEqual(new Error('Expected condition for <#does-not-exist> not met: el => el.length === 1'));
done(); done();
} }
}); });

View File

@ -1,169 +1,215 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../enzymeHelpers'; import { mountWithContexts, waitForElement } from '../enzymeHelpers';
import { asyncFlush } from '../../jest.setup';
import AWXLogin from '../../src/pages/Login'; import AWXLogin from '../../src/pages/Login';
import { RootAPI } from '../../src/api'; import { RootAPI } from '../../src/api';
jest.mock('../../src/api'); jest.mock('../../src/api');
describe('<Login />', () => { describe('<Login />', () => {
let loginWrapper; async function findChildren (wrapper) {
let awxLogin; const [
let loginPage; awxLogin,
let loginForm; loginPage,
let usernameInput; loginForm,
let passwordInput; usernameInput,
let submitButton; passwordInput,
let loginHeaderLogo; submitButton,
loginHeaderLogo,
] = await Promise.all([
waitForElement(wrapper, 'AWXLogin', (el) => el.length === 1),
waitForElement(wrapper, 'LoginPage', (el) => el.length === 1),
waitForElement(wrapper, 'LoginForm', (el) => el.length === 1),
waitForElement(wrapper, 'input#pf-login-username-id', (el) => el.length === 1),
waitForElement(wrapper, 'input#pf-login-password-id', (el) => el.length === 1),
waitForElement(wrapper, 'Button[type="submit"]', (el) => el.length === 1),
waitForElement(wrapper, 'img', (el) => el.length === 1),
]);
return {
awxLogin,
loginPage,
loginForm,
usernameInput,
passwordInput,
submitButton,
loginHeaderLogo,
};
}
const mountLogin = () => { beforeEach(() => {
loginWrapper = mountWithContexts(<AWXLogin />, { context: { network: {} } }); RootAPI.read.mockResolvedValue({
}; data: {
custom_login_info: '',
const findChildren = () => { custom_logo: 'images/foo.jpg'
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 = loginPage.find('img');
};
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
loginWrapper.unmount();
}); });
test('initially renders without crashing', () => { test('initially renders without crashing', async (done) => {
mountLogin(); const loginWrapper = mountWithContexts(
findChildren(); <AWXLogin isAuthenticated={() => false} />
expect(loginWrapper.length).toBe(1); );
expect(loginPage.length).toBe(1); const {
expect(loginForm.length).toBe(1); awxLogin,
expect(usernameInput.length).toBe(1); usernameInput,
passwordInput,
submitButton,
} = await findChildren(loginWrapper);
expect(usernameInput.props().value).toBe(''); expect(usernameInput.props().value).toBe('');
expect(passwordInput.length).toBe(1);
expect(passwordInput.props().value).toBe(''); expect(passwordInput.props().value).toBe('');
expect(awxLogin.state().isInputValid).toBe(true); expect(awxLogin.state('validationError')).toBe(false);
expect(submitButton.length).toBe(1);
expect(submitButton.props().isDisabled).toBe(false); expect(submitButton.props().isDisabled).toBe(false);
expect(loginHeaderLogo.length).toBe(1); done();
}); });
test('custom logo renders Brand component with correct src and alt', () => { test('custom logo renders Brand component with correct src and alt', async (done) => {
loginWrapper = mountWithContexts(<AWXLogin logo="images/foo.jpg" alt="Foo Application" />); const loginWrapper = mountWithContexts(
findChildren(); <AWXLogin alt="Foo Application" isAuthenticated={() => false} />
expect(loginHeaderLogo.length).toBe(1); );
expect(loginHeaderLogo.props().src).toBe('data:image/jpeg;images/foo.jpg'); const { loginHeaderLogo } = await findChildren(loginWrapper);
expect(loginHeaderLogo.props().alt).toBe('Foo Application'); const { alt, src } = loginHeaderLogo.props();
expect([alt, src]).toEqual(['Foo Application', 'data:image/jpeg;images/foo.jpg']);
done();
}); });
test('default logo renders Brand component with correct src and alt', () => { test('default logo renders Brand component with correct src and alt', async (done) => {
mountLogin(); RootAPI.read.mockResolvedValue({ data: {} });
findChildren(); const loginWrapper = mountWithContexts(
expect(loginHeaderLogo.length).toBe(1); <AWXLogin isAuthenticated={() => false} />
expect(loginHeaderLogo.props().src).toBe('brand-logo.svg'); );
expect(loginHeaderLogo.props().alt).toBe('AWX'); const { loginHeaderLogo } = await findChildren(loginWrapper);
const { alt, src } = loginHeaderLogo.props();
expect([alt, src]).toEqual(['AWX', 'brand-logo.svg']);
done();
}); });
test('state maps to un/pw input value props', () => { test('default logo renders on data initialization error', async (done) => {
mountLogin(); RootAPI.read.mockRejectedValueOnce({ response: { status: 500 } });
findChildren(); const loginWrapper = mountWithContexts(
awxLogin.setState({ username: 'un', password: 'pw' }); <AWXLogin isAuthenticated={() => false} />
expect(awxLogin.state().username).toBe('un'); );
expect(awxLogin.state().password).toBe('pw'); const { loginHeaderLogo } = await findChildren(loginWrapper);
findChildren(); const { alt, src } = loginHeaderLogo.props();
expect(usernameInput.props().value).toBe('un'); expect([alt, src]).toEqual(['AWX', 'brand-logo.svg']);
expect(passwordInput.props().value).toBe('pw'); done();
}); });
test('updating un/pw clears out error', () => { test('state maps to un/pw input value props', async (done) => {
mountLogin(); const loginWrapper = mountWithContexts(
findChildren(); <AWXLogin isAuthenticated={() => false} />
awxLogin.setState({ isInputValid: false }); );
expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(1); const { usernameInput, passwordInput } = await findChildren(loginWrapper);
usernameInput.instance().value = 'uname'; usernameInput.props().onChange({ currentTarget: { value: 'un' } });
usernameInput.simulate('change'); passwordInput.props().onChange({ currentTarget: { value: 'pw' } });
expect(awxLogin.state().username).toBe('uname'); await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'un');
expect(awxLogin.state().isInputValid).toBe(true); await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'pw');
expect(loginWrapper.find('.pf-c-form__helper-text.pf-m-error').length).toBe(0); done();
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(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('login API not called when loading', () => { test('handles input validation errors and clears on input value change', async (done) => {
mountLogin(); const formError = '.pf-c-form__helper-text.pf-m-error';
findChildren(); const loginWrapper = mountWithContexts(
expect(awxLogin.state().isLoading).toBe(false); <AWXLogin isAuthenticated={() => false} />
awxLogin.setState({ isLoading: true }); );
const {
usernameInput,
passwordInput,
submitButton
} = await findChildren(loginWrapper);
RootAPI.login.mockRejectedValueOnce({ response: { status: 401 } });
usernameInput.props().onChange({ currentTarget: { value: 'invalid' } });
passwordInput.props().onChange({ currentTarget: { value: 'invalid' } });
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'invalid');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'invalid');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('validationError') === true);
await waitForElement(loginWrapper, formError, (el) => el.length === 1);
usernameInput.props().onChange({ currentTarget: { value: 'dsarif' } });
passwordInput.props().onChange({ currentTarget: { value: 'freneticpny' } });
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'dsarif');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'freneticpny');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('validationError') === false);
await waitForElement(loginWrapper, formError, (el) => el.length === 0);
done();
});
test('handles other errors and clears on resubmit', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const {
usernameInput,
passwordInput,
submitButton
} = await findChildren(loginWrapper);
RootAPI.login.mockRejectedValueOnce({ response: { status: 500 } });
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('authenticationError') === true);
usernameInput.props().onChange({ currentTarget: { value: 'sgrimes' } });
passwordInput.props().onChange({ currentTarget: { value: 'ovid' } });
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('username') === 'sgrimes');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('password') === 'ovid');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('authenticationError') === true);
submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('authenticationError') === false);
done();
});
test('no login requests are made when already authenticating', async (done) => {
const loginWrapper = mountWithContexts(
<AWXLogin isAuthenticated={() => false} />
);
const { awxLogin, submitButton } = await findChildren(loginWrapper);
awxLogin.setState({ isAuthenticating: true });
submitButton.simulate('click');
submitButton.simulate('click'); submitButton.simulate('click');
expect(RootAPI.login).toHaveBeenCalledTimes(0); expect(RootAPI.login).toHaveBeenCalledTimes(0);
});
test('submit calls login API successfully', async () => { awxLogin.setState({ isAuthenticating: false });
RootAPI.login = jest.fn().mockImplementation(() => Promise.resolve({})); submitButton.simulate('click');
mountLogin();
findChildren();
expect(awxLogin.state().isLoading).toBe(false);
awxLogin.setState({ username: 'unamee', password: 'pwordd' });
submitButton.simulate('click'); submitButton.simulate('click');
expect(RootAPI.login).toHaveBeenCalledTimes(1); expect(RootAPI.login).toHaveBeenCalledTimes(1);
expect(RootAPI.login).toHaveBeenCalledWith('unamee', 'pwordd');
expect(awxLogin.state().isLoading).toBe(true); done();
await asyncFlush();
expect(awxLogin.state().isLoading).toBe(false);
}); });
test('submit calls login API and handles 401 error', async () => { test('submit calls api.login successfully', async (done) => {
RootAPI.login = jest.fn().mockImplementation(() => { const loginWrapper = mountWithContexts(
const err = new Error('401 error'); <AWXLogin isAuthenticated={() => false} />
err.response = { status: 401, message: 'problem' }; );
return Promise.reject(err); const {
}); usernameInput,
mountLogin(); passwordInput,
findChildren(); submitButton,
expect(awxLogin.state().isLoading).toBe(false); } = await findChildren(loginWrapper);
expect(awxLogin.state().isInputValid).toBe(true);
awxLogin.setState({ username: 'unamee', password: 'pwordd' }); usernameInput.props().onChange({ currentTarget: { value: 'gthorpe' } });
passwordInput.props().onChange({ currentTarget: { value: 'hydro' } });
submitButton.simulate('click'); submitButton.simulate('click');
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('isAuthenticating') === true);
await waitForElement(loginWrapper, 'AWXLogin', (el) => el.state('isAuthenticating') === false);
expect(RootAPI.login).toHaveBeenCalledTimes(1); expect(RootAPI.login).toHaveBeenCalledTimes(1);
expect(RootAPI.login).toHaveBeenCalledWith('unamee', 'pwordd'); expect(RootAPI.login).toHaveBeenCalledWith('gthorpe', 'hydro');
expect(awxLogin.state().isLoading).toBe(true);
await asyncFlush(); done();
expect(awxLogin.state().isInputValid).toBe(false);
expect(awxLogin.state().isLoading).toBe(false);
}); });
test('submit calls login API and handles non-401 error', async () => { test('render Redirect to / when already authenticated', async (done) => {
RootAPI.login = jest.fn().mockImplementation(() => { const loginWrapper = mountWithContexts(
const err = new Error('500 error'); <AWXLogin isAuthenticated={() => true} />
err.response = { status: 500, message: 'problem' }; );
return Promise.reject(err); await waitForElement(loginWrapper, 'Redirect', (el) => el.length === 1);
}); await waitForElement(loginWrapper, 'Redirect', (el) => el.props().to === '/');
mountLogin(); done();
findChildren();
expect(awxLogin.state().isLoading).toBe(false);
awxLogin.setState({ username: 'unamee', password: 'pwordd' });
submitButton.simulate('click');
expect(RootAPI.login).toHaveBeenCalledTimes(1);
expect(RootAPI.login).toHaveBeenCalledWith('unamee', 'pwordd');
expect(awxLogin.state().isLoading).toBe(true);
await asyncFlush();
expect(awxLogin.state().isLoading).toBe(false);
});
test('render Redirect to / when already authenticated', () => {
mountLogin();
findChildren();
awxLogin.setState({ isAuthenticated: true });
const redirectElem = loginWrapper.find('Redirect');
expect(redirectElem.length).toBe(1);
expect(redirectElem.props().to).toBe('/');
}); });
}); });

View File

@ -2,6 +2,8 @@ import React from 'react';
import { mountWithContexts } from '../../enzymeHelpers'; import { mountWithContexts } from '../../enzymeHelpers';
import Organizations from '../../../src/pages/Organizations/Organizations'; import Organizations from '../../../src/pages/Organizations/Organizations';
jest.mock('../../../src/api');
describe('<Organizations />', () => { describe('<Organizations />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(

View File

@ -1,63 +1,232 @@
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '../../../../enzymeHelpers';
import { mountWithContexts } from '../../../../enzymeHelpers';
import { sleep } from '../../../../testUtils';
import Organization from '../../../../../src/pages/Organizations/screens/Organization/Organization'; import Organization from '../../../../../src/pages/Organizations/screens/Organization/Organization';
import { OrganizationsAPI } from '../../../../../src/api'; import { OrganizationsAPI } from '../../../../../src/api';
jest.mock('../../../../../src/api'); jest.mock('../../../../../src/api');
describe.only('<Organization />', () => { const mockMe = {
const me = { is_super_user: true,
is_super_user: true, is_system_auditor: false
is_system_auditor: false };
};
const mockNoResults = {
count: 0,
next: null,
previous: null,
data: { results: [] }
};
const mockDetails = {
data: {
id: 1,
type: 'organization',
url: '/api/v2/organizations/1/',
related: {
notification_templates: '/api/v2/organizations/1/notification_templates/',
notification_templates_any: '/api/v2/organizations/1/notification_templates_any/',
notification_templates_success: '/api/v2/organizations/1/notification_templates_success/',
notification_templates_error: '/api/v2/organizations/1/notification_templates_error/',
object_roles: '/api/v2/organizations/1/object_roles/',
access_list: '/api/v2/organizations/1/access_list/',
instance_groups: '/api/v2/organizations/1/instance_groups/'
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
object_roles: {
admin_role: {
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 42
},
notification_admin_role: {
description: 'Can manage all notifications of the organization',
name: 'Notification Admin',
id: 1683
},
auditor_role: {
description: 'Can view all aspects of the organization',
name: 'Auditor',
id: 41
},
},
user_capabilities: {
edit: true,
delete: true
},
related_field_counts: {
users: 51,
admins: 19,
inventories: 23,
teams: 12,
projects: 33,
job_templates: 30
}
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Sarif Industries',
description: '',
max_hosts: 0,
custom_virtualenv: null
}
};
const adminOrganization = {
id: 1,
type: 'organization',
url: '/api/v2/organizations/1/',
related: {
instance_groups: '/api/v2/organizations/1/instance_groups/',
object_roles: '/api/v2/organizations/1/object_roles/',
access_list: '/api/v2/organizations/1/access_list/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Sarif Industries',
description: '',
max_hosts: 0,
custom_virtualenv: null
};
const auditorOrganization = {
id: 2,
type: 'organization',
url: '/api/v2/organizations/2/',
related: {
instance_groups: '/api/v2/organizations/2/instance_groups/',
object_roles: '/api/v2/organizations/2/object_roles/',
access_list: '/api/v2/organizations/2/access_list/',
},
summary_fields: {
created_by: {
id: 2,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 2,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Autobots',
description: '',
max_hosts: 0,
custom_virtualenv: null
};
const notificationAdminOrganization = {
id: 3,
type: 'organization',
url: '/api/v2/organizations/3/',
related: {
instance_groups: '/api/v2/organizations/3/instance_groups/',
object_roles: '/api/v2/organizations/3/object_roles/',
access_list: '/api/v2/organizations/3/access_list/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User'
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Decepticons',
description: '',
max_hosts: 0,
custom_virtualenv: null
};
const allOrganizations = [
adminOrganization,
auditorOrganization,
notificationAdminOrganization
];
async function getOrganizations (params) {
let results = allOrganizations;
if (params && params.role_level) {
if (params.role_level === 'admin_role') {
results = [adminOrganization];
}
if (params.role_level === 'auditor_role') {
results = [auditorOrganization];
}
if (params.role_level === 'notification_admin_role') {
results = [notificationAdminOrganization];
}
}
return {
count: results.length,
next: null,
previous: null,
data: { results }
};
}
describe.only('<Organization />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Organization me={me} />); OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockImplementation(getOrganizations);
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
}); });
test('notifications tab shown/hidden based on permissions', async () => { test('notifications tab shown for admins', async (done) => {
OrganizationsAPI.readDetail.mockResolvedValue({ OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
data: { OrganizationsAPI.read.mockImplementation(getOrganizations);
id: 1,
name: 'foo' const wrapper = mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
} const tabs = await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 4);
}); expect(tabs.last().text()).toEqual('Notifications');
OrganizationsAPI.read.mockResolvedValue({ done();
data: { });
results: []
} test('notifications tab hidden with reduced permissions', async (done) => {
}); OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
const history = createMemoryHistory({ OrganizationsAPI.read.mockResolvedValue(mockNoResults);
initialEntries: ['/organizations/1/details'],
}); const wrapper = mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
const match = { path: '/organizations/:id', url: '/organizations/1' }; const tabs = await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3);
const wrapper = mountWithContexts( tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
<Organization done();
me={me}
setBreadcrumb={() => {}}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match
}
}
}
}
);
await sleep(0);
wrapper.update();
expect(wrapper.find('.pf-c-tabs__item').length).toBe(3);
expect(wrapper.find('.pf-c-tabs__button[children="Notifications"]').length).toBe(0);
wrapper.find('Organization').setState({
isNotifAdmin: true
});
expect(wrapper.find('.pf-c-tabs__item').length).toBe(4);
expect(wrapper.find('button.pf-c-tabs__button[children="Notifications"]').length).toBe(1);
}); });
}); });

View File

@ -1,14 +1,12 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../enzymeHelpers'; import { mountWithContexts, waitForElement } from '../../../../enzymeHelpers';
import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess'; import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess';
import { sleep } from '../../../../testUtils'; import { sleep } from '../../../../testUtils';
import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../../src/api'; import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../../src/api';
jest.mock('../../../../../src/api'); jest.mock('../../../../../src/api');
describe('<OrganizationAccess />', () => { describe('<OrganizationAccess />', () => {
const network = {};
const organization = { const organization = {
id: 1, id: 1,
name: 'Default', name: 'Default',
@ -64,7 +62,9 @@ describe('<OrganizationAccess />', () => {
}; };
beforeEach(() => { beforeEach(() => {
OrganizationsAPI.readAccessList.mockReturnValue({ data }); OrganizationsAPI.readAccessList.mockResolvedValue({ data });
TeamsAPI.disassociateRole.mockResolvedValue({});
UsersAPI.disassociateRole.mockResolvedValue({});
}); });
afterEach(() => { afterEach(() => {
@ -72,31 +72,21 @@ describe('<OrganizationAccess />', () => {
}); });
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
<OrganizationAccess organization={organization} />,
{ context: { network } }
);
expect(wrapper.find('OrganizationAccess')).toMatchSnapshot(); expect(wrapper.find('OrganizationAccess')).toMatchSnapshot();
}); });
test('should fetch and display access records on mount', async () => { test('should fetch and display access records on mount', async (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
<OrganizationAccess organization={organization} />, await waitForElement(wrapper, 'OrganizationAccessItem', el => el.length === 2);
{ context: { network } }
);
await sleep(0);
wrapper.update();
expect(OrganizationsAPI.readAccessList).toHaveBeenCalled();
expect(wrapper.find('OrganizationAccess').state('isInitialized')).toBe(true);
expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results); expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results);
expect(wrapper.find('OrganizationAccessItem')).toHaveLength(2); expect(wrapper.find('OrganizationAccess').state('contentLoading')).toBe(false);
expect(wrapper.find('OrganizationAccess').state('contentError')).toBe(false);
done();
}); });
test('should open confirmation dialog when deleting role', async () => { test('should open confirmation dialog when deleting role', async (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
<OrganizationAccess organization={organization} />,
{ context: { network } }
);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -105,18 +95,16 @@ describe('<OrganizationAccess />', () => {
wrapper.update(); wrapper.update();
const component = wrapper.find('OrganizationAccess'); const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')) expect(component.state('deletionRole'))
.toEqual(data.results[0].summary_fields.direct_access[0].role); .toEqual(data.results[0].summary_fields.direct_access[0].role);
expect(component.state('roleToDeleteAccessRecord')) expect(component.state('deletionRecord'))
.toEqual(data.results[0]); .toEqual(data.results[0]);
expect(component.find('DeleteRoleConfirmationModal')).toHaveLength(1); expect(component.find('DeleteRoleConfirmationModal')).toHaveLength(1);
done();
}); });
it('should close dialog when cancel button clicked', async () => { it('should close dialog when cancel button clicked', async (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
<OrganizationAccess organization={organization} />,
{ context: { network } }
);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
const button = wrapper.find('ChipButton').at(0); const button = wrapper.find('ChipButton').at(0);
@ -125,55 +113,50 @@ describe('<OrganizationAccess />', () => {
wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')(); wrapper.find('DeleteRoleConfirmationModal').prop('onCancel')();
const component = wrapper.find('OrganizationAccess'); const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')).toBeNull(); expect(component.state('deletionRole')).toBeNull();
expect(component.state('roleToDeleteAccessRecord')).toBeNull(); expect(component.state('deletionRecord')).toBeNull();
expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled();
expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); expect(UsersAPI.disassociateRole).not.toHaveBeenCalled();
done();
}); });
it('should delete user role', async () => { it('should delete user role', async (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
<OrganizationAccess organization={organization} />, const button = await waitForElement(wrapper, 'ChipButton', el => el.length === 2);
{ context: { network } } button.at(0).prop('onClick')();
);
const confirmation = await waitForElement(wrapper, 'DeleteRoleConfirmationModal');
confirmation.prop('onConfirm')();
await waitForElement(wrapper, 'DeleteRoleConfirmationModal', el => el.length === 0);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
const button = wrapper.find('ChipButton').at(0);
button.prop('onClick')();
wrapper.update();
wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')();
await sleep(0);
wrapper.update();
const component = wrapper.find('OrganizationAccess'); const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')).toBeNull(); expect(component.state('deletionRole')).toBeNull();
expect(component.state('roleToDeleteAccessRecord')).toBeNull(); expect(component.state('deletionRecord')).toBeNull();
expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled(); expect(TeamsAPI.disassociateRole).not.toHaveBeenCalled();
expect(UsersAPI.disassociateRole).toHaveBeenCalledWith(1, 1); expect(UsersAPI.disassociateRole).toHaveBeenCalledWith(1, 1);
expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2);
done();
}); });
it('should delete team role', async () => { it('should delete team role', async (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />);
<OrganizationAccess organization={organization} />, const button = await waitForElement(wrapper, 'ChipButton', el => el.length === 2);
{ context: { network } } button.at(1).prop('onClick')();
);
const confirmation = await waitForElement(wrapper, 'DeleteRoleConfirmationModal');
confirmation.prop('onConfirm')();
await waitForElement(wrapper, 'DeleteRoleConfirmationModal', el => el.length === 0);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
const button = wrapper.find('ChipButton').at(1);
button.prop('onClick')();
wrapper.update();
wrapper.find('DeleteRoleConfirmationModal').prop('onConfirm')();
await sleep(0);
wrapper.update();
const component = wrapper.find('OrganizationAccess'); const component = wrapper.find('OrganizationAccess');
expect(component.state('roleToDelete')).toBeNull(); expect(component.state('deletionRole')).toBeNull();
expect(component.state('roleToDeleteAccessRecord')).toBeNull(); expect(component.state('deletionRecord')).toBeNull();
expect(TeamsAPI.disassociateRole).toHaveBeenCalledWith(5, 3); expect(TeamsAPI.disassociateRole).toHaveBeenCalledWith(5, 3);
expect(UsersAPI.disassociateRole).not.toHaveBeenCalled(); expect(UsersAPI.disassociateRole).not.toHaveBeenCalled();
expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2); expect(OrganizationsAPI.readAccessList).toHaveBeenCalledTimes(2);
done();
}); });
}); });

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../enzymeHelpers'; import { mountWithContexts, waitForElement } from '../../../../enzymeHelpers';
import OrganizationDetail from '../../../../../src/pages/Organizations/screens/Organization/OrganizationDetail'; import OrganizationDetail from '../../../../../src/pages/Organizations/screens/Organization/OrganizationDetail';
import { OrganizationsAPI } from '../../../../../src/api'; import { OrganizationsAPI } from '../../../../../src/api';
jest.mock('../../../../../src/api'); jest.mock('../../../../../src/api');
describe('<OrganizationDetail />', () => { describe('<OrganizationDetail />', () => {
const mockDetails = { const mockOrganization = {
name: 'Foo', name: 'Foo',
description: 'Bar', description: 'Bar',
custom_virtualenv: 'Fizz', custom_virtualenv: 'Fizz',
@ -19,107 +19,75 @@ describe('<OrganizationDetail />', () => {
} }
} }
}; };
const mockInstanceGroups = {
data: {
results: [
{ name: 'One', id: 1 },
{ name: 'Two', id: 2 }
]
}
};
beforeEach(() => {
OrganizationsAPI.readInstanceGroups.mockResolvedValue(mockInstanceGroups);
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
<OrganizationDetail
organization={mockDetails}
/>
);
}); });
test('should request instance groups from api', () => { test('should request instance groups from api', () => {
mountWithContexts( mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
<OrganizationDetail
organization={mockDetails}
/>, { context: {
network: { handleHttpError: () => {} }
} }
).find('OrganizationDetail');
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
}); });
test('should handle setting instance groups to state', async () => { test('should handle setting instance groups to state', async (done) => {
const mockInstanceGroups = [
{ name: 'One', id: 1 },
{ name: 'Two', id: 2 }
];
OrganizationsAPI.readInstanceGroups.mockResolvedValue({
data: { results: mockInstanceGroups }
});
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationDetail <OrganizationDetail organization={mockOrganization} />
organization={mockDetails}
/>, { context: {
network: { handleHttpError: () => {} }
} }
).find('OrganizationDetail');
await OrganizationsAPI.readInstanceGroups();
expect(wrapper.state().instanceGroups).toEqual(mockInstanceGroups);
});
test('should render Details', async () => {
const wrapper = mountWithContexts(
<OrganizationDetail
organization={mockDetails}
/>
); );
const component = await waitForElement(wrapper, 'OrganizationDetail');
const detailWrapper = wrapper.find('Detail'); expect(component.state().instanceGroups).toEqual(mockInstanceGroups.data.results);
expect(detailWrapper.length).toBe(6); done();
const nameDetail = detailWrapper.findWhere(node => node.props().label === 'Name');
const descriptionDetail = detailWrapper.findWhere(node => node.props().label === 'Description');
const custom_virtualenvDetail = detailWrapper.findWhere(node => node.props().label === 'Ansible Environment');
const max_hostsDetail = detailWrapper.findWhere(node => node.props().label === 'Max Hosts');
const createdDetail = detailWrapper.findWhere(node => node.props().label === 'Created');
const modifiedDetail = detailWrapper.findWhere(node => node.props().label === 'Last Modified');
expect(nameDetail.find('dt').text()).toBe('Name');
expect(nameDetail.find('dd').text()).toBe('Foo');
expect(descriptionDetail.find('dt').text()).toBe('Description');
expect(descriptionDetail.find('dd').text()).toBe('Bar');
expect(custom_virtualenvDetail.find('dt').text()).toBe('Ansible Environment');
expect(custom_virtualenvDetail.find('dd').text()).toBe('Fizz');
expect(createdDetail.find('dt').text()).toBe('Created');
expect(createdDetail.find('dd').text()).toBe('Bat');
expect(modifiedDetail.find('dt').text()).toBe('Last Modified');
expect(modifiedDetail.find('dd').text()).toBe('Boo');
expect(max_hostsDetail.find('dt').text()).toBe('Max Hosts');
expect(max_hostsDetail.find('dd').text()).toBe('0');
}); });
test('should show edit button for users with edit permission', () => { test('should render Details', async (done) => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
<OrganizationDetail const testParams = [
organization={mockDetails} { label: 'Name', value: 'Foo' },
/> { label: 'Description', value: 'Bar' },
).find('OrganizationDetail'); { label: 'Ansible Environment', value: 'Fizz' },
const editButton = wrapper.find('Button'); { label: 'Created', value: 'Bat' },
expect((editButton).prop('to')).toBe('/organizations/undefined/edit'); { label: 'Last Modified', value: 'Boo' },
{ label: 'Max Hosts', value: '0' },
];
// eslint-disable-next-line no-restricted-syntax
for (const { label, value } of testParams) {
// eslint-disable-next-line no-await-in-loop
const detail = await waitForElement(wrapper, `Detail[label="${label}"]`);
expect(detail.find('dt').text()).toBe(label);
expect(detail.find('dd').text()).toBe(value);
}
done();
}); });
test('should hide edit button for users without edit permission', () => { test('should show edit button for users with edit permission', async (done) => {
const readOnlyOrg = { ...mockDetails }; const wrapper = mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
const editButton = await waitForElement(wrapper, 'OrganizationDetail Button');
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe('/organizations/undefined/edit');
done();
});
test('should hide edit button for users without edit permission', async (done) => {
const readOnlyOrg = { ...mockOrganization };
readOnlyOrg.summary_fields.user_capabilities.edit = false; readOnlyOrg.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts( const wrapper = mountWithContexts(<OrganizationDetail organization={readOnlyOrg} />);
<OrganizationDetail await waitForElement(wrapper, 'OrganizationDetail');
organization={readOnlyOrg} expect(wrapper.find('OrganizationDetail Button').length).toBe(0);
/> done();
).find('OrganizationDetail');
const editLink = wrapper
.findWhere(node => node.props().to === '/organizations/undefined/edit');
expect(editLink.length).toBe(0);
}); });
}); });

View File

@ -37,7 +37,6 @@ describe('<OrganizationEdit />', () => {
organization={mockData} organization={mockData}
/>, { context: { network: { />, { context: { network: {
api, api,
handleHttpError: () => {}
} } } } } }
); );
@ -57,7 +56,6 @@ describe('<OrganizationEdit />', () => {
organization={mockData} organization={mockData}
/>, { context: { network: { />, { context: { network: {
api, api,
handleHttpError: () => {}
} } } } } }
); );
@ -84,7 +82,6 @@ describe('<OrganizationEdit />', () => {
/>, { context: { />, { context: {
network: { network: {
api: { api }, api: { api },
handleHttpError: () => {}
}, },
router: { history } router: { history }
} } } }

View File

@ -8,7 +8,6 @@ jest.mock('../../../../../src/api');
describe('<OrganizationNotifications />', () => { describe('<OrganizationNotifications />', () => {
let data; let data;
const network = {};
beforeEach(() => { beforeEach(() => {
data = { data = {
@ -40,8 +39,7 @@ describe('<OrganizationNotifications />', () => {
test('initially renders succesfully', async () => { test('initially renders succesfully', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />, <OrganizationNotifications id={1} canToggleNotifications />
{ context: { network } }
); );
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -50,10 +48,7 @@ describe('<OrganizationNotifications />', () => {
test('should render list fetched of items', async () => { test('should render list fetched of items', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />, <OrganizationNotifications id={1} canToggleNotifications />
{
context: { network }
}
); );
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -71,10 +66,7 @@ describe('<OrganizationNotifications />', () => {
test('should enable success notification', async () => { test('should enable success notification', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />, <OrganizationNotifications id={1} canToggleNotifications />
{
context: { network }
}
); );
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -84,7 +76,7 @@ describe('<OrganizationNotifications />', () => {
).toEqual([1]); ).toEqual([1]);
const items = wrapper.find('NotificationListItem'); const items = wrapper.find('NotificationListItem');
items.at(1).find('Switch').at(0).prop('onChange')(); items.at(1).find('Switch').at(0).prop('onChange')();
expect(OrganizationsAPI.associateNotificationTemplatesSuccess).toHaveBeenCalled(); expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 2, 'success', true);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect( expect(
@ -94,10 +86,7 @@ describe('<OrganizationNotifications />', () => {
test('should enable error notification', async () => { test('should enable error notification', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />, <OrganizationNotifications id={1} canToggleNotifications />
{
context: { network }
}
); );
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -107,7 +96,7 @@ describe('<OrganizationNotifications />', () => {
).toEqual([2]); ).toEqual([2]);
const items = wrapper.find('NotificationListItem'); const items = wrapper.find('NotificationListItem');
items.at(0).find('Switch').at(1).prop('onChange')(); items.at(0).find('Switch').at(1).prop('onChange')();
expect(OrganizationsAPI.associateNotificationTemplatesError).toHaveBeenCalled(); expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 1, 'error', true);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect( expect(
@ -117,10 +106,7 @@ describe('<OrganizationNotifications />', () => {
test('should disable success notification', async () => { test('should disable success notification', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />, <OrganizationNotifications id={1} canToggleNotifications />
{
context: { network }
}
); );
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -130,7 +116,7 @@ describe('<OrganizationNotifications />', () => {
).toEqual([1]); ).toEqual([1]);
const items = wrapper.find('NotificationListItem'); const items = wrapper.find('NotificationListItem');
items.at(0).find('Switch').at(0).prop('onChange')(); items.at(0).find('Switch').at(0).prop('onChange')();
expect(OrganizationsAPI.disassociateNotificationTemplatesSuccess).toHaveBeenCalled(); expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 1, 'success', false);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect( expect(
@ -140,10 +126,7 @@ describe('<OrganizationNotifications />', () => {
test('should disable error notification', async () => { test('should disable error notification', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications id={1} canToggleNotifications />, <OrganizationNotifications id={1} canToggleNotifications />
{
context: { network }
}
); );
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
@ -153,7 +136,7 @@ describe('<OrganizationNotifications />', () => {
).toEqual([2]); ).toEqual([2]);
const items = wrapper.find('NotificationListItem'); const items = wrapper.find('NotificationListItem');
items.at(1).find('Switch').at(1).prop('onChange')(); items.at(1).find('Switch').at(1).prop('onChange')();
expect(OrganizationsAPI.disassociateNotificationTemplatesError).toHaveBeenCalled(); expect(OrganizationsAPI.updateNotificationTemplateAssociation).toHaveBeenCalledWith(1, 2, 'error', false);
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect( expect(

View File

@ -20,22 +20,21 @@ const listData = {
} }
}; };
beforeEach(() => {
OrganizationsAPI.readTeams.mockResolvedValue(listData);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('<OrganizationTeams />', () => { describe('<OrganizationTeams />', () => {
beforeEach(() => {
OrganizationsAPI.readTeams.mockResolvedValue(listData);
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders succesfully', () => { test('renders succesfully', () => {
shallow( shallow(
<_OrganizationTeams <_OrganizationTeams
id={1} id={1}
searchString="" searchString=""
location={{ search: '', pathname: '/organizations/1/teams' }} location={{ search: '', pathname: '/organizations/1/teams' }}
handleHttpError={() => {}}
/> />
); );
}); });
@ -45,9 +44,7 @@ describe('<OrganizationTeams />', () => {
<OrganizationTeams <OrganizationTeams
id={1} id={1}
searchString="" searchString=""
/>, { context: { />
network: {} }
}
).find('OrganizationTeams'); ).find('OrganizationTeams');
expect(OrganizationsAPI.readTeams).toHaveBeenCalledWith(1, { expect(OrganizationsAPI.readTeams).toHaveBeenCalledWith(1, {
page: 1, page: 1,
@ -61,9 +58,7 @@ describe('<OrganizationTeams />', () => {
<OrganizationTeams <OrganizationTeams
id={1} id={1}
searchString="" searchString=""
/>, { context: { />
network: { handleHttpError: () => {} } }
}
); );
await sleep(0); await sleep(0);

View File

@ -2,7 +2,6 @@
exports[`<OrganizationAccess /> initially renders succesfully 1`] = ` exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
<OrganizationAccess <OrganizationAccess
handleHttpError={[Function]}
history={"/history/"} history={"/history/"}
i18n={"/i18n/"} i18n={"/i18n/"}
location={ location={
@ -34,8 +33,228 @@ exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
} }
} }
> >
<div> <WithI18n
Loading... contentError={false}
</div> contentLoading={true}
itemCount={0}
itemName="role"
items={Array []}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "first_name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "access",
}
}
renderItem={[Function]}
renderToolbar={[Function]}
toolbarColumns={
Array [
Object {
"isSortable": true,
"key": "first_name",
"name": "Name",
},
Object {
"isSortable": true,
"key": "username",
"name": "Username",
},
Object {
"isSortable": true,
"key": "last_name",
"name": "Last Name",
},
]
}
>
<I18n
update={true}
withHash={true}
>
<withRouter(PaginatedDataList)
contentError={false}
contentLoading={true}
i18n={"/i18n/"}
itemCount={0}
itemName="role"
items={Array []}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "first_name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "access",
}
}
renderItem={[Function]}
renderToolbar={[Function]}
toolbarColumns={
Array [
Object {
"isSortable": true,
"key": "first_name",
"name": "Name",
},
Object {
"isSortable": true,
"key": "username",
"name": "Username",
},
Object {
"isSortable": true,
"key": "last_name",
"name": "Last Name",
},
]
}
>
<Route>
<PaginatedDataList
contentError={false}
contentLoading={true}
history={"/history/"}
i18n={"/i18n/"}
itemCount={0}
itemName="role"
itemNamePlural=""
items={Array []}
location={
Object {
"hash": "",
"pathname": "",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "",
"url": "",
}
}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "first_name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "access",
}
}
renderItem={[Function]}
renderToolbar={[Function]}
showPageSizeOptions={true}
toolbarColumns={
Array [
Object {
"isSortable": true,
"key": "first_name",
"name": "Name",
},
Object {
"isSortable": true,
"key": "username",
"name": "Username",
},
Object {
"isSortable": true,
"key": "last_name",
"name": "Last Name",
},
]
}
>
<WithI18n>
<I18n
update={true}
withHash={true}
>
<ContentLoading
i18n={"/i18n/"}
>
<EmptyState
className=""
variant="large"
>
<div
className="pf-c-empty-state pf-m-lg"
>
<EmptyStateBody
className=""
>
<p
className="pf-c-empty-state__body"
>
Loading...
</p>
</EmptyStateBody>
</div>
</EmptyState>
</ContentLoading>
</I18n>
</WithI18n>
</PaginatedDataList>
</Route>
</withRouter(PaginatedDataList)>
</I18n>
</WithI18n>
<_default
isOpen={false}
onClose={[Function]}
title="Error!"
variant="danger"
>
<Modal
actions={Array []}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
isLarge={false}
isOpen={false}
isSmall={false}
onClose={[Function]}
title="Error!"
width={null}
>
<Portal
containerInfo={<div />}
>
<ModalContent
actions={Array []}
ariaDescribedById=""
className="awx-c-modal at-c-alertModal at-c-alertModal--danger"
hideTitle={false}
id="pf-modal-0"
isLarge={false}
isOpen={false}
isSmall={false}
onClose={[Function]}
title="Error!"
width={null}
/>
</Portal>
</Modal>
</_default>
</OrganizationAccess> </OrganizationAccess>
`; `;

View File

@ -1,27 +1,15 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../enzymeHelpers'; import { mountWithContexts, waitForElement } from '../../../enzymeHelpers';
import OrganizationAdd from '../../../../src/pages/Organizations/screens/OrganizationAdd'; import OrganizationAdd from '../../../../src/pages/Organizations/screens/OrganizationAdd';
import { OrganizationsAPI } from '../../../../src/api'; import { OrganizationsAPI } from '../../../../src/api';
jest.mock('../../../../src/api'); jest.mock('../../../../src/api');
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
describe('<OrganizationAdd />', () => { describe('<OrganizationAdd />', () => {
let networkProviderValue;
beforeEach(() => {
networkProviderValue = {
handleHttpError: () => {}
};
});
test('handleSubmit should post to api', () => { test('handleSubmit should post to api', () => {
const wrapper = mountWithContexts(<OrganizationAdd />, { const wrapper = mountWithContexts(<OrganizationAdd />);
context: { network: networkProviderValue }
});
const updatedOrgData = { const updatedOrgData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
@ -35,9 +23,10 @@ describe('<OrganizationAdd />', () => {
const history = { const history = {
push: jest.fn(), push: jest.fn(),
}; };
const wrapper = mountWithContexts(<OrganizationAdd />, { const wrapper = mountWithContexts(
context: { router: { history } } <OrganizationAdd />,
}); { context: { router: { history } } }
);
expect(history.push).not.toHaveBeenCalled(); expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/organizations'); expect(history.push).toHaveBeenCalledWith('/organizations');
@ -47,15 +36,16 @@ describe('<OrganizationAdd />', () => {
const history = { const history = {
push: jest.fn(), push: jest.fn(),
}; };
const wrapper = mountWithContexts(<OrganizationAdd />, { const wrapper = mountWithContexts(
context: { router: { history } } <OrganizationAdd />,
}); { context: { router: { history } } }
);
expect(history.push).not.toHaveBeenCalled(); expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Close"]').prop('onClick')(); wrapper.find('button[aria-label="Close"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/organizations'); expect(history.push).toHaveBeenCalledWith('/organizations');
}); });
test('successful form submission should trigger redirect', async () => { test('successful form submission should trigger redirect', async (done) => {
const history = { const history = {
push: jest.fn(), push: jest.fn(),
}; };
@ -64,7 +54,7 @@ describe('<OrganizationAdd />', () => {
description: 'new description', description: 'new description',
custom_virtualenv: 'Buzz', custom_virtualenv: 'Buzz',
}; };
OrganizationsAPI.create.mockReturnValueOnce({ OrganizationsAPI.create.mockResolvedValueOnce({
data: { data: {
id: 5, id: 5,
related: { related: {
@ -73,24 +63,23 @@ describe('<OrganizationAdd />', () => {
...orgData, ...orgData,
} }
}); });
const wrapper = mountWithContexts(<OrganizationAdd />, { const wrapper = mountWithContexts(
context: { router: { history }, network: networkProviderValue } <OrganizationAdd />,
}); { context: { router: { history } } }
wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [], []); );
await sleep(0); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []);
expect(history.push).toHaveBeenCalledWith('/organizations/5'); expect(history.push).toHaveBeenCalledWith('/organizations/5');
done();
}); });
test('handleSubmit should post instance groups', async () => { test('handleSubmit should post instance groups', async (done) => {
const wrapper = mountWithContexts(<OrganizationAdd />, {
context: { network: networkProviderValue }
});
const orgData = { const orgData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
custom_virtualenv: 'Buzz', custom_virtualenv: 'Buzz',
}; };
OrganizationsAPI.create.mockReturnValueOnce({ OrganizationsAPI.create.mockResolvedValueOnce({
data: { data: {
id: 5, id: 5,
related: { related: {
@ -99,19 +88,22 @@ describe('<OrganizationAdd />', () => {
...orgData, ...orgData,
} }
}); });
wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []); const wrapper = mountWithContexts(<OrganizationAdd />);
await sleep(0); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []);
expect(OrganizationsAPI.associateInstanceGroup) expect(OrganizationsAPI.associateInstanceGroup)
.toHaveBeenCalledWith(5, 3); .toHaveBeenCalledWith(5, 3);
done();
}); });
test('AnsibleSelect component renders if there are virtual environments', () => { test('AnsibleSelect component renders if there are virtual environments', () => {
const config = { const config = {
custom_virtualenvs: ['foo', 'bar'], custom_virtualenvs: ['foo', 'bar'],
}; };
const wrapper = mountWithContexts(<OrganizationAdd />, { const wrapper = mountWithContexts(
context: { network: networkProviderValue, config } <OrganizationAdd />,
}).find('AnsibleSelect'); { context: { config } }
).find('AnsibleSelect');
expect(wrapper.find('FormSelect')).toHaveLength(1); expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(2); expect(wrapper.find('FormSelectOption')).toHaveLength(2);
}); });
@ -120,9 +112,10 @@ describe('<OrganizationAdd />', () => {
const config = { const config = {
custom_virtualenvs: [], custom_virtualenvs: [],
}; };
const wrapper = mountWithContexts(<OrganizationAdd />, { const wrapper = mountWithContexts(
context: { network: networkProviderValue, config } <OrganizationAdd />,
}).find('AnsibleSelect'); { context: { config } }
).find('AnsibleSelect');
expect(wrapper.find('FormSelect')).toHaveLength(0); expect(wrapper.find('FormSelect')).toHaveLength(0);
}); });
}); });

View File

@ -59,25 +59,13 @@ const mockAPIOrgsList = {
describe('<OrganizationsList />', () => { describe('<OrganizationsList />', () => {
let wrapper; let wrapper;
let api;
beforeEach(() => {
api = {
getOrganizations: () => {},
destroyOrganization: jest.fn(),
};
});
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(<OrganizationsList />);
<OrganizationsList />
);
}); });
test('Puts 1 selected Org in state when handleSelect is called.', () => { test('Puts 1 selected Org in state when handleSelect is called.', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<OrganizationsList />).find('OrganizationsList');
<OrganizationsList />
).find('OrganizationsList');
wrapper.setState({ wrapper.setState({
organizations: mockAPIOrgsList.data.results, organizations: mockAPIOrgsList.data.results,
@ -91,9 +79,7 @@ describe('<OrganizationsList />', () => {
}); });
test('Puts all Orgs in state when handleSelectAll is called.', () => { test('Puts all Orgs in state when handleSelectAll is called.', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(<OrganizationsList />);
<OrganizationsList />
);
const list = wrapper.find('OrganizationsList'); const list = wrapper.find('OrganizationsList');
list.setState({ list.setState({
organizations: mockAPIOrgsList.data.results, organizations: mockAPIOrgsList.data.results,
@ -108,16 +94,7 @@ describe('<OrganizationsList />', () => {
}); });
test('api is called to delete Orgs for each org in selected.', () => { test('api is called to delete Orgs for each org in selected.', () => {
const fetchOrganizations = jest.fn(() => wrapper.find('OrganizationsList').setState({ wrapper = mountWithContexts(<OrganizationsList />);
organizations: []
}));
wrapper = mountWithContexts(
<OrganizationsList
fetchOrganizations={fetchOrganizations}
/>, {
context: { network: { api } }
}
);
const component = wrapper.find('OrganizationsList'); const component = wrapper.find('OrganizationsList');
wrapper.find('OrganizationsList').setState({ wrapper.find('OrganizationsList').setState({
organizations: mockAPIOrgsList.data.results, organizations: mockAPIOrgsList.data.results,
@ -130,14 +107,10 @@ describe('<OrganizationsList />', () => {
expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(component.state('selected').length); expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(component.state('selected').length);
}); });
test('call fetchOrganizations after org(s) have been deleted', () => { test('call loadOrganizations after org(s) have been deleted', () => {
const fetchOrgs = jest.spyOn(_OrganizationsList.prototype, 'fetchOrganizations'); const fetchOrgs = jest.spyOn(_OrganizationsList.prototype, 'loadOrganizations');
const event = { preventDefault: () => { } }; const event = { preventDefault: () => { } };
wrapper = mountWithContexts( wrapper = mountWithContexts(<OrganizationsList />);
<OrganizationsList />, {
context: { network: { api } }
}
);
wrapper.find('OrganizationsList').setState({ wrapper.find('OrganizationsList').setState({
organizations: mockAPIOrgsList.data.results, organizations: mockAPIOrgsList.data.results,
itemCount: 3, itemCount: 3,
@ -153,13 +126,9 @@ describe('<OrganizationsList />', () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['organizations?order_by=name&page=1&page_size=5'], initialEntries: ['organizations?order_by=name&page=1&page_size=5'],
}); });
const handleError = jest.fn();
wrapper = mountWithContexts( wrapper = mountWithContexts(
<OrganizationsList />, { <OrganizationsList />,
context: { { context: { router: { history } } }
router: { history }, network: { api, handleHttpError: handleError }
}
}
); );
await wrapper.setState({ await wrapper.setState({
organizations: mockAPIOrgsList.data.results, organizations: mockAPIOrgsList.data.results,
@ -173,6 +142,5 @@ describe('<OrganizationsList />', () => {
wrapper.update(); wrapper.update();
const component = wrapper.find('OrganizationsList'); const component = wrapper.find('OrganizationsList');
component.instance().handleOrgDelete(); component.instance().handleOrgDelete();
expect(handleError).toHaveBeenCalled();
}); });
}); });

View File

@ -1,21 +1,11 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../enzymeHelpers'; import { mountWithContexts, waitForElement } from '../../enzymeHelpers';
import TemplatesList, { _TemplatesList } from '../../../src/pages/Templates/TemplatesList'; import TemplatesList, { _TemplatesList } from '../../../src/pages/Templates/TemplatesList';
import { UnifiedJobTemplatesAPI } from '../../../src/api';
jest.mock('../../../src/api'); jest.mock('../../../src/api');
const setDefaultState = (templatesList) => { const mockTemplates = [{
templatesList.setState({
itemCount: mockUnifiedJobTemplatesFromAPI.length,
isLoading: false,
isInitialized: true,
selected: [],
templates: mockUnifiedJobTemplatesFromAPI,
});
templatesList.update();
};
const mockUnifiedJobTemplatesFromAPI = [{
id: 1, id: 1,
name: 'Template 1', name: 'Template 1',
url: '/templates/job_template/1', url: '/templates/job_template/1',
@ -47,6 +37,19 @@ const mockUnifiedJobTemplatesFromAPI = [{
}]; }];
describe('<TemplatesList />', () => { describe('<TemplatesList />', () => {
beforeEach(() => {
UnifiedJobTemplatesAPI.read.mockResolvedValue({
data: {
count: mockTemplates.length,
results: mockTemplates
}
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(
<TemplatesList <TemplatesList
@ -55,46 +58,33 @@ describe('<TemplatesList />', () => {
/> />
); );
}); });
test('Templates are retrieved from the api and the components finishes loading', async (done) => { test('Templates are retrieved from the api and the components finishes loading', async (done) => {
const readTemplates = jest.spyOn(_TemplatesList.prototype, 'readUnifiedJobTemplates'); const loadUnifiedJobTemplates = jest.spyOn(_TemplatesList.prototype, 'loadUnifiedJobTemplates');
const wrapper = mountWithContexts(<TemplatesList />);
const wrapper = mountWithContexts(<TemplatesList />).find('TemplatesList'); await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === true);
expect(loadUnifiedJobTemplates).toHaveBeenCalled();
expect(wrapper.state('isLoading')).toBe(true); await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === false);
await expect(readTemplates).toHaveBeenCalled();
wrapper.update();
expect(wrapper.state('isLoading')).toBe(false);
done(); done();
}); });
test('handleSelect is called when a template list item is selected', async () => { test('handleSelect is called when a template list item is selected', async (done) => {
const handleSelect = jest.spyOn(_TemplatesList.prototype, 'handleSelect'); const handleSelect = jest.spyOn(_TemplatesList.prototype, 'handleSelect');
const wrapper = mountWithContexts(<TemplatesList />); const wrapper = mountWithContexts(<TemplatesList />);
await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === false);
const templatesList = wrapper.find('TemplatesList');
setDefaultState(templatesList);
expect(templatesList.state('isLoading')).toBe(false);
wrapper.find('DataListCheck#select-jobTemplate-1').props().onChange(); wrapper.find('DataListCheck#select-jobTemplate-1').props().onChange();
expect(handleSelect).toBeCalled(); expect(handleSelect).toBeCalled();
templatesList.update(); await waitForElement(wrapper, 'TemplatesList', (el) => el.state('selected').length === 1);
expect(templatesList.state('selected').length).toBe(1); done();
}); });
test('handleSelectAll is called when a template list item is selected', async () => { test('handleSelectAll is called when a template list item is selected', async (done) => {
const handleSelectAll = jest.spyOn(_TemplatesList.prototype, 'handleSelectAll'); const handleSelectAll = jest.spyOn(_TemplatesList.prototype, 'handleSelectAll');
const wrapper = mountWithContexts(<TemplatesList />); const wrapper = mountWithContexts(<TemplatesList />);
await waitForElement(wrapper, 'TemplatesList', (el) => el.state('contentLoading') === false);
const templatesList = wrapper.find('TemplatesList');
setDefaultState(templatesList);
expect(templatesList.state('isLoading')).toBe(false);
wrapper.find('Checkbox#select-all').props().onChange(true); wrapper.find('Checkbox#select-all').props().onChange(true);
expect(handleSelectAll).toBeCalled(); expect(handleSelectAll).toBeCalled();
wrapper.update(); await waitForElement(wrapper, 'TemplatesList', (el) => el.state('selected').length === 3);
expect(templatesList.state('selected').length).toEqual(templatesList.state('templates') done();
.length);
}); });
}); });

View File

@ -6,19 +6,16 @@ import {
Page, Page,
PageHeader as PFPageHeader, PageHeader as PFPageHeader,
PageSidebar, PageSidebar,
Button
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { RootDialog } from './contexts/RootDialog'; import { ConfigAPI, MeAPI, RootAPI } from './api';
import { withNetwork } from './contexts/Network'; import { ConfigProvider } from './contexts/Config';
import { Config } from './contexts/Config';
import { RootAPI } from './api';
import AlertModal from './components/AlertModal';
import About from './components/About'; import About from './components/About';
import AlertModal from './components/AlertModal';
import NavExpandableGroup from './components/NavExpandableGroup'; import NavExpandableGroup from './components/NavExpandableGroup';
import BrandLogo from './components/BrandLogo'; import BrandLogo from './components/BrandLogo';
import PageHeaderToolbar from './components/PageHeaderToolbar'; import PageHeaderToolbar from './components/PageHeaderToolbar';
@ -46,129 +43,145 @@ class App extends Component {
&& window.innerWidth >= parseInt(global_breakpoint_md.value, 10); && window.innerWidth >= parseInt(global_breakpoint_md.value, 10);
this.state = { this.state = {
ansible_version: null,
custom_virtualenvs: null,
me: null,
version: null,
isAboutModalOpen: false, isAboutModalOpen: false,
isNavOpen isNavOpen,
configError: false,
}; };
this.onLogout = this.onLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
this.onAboutModalClose = this.onAboutModalClose.bind(this); this.handleAboutClose = this.handleAboutClose.bind(this);
this.onAboutModalOpen = this.onAboutModalOpen.bind(this); this.handleAboutOpen = this.handleAboutOpen.bind(this);
this.onNavToggle = this.onNavToggle.bind(this); this.handleNavToggle = this.handleNavToggle.bind(this);
this.handleConfigErrorClose = this.handleConfigErrorClose.bind(this);
} }
async onLogout () { async componentDidMount () {
const { handleHttpError } = this.props; await this.loadConfig();
try {
await RootAPI.logout();
window.location.replace('/#/login');
} catch (err) {
handleHttpError(err);
}
} }
onAboutModalOpen () { // eslint-disable-next-line class-methods-use-this
async handleLogout () {
await RootAPI.logout();
window.location.replace('/#/login');
}
handleAboutOpen () {
this.setState({ isAboutModalOpen: true }); this.setState({ isAboutModalOpen: true });
} }
onAboutModalClose () { handleAboutClose () {
this.setState({ isAboutModalOpen: false }); this.setState({ isAboutModalOpen: false });
} }
onNavToggle () { handleNavToggle () {
this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen }));
} }
render () { handleConfigErrorClose () {
const { isAboutModalOpen, isNavOpen } = this.state; this.setState({ configError: false });
}
const { render, routeGroups = [], navLabel = '', i18n } = this.props; async loadConfig () {
try {
const [configRes, meRes] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
const { data: { ansible_version, custom_virtualenvs, version } } = configRes;
const { data: { results: [me] } } = meRes;
this.setState({ ansible_version, custom_virtualenvs, version, me });
} catch (err) {
this.setState({ configError: true });
}
}
render () {
const {
ansible_version,
custom_virtualenvs,
isAboutModalOpen,
isNavOpen,
me,
version,
configError,
} = this.state;
const {
i18n,
render = () => {},
routeGroups = [],
navLabel = '',
} = this.props;
const header = (
<PageHeader
showNavToggle
onNavToggle={this.handleNavToggle}
logo={<BrandLogo />}
logoProps={{ href: '/' }}
toolbar={(
<PageHeaderToolbar
loggedInUser={me}
isAboutDisabled={!version}
onAboutClick={this.handleAboutOpen}
onLogoutClick={this.handleLogout}
/>
)}
/>
);
const 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>
)}
/>
);
return ( return (
<Config> <Fragment>
{({ ansible_version, version, me }) => ( <Page
<RootDialog> usecondensed="True"
{({ header={header}
title, sidebar={sidebar}
bodyText, >
variant = 'info', <ConfigProvider value={{ ansible_version, custom_virtualenvs, me, version }}>
clearRootDialogMessage {render({ routeGroups })}
}) => ( </ConfigProvider>
<Fragment> </Page>
{(title || bodyText) && ( <About
<AlertModal ansible_version={ansible_version}
variant={variant} version={version}
isOpen={!!(title || bodyText)} isOpen={isAboutModalOpen}
onClose={clearRootDialogMessage} onClose={this.handleAboutClose}
title={title} />
actions={[ <AlertModal
<Button isOpen={configError}
key="close" variant="danger"
variant="secondary" title={i18n._(t`Error!`)}
onClick={clearRootDialogMessage} onClose={this.handleConfigErrorClose}
> >
{i18n._(t`Close`)} {i18n._(t`Failed to retrieve configuration.`)}
</Button> </AlertModal>
]} </Fragment>
>
{bodyText}
</AlertModal>
)}
<Page
usecondensed="True"
header={(
<PageHeader
showNavToggle
onNavToggle={this.onNavToggle}
logo={<BrandLogo />}
logoProps={{ href: '/' }}
toolbar={(
<PageHeaderToolbar
loggedInUser={me}
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>
)}
/>
)}
>
{render && render({ routeGroups })}
</Page>
<About
ansible_version={ansible_version}
version={version}
isOpen={isAboutModalOpen}
onClose={this.onAboutModalClose}
/>
</Fragment>
)}
</RootDialog>
)}
</Config>
); );
} }
} }
export { App as _App }; export { App as _App };
export default withI18n()(withNetwork(App)); export default withI18n()(App);

View File

@ -7,10 +7,6 @@ import {
HashRouter HashRouter
} from 'react-router-dom'; } from 'react-router-dom';
import { NetworkProvider } from './contexts/Network';
import { RootDialogProvider } from './contexts/RootDialog';
import { ConfigProvider } from './contexts/Config';
import ja from '../build/locales/ja/messages'; import ja from '../build/locales/ja/messages';
import en from '../build/locales/en/messages'; import en from '../build/locales/en/messages';
@ -34,13 +30,7 @@ class RootProvider extends Component {
language={language} language={language}
catalogs={catalogs} catalogs={catalogs}
> >
<RootDialogProvider> {children}
<NetworkProvider>
<ConfigProvider>
{children}
</ConfigProvider>
</NetworkProvider>
</RootDialogProvider>
</I18nProvider> </I18nProvider>
</HashRouter> </HashRouter>
); );

View File

@ -26,6 +26,36 @@ const NotificationsMixin = (parent) => class extends parent {
disassociateNotificationTemplatesError (resourceId, notificationId) { disassociateNotificationTemplatesError (resourceId, notificationId) {
return this.http.post(`${this.baseUrl}${resourceId}/notification_templates_error/`, { id: notificationId, disassociate: true }); return this.http.post(`${this.baseUrl}${resourceId}/notification_templates_error/`, { id: notificationId, disassociate: true });
} }
/**
* This is a helper method meant to simplify setting the "on" or "off" status of
* a related notification.
*
* @param[resourceId] - id of the base resource
* @param[notificationId] - id of the notification
* @param[notificationType] - the type of notification, options are "success" and "error"
* @param[associationState] - Boolean for associating or disassociating, options are true or false
*/
// eslint-disable-next-line max-len
updateNotificationTemplateAssociation (resourceId, notificationId, notificationType, associationState) {
if (notificationType === 'success' && associationState === true) {
return this.associateNotificationTemplatesSuccess(resourceId, notificationId);
}
if (notificationType === 'success' && associationState === false) {
return this.disassociateNotificationTemplatesSuccess(resourceId, notificationId);
}
if (notificationType === 'error' && associationState === true) {
return this.associateNotificationTemplatesError(resourceId, notificationId);
}
if (notificationType === 'error' && associationState === false) {
return this.disassociateNotificationTemplatesSuccess(resourceId, notificationId);
}
throw new Error(`Unsupported notificationType, associationState combination: ${notificationType}, ${associationState}`);
}
}; };
export default NotificationsMixin; export default NotificationsMixin;

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core'; import { Wizard } from '@patternfly/react-core';
import { withNetwork } from '../../contexts/Network';
import SelectResourceStep from './SelectResourceStep'; import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep'; import SelectRoleStep from './SelectRoleStep';
import SelectableCard from './SelectableCard'; import SelectableCard from './SelectableCard';
@ -245,4 +244,4 @@ AddResourceRole.defaultProps = {
}; };
export { AddResourceRole as _AddResourceRole }; export { AddResourceRole as _AddResourceRole };
export default withI18n()(withNetwork(AddResourceRole)); export default withI18n()(AddResourceRole);

View File

@ -0,0 +1,25 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
const ContentEmpty = ({ i18n, title = '', message = '' }) => (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
{title || i18n._(t`No items found.`)}
</Title>
<EmptyStateBody>
{message}
</EmptyStateBody>
</EmptyState>
);
export { ContentEmpty as _ContentEmpty };
export default withI18n()(ContentEmpty);

View File

@ -0,0 +1,26 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody
} from '@patternfly/react-core';
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
// TODO: Pass actual error as prop and display expandable details for network errors.
const ContentError = ({ i18n }) => (
<EmptyState>
<EmptyStateIcon icon={ExclamationTriangleIcon} />
<Title size="lg">
{i18n._(t`Something went wrong...`)}
</Title>
<EmptyStateBody>
{i18n._(t`There was an error loading this content. Please reload the page.`)}
</EmptyStateBody>
</EmptyState>
);
export { ContentError as _ContentError };
export default withI18n()(ContentError);

View File

@ -0,0 +1,19 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
EmptyState,
EmptyStateBody
} from '@patternfly/react-core';
// TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ i18n }) => (
<EmptyState>
<EmptyStateBody>
{i18n._(t`Loading...`)}
</EmptyStateBody>
</EmptyState>
);
export { ContentLoading as _ContentLoading };
export default withI18n()(ContentLoading);

View File

@ -11,7 +11,6 @@ import {
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withNetwork } from '../../contexts/Network';
import PaginatedDataList from '../PaginatedDataList'; import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../ListItem'; import CheckboxListItem from '../ListItem';
@ -53,8 +52,8 @@ class Lookup extends React.Component {
} }
async getData () { async getData () {
const { getItems, handleHttpError, location } = this.props; const { getItems, location: { search } } = this.props;
const queryParams = parseNamespacedQueryString(this.qsConfig, location.search); const queryParams = parseNamespacedQueryString(this.qsConfig, search);
this.setState({ error: false }); this.setState({ error: false });
try { try {
@ -66,7 +65,7 @@ class Lookup extends React.Component {
count count
}); });
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: true }); this.setState({ error: true });
} }
} }
@ -214,4 +213,4 @@ Lookup.defaultProps = {
}; };
export { Lookup as _Lookup }; export { Lookup as _Lookup };
export default withI18n()(withNetwork(withRouter(Lookup))); export default withI18n()(withRouter(Lookup));

View File

@ -1,41 +0,0 @@
import React, { Fragment } from 'react';
import { Redirect, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withRootDialog } from '../contexts/RootDialog';
const NotifyAndRedirect = ({
to,
push,
from,
exact,
strict,
sensitive,
setRootDialogMessage,
location,
i18n
}) => {
setRootDialogMessage({
title: '404',
bodyText: (
<Fragment>{i18n._(t`Cannot find route ${(<strong>{location.pathname}</strong>)}.`)}</Fragment>
),
variant: 'warning'
});
return (
<Redirect
to={to}
push={push}
from={from}
exact={exact}
strict={strict}
sensitive={sensitive}
/>
);
};
export { NotifyAndRedirect as _NotifyAndRedirect };
export default withI18n()(withRootDialog(withRouter(NotifyAndRedirect)));

View File

@ -1,18 +1,14 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes, { arrayOf, shape, string, bool } from 'prop-types'; import PropTypes, { arrayOf, shape, string, bool } from 'prop-types';
import { import { DataList } from '@patternfly/react-core';
DataList,
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import ContentEmpty from '../ContentEmpty';
import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import Pagination from '../Pagination'; import Pagination from '../Pagination';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import PaginatedDataListItem from './PaginatedDataListItem'; import PaginatedDataListItem from './PaginatedDataListItem';
@ -37,11 +33,6 @@ const EmptyStateControlsWrapper = styled.div`
class PaginatedDataList extends React.Component { class PaginatedDataList extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.state = {
error: null,
};
this.handleSetPage = this.handleSetPage.bind(this); this.handleSetPage = this.handleSetPage.bind(this);
this.handleSetPageSize = this.handleSetPageSize.bind(this); this.handleSetPageSize = this.handleSetPageSize.bind(this);
this.handleSort = this.handleSort.bind(this); this.handleSort = this.handleSort.bind(this);
@ -79,7 +70,10 @@ class PaginatedDataList extends React.Component {
} }
render () { render () {
const [orderBy, sortOrder] = this.getSortOrder();
const { const {
contentError,
contentLoading,
emptyStateControls, emptyStateControls,
items, items,
itemCount, itemCount,
@ -93,66 +87,67 @@ class PaginatedDataList extends React.Component {
i18n, i18n,
renderToolbar, renderToolbar,
} = this.props; } = this.props;
const { error } = this.state;
const [orderBy, sortOrder] = this.getSortOrder();
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
const columns = toolbarColumns.length ? toolbarColumns : [{ name: i18n._(t`Name`), key: 'name', isSortable: true }]; const columns = toolbarColumns.length ? toolbarColumns : [{ name: i18n._(t`Name`), key: 'name', isSortable: true }];
return ( const queryParams = parseNamespacedQueryString(qsConfig, location.search);
<Fragment>
{error && ( const itemDisplayName = ucFirst(pluralize(itemName));
<Fragment> const itemDisplayNamePlural = ucFirst(itemNamePlural || pluralize(itemName));
<div>{error.message}</div>
{error.response && ( const dataListLabel = i18n._(t`${itemDisplayName} List`);
<div>{error.response.data.detail}</div> const emptyContentMessage = i18n._(t`Please add ${itemDisplayNamePlural} to populate this list `);
)} const emptyContentTitle = i18n._(t`No ${itemDisplayNamePlural} Found `);
</Fragment> // TODO: replace with proper error handling
)} let Content;
{items.length === 0 ? ( if (contentLoading && items.length <= 0) {
<Fragment> Content = (<ContentLoading />);
} else if (contentError) {
Content = (<ContentError />);
} else if (items.length <= 0) {
Content = (<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />);
} else {
Content = (<DataList aria-label={dataListLabel}>{items.map(renderItem)}</DataList>);
}
if (items.length <= 0) {
return (
<Fragment>
{emptyStateControls && (
<EmptyStateControlsWrapper> <EmptyStateControlsWrapper>
{emptyStateControls} {emptyStateControls}
</EmptyStateControlsWrapper> </EmptyStateControlsWrapper>
)}
{emptyStateControls && (
<div css="border-bottom: 1px solid #d2d2d2" /> <div css="border-bottom: 1px solid #d2d2d2" />
<EmptyState> )}
<EmptyStateIcon icon={CubesIcon} /> {Content}
<Title size="lg"> </Fragment>
{i18n._(t`No ${ucFirst(itemNamePlural || pluralize(itemName))} Found `)} );
</Title> }
<EmptyStateBody>
{i18n._(t`Please add ${ucFirst(itemNamePlural || pluralize(itemName))} to populate this list `)} return (
</EmptyStateBody> <Fragment>
</EmptyState> {renderToolbar({
</Fragment> sortedColumnKey: orderBy,
) : ( sortOrder,
<Fragment> columns,
{renderToolbar({ onSearch: () => { },
sortedColumnKey: orderBy, onSort: this.handleSort,
sortOrder, })}
columns, {Content}
onSearch: () => { }, <Pagination
onSort: this.handleSort, variant="bottom"
})} itemCount={itemCount}
<DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}> page={queryParams.page || 1}
{items.map(item => (renderItem ? renderItem(item) : ( perPage={queryParams.page_size}
<PaginatedDataListItem key={item.id} item={item} /> perPageOptions={showPageSizeOptions ? [
)))} { title: '5', value: 5 },
</DataList> { title: '10', value: 10 },
<Pagination { title: '20', value: 20 },
variant="bottom" { title: '50', value: 50 }
itemCount={itemCount} ] : []}
page={queryParams.page || 1} onSetPage={this.handleSetPage}
perPage={queryParams.page_size} onPerPageSelect={this.handleSetPageSize}
perPageOptions={showPageSizeOptions ? [ />
{ title: '5', value: 5 },
{ title: '10', value: 10 },
{ title: '20', value: 20 },
{ title: '50', value: 50 }
] : []}
onSetPage={this.handleSetPage}
onPerPageSelect={this.handleSetPageSize}
/>
</Fragment>
)}
</Fragment> </Fragment>
); );
} }
@ -178,14 +173,18 @@ PaginatedDataList.propTypes = {
})), })),
showPageSizeOptions: PropTypes.bool, showPageSizeOptions: PropTypes.bool,
renderToolbar: PropTypes.func, renderToolbar: PropTypes.func,
contentLoading: PropTypes.bool,
contentError: PropTypes.bool,
}; };
PaginatedDataList.defaultProps = { PaginatedDataList.defaultProps = {
renderItem: null, contentLoading: false,
contentError: false,
toolbarColumns: [], toolbarColumns: [],
itemName: 'item', itemName: 'item',
itemNamePlural: '', itemNamePlural: '',
showPageSizeOptions: true, showPageSizeOptions: true,
renderItem: (item) => (<PaginatedDataListItem key={item.id} item={item} />),
renderToolbar: (props) => (<DataListToolbar {...props} />), renderToolbar: (props) => (<DataListToolbar {...props} />),
}; };

View File

@ -1,136 +1,7 @@
import React, { Component } from 'react'; import React from 'react';
import { withNetwork } from './Network'; // eslint-disable-next-line import/prefer-default-export
export const ConfigContext = React.createContext({});
import { ConfigAPI, MeAPI, RootAPI } from '../api'; export const ConfigProvider = ConfigContext.Provider;
export const Config = ConfigContext.Consumer;
const ConfigContext = React.createContext({});
class Provider extends Component {
constructor (props) {
super(props);
this.state = {
value: {
ansible_version: null,
custom_virtualenvs: null,
version: null,
custom_logo: null,
custom_login_info: null,
me: {},
...props.value
}
};
this.fetchConfig = this.fetchConfig.bind(this);
this.fetchMe = this.fetchMe.bind(this);
this.updateConfig = this.updateConfig.bind(this);
}
componentDidMount () {
const { value } = this.props;
if (!value) {
this.fetchConfig();
}
}
updateConfig = config => {
const { ansible_version, custom_virtualenvs, version } = config;
this.setState(prevState => ({
value: {
...prevState.value,
ansible_version,
custom_virtualenvs,
version
}
}));
};
async fetchMe () {
const { handleHttpError } = this.props;
try {
const {
data: {
results: [me]
}
} = await MeAPI.read();
this.setState(prevState => ({
value: {
...prevState.value,
me
}
}));
} catch (err) {
handleHttpError(err)
|| this.setState({
value: {
ansible_version: null,
custom_virtualenvs: null,
version: null,
custom_logo: null,
custom_login_info: null,
me: {}
}
});
}
}
async fetchConfig () {
const { handleHttpError } = this.props;
try {
const [configRes, rootRes, meRes] = await Promise.all([
ConfigAPI.read(),
RootAPI.read(),
MeAPI.read()
]);
this.setState({
value: {
ansible_version: configRes.data.ansible_version,
custom_virtualenvs: configRes.data.custom_virtualenvs,
version: configRes.data.version,
custom_logo: rootRes.data.custom_logo,
custom_login_info: rootRes.data.custom_login_info,
me: meRes.data.results[0]
}
});
} catch (err) {
handleHttpError(err)
|| this.setState({
value: {
ansible_version: null,
custom_virtualenvs: null,
version: null,
custom_logo: null,
custom_login_info: null,
me: {}
}
});
}
}
render () {
const { value } = this.state;
const { children } = this.props;
return (
<ConfigContext.Provider
value={{
...value,
fetchMe: this.fetchMe,
updateConfig: this.updateConfig
}}
>
{children}
</ConfigContext.Provider>
);
}
}
export const ConfigProvider = withNetwork(Provider);
export const Config = ({ children }) => (
<ConfigContext.Consumer>{value => children(value)}</ConfigContext.Consumer>
);

View File

@ -1,80 +0,0 @@
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withRootDialog } from './RootDialog';
const NetworkContext = React.createContext({});
class Provider extends Component {
constructor (props) {
super(props);
this.state = {
value: {
handleHttpError: err => {
if (err.response.status === 401) {
this.handle401();
} else if (err.response.status === 404) {
this.handle404();
}
return (err.response.status === 401 || err.response.status === 404);
},
...props.value
}
};
}
handle401 () {
const { handle401, history, setRootDialogMessage, i18n } = this.props;
if (handle401) {
handle401();
return;
}
history.replace('/login');
setRootDialogMessage({
bodyText: i18n._(t`You have been logged out.`)
});
}
handle404 () {
const { handle404, history, setRootDialogMessage, i18n } = this.props;
if (handle404) {
handle404();
return;
}
history.replace('/home');
setRootDialogMessage({
title: i18n._(t`404`),
bodyText: i18n._(t`Cannot find resource.`),
variant: 'warning'
});
}
render () {
const { value } = this.state;
const { children } = this.props;
return (
<NetworkContext.Provider value={value}>
{children}
</NetworkContext.Provider>
);
}
}
export { Provider as _NetworkProvider };
export const NetworkProvider = withI18n()(withRootDialog(withRouter(Provider)));
export function withNetwork (Child) {
return (props) => (
<NetworkContext.Consumer>
{context => <Child {...props} {...context} />}
</NetworkContext.Consumer>
);
}

View File

@ -1,54 +0,0 @@
import React, { Component } from 'react';
const RootDialogContext = React.createContext({});
export class RootDialogProvider extends Component {
constructor (props) {
super(props);
this.state = {
value: {
title: null,
setRootDialogMessage: ({ title, bodyText, variant }) => {
const { value } = this.state;
this.setState({ value: { ...value, title, bodyText, variant } });
},
clearRootDialogMessage: () => {
const { value } = this.state;
this.setState({ value: { ...value, title: null, bodyText: null, variant: null } });
},
...props.value
}
};
}
render () {
const {
children
} = this.props;
const {
value
} = this.state;
return (
<RootDialogContext.Provider value={value}>
{children}
</RootDialogContext.Provider>
);
}
}
export const RootDialog = ({ children }) => (
<RootDialogContext.Consumer>
{value => children(value)}
</RootDialogContext.Consumer>
);
export function withRootDialog (Child) {
return (props) => (
<RootDialogContext.Consumer>
{context => <Child {...props} {...context} />}
</RootDialogContext.Consumer>
);
}

View File

@ -13,15 +13,12 @@ import { t } from '@lingui/macro';
import '@patternfly/react-core/dist/styles/base.css'; import '@patternfly/react-core/dist/styles/base.css';
import './app.scss'; import './app.scss';
import { Config } from './contexts/Config';
import { BrandName } from './variables';
import Background from './components/Background'; import Background from './components/Background';
import NotifyAndRedirect from './components/NotifyAndRedirect';
import RootProvider from './RootProvider'; import RootProvider from './RootProvider';
import App from './App'; import App from './App';
import { BrandName } from './variables';
import { isAuthenticated } from './util/auth';
import Applications from './pages/Applications'; import Applications from './pages/Applications';
import Credentials from './pages/Credentials'; import Credentials from './pages/Credentials';
@ -52,185 +49,188 @@ export function main (render) {
const el = document.getElementById('app'); const el = document.getElementById('app');
document.title = `Ansible ${BrandName}`; document.title = `Ansible ${BrandName}`;
const defaultRedirect = () => (<Redirect to="/home" />);
const removeTrailingSlash = (
<Route
exact
strict
path="/*/"
render={({ history: { location: { pathname, search, hash } } }) => (
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
)}
/>
);
const loginRoutes = (
<Switch>
{removeTrailingSlash}
<Route
path="/login"
render={() => (
<Login isAuthenticated={isAuthenticated} />
)}
/>
<Redirect to="/login" />
</Switch>
);
return render( return render(
<RootProvider> <RootProvider>
<I18n> <I18n>
{({ i18n }) => ( {({ i18n }) => (
<Background> <Background>
<Switch> {!isAuthenticated() ? loginRoutes : (
<Route <Switch>
exact {removeTrailingSlash}
strict <Route path="/login" render={defaultRedirect} />
path="/*/" <Route exact path="/" render={defaultRedirect} />
render={({ history: { location: { pathname, search, hash } } }) => ( <Route
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} /> render={() => (
)} <App
/> navLabel={i18n._(t`Primary Navigation`)}
<Route routeGroups={[
path="/login" {
render={() => ( groupTitle: i18n._(t`Views`),
<Config> groupId: 'views_group',
{({ custom_logo, custom_login_info, fetchMe, updateConfig }) => ( routes: [
<Login {
logo={custom_logo} title: i18n._(t`Dashboard`),
loginInfo={custom_login_info} path: '/home',
fetchMe={fetchMe} component: Dashboard
updateConfig={updateConfig} },
/> {
)} title: i18n._(t`Jobs`),
</Config> path: '/jobs',
)} component: Jobs
/> },
<Route exact path="/" render={() => <Redirect to="/home" />} /> {
<Route title: i18n._(t`Schedules`),
render={() => ( path: '/schedules',
<App component: Schedules
navLabel={i18n._(t`Primary Navigation`)} },
routeGroups={[ {
{ title: i18n._(t`My View`),
groupTitle: i18n._(t`Views`), path: '/portal',
groupId: 'views_group', component: Portal
routes: [ },
{ ],
title: i18n._(t`Dashboard`), },
path: '/home', {
component: Dashboard groupTitle: i18n._(t`Resources`),
}, groupId: 'resources_group',
{ routes: [
title: i18n._(t`Jobs`), {
path: '/jobs', title: i18n._(t`Templates`),
component: Jobs path: '/templates',
}, component: Templates
{ },
title: i18n._(t`Schedules`), {
path: '/schedules', title: i18n._(t`Credentials`),
component: Schedules path: '/credentials',
}, component: Credentials
{ },
title: i18n._(t`My View`), {
path: '/portal', title: i18n._(t`Projects`),
component: Portal path: '/projects',
}, component: Projects
], },
}, {
{ title: i18n._(t`Inventories`),
groupTitle: i18n._(t`Resources`), path: '/inventories',
groupId: 'resources_group', component: Inventories
routes: [ },
{ {
title: i18n._(t`Templates`), title: i18n._(t`Inventory Scripts`),
path: '/templates', path: '/inventory_scripts',
component: Templates component: InventoryScripts
}, },
{ ],
title: i18n._(t`Credentials`), },
path: '/credentials', {
component: Credentials groupTitle: i18n._(t`Access`),
}, groupId: 'access_group',
{ routes: [
title: i18n._(t`Projects`), {
path: '/projects', title: i18n._(t`Organizations`),
component: Projects path: '/organizations',
}, component: Organizations
{ },
title: i18n._(t`Inventories`), {
path: '/inventories', title: i18n._(t`Users`),
component: Inventories path: '/users',
}, component: Users
{ },
title: i18n._(t`Inventory Scripts`), {
path: '/inventory_scripts', title: i18n._(t`Teams`),
component: InventoryScripts path: '/teams',
}, component: Teams
], },
}, ],
{ },
groupTitle: i18n._(t`Access`), {
groupId: 'access_group', groupTitle: i18n._(t`Administration`),
routes: [ groupId: 'administration_group',
{ routes: [
title: i18n._(t`Organizations`), {
path: '/organizations', title: i18n._(t`Credential Types`),
component: Organizations path: '/credential_types',
}, component: CredentialTypes
{ },
title: i18n._(t`Users`), {
path: '/users', title: i18n._(t`Notifications`),
component: Users path: '/notification_templates',
}, component: NotificationTemplates
{ },
title: i18n._(t`Teams`), {
path: '/teams', title: i18n._(t`Management Jobs`),
component: Teams path: '/management_jobs',
}, component: ManagementJobs
], },
}, {
{ title: i18n._(t`Instance Groups`),
groupTitle: i18n._(t`Administration`), path: '/instance_groups',
groupId: 'administration_group', component: InstanceGroups
routes: [ },
{ {
title: i18n._(t`Credential Types`), title: i18n._(t`Integrations`),
path: '/credential_types', path: '/applications',
component: CredentialTypes component: Applications
}, },
{ ],
title: i18n._(t`Notifications`), },
path: '/notification_templates', {
component: NotificationTemplates groupTitle: i18n._(t`Settings`),
}, groupId: 'settings_group',
{ routes: [
title: i18n._(t`Management Jobs`), {
path: '/management_jobs', title: i18n._(t`Authentication`),
component: ManagementJobs path: '/auth_settings',
}, component: AuthSettings
{ },
title: i18n._(t`Instance Groups`), {
path: '/instance_groups', title: i18n._(t`Jobs`),
component: InstanceGroups path: '/jobs_settings',
}, component: JobsSettings
{ },
title: i18n._(t`Integrations`), {
path: '/applications', title: i18n._(t`System`),
component: Applications path: '/system_settings',
}, component: SystemSettings
], },
}, {
{ title: i18n._(t`User Interface`),
groupTitle: i18n._(t`Settings`), path: '/ui_settings',
groupId: 'settings_group', component: UISettings
routes: [ },
{ {
title: i18n._(t`Authentication`), title: i18n._(t`License`),
path: '/auth_settings', path: '/license',
component: AuthSettings component: License
}, },
{ ],
title: i18n._(t`Jobs`), },
path: '/jobs_settings', ]}
component: JobsSettings render={({ routeGroups }) => (
}, routeGroups
{
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 }) => (
<Switch>
{routeGroups
.reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) .reduce((allRoutes, { routes }) => allRoutes.concat(routes), [])
.map(({ component: PageComponent, path }) => ( .map(({ component: PageComponent, path }) => (
<Route <Route
@ -241,15 +241,12 @@ export function main (render) {
)} )}
/> />
)) ))
.concat([ )}
<NotifyAndRedirect key="redirect" to="/" /> />
])} )}
</Switch> />
)} </Switch>
/> )}
)}
/>
</Switch>
</Background> </Background>
)} )}
</I18n> </I18n>

View File

@ -7,13 +7,10 @@ import {
LoginForm, LoginForm,
LoginPage as PFLoginPage, LoginPage as PFLoginPage,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { withRootDialog } from '../contexts/RootDialog';
import { withNetwork } from '../contexts/Network';
import { RootAPI } from '../api'; import { RootAPI } from '../api';
import { BrandName } from '../variables'; import { BrandName } from '../variables';
import logoImg from '../../images/brand-logo.svg'; import brandLogo from '../../images/brand-logo.svg';
const LoginPage = styled(PFLoginPage)` const LoginPage = styled(PFLoginPage)`
& .pf-c-brand { & .pf-c-brand {
@ -28,80 +25,122 @@ class AWXLogin extends Component {
this.state = { this.state = {
username: '', username: '',
password: '', password: '',
isInputValid: true, authenticationError: false,
isLoading: false, validationError: false,
isAuthenticated: false isAuthenticating: false,
isLoading: true,
logo: null,
loginInfo: null,
}; };
this.onChangeUsername = this.onChangeUsername.bind(this); this.handleChangeUsername = this.handleChangeUsername.bind(this);
this.onChangePassword = this.onChangePassword.bind(this); this.handleChangePassword = this.handleChangePassword.bind(this);
this.onLoginButtonClick = this.onLoginButtonClick.bind(this); this.handleLoginButtonClick = this.handleLoginButtonClick.bind(this);
this.loadCustomLoginInfo = this.loadCustomLoginInfo.bind(this);
} }
onChangeUsername (value) { async componentDidMount () {
this.setState({ username: value, isInputValid: true }); await this.loadCustomLoginInfo();
} }
onChangePassword (value) { async loadCustomLoginInfo () {
this.setState({ password: value, isInputValid: true }); this.setState({ isLoading: true });
try {
const { data: { custom_logo, custom_login_info } } = await RootAPI.read();
const logo = custom_logo ? `data:image/jpeg;${custom_logo}` : brandLogo;
this.setState({ logo, loginInfo: custom_login_info });
} catch (err) {
this.setState({ logo: brandLogo });
} finally {
this.setState({ isLoading: false });
}
} }
async onLoginButtonClick (event) { async handleLoginButtonClick (event) {
const { username, password, isLoading } = this.state; const { username, password, isAuthenticating } = this.state;
const { handleHttpError, clearRootDialogMessage, fetchMe, updateConfig } = this.props;
event.preventDefault(); event.preventDefault();
if (isLoading) { if (isAuthenticating) {
return; return;
} }
clearRootDialogMessage(); this.setState({ authenticationError: false, isAuthenticating: true });
this.setState({ isLoading: true });
try { try {
const { data } = await RootAPI.login(username, password); // note: if authentication is successful, the appropriate cookie will be set automatically
updateConfig(data); // and isAuthenticated() (the source of truth) will start returning true.
await fetchMe(); await RootAPI.login(username, password);
this.setState({ isAuthenticated: true, isLoading: false }); } catch (err) {
} catch (error) { if (err && err.response && err.response.status === 401) {
handleHttpError(error) || this.setState({ isInputValid: false, isLoading: false }); this.setState({ validationError: true });
} else {
this.setState({ authenticationError: true });
}
} finally {
this.setState({ isAuthenticating: false });
} }
} }
handleChangeUsername (value) {
this.setState({ username: value, validationError: false });
}
handleChangePassword (value) {
this.setState({ password: value, validationError: false });
}
render () { render () {
const { username, password, isInputValid, isAuthenticated } = this.state; const {
const { alt, loginInfo, logo, bodyText: errorMessage, i18n } = this.props; authenticationError,
const logoSrc = logo ? `data:image/jpeg;${logo}` : logoImg; validationError,
username,
password,
isLoading,
logo,
loginInfo,
} = this.state;
const { alt, i18n, isAuthenticated } = this.props;
// Setting BrandName to a variable here is necessary to get the jest tests // Setting BrandName to a variable here is necessary to get the jest tests
// passing. Attempting to use BrandName in the template literal results // passing. Attempting to use BrandName in the template literal results
// in failing tests. // in failing tests.
const brandName = BrandName; const brandName = BrandName;
if (isAuthenticated) { if (isLoading) {
return null;
}
if (isAuthenticated()) {
return (<Redirect to="/" />); return (<Redirect to="/" />);
} }
let helperText;
if (validationError) {
helperText = i18n._(t`Invalid username or password. Please try again.`);
} else {
helperText = i18n._(t`There was a problem signing in. Please try again.`);
}
return ( return (
<LoginPage <LoginPage
brandImgSrc={logoSrc} brandImgSrc={logo}
brandImgAlt={alt || brandName} brandImgAlt={alt || brandName}
loginTitle={i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)} loginTitle={i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)}
textContent={loginInfo} textContent={loginInfo}
> >
<LoginForm <LoginForm
className={errorMessage && 'pf-m-error'} className={(authenticationError || validationError) ? 'pf-m-error' : ''}
usernameLabel={i18n._(t`Username`)} usernameLabel={i18n._(t`Username`)}
passwordLabel={i18n._(t`Password`)} passwordLabel={i18n._(t`Password`)}
showHelperText={!isInputValid || !!errorMessage} showHelperText={(authenticationError || validationError)}
helperText={errorMessage || i18n._(t`Invalid username or password. Please try again.`)} helperText={helperText}
usernameValue={username} usernameValue={username}
passwordValue={password} passwordValue={password}
isValidUsername={isInputValid} isValidUsername={!validationError}
isValidPassword={isInputValid} isValidPassword={!validationError}
onChangeUsername={this.onChangeUsername} onChangeUsername={this.handleChangeUsername}
onChangePassword={this.onChangePassword} onChangePassword={this.handleChangePassword}
onLoginButtonClick={this.onLoginButtonClick} onLoginButtonClick={this.handleLoginButtonClick}
/> />
</LoginPage> </LoginPage>
); );
@ -109,4 +148,4 @@ class AWXLogin extends Component {
} }
export { AWXLogin as _AWXLogin }; export { AWXLogin as _AWXLogin };
export default withI18n()(withNetwork(withRootDialog(withRouter(AWXLogin)))); export default withI18n()(withRouter(AWXLogin));

View File

@ -4,9 +4,6 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import { NetworkProvider } from '../../contexts/Network';
import { withRootDialog } from '../../contexts/RootDialog';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import OrganizationsList from './screens/OrganizationsList'; import OrganizationsList from './screens/OrganizationsList';
@ -49,7 +46,7 @@ class Organizations extends Component {
} }
render () { render () {
const { match, history, location, setRootDialogMessage, i18n } = this.props; const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state; const { breadcrumbConfig } = this.state;
return ( return (
@ -66,34 +63,17 @@ class Organizations extends Component {
/> />
<Route <Route
path={`${match.path}/:id`} path={`${match.path}/:id`}
render={({ match: newRouteMatch }) => ( render={() => (
<NetworkProvider <Config>
handle404={() => { {({ me }) => (
history.replace('/organizations'); <Organization
setRootDialogMessage({ history={history}
title: '404', location={location}
bodyText: ( setBreadcrumb={this.setBreadcrumbConfig}
<Fragment> me={me || {}}
{i18n._(t`Cannot find organization with ID`)} />
<strong>{` ${newRouteMatch.params.id}`}</strong> )}
. </Config>
</Fragment>
),
variant: 'warning'
});
}}
>
<Config>
{({ me }) => (
<Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
</NetworkProvider>
)} )}
/> />
<Route <Route
@ -109,4 +89,4 @@ class Organizations extends Component {
} }
export { Organizations as _Organizations }; export { Organizations as _Organizations };
export default withI18n()(withRootDialog(withRouter(Organizations))); export default withI18n()(withRouter(Organizations));

View File

@ -7,8 +7,6 @@ import { QuestionCircleIcon } from '@patternfly/react-icons';
import Lookup from '../../../components/Lookup'; import Lookup from '../../../components/Lookup';
import { withNetwork } from '../../../contexts/Network';
import { InstanceGroupsAPI } from '../../../api'; import { InstanceGroupsAPI } from '../../../api';
const getInstanceGroups = async (params) => InstanceGroupsAPI.read(params); const getInstanceGroups = async (params) => InstanceGroupsAPI.read(params);
@ -66,4 +64,4 @@ InstanceGroupsLookup.defaultProps = {
tooltip: '', tooltip: '',
}; };
export default withI18n()(withNetwork(InstanceGroupsLookup)); export default withI18n()(InstanceGroupsLookup);

View File

@ -14,7 +14,6 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { Config } from '../../../contexts/Config'; import { Config } from '../../../contexts/Config';
import { withNetwork } from '../../../contexts/Network';
import FormRow from '../../../components/FormRow'; import FormRow from '../../../components/FormRow';
import FormField from '../../../components/FormField'; import FormField from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
@ -210,4 +209,4 @@ OrganizationForm.contextTypes = {
}; };
export { OrganizationForm as _OrganizationForm }; export { OrganizationForm as _OrganizationForm };
export default withI18n()(withNetwork(withRouter(OrganizationForm))); export default withI18n()(withRouter(OrganizationForm));

View File

@ -3,9 +3,8 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect } from 'react-router-dom'; import { Switch, Route, withRouter, Redirect } from 'react-router-dom';
import { Card, CardHeader, PageSection } from '@patternfly/react-core'; import { Card, CardHeader, PageSection } from '@patternfly/react-core';
import { withNetwork } from '../../../../contexts/Network';
import NotifyAndRedirect from '../../../../components/NotifyAndRedirect';
import CardCloseButton from '../../../../components/CardCloseButton'; import CardCloseButton from '../../../../components/CardCloseButton';
import ContentError from '../../../../components/ContentError';
import OrganizationAccess from './OrganizationAccess'; import OrganizationAccess from './OrganizationAccess';
import OrganizationDetail from './OrganizationDetail'; import OrganizationDetail from './OrganizationDetail';
import OrganizationEdit from './OrganizationEdit'; import OrganizationEdit from './OrganizationEdit';
@ -20,77 +19,74 @@ class Organization extends Component {
this.state = { this.state = {
organization: null, organization: null,
error: false, contentLoading: true,
loading: true, contentError: false,
isInitialized: false,
isNotifAdmin: false, isNotifAdmin: false,
isAuditorOfThisOrg: false, isAuditorOfThisOrg: false,
isAdminOfThisOrg: false isAdminOfThisOrg: false,
}; };
this.loadOrganization = this.loadOrganization.bind(this);
this.fetchOrganization = this.fetchOrganization.bind(this); this.loadOrganizationAndRoles = this.loadOrganizationAndRoles.bind(this);
this.fetchOrganizationAndRoles = this.fetchOrganizationAndRoles.bind(this);
} }
componentDidMount () { async componentDidMount () {
this.fetchOrganizationAndRoles(); await this.loadOrganizationAndRoles();
this.setState({ isInitialized: true });
} }
async componentDidUpdate (prevProps) { async componentDidUpdate (prevProps) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) { if (location !== prevProps.location) {
await this.fetchOrganization(); await this.loadOrganization();
} }
} }
async fetchOrganizationAndRoles () { async loadOrganizationAndRoles () {
const { const {
match, match,
setBreadcrumb, setBreadcrumb,
handleHttpError
} = this.props; } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: false, contentLoading: true });
try { try {
const [{ data }, notifAdminRest, auditorRes, adminRes] = await Promise.all([ const [{ data }, notifAdminRes, auditorRes, adminRes] = await Promise.all([
OrganizationsAPI.readDetail(parseInt(match.params.id, 10)), OrganizationsAPI.readDetail(id),
OrganizationsAPI.read({ OrganizationsAPI.read({ page_size: 1, role_level: 'notification_admin_role' }),
role_level: 'notification_admin_role', OrganizationsAPI.read({ id, role_level: 'auditor_role' }),
page_size: 1 OrganizationsAPI.read({ id, role_level: 'admin_role' }),
}),
OrganizationsAPI.read({
role_level: 'auditor_role',
id: parseInt(match.params.id, 10)
}),
OrganizationsAPI.read({
role_level: 'admin_role',
id: parseInt(match.params.id, 10)
})
]); ]);
setBreadcrumb(data); setBreadcrumb(data);
this.setState({ this.setState({
organization: data, organization: data,
loading: false, isNotifAdmin: notifAdminRes.data.results.length > 0,
isNotifAdmin: notifAdminRest.data.results.length > 0,
isAuditorOfThisOrg: auditorRes.data.results.length > 0, isAuditorOfThisOrg: auditorRes.data.results.length > 0,
isAdminOfThisOrg: adminRes.data.results.length > 0 isAdminOfThisOrg: adminRes.data.results.length > 0
}); });
} catch (error) { } catch (err) {
handleHttpError(error) || this.setState({ error: true, loading: false }); this.setState(({ contentError: true }));
} finally {
this.setState({ contentLoading: false });
} }
} }
async fetchOrganization () { async loadOrganization () {
const { const {
match, match,
setBreadcrumb, setBreadcrumb,
handleHttpError
} = this.props; } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: false, contentLoading: true });
try { try {
const { data } = await OrganizationsAPI.readDetail(parseInt(match.params.id, 10)); const { data } = await OrganizationsAPI.readDetail(id);
setBreadcrumb(data); setBreadcrumb(data);
this.setState({ organization: data, loading: false }); this.setState({ organization: data });
} catch (error) { } catch (err) {
handleHttpError(error) || this.setState({ error: true, loading: false }); this.setState(({ contentError: true }));
} finally {
this.setState({ contentLoading: false });
} }
} }
@ -105,8 +101,9 @@ class Organization extends Component {
const { const {
organization, organization,
error, contentError,
loading, contentLoading,
isInitialized,
isNotifAdmin, isNotifAdmin,
isAuditorOfThisOrg, isAuditorOfThisOrg,
isAdminOfThisOrg isAdminOfThisOrg
@ -134,25 +131,28 @@ class Organization extends Component {
} }
let cardHeader = ( let cardHeader = (
loading ? '' : ( <CardHeader style={{ padding: 0 }}>
<CardHeader style={{ padding: 0 }}> <React.Fragment>
<React.Fragment> <div className="awx-orgTabs-container">
<div className="awx-orgTabs-container"> <RoutedTabs
<RoutedTabs match={match}
match={match} history={history}
history={history} labeltext={i18n._(t`Organization detail tabs`)}
labeltext={i18n._(t`Organization detail tabs`)} tabsArray={tabsArray}
tabsArray={tabsArray} />
/> <CardCloseButton linkTo="/organizations" />
<CardCloseButton linkTo="/organizations" /> <div
<div className="awx-orgTabs__bottom-border"
className="awx-orgTabs__bottom-border" />
/> </div>
</div> </React.Fragment>
</React.Fragment> </CardHeader>
</CardHeader>
)
); );
if (!isInitialized) {
cardHeader = null;
}
if (!match) { if (!match) {
cardHeader = null; cardHeader = null;
} }
@ -161,10 +161,20 @@ class Organization extends Component {
cardHeader = null; cardHeader = null;
} }
if (!contentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError />
</Card>
</PageSection>
);
}
return ( return (
<PageSection> <PageSection>
<Card className="awx-c-card"> <Card className="awx-c-card">
{ cardHeader } {cardHeader}
<Switch> <Switch>
<Redirect <Redirect
from="/organizations/:id" from="/organizations/:id"
@ -220,18 +230,12 @@ class Organization extends Component {
)} )}
/> />
)} )}
{organization && (
<NotifyAndRedirect
to={`/organizations/${match.params.id}/details`}
/>
)}
</Switch> </Switch>
{error ? 'error!' : ''}
{loading ? 'loading...' : ''}
</Card> </Card>
</PageSection> </PageSection>
); );
} }
} }
export default withI18n()(withNetwork(withRouter(Organization)));
export default withI18n()(withRouter(Organization));
export { Organization as _Organization }; export { Organization as _Organization };

View File

@ -2,13 +2,17 @@ import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import AlertModal from '../../../../components/AlertModal';
import PaginatedDataList, { ToolbarAddButton } from '../../../../components/PaginatedDataList'; import PaginatedDataList, { ToolbarAddButton } from '../../../../components/PaginatedDataList';
import DataListToolbar from '../../../../components/DataListToolbar'; import DataListToolbar from '../../../../components/DataListToolbar';
import OrganizationAccessItem from '../../components/OrganizationAccessItem'; import OrganizationAccessItem from '../../components/OrganizationAccessItem';
import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal'; import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal';
import AddResourceRole from '../../../../components/AddRole/AddResourceRole'; import AddResourceRole from '../../../../components/AddRole/AddResourceRole';
import { withNetwork } from '../../../../contexts/Network'; import {
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; getQSConfig,
encodeQueryString,
parseNamespacedQueryString
} from '../../../../util/qs';
import { Organization } from '../../../../types'; import { Organization } from '../../../../types';
import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../api'; import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../api';
@ -25,183 +29,191 @@ class OrganizationAccess extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.readOrgAccessList = this.readOrgAccessList.bind(this);
this.confirmRemoveRole = this.confirmRemoveRole.bind(this);
this.cancelRemoveRole = this.cancelRemoveRole.bind(this);
this.removeRole = this.removeRole.bind(this);
this.toggleAddModal = this.toggleAddModal.bind(this);
this.handleSuccessfulRoleAdd = this.handleSuccessfulRoleAdd.bind(this);
this.state = { this.state = {
isLoading: false,
isInitialized: false,
isAddModalOpen: false,
error: null,
itemCount: 0,
accessRecords: [], accessRecords: [],
roleToDelete: null, contentError: false,
roleToDeleteAccessRecord: null, contentLoading: true,
deletionError: false,
deletionRecord: null,
deletionRole: null,
isAddModalOpen: false,
itemCount: 0,
}; };
this.loadAccessList = this.loadAccessList.bind(this);
this.handleAddClose = this.handleAddClose.bind(this);
this.handleAddOpen = this.handleAddOpen.bind(this);
this.handleAddSuccess = this.handleAddSuccess.bind(this);
this.handleDeleteCancel = this.handleDeleteCancel.bind(this);
this.handleDeleteConfirm = this.handleDeleteConfirm.bind(this);
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.handleDeleteOpen = this.handleDeleteOpen.bind(this);
} }
componentDidMount () { componentDidMount () {
this.readOrgAccessList(); this.loadAccessList();
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) {
this.readOrgAccessList(); const prevParams = parseNamespacedQueryString(QS_CONFIG, prevProps.location.search);
const currentParams = parseNamespacedQueryString(QS_CONFIG, location.search);
if (encodeQueryString(currentParams) !== encodeQueryString(prevParams)) {
this.loadAccessList();
} }
} }
async readOrgAccessList () { async loadAccessList () {
const { organization, handleHttpError, location } = this.props; const { organization, location } = this.props;
this.setState({ isLoading: true }); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ contentError: false, contentLoading: true });
try { try {
const { data } = await OrganizationsAPI.readAccessList( const {
organization.id, data: {
parseNamespacedQueryString(QS_CONFIG, location.search) results: accessRecords = [],
); count: itemCount = 0
this.setState({ }
itemCount: data.count || 0, } = await OrganizationsAPI.readAccessList(organization.id, params);
accessRecords: data.results || [], this.setState({ itemCount, accessRecords });
isLoading: false,
isInitialized: true,
});
} catch (error) { } catch (error) {
handleHttpError(error) || this.setState({ this.setState({ contentError: true });
error, } finally {
isLoading: false, this.setState({ contentLoading: false });
});
} }
} }
confirmRemoveRole (role, accessRecord) { handleDeleteOpen (deletionRole, deletionRecord) {
this.setState({ deletionRole, deletionRecord });
}
handleDeleteCancel () {
this.setState({ deletionRole: null, deletionRecord: null });
}
handleDeleteErrorClose () {
this.setState({ this.setState({
roleToDelete: role, deletionError: false,
roleToDeleteAccessRecord: accessRecord, deletionRecord: null,
deletionRole: null
}); });
} }
cancelRemoveRole () { async handleDeleteConfirm () {
this.setState({ const { deletionRole, deletionRecord } = this.state;
roleToDelete: null,
roleToDeleteAccessRecord: null
});
}
async removeRole () { if (!deletionRole || !deletionRecord) {
const { handleHttpError } = this.props;
const { roleToDelete: role, roleToDeleteAccessRecord: accessRecord } = this.state;
if (!role || !accessRecord) {
return; return;
} }
const type = typeof role.team_id === 'undefined' ? 'users' : 'teams';
this.setState({ isLoading: true }); let promise;
if (typeof deletionRole.team_id !== 'undefined') {
promise = TeamsAPI.disassociateRole(deletionRole.team_id, deletionRole.id);
} else {
promise = UsersAPI.disassociateRole(deletionRecord.id, deletionRole.id);
}
this.setState({ contentLoading: true });
try { try {
if (type === 'teams') { await promise.then(this.loadAccessList);
await TeamsAPI.disassociateRole(role.team_id, role.id);
} else {
await UsersAPI.disassociateRole(accessRecord.id, role.id);
}
this.setState({ this.setState({
isLoading: false, deletionRole: null,
roleToDelete: null, deletionRecord: null
roleToDeleteAccessRecord: null,
}); });
this.readOrgAccessList();
} catch (error) { } catch (error) {
handleHttpError(error) || this.setState({ this.setState({
error, contentLoading: false,
isLoading: false, deletionError: true
}); });
} }
} }
toggleAddModal () { handleAddClose () {
const { isAddModalOpen } = this.state; this.setState({ isAddModalOpen: false });
this.setState({
isAddModalOpen: !isAddModalOpen,
});
} }
handleSuccessfulRoleAdd () { handleAddOpen () {
this.toggleAddModal(); this.setState({ isAddModalOpen: true });
this.readOrgAccessList(); }
handleAddSuccess () {
this.setState({ isAddModalOpen: false });
this.loadAccessList();
} }
render () { render () {
const { organization, i18n } = this.props; const { organization, i18n } = this.props;
const { const {
isLoading, accessRecords,
isInitialized, contentError,
contentLoading,
deletionRole,
deletionRecord,
deletionError,
itemCount, itemCount,
isAddModalOpen, isAddModalOpen,
accessRecords,
roleToDelete,
roleToDeleteAccessRecord,
error,
} = this.state; } = this.state;
const canEdit = organization.summary_fields.user_capabilities.edit; const canEdit = organization.summary_fields.user_capabilities.edit;
const isDeleteModalOpen = !contentLoading && !deletionError && deletionRole;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return ( return (
<Fragment> <Fragment>
{isLoading && (<div>Loading...</div>)} <PaginatedDataList
{roleToDelete && ( contentError={contentError}
<DeleteRoleConfirmationModal contentLoading={contentLoading}
role={roleToDelete} items={accessRecords}
username={roleToDeleteAccessRecord.username} itemCount={itemCount}
onCancel={this.cancelRemoveRole} itemName="role"
onConfirm={this.removeRole} qsConfig={QS_CONFIG}
/> toolbarColumns={[
)} { name: i18n._(t`Name`), key: 'first_name', isSortable: true },
{isInitialized && ( { name: i18n._(t`Username`), key: 'username', isSortable: true },
<PaginatedDataList { name: i18n._(t`Last Name`), key: 'last_name', isSortable: true },
items={accessRecords} ]}
itemCount={itemCount} renderToolbar={(props) => (
itemName="role" <DataListToolbar
qsConfig={QS_CONFIG} {...props}
toolbarColumns={[ additionalControls={canEdit ? [
{ name: i18n._(t`Name`), key: 'first_name', isSortable: true }, <ToolbarAddButton key="add" onClick={this.handleAddOpen} />
{ name: i18n._(t`Username`), key: 'username', isSortable: true }, ] : null}
{ name: i18n._(t`Last Name`), key: 'last_name', isSortable: true }, />
]} )}
renderToolbar={(props) => ( renderItem={accessRecord => (
<DataListToolbar <OrganizationAccessItem
{...props} key={accessRecord.id}
additionalControls={canEdit ? [ accessRecord={accessRecord}
<ToolbarAddButton key="add" onClick={this.toggleAddModal} /> onRoleDelete={this.handleDeleteOpen}
] : null} />
/> )}
)} />
renderItem={accessRecord => (
<OrganizationAccessItem
key={accessRecord.id}
accessRecord={accessRecord}
onRoleDelete={this.confirmRemoveRole}
/>
)}
/>
)}
{isAddModalOpen && ( {isAddModalOpen && (
<AddResourceRole <AddResourceRole
onClose={this.toggleAddModal} onClose={this.handleAddClose}
onSave={this.handleSuccessfulRoleAdd} onSave={this.handleAddSuccess}
roles={organization.summary_fields.object_roles} roles={organization.summary_fields.object_roles}
/> />
)} )}
{isDeleteModalOpen && (
<DeleteRoleConfirmationModal
role={deletionRole}
username={deletionRecord.username}
onCancel={this.handleDeleteCancel}
onConfirm={this.handleDeleteConfirm}
/>
)}
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete role`)}
</AlertModal>
</Fragment> </Fragment>
); );
} }
} }
export { OrganizationAccess as _OrganizationAccess }; export { OrganizationAccess as _OrganizationAccess };
export default withI18n()(withNetwork(withRouter(OrganizationAccess))); export default withI18n()(withRouter(OrganizationAccess));

View File

@ -4,9 +4,11 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { CardBody as PFCardBody, Button } from '@patternfly/react-core'; import { CardBody as PFCardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { DetailList, Detail } from '../../../../components/DetailList'; import { DetailList, Detail } from '../../../../components/DetailList';
import { withNetwork } from '../../../../contexts/Network';
import { ChipGroup, Chip } from '../../../../components/Chip'; import { ChipGroup, Chip } from '../../../../components/Chip';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { OrganizationsAPI } from '../../../../api'; import { OrganizationsAPI } from '../../../../api';
const CardBody = styled(PFCardBody)` const CardBody = styled(PFCardBody)`
@ -18,8 +20,9 @@ class OrganizationDetail extends Component {
super(props); super(props);
this.state = { this.state = {
contentError: false,
contentLoading: true,
instanceGroups: [], instanceGroups: [],
error: false
}; };
this.loadInstanceGroups = this.loadInstanceGroups.bind(this); this.loadInstanceGroups = this.loadInstanceGroups.bind(this);
} }
@ -29,25 +32,23 @@ class OrganizationDetail extends Component {
} }
async loadInstanceGroups () { async loadInstanceGroups () {
const { const { match: { params: { id } } } = this.props;
handleHttpError,
match this.setState({ contentLoading: true });
} = this.props;
try { try {
const { const { data: { results = [] } } = await OrganizationsAPI.readInstanceGroups(id);
data this.setState({ instanceGroups: [...results] });
} = await OrganizationsAPI.readInstanceGroups(match.params.id);
this.setState({
instanceGroups: [...data.results]
});
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: true }); this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
} }
} }
render () { render () {
const { const {
error, contentLoading,
contentError,
instanceGroups, instanceGroups,
} = this.state; } = this.state;
@ -65,6 +66,14 @@ class OrganizationDetail extends Component {
i18n i18n
} = this.props; } = this.props;
if (contentLoading) {
return (<ContentLoading />);
}
if (contentError) {
return (<ContentError />);
}
return ( return (
<CardBody> <CardBody>
<DetailList> <DetailList>
@ -116,10 +125,9 @@ class OrganizationDetail extends Component {
</Button> </Button>
</div> </div>
)} )}
{error ? 'error!' : ''}
</CardBody> </CardBody>
); );
} }
} }
export default withI18n()(withRouter(withNetwork(OrganizationDetail))); export default withI18n()(withRouter(OrganizationDetail));

View File

@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core'; import { CardBody } from '@patternfly/react-core';
import OrganizationForm from '../../components/OrganizationForm'; import OrganizationForm from '../../components/OrganizationForm';
import { Config } from '../../../../contexts/Config'; import { Config } from '../../../../contexts/Config';
import { withNetwork } from '../../../../contexts/Network';
import { OrganizationsAPI } from '../../../../api'; import { OrganizationsAPI } from '../../../../api';
class OrganizationEdit extends Component { class OrganizationEdit extends Component {
@ -22,13 +22,13 @@ class OrganizationEdit extends Component {
} }
async handleSubmit (values, groupsToAssociate, groupsToDisassociate) { async handleSubmit (values, groupsToAssociate, groupsToDisassociate) {
const { organization, handleHttpError } = this.props; const { organization } = this.props;
try { try {
await OrganizationsAPI.update(organization.id, values); await OrganizationsAPI.update(organization.id, values);
await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate); await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate);
this.handleSuccess(); this.handleSuccess();
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: err }); this.setState({ error: err });
} }
} }
@ -43,8 +43,7 @@ class OrganizationEdit extends Component {
} }
async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) { async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) {
const { organization, handleHttpError } = this.props; const { organization } = this.props;
try { try {
await Promise.all( await Promise.all(
groupsToAssociate.map(id => OrganizationsAPI.associateInstanceGroup(organization.id, id)) groupsToAssociate.map(id => OrganizationsAPI.associateInstanceGroup(organization.id, id))
@ -55,7 +54,7 @@ class OrganizationEdit extends Component {
) )
); );
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: err }); this.setState({ error: err });
} }
} }
@ -90,4 +89,4 @@ OrganizationEdit.contextTypes = {
}; };
export { OrganizationEdit as _OrganizationEdit }; export { OrganizationEdit as _OrganizationEdit };
export default withNetwork(withRouter(OrganizationEdit)); export default withRouter(OrganizationEdit);

View File

@ -1,7 +1,10 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { number, shape, func, string, bool } from 'prop-types'; import { number, shape, string, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withNetwork } from '../../../../contexts/Network'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../../../../components/AlertModal';
import PaginatedDataList from '../../../../components/PaginatedDataList'; import PaginatedDataList from '../../../../components/PaginatedDataList';
import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem'; import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem';
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
@ -22,194 +25,159 @@ const COLUMNS = [
class OrganizationNotifications extends Component { class OrganizationNotifications extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.readNotifications = this.readNotifications.bind(this);
this.readSuccessesAndErrors = this.readSuccessesAndErrors.bind(this);
this.toggleNotification = this.toggleNotification.bind(this);
this.state = { this.state = {
isInitialized: false, contentError: false,
isLoading: false, contentLoading: true,
error: null, toggleError: false,
toggleLoading: false,
itemCount: 0, itemCount: 0,
notifications: [], notifications: [],
successTemplateIds: [], successTemplateIds: [],
errorTemplateIds: [], errorTemplateIds: [],
}; };
this.handleNotificationToggle = this.handleNotificationToggle.bind(this);
this.handleNotificationErrorClose = this.handleNotificationErrorClose.bind(this);
this.loadNotifications = this.loadNotifications.bind(this);
} }
componentDidMount () { componentDidMount () {
this.readNotifications(); this.loadNotifications();
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) { if (location !== prevProps.location) {
this.readNotifications(); this.loadNotifications();
} }
} }
async readNotifications () { async loadNotifications () {
const { id, handleHttpError, location } = this.props; const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ isLoading: true });
try {
const { data } = await OrganizationsAPI.readNotificationTemplates(id, params);
this.setState(
{
itemCount: data.count || 0,
notifications: data.results || [],
isLoading: false,
isInitialized: true,
},
this.readSuccessesAndErrors
);
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
async readSuccessesAndErrors () { this.setState({ contentError: false, contentLoading: true });
const { handleHttpError, id } = this.props;
const { notifications } = this.state;
if (!notifications.length) {
return;
}
const ids = notifications.map(n => n.id).join(',');
try { try {
const successTemplatesPromise = OrganizationsAPI.readNotificationTemplatesSuccess( const {
id, data: {
{ id__in: ids } count: itemCount = 0,
); results: notifications = [],
const errorTemplatesPromise = OrganizationsAPI.readNotificationTemplatesError( }
id, } = await OrganizationsAPI.readNotificationTemplates(id, params);
{ id__in: ids }
);
const { data: successTemplates } = await successTemplatesPromise; let idMatchParams;
const { data: errorTemplates } = await errorTemplatesPromise; if (notifications.length > 0) {
idMatchParams = { id__in: notifications.map(n => n.id).join(',') };
} else {
idMatchParams = {};
}
const [
{ data: successTemplates },
{ data: errorTemplates },
] = await Promise.all([
OrganizationsAPI.readNotificationTemplatesSuccess(id, idMatchParams),
OrganizationsAPI.readNotificationTemplatesError(id, idMatchParams),
]);
this.setState({ this.setState({
itemCount,
notifications,
successTemplateIds: successTemplates.results.map(s => s.id), successTemplateIds: successTemplates.results.map(s => s.id),
errorTemplateIds: errorTemplates.results.map(e => e.id), errorTemplateIds: errorTemplates.results.map(e => e.id),
}); });
} catch (error) { } catch {
handleHttpError(error) || this.setState({ this.setState({ contentError: true });
error, } finally {
isLoading: false, this.setState({ contentLoading: false });
}
}
async handleNotificationToggle (notificationId, isCurrentlyOn, status) {
const { id } = this.props;
let stateArrayName;
if (status === 'success') {
stateArrayName = 'successTemplateIds';
} else {
stateArrayName = 'errorTemplateIds';
}
let stateUpdateFunction;
if (isCurrentlyOn) {
// when switching off, remove the toggled notification id from the array
stateUpdateFunction = (prevState) => ({
[stateArrayName]: prevState[stateArrayName].filter(i => i !== notificationId)
});
} else {
// when switching on, add the toggled notification id to the array
stateUpdateFunction = (prevState) => ({
[stateArrayName]: prevState[stateArrayName].concat(notificationId)
}); });
} }
}
toggleNotification = (notificationId, isCurrentlyOn, status) => { this.setState({ toggleLoading: true });
if (status === 'success') {
if (isCurrentlyOn) {
this.disassociateSuccess(notificationId);
} else {
this.associateSuccess(notificationId);
}
} else if (status === 'error') {
if (isCurrentlyOn) {
this.disassociateError(notificationId);
} else {
this.associateError(notificationId);
}
}
};
async associateSuccess (notificationId) {
const { id, handleHttpError } = this.props;
try { try {
await OrganizationsAPI.associateNotificationTemplatesSuccess(id, notificationId); await OrganizationsAPI.updateNotificationTemplateAssociation(
this.setState(prevState => ({ id,
successTemplateIds: [...prevState.successTemplateIds, notificationId] notificationId,
})); status,
!isCurrentlyOn
);
this.setState(stateUpdateFunction);
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: true }); this.setState({ toggleError: true });
} finally {
this.setState({ toggleLoading: false });
} }
} }
async disassociateSuccess (notificationId) { handleNotificationErrorClose () {
const { id, handleHttpError } = this.props; this.setState({ toggleError: false });
try {
await OrganizationsAPI.disassociateNotificationTemplatesSuccess(id, notificationId);
this.setState((prevState) => ({
successTemplateIds: prevState.successTemplateIds
.filter((templateId) => templateId !== notificationId)
}));
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
}
async associateError (notificationId) {
const { id, handleHttpError } = this.props;
try {
await OrganizationsAPI.associateNotificationTemplatesError(id, notificationId);
this.setState(prevState => ({
errorTemplateIds: [...prevState.errorTemplateIds, notificationId]
}));
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
}
async disassociateError (notificationId) {
const { id, handleHttpError } = this.props;
try {
await OrganizationsAPI.disassociateNotificationTemplatesError(id, notificationId);
this.setState((prevState) => ({
errorTemplateIds: prevState.errorTemplateIds
.filter((templateId) => templateId !== notificationId)
}));
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
} }
render () { render () {
const { canToggleNotifications } = this.props; const { canToggleNotifications, i18n } = this.props;
const { const {
notifications, contentError,
contentLoading,
toggleError,
toggleLoading,
itemCount, itemCount,
isLoading, notifications,
isInitialized,
error,
successTemplateIds, successTemplateIds,
errorTemplateIds, errorTemplateIds,
} = this.state; } = this.state;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return ( return (
<Fragment> <Fragment>
{isLoading && (<div>Loading...</div>)} <PaginatedDataList
{isInitialized && ( contentError={contentError}
<PaginatedDataList contentLoading={contentLoading}
items={notifications} items={notifications}
itemCount={itemCount} itemCount={itemCount}
itemName="notification" itemName="notification"
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS} toolbarColumns={COLUMNS}
renderItem={(notification) => ( renderItem={(notification) => (
<NotificationListItem <NotificationListItem
key={notification.id} key={notification.id}
notification={notification} notification={notification}
detailUrl={`/notifications/${notification.id}`} detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={canToggleNotifications} canToggleNotifications={canToggleNotifications && !toggleLoading}
toggleNotification={this.toggleNotification} toggleNotification={this.handleNotificationToggle}
errorTurnedOn={errorTemplateIds.includes(notification.id)} errorTurnedOn={errorTemplateIds.includes(notification.id)}
successTurnedOn={successTemplateIds.includes(notification.id)} successTurnedOn={successTemplateIds.includes(notification.id)}
/> />
)} )}
/> />
)} <AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={this.handleNotificationErrorClose}
>
{i18n._(t`Failed to toggle notification.`)}
</AlertModal>
</Fragment> </Fragment>
); );
} }
@ -218,11 +186,10 @@ class OrganizationNotifications extends Component {
OrganizationNotifications.propTypes = { OrganizationNotifications.propTypes = {
id: number.isRequired, id: number.isRequired,
canToggleNotifications: bool.isRequired, canToggleNotifications: bool.isRequired,
handleHttpError: func.isRequired,
location: shape({ location: shape({
search: string.isRequired, search: string.isRequired,
}).isRequired, }).isRequired,
}; };
export { OrganizationNotifications as _OrganizationNotifications }; export { OrganizationNotifications as _OrganizationNotifications };
export default withNetwork(withRouter(OrganizationNotifications)); export default withI18n()(withRouter(OrganizationNotifications));

View File

@ -1,9 +1,8 @@
import React, { Fragment } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import PaginatedDataList from '../../../../components/PaginatedDataList'; import PaginatedDataList from '../../../../components/PaginatedDataList';
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
import { withNetwork } from '../../../../contexts/Network';
import { OrganizationsAPI } from '../../../../api'; import { OrganizationsAPI } from '../../../../api';
const QS_CONFIG = getQSConfig('team', { const QS_CONFIG = getQSConfig('team', {
@ -16,32 +15,32 @@ class OrganizationTeams extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.readOrganizationTeamsList = this.readOrganizationTeamsList.bind(this); this.loadOrganizationTeamsList = this.loadOrganizationTeamsList.bind(this);
this.state = { this.state = {
isInitialized: false, contentError: false,
isLoading: false, contentLoading: true,
error: null,
itemCount: 0, itemCount: 0,
teams: [], teams: [],
}; };
} }
componentDidMount () { componentDidMount () {
this.readOrganizationTeamsList(); this.loadOrganizationTeamsList();
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) { if (location !== prevProps.location) {
this.readOrganizationTeamsList(); this.loadOrganizationTeamsList();
} }
} }
async readOrganizationTeamsList () { async loadOrganizationTeamsList () {
const { id, handleHttpError, location } = this.props; const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ isLoading: true, error: null });
this.setState({ contentLoading: true, contentError: false });
try { try {
const { const {
data: { count = 0, results = [] }, data: { count = 0, results = [] },
@ -49,38 +48,25 @@ class OrganizationTeams extends React.Component {
this.setState({ this.setState({
itemCount: count, itemCount: count,
teams: results, teams: results,
isLoading: false,
isInitialized: true,
});
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
}); });
} catch {
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
} }
} }
render () { render () {
const { teams, itemCount, isLoading, isInitialized, error } = this.state; const { contentError, contentLoading, teams, itemCount } = this.state;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return ( return (
<Fragment> <PaginatedDataList
{isLoading && (<div>Loading...</div>)} contentError={contentError}
{isInitialized && ( contentLoading={contentLoading}
<PaginatedDataList items={teams}
items={teams} itemCount={itemCount}
itemCount={itemCount} itemName="team"
itemName="team" qsConfig={QS_CONFIG}
qsConfig={QS_CONFIG} />
/>
)}
</Fragment>
); );
} }
} }
@ -90,4 +76,4 @@ OrganizationTeams.propTypes = {
}; };
export { OrganizationTeams as _OrganizationTeams }; export { OrganizationTeams as _OrganizationTeams };
export default withNetwork(withRouter(OrganizationTeams)); export default withRouter(OrganizationTeams);

View File

@ -12,7 +12,6 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { Config } from '../../../contexts/Config'; import { Config } from '../../../contexts/Config';
import { withNetwork } from '../../../contexts/Network';
import CardCloseButton from '../../../components/CardCloseButton'; import CardCloseButton from '../../../components/CardCloseButton';
import OrganizationForm from '../components/OrganizationForm'; import OrganizationForm from '../components/OrganizationForm';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
@ -20,29 +19,20 @@ import { OrganizationsAPI } from '../../../api';
class OrganizationAdd extends React.Component { class OrganizationAdd extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this); this.handleCancel = this.handleCancel.bind(this);
this.handleSuccess = this.handleSuccess.bind(this); this.state = { error: '' };
this.state = {
error: '',
};
} }
async handleSubmit (values, groupsToAssociate) { async handleSubmit (values, groupsToAssociate) {
const { handleHttpError } = this.props; const { history } = this.props;
try { try {
const { data: response } = await OrganizationsAPI.create(values); const { data: response } = await OrganizationsAPI.create(values);
try { await Promise.all(groupsToAssociate.map(id => OrganizationsAPI
await Promise.all(groupsToAssociate.map(id => OrganizationsAPI .associateInstanceGroup(response.id, id)));
.associateInstanceGroup(response.id, id))); history.push(`/organizations/${response.id}`);
this.handleSuccess(response.id); } catch (error) {
} catch (err) { this.setState({ error });
handleHttpError(err) || this.setState({ error: err });
}
} catch (err) {
this.setState({ error: err });
} }
} }
@ -51,11 +41,6 @@ class OrganizationAdd extends React.Component {
history.push('/organizations'); history.push('/organizations');
} }
handleSuccess (id) {
const { history } = this.props;
history.push(`/organizations/${id}`);
}
render () { render () {
const { error } = this.state; const { error } = this.state;
const { i18n } = this.props; const { i18n } = this.props;
@ -94,4 +79,4 @@ OrganizationAdd.contextTypes = {
}; };
export { OrganizationAdd as _OrganizationAdd }; export { OrganizationAdd as _OrganizationAdd };
export default withI18n()(withNetwork(withRouter(OrganizationAdd))); export default withI18n()(withRouter(OrganizationAdd));

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@ -8,13 +8,13 @@ import {
PageSectionVariants, PageSectionVariants,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { withNetwork } from '../../../contexts/Network';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarDeleteButton, ToolbarDeleteButton,
ToolbarAddButton ToolbarAddButton
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import OrganizationListItem from '../components/OrganizationListItem'; import OrganizationListItem from '../components/OrganizationListItem';
import AlertModal from '../../../components/AlertModal';
import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
@ -29,29 +29,30 @@ class OrganizationsList extends Component {
super(props); super(props);
this.state = { this.state = {
error: null, contentLoading: true,
isLoading: true, contentError: false,
isInitialized: false, deletionError: false,
organizations: [], organizations: [],
selected: [] selected: [],
itemCount: 0,
actions: null,
}; };
this.handleSelectAll = this.handleSelectAll.bind(this); this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleSelect = this.handleSelect.bind(this); this.handleSelect = this.handleSelect.bind(this);
this.fetchOptionsOrganizations = this.fetchOptionsOrganizations.bind(this);
this.fetchOrganizations = this.fetchOrganizations.bind(this);
this.handleOrgDelete = this.handleOrgDelete.bind(this); this.handleOrgDelete = this.handleOrgDelete.bind(this);
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.loadOrganizations = this.loadOrganizations.bind(this);
} }
componentDidMount () { componentDidMount () {
this.fetchOptionsOrganizations(); this.loadOrganizations();
this.fetchOrganizations();
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) { if (location !== prevProps.location) {
this.fetchOrganizations(); this.loadOrganizations();
} }
} }
@ -72,63 +73,54 @@ class OrganizationsList extends Component {
} }
} }
handleDeleteErrorClose () {
this.setState({ deletionError: false });
}
async handleOrgDelete () { async handleOrgDelete () {
const { selected } = this.state; const { selected } = this.state;
const { handleHttpError } = this.props;
let errorHandled;
this.setState({ contentLoading: true, deletionError: false });
try { try {
await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id))); await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id)));
this.setState({ this.setState({ selected: [] });
selected: []
});
} catch (err) { } catch (err) {
errorHandled = handleHttpError(err); this.setState({ deletionError: true });
} finally { } finally {
if (!errorHandled) { await this.loadOrganizations();
this.fetchOrganizations();
}
} }
} }
async fetchOrganizations () { async loadOrganizations () {
const { handleHttpError, location } = this.props; const { location } = this.props;
const { actions: cachedActions } = this.state;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ error: false, isLoading: true }); let optionsPromise;
if (cachedActions) {
optionsPromise = Promise.resolve({ data: { actions: cachedActions } });
} else {
optionsPromise = OrganizationsAPI.readOptions();
}
const promises = Promise.all([
OrganizationsAPI.read(params),
optionsPromise,
]);
this.setState({ contentError: false, contentLoading: true });
try { try {
const { data } = await OrganizationsAPI.read(params); const [{ data: { count, results } }, { data: { actions } }] = await promises;
const { count, results } = data; this.setState({
actions,
const stateToUpdate = {
itemCount: count, itemCount: count,
organizations: results, organizations: results,
selected: [], selected: [],
isLoading: false, });
isInitialized: true,
};
this.setState(stateToUpdate);
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: true, isLoading: false }); this.setState(({ contentError: true }));
}
}
async fetchOptionsOrganizations () {
try {
const { data } = await OrganizationsAPI.readOptions();
const { actions } = data;
const stateToUpdate = {
canAdd: Object.prototype.hasOwnProperty.call(actions, 'POST')
};
this.setState(stateToUpdate);
} catch (err) {
this.setState({ error: true });
} finally { } finally {
this.setState({ isLoading: false }); this.setState({ contentLoading: false });
} }
} }
@ -137,23 +129,26 @@ class OrganizationsList extends Component {
medium, medium,
} = PageSectionVariants; } = PageSectionVariants;
const { const {
canAdd, actions,
itemCount, itemCount,
error, contentError,
isLoading, contentLoading,
isInitialized, deletionError,
selected, selected,
organizations organizations,
} = this.state; } = this.state;
const { match, i18n } = this.props; const { match, i18n } = this.props;
const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length === organizations.length; const isAllSelected = selected.length === organizations.length;
return ( return (
<PageSection variant={medium}> <Fragment>
<Card> <PageSection variant={medium}>
{isInitialized && ( <Card>
<PaginatedDataList <PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={organizations} items={organizations}
itemCount={itemCount} itemCount={itemCount}
itemName="organization" itemName="organization"
@ -196,14 +191,20 @@ class OrganizationsList extends Component {
: null : null
} }
/> />
)} </Card>
{ isLoading ? <div>loading...</div> : '' } </PageSection>
{ error ? <div>error</div> : '' } <AlertModal
</Card> isOpen={deletionError}
</PageSection> variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more organizations.`)}
</AlertModal>
</Fragment>
); );
} }
} }
export { OrganizationsList as _OrganizationsList }; export { OrganizationsList as _OrganizationsList };
export default withI18n()(withNetwork(withRouter(OrganizationsList))); export default withI18n()(withRouter(OrganizationsList));

View File

@ -2,8 +2,6 @@ import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Route, withRouter, Switch } from 'react-router-dom'; import { Route, withRouter, Switch } from 'react-router-dom';
import { NetworkProvider } from '../../contexts/Network';
import { withRootDialog } from '../../contexts/RootDialog';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import TemplatesList from './TemplatesList'; import TemplatesList from './TemplatesList';
@ -21,32 +19,12 @@ class Templates extends Component {
} }
render () { render () {
const { match, history, setRootDialogMessage, i18n } = this.props; const { match } = this.props;
const { breadcrumbConfig } = this.state; const { breadcrumbConfig } = this.state;
return ( return (
<Fragment> <Fragment>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route
path={`${match.path}/:templateType/:id`}
render={({ match: newRouteMatch }) => (
<NetworkProvider
handle404={() => {
history.replace('/templates');
setRootDialogMessage({
title: '404',
bodyText: (
<Fragment>
{i18n._(t`Cannot find template with ID`)}
<strong>{` ${newRouteMatch.params.id}`}</strong>
</Fragment>
),
variant: 'warning'
});
}}
/>
)}
/>
<Route <Route
path={`${match.path}`} path={`${match.path}`}
render={() => ( render={() => (
@ -60,4 +38,4 @@ class Templates extends Component {
} }
export { Templates as _Templates }; export { Templates as _Templates };
export default withI18n()(withRootDialog(withRouter(Templates))); export default withI18n()(withRouter(Templates));

View File

@ -7,7 +7,6 @@ import {
PageSection, PageSection,
PageSectionVariants, PageSectionVariants,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { withNetwork } from '../../contexts/Network';
import { UnifiedJobTemplatesAPI } from '../../api'; import { UnifiedJobTemplatesAPI } from '../../api';
import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../util/qs';
@ -29,25 +28,25 @@ class TemplatesList extends Component {
super(props); super(props);
this.state = { this.state = {
error: null, contentError: false,
isLoading: true, contentLoading: true,
isInitialized: false,
selected: [], selected: [],
templates: [], templates: [],
itemCount: 0,
}; };
this.readUnifiedJobTemplates = this.readUnifiedJobTemplates.bind(this); this.loadUnifiedJobTemplates = this.loadUnifiedJobTemplates.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this); this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleSelect = this.handleSelect.bind(this); this.handleSelect = this.handleSelect.bind(this);
} }
componentDidMount () { componentDidMount () {
this.readUnifiedJobTemplates(); this.loadUnifiedJobTemplates();
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { location } = this.props; const { location } = this.props;
if (location !== prevProps.location) { if (location !== prevProps.location) {
this.readUnifiedJobTemplates(); this.loadUnifiedJobTemplates();
} }
} }
@ -66,33 +65,29 @@ class TemplatesList extends Component {
} }
} }
async readUnifiedJobTemplates () { async loadUnifiedJobTemplates () {
const { handleHttpError, location } = this.props; const { location } = this.props;
this.setState({ error: false, isLoading: true });
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ contentError: false, contentLoading: true });
try { try {
const { data } = await UnifiedJobTemplatesAPI.read(params); const { data: { count, results } } = await UnifiedJobTemplatesAPI.read(params);
const { count, results } = data; this.setState({
const stateToUpdate = {
itemCount: count, itemCount: count,
templates: results, templates: results,
selected: [], selected: [],
isInitialized: true, });
isLoading: false,
};
this.setState(stateToUpdate);
} catch (err) { } catch (err) {
handleHttpError(err) || this.setState({ error: true, isLoading: false }); this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
} }
} }
render () { render () {
const { const {
error, contentError,
isInitialized, contentLoading,
isLoading,
templates, templates,
itemCount, itemCount,
selected, selected,
@ -106,44 +101,43 @@ class TemplatesList extends Component {
return ( return (
<PageSection variant={medium}> <PageSection variant={medium}>
<Card> <Card>
{isInitialized && ( <PaginatedDataList
<PaginatedDataList contentError={contentError}
items={templates} contentLoading={contentLoading}
itemCount={itemCount} items={templates}
itemName={i18n._(t`Template`)} itemCount={itemCount}
qsConfig={QS_CONFIG} itemName={i18n._(t`Template`)}
toolbarColumns={[ qsConfig={QS_CONFIG}
{ name: i18n._(t`Name`), key: 'name', isSortable: true }, toolbarColumns={[
{ name: i18n._(t`Modified`), key: 'modified', isSortable: true, isNumeric: true }, { name: i18n._(t`Name`), key: 'name', isSortable: true },
{ name: i18n._(t`Created`), key: 'created', isSortable: true, isNumeric: true }, { name: i18n._(t`Modified`), key: 'modified', isSortable: true, isNumeric: true },
]} { name: i18n._(t`Created`), key: 'created', isSortable: true, isNumeric: true },
renderToolbar={(props) => ( ]}
<DatalistToolbar renderToolbar={(props) => (
{...props} <DatalistToolbar
showSelectAll {...props}
showExpandCollapse showSelectAll
isAllSelected={isAllSelected} showExpandCollapse
onSelectAll={this.handleSelectAll} isAllSelected={isAllSelected}
/> onSelectAll={this.handleSelectAll}
)} />
renderItem={(template) => ( )}
<TemplateListItem renderItem={(template) => (
key={template.id} <TemplateListItem
value={template.name} key={template.id}
template={template} value={template.name}
detailUrl={`${match.url}/${template.type}/${template.id}`} template={template}
onSelect={() => this.handleSelect(template)} detailUrl={`${match.url}/${template.type}/${template.id}`}
isSelected={selected.some(row => row.id === template.id)} onSelect={() => this.handleSelect(template)}
/> isSelected={selected.some(row => row.id === template.id)}
)} />
/> )}
)} />
{isLoading ? <div>loading....</div> : ''}
{error ? <div>error</div> : '' }
</Card> </Card>
</PageSection> </PageSection>
); );
} }
} }
export { TemplatesList as _TemplatesList }; export { TemplatesList as _TemplatesList };
export default withI18n()(withNetwork(withRouter(TemplatesList))); export default withI18n()(withRouter(TemplatesList));

8
src/util/auth.js Normal file
View File

@ -0,0 +1,8 @@
// eslint-disable-next-line import/prefer-default-export
export function isAuthenticated () {
const parsed = (`; ${document.cookie}`).split('; userLoggedIn=');
if (parsed.length === 2) {
return parsed.pop().split(';').shift() === 'true';
}
return false;
}