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)
* [Accessing the AWX web interface](#accessing-the-awx-web-interface)
* [AWX REST API Interaction](#awx-rest-api-interaction)
* [Handling API Errors](#handling-api-errors)
* [Working with React](#working-with-react)
* [App structure](#app-structure)
* [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
### App structure

View File

@ -24,6 +24,6 @@ To run the unit tests on files that you've changed:
* `npm 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

View File

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

View File

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

View File

@ -19,7 +19,6 @@ describe('<Lookup />', () => {
getItems={() => { }}
columns={mockColumns}
sortedColumnKey="name"
handleHttpError={() => {}}
/>
);
});
@ -34,7 +33,6 @@ describe('<Lookup />', () => {
getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })}
columns={mockColumns}
sortedColumnKey="name"
handleHttpError={() => {}}
/>
).find('Lookup');
@ -57,7 +55,6 @@ describe('<Lookup />', () => {
getItems={() => { }}
columns={mockColumns}
sortedColumnKey="name"
handleHttpError={() => {}}
/>
).find('Lookup');
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
*/
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 { I18nProvider } from '@lingui/react';
import { ConfigProvider } from '../src/contexts/Config';
import { _NetworkProvider } from '../src/contexts/Network';
import { RootDialogProvider } from '../src/contexts/RootDialog';
const language = 'en-US';
const intlProvider = new I18nProvider(
@ -36,8 +34,6 @@ const defaultContexts = {
ansible_version: null,
custom_virtualenvs: [],
version: null,
custom_logo: null,
custom_login_info: null,
toJSON: () => '/config/'
},
router: {
@ -69,30 +65,19 @@ const defaultContexts = {
},
toJSON: () => '/router/',
},
network: {
handleHttpError: () => {},
},
dialog: {}
};
function wrapContexts (node, context) {
const { config, network, dialog } = context;
const { config } = context;
class Wrap extends React.Component {
render () {
// eslint-disable-next-line react/no-this-in-sfc
const { children, ...props } = this.props;
const component = React.cloneElement(children, props);
return (
<RootDialogProvider value={dialog}>
<_NetworkProvider value={network}>
<ConfigProvider
value={config}
i18n={defaultContexts.linguiPublisher.i18n}
>
{component}
</ConfigProvider>
</_NetworkProvider>
</RootDialogProvider>
<ConfigProvider value={config}>
{component}
</ConfigProvider>
);
}
}
@ -131,8 +116,6 @@ export function mountWithContexts (node, options = {}) {
ansible_version: string,
custom_virtualenvs: arrayOf(string),
version: string,
custom_logo: string,
custom_login_info: string,
}),
router: shape({
route: shape({
@ -141,36 +124,31 @@ export function mountWithContexts (node, options = {}) {
}).isRequired,
history: shape({}).isRequired,
}),
network: shape({
handleHttpError: func.isRequired,
}),
dialog: shape({
title: string,
setRootDialogMessage: func,
clearRootDialogMessage: func,
}),
...options.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[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;
return new Promise((resolve, reject) => {
let attempts = 30;
(function pollElement () {
wrapper.update();
if (wrapper.exists(selector)) {
return resolve(wrapper.find(selector));
const el = wrapper.find(selector);
if (callback(el)) {
return resolve(el);
}
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);
}());

View File

@ -4,7 +4,6 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { mountWithContexts, waitForElement } from './enzymeHelpers';
import { Config } from '../src/contexts/Config';
import { withRootDialog } from '../src/contexts/RootDialog';
describe('mountWithContexts', () => {
describe('injected I18nProvider', () => {
@ -109,68 +108,6 @@ describe('mountWithContexts', () => {
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 () {
setTimeout(() => {
this.setState({ displayElement: true });
}, 1000);
setTimeout(() => this.setState({ displayElement: true }), 500);
}
render () {
@ -211,16 +146,15 @@ describe('waitForElement', () => {
});
it('eventually throws an error for elements that don\'t exist', async (done) => {
const selector = '#does-not-exist';
const wrapper = mountWithContexts(<div />);
let error;
try {
await waitForElement(wrapper, selector);
await waitForElement(wrapper, '#does-not-exist');
} catch (err) {
error = err;
} 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();
}
});

View File

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

View File

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

View File

@ -1,63 +1,232 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../enzymeHelpers';
import { sleep } from '../../../../testUtils';
import { mountWithContexts, waitForElement } from '../../../../enzymeHelpers';
import Organization from '../../../../../src/pages/Organizations/screens/Organization/Organization';
import { OrganizationsAPI } from '../../../../../src/api';
jest.mock('../../../../../src/api');
describe.only('<Organization />', () => {
const me = {
is_super_user: true,
is_system_auditor: false
};
const mockMe = {
is_super_user: true,
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', () => {
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 () => {
OrganizationsAPI.readDetail.mockResolvedValue({
data: {
id: 1,
name: 'foo'
}
});
OrganizationsAPI.read.mockResolvedValue({
data: {
results: []
}
});
const history = createMemoryHistory({
initialEntries: ['/organizations/1/details'],
});
const match = { path: '/organizations/:id', url: '/organizations/1' };
const wrapper = mountWithContexts(
<Organization
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);
test('notifications tab shown for admins', async (done) => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockImplementation(getOrganizations);
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');
done();
});
test('notifications tab hidden with reduced permissions', async (done) => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockResolvedValue(mockNoResults);
const wrapper = mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
const tabs = await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3);
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
});
});

View File

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

View File

@ -1,12 +1,12 @@
import React from 'react';
import { mountWithContexts } from '../../../../enzymeHelpers';
import { mountWithContexts, waitForElement } from '../../../../enzymeHelpers';
import OrganizationDetail from '../../../../../src/pages/Organizations/screens/Organization/OrganizationDetail';
import { OrganizationsAPI } from '../../../../../src/api';
jest.mock('../../../../../src/api');
describe('<OrganizationDetail />', () => {
const mockDetails = {
const mockOrganization = {
name: 'Foo',
description: 'Bar',
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(() => {
jest.clearAllMocks();
});
test('initially renders succesfully', () => {
mountWithContexts(
<OrganizationDetail
organization={mockDetails}
/>
);
mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
});
test('should request instance groups from api', () => {
mountWithContexts(
<OrganizationDetail
organization={mockDetails}
/>, { context: {
network: { handleHttpError: () => {} }
} }
).find('OrganizationDetail');
mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
});
test('should handle setting instance groups to state', async () => {
const mockInstanceGroups = [
{ name: 'One', id: 1 },
{ name: 'Two', id: 2 }
];
OrganizationsAPI.readInstanceGroups.mockResolvedValue({
data: { results: mockInstanceGroups }
});
test('should handle setting instance groups to state', async (done) => {
const wrapper = mountWithContexts(
<OrganizationDetail
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}
/>
<OrganizationDetail organization={mockOrganization} />
);
const detailWrapper = wrapper.find('Detail');
expect(detailWrapper.length).toBe(6);
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');
const component = await waitForElement(wrapper, 'OrganizationDetail');
expect(component.state().instanceGroups).toEqual(mockInstanceGroups.data.results);
done();
});
test('should show edit button for users with edit permission', () => {
const wrapper = mountWithContexts(
<OrganizationDetail
organization={mockDetails}
/>
).find('OrganizationDetail');
const editButton = wrapper.find('Button');
expect((editButton).prop('to')).toBe('/organizations/undefined/edit');
test('should render Details', async (done) => {
const wrapper = mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
const testParams = [
{ label: 'Name', value: 'Foo' },
{ label: 'Description', value: 'Bar' },
{ label: 'Ansible Environment', value: 'Fizz' },
{ label: 'Created', value: 'Bat' },
{ 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', () => {
const readOnlyOrg = { ...mockDetails };
test('should show edit button for users with edit permission', async (done) => {
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;
const wrapper = mountWithContexts(
<OrganizationDetail
organization={readOnlyOrg}
/>
).find('OrganizationDetail');
const editLink = wrapper
.findWhere(node => node.props().to === '/organizations/undefined/edit');
expect(editLink.length).toBe(0);
const wrapper = mountWithContexts(<OrganizationDetail organization={readOnlyOrg} />);
await waitForElement(wrapper, 'OrganizationDetail');
expect(wrapper.find('OrganizationDetail Button').length).toBe(0);
done();
});
});

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@
exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
<OrganizationAccess
handleHttpError={[Function]}
history={"/history/"}
i18n={"/i18n/"}
location={
@ -34,8 +33,228 @@ exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
}
}
>
<div>
Loading...
</div>
<WithI18n
contentError={false}
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>
`;

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,6 @@ import {
HashRouter
} 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 en from '../build/locales/en/messages';
@ -34,13 +30,7 @@ class RootProvider extends Component {
language={language}
catalogs={catalogs}
>
<RootDialogProvider>
<NetworkProvider>
<ConfigProvider>
{children}
</ConfigProvider>
</NetworkProvider>
</RootDialogProvider>
{children}
</I18nProvider>
</HashRouter>
);

View File

@ -26,6 +26,36 @@ const NotificationsMixin = (parent) => class extends parent {
disassociateNotificationTemplatesError (resourceId, notificationId) {
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;

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core';
import { withNetwork } from '../../contexts/Network';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import SelectableCard from './SelectableCard';
@ -245,4 +244,4 @@ AddResourceRole.defaultProps = {
};
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 { t } from '@lingui/macro';
import { withNetwork } from '../../contexts/Network';
import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../ListItem';
@ -53,8 +52,8 @@ class Lookup extends React.Component {
}
async getData () {
const { getItems, handleHttpError, location } = this.props;
const queryParams = parseNamespacedQueryString(this.qsConfig, location.search);
const { getItems, location: { search } } = this.props;
const queryParams = parseNamespacedQueryString(this.qsConfig, search);
this.setState({ error: false });
try {
@ -66,7 +65,7 @@ class Lookup extends React.Component {
count
});
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
this.setState({ error: true });
}
}
@ -214,4 +213,4 @@ Lookup.defaultProps = {
};
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 PropTypes, { arrayOf, shape, string, bool } from 'prop-types';
import {
DataList,
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { DataList } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
import ContentEmpty from '../ContentEmpty';
import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import Pagination from '../Pagination';
import DataListToolbar from '../DataListToolbar';
import PaginatedDataListItem from './PaginatedDataListItem';
@ -37,11 +33,6 @@ const EmptyStateControlsWrapper = styled.div`
class PaginatedDataList extends React.Component {
constructor (props) {
super(props);
this.state = {
error: null,
};
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSetPageSize = this.handleSetPageSize.bind(this);
this.handleSort = this.handleSort.bind(this);
@ -79,7 +70,10 @@ class PaginatedDataList extends React.Component {
}
render () {
const [orderBy, sortOrder] = this.getSortOrder();
const {
contentError,
contentLoading,
emptyStateControls,
items,
itemCount,
@ -93,66 +87,67 @@ class PaginatedDataList extends React.Component {
i18n,
renderToolbar,
} = 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 }];
return (
<Fragment>
{error && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{items.length === 0 ? (
<Fragment>
const queryParams = parseNamespacedQueryString(qsConfig, location.search);
const itemDisplayName = ucFirst(pluralize(itemName));
const itemDisplayNamePlural = ucFirst(itemNamePlural || pluralize(itemName));
const dataListLabel = i18n._(t`${itemDisplayName} List`);
const emptyContentMessage = i18n._(t`Please add ${itemDisplayNamePlural} to populate this list `);
const emptyContentTitle = i18n._(t`No ${itemDisplayNamePlural} Found `);
let Content;
if (contentLoading && items.length <= 0) {
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>
{emptyStateControls}
</EmptyStateControlsWrapper>
)}
{emptyStateControls && (
<div css="border-bottom: 1px solid #d2d2d2" />
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
{i18n._(t`No ${ucFirst(itemNamePlural || pluralize(itemName))} Found `)}
</Title>
<EmptyStateBody>
{i18n._(t`Please add ${ucFirst(itemNamePlural || pluralize(itemName))} to populate this list `)}
</EmptyStateBody>
</EmptyState>
</Fragment>
) : (
<Fragment>
{renderToolbar({
sortedColumnKey: orderBy,
sortOrder,
columns,
onSearch: () => { },
onSort: this.handleSort,
})}
<DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}>
{items.map(item => (renderItem ? renderItem(item) : (
<PaginatedDataListItem key={item.id} item={item} />
)))}
</DataList>
<Pagination
variant="bottom"
itemCount={itemCount}
page={queryParams.page || 1}
perPage={queryParams.page_size}
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>
)}
)}
{Content}
</Fragment>
);
}
return (
<Fragment>
{renderToolbar({
sortedColumnKey: orderBy,
sortOrder,
columns,
onSearch: () => { },
onSort: this.handleSort,
})}
{Content}
<Pagination
variant="bottom"
itemCount={itemCount}
page={queryParams.page || 1}
perPage={queryParams.page_size}
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>
);
}
@ -178,14 +173,18 @@ PaginatedDataList.propTypes = {
})),
showPageSizeOptions: PropTypes.bool,
renderToolbar: PropTypes.func,
contentLoading: PropTypes.bool,
contentError: PropTypes.bool,
};
PaginatedDataList.defaultProps = {
renderItem: null,
contentLoading: false,
contentError: false,
toolbarColumns: [],
itemName: 'item',
itemNamePlural: '',
showPageSizeOptions: true,
renderItem: (item) => (<PaginatedDataListItem key={item.id} item={item} />),
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';
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>
);
export const ConfigProvider = ConfigContext.Provider;
export const Config = 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 './app.scss';
import { Config } from './contexts/Config';
import { BrandName } from './variables';
import Background from './components/Background';
import NotifyAndRedirect from './components/NotifyAndRedirect';
import RootProvider from './RootProvider';
import App from './App';
import { BrandName } from './variables';
import { isAuthenticated } from './util/auth';
import Applications from './pages/Applications';
import Credentials from './pages/Credentials';
@ -52,185 +49,188 @@ export function main (render) {
const el = document.getElementById('app');
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(
<RootProvider>
<I18n>
{({ i18n }) => (
<Background>
<Switch>
<Route
exact
strict
path="/*/"
render={({ history: { location: { pathname, search, hash } } }) => (
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
)}
/>
<Route
path="/login"
render={() => (
<Config>
{({ custom_logo, custom_login_info, fetchMe, updateConfig }) => (
<Login
logo={custom_logo}
loginInfo={custom_login_info}
fetchMe={fetchMe}
updateConfig={updateConfig}
/>
)}
</Config>
)}
/>
<Route exact path="/" render={() => <Redirect to="/home" />} />
<Route
render={() => (
<App
navLabel={i18n._(t`Primary Navigation`)}
routeGroups={[
{
groupTitle: i18n._(t`Views`),
groupId: 'views_group',
routes: [
{
title: i18n._(t`Dashboard`),
path: '/home',
component: Dashboard
},
{
title: i18n._(t`Jobs`),
path: '/jobs',
component: Jobs
},
{
title: i18n._(t`Schedules`),
path: '/schedules',
component: Schedules
},
{
title: i18n._(t`My View`),
path: '/portal',
component: Portal
},
],
},
{
groupTitle: i18n._(t`Resources`),
groupId: 'resources_group',
routes: [
{
title: i18n._(t`Templates`),
path: '/templates',
component: Templates
},
{
title: i18n._(t`Credentials`),
path: '/credentials',
component: Credentials
},
{
title: i18n._(t`Projects`),
path: '/projects',
component: Projects
},
{
title: i18n._(t`Inventories`),
path: '/inventories',
component: Inventories
},
{
title: i18n._(t`Inventory Scripts`),
path: '/inventory_scripts',
component: InventoryScripts
},
],
},
{
groupTitle: i18n._(t`Access`),
groupId: 'access_group',
routes: [
{
title: i18n._(t`Organizations`),
path: '/organizations',
component: Organizations
},
{
title: i18n._(t`Users`),
path: '/users',
component: Users
},
{
title: i18n._(t`Teams`),
path: '/teams',
component: Teams
},
],
},
{
groupTitle: i18n._(t`Administration`),
groupId: 'administration_group',
routes: [
{
title: i18n._(t`Credential Types`),
path: '/credential_types',
component: CredentialTypes
},
{
title: i18n._(t`Notifications`),
path: '/notification_templates',
component: NotificationTemplates
},
{
title: i18n._(t`Management Jobs`),
path: '/management_jobs',
component: ManagementJobs
},
{
title: i18n._(t`Instance Groups`),
path: '/instance_groups',
component: InstanceGroups
},
{
title: i18n._(t`Integrations`),
path: '/applications',
component: Applications
},
],
},
{
groupTitle: i18n._(t`Settings`),
groupId: 'settings_group',
routes: [
{
title: i18n._(t`Authentication`),
path: '/auth_settings',
component: AuthSettings
},
{
title: i18n._(t`Jobs`),
path: '/jobs_settings',
component: JobsSettings
},
{
title: i18n._(t`System`),
path: '/system_settings',
component: SystemSettings
},
{
title: i18n._(t`User Interface`),
path: '/ui_settings',
component: UISettings
},
{
title: i18n._(t`License`),
path: '/license',
component: License
},
],
},
]}
render={({ routeGroups }) => (
<Switch>
{routeGroups
{!isAuthenticated() ? loginRoutes : (
<Switch>
{removeTrailingSlash}
<Route path="/login" render={defaultRedirect} />
<Route exact path="/" render={defaultRedirect} />
<Route
render={() => (
<App
navLabel={i18n._(t`Primary Navigation`)}
routeGroups={[
{
groupTitle: i18n._(t`Views`),
groupId: 'views_group',
routes: [
{
title: i18n._(t`Dashboard`),
path: '/home',
component: Dashboard
},
{
title: i18n._(t`Jobs`),
path: '/jobs',
component: Jobs
},
{
title: i18n._(t`Schedules`),
path: '/schedules',
component: Schedules
},
{
title: i18n._(t`My View`),
path: '/portal',
component: Portal
},
],
},
{
groupTitle: i18n._(t`Resources`),
groupId: 'resources_group',
routes: [
{
title: i18n._(t`Templates`),
path: '/templates',
component: Templates
},
{
title: i18n._(t`Credentials`),
path: '/credentials',
component: Credentials
},
{
title: i18n._(t`Projects`),
path: '/projects',
component: Projects
},
{
title: i18n._(t`Inventories`),
path: '/inventories',
component: Inventories
},
{
title: i18n._(t`Inventory Scripts`),
path: '/inventory_scripts',
component: InventoryScripts
},
],
},
{
groupTitle: i18n._(t`Access`),
groupId: 'access_group',
routes: [
{
title: i18n._(t`Organizations`),
path: '/organizations',
component: Organizations
},
{
title: i18n._(t`Users`),
path: '/users',
component: Users
},
{
title: i18n._(t`Teams`),
path: '/teams',
component: Teams
},
],
},
{
groupTitle: i18n._(t`Administration`),
groupId: 'administration_group',
routes: [
{
title: i18n._(t`Credential Types`),
path: '/credential_types',
component: CredentialTypes
},
{
title: i18n._(t`Notifications`),
path: '/notification_templates',
component: NotificationTemplates
},
{
title: i18n._(t`Management Jobs`),
path: '/management_jobs',
component: ManagementJobs
},
{
title: i18n._(t`Instance Groups`),
path: '/instance_groups',
component: InstanceGroups
},
{
title: i18n._(t`Integrations`),
path: '/applications',
component: Applications
},
],
},
{
groupTitle: i18n._(t`Settings`),
groupId: 'settings_group',
routes: [
{
title: i18n._(t`Authentication`),
path: '/auth_settings',
component: AuthSettings
},
{
title: i18n._(t`Jobs`),
path: '/jobs_settings',
component: JobsSettings
},
{
title: i18n._(t`System`),
path: '/system_settings',
component: SystemSettings
},
{
title: i18n._(t`User Interface`),
path: '/ui_settings',
component: UISettings
},
{
title: i18n._(t`License`),
path: '/license',
component: License
},
],
},
]}
render={({ routeGroups }) => (
routeGroups
.reduce((allRoutes, { routes }) => allRoutes.concat(routes), [])
.map(({ component: PageComponent, path }) => (
<Route
@ -241,15 +241,12 @@ export function main (render) {
)}
/>
))
.concat([
<NotifyAndRedirect key="redirect" to="/" />
])}
</Switch>
)}
/>
)}
/>
</Switch>
)}
/>
)}
/>
</Switch>
)}
</Background>
)}
</I18n>

View File

@ -7,13 +7,10 @@ import {
LoginForm,
LoginPage as PFLoginPage,
} from '@patternfly/react-core';
import { withRootDialog } from '../contexts/RootDialog';
import { withNetwork } from '../contexts/Network';
import { RootAPI } from '../api';
import { BrandName } from '../variables';
import logoImg from '../../images/brand-logo.svg';
import brandLogo from '../../images/brand-logo.svg';
const LoginPage = styled(PFLoginPage)`
& .pf-c-brand {
@ -28,80 +25,122 @@ class AWXLogin extends Component {
this.state = {
username: '',
password: '',
isInputValid: true,
isLoading: false,
isAuthenticated: false
authenticationError: false,
validationError: false,
isAuthenticating: false,
isLoading: true,
logo: null,
loginInfo: null,
};
this.onChangeUsername = this.onChangeUsername.bind(this);
this.onChangePassword = this.onChangePassword.bind(this);
this.onLoginButtonClick = this.onLoginButtonClick.bind(this);
this.handleChangeUsername = this.handleChangeUsername.bind(this);
this.handleChangePassword = this.handleChangePassword.bind(this);
this.handleLoginButtonClick = this.handleLoginButtonClick.bind(this);
this.loadCustomLoginInfo = this.loadCustomLoginInfo.bind(this);
}
onChangeUsername (value) {
this.setState({ username: value, isInputValid: true });
async componentDidMount () {
await this.loadCustomLoginInfo();
}
onChangePassword (value) {
this.setState({ password: value, isInputValid: true });
async loadCustomLoginInfo () {
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) {
const { username, password, isLoading } = this.state;
const { handleHttpError, clearRootDialogMessage, fetchMe, updateConfig } = this.props;
async handleLoginButtonClick (event) {
const { username, password, isAuthenticating } = this.state;
event.preventDefault();
if (isLoading) {
if (isAuthenticating) {
return;
}
clearRootDialogMessage();
this.setState({ isLoading: true });
this.setState({ authenticationError: false, isAuthenticating: true });
try {
const { data } = await RootAPI.login(username, password);
updateConfig(data);
await fetchMe();
this.setState({ isAuthenticated: true, isLoading: false });
} catch (error) {
handleHttpError(error) || this.setState({ isInputValid: false, isLoading: false });
// note: if authentication is successful, the appropriate cookie will be set automatically
// and isAuthenticated() (the source of truth) will start returning true.
await RootAPI.login(username, password);
} catch (err) {
if (err && err.response && err.response.status === 401) {
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 () {
const { username, password, isInputValid, isAuthenticated } = this.state;
const { alt, loginInfo, logo, bodyText: errorMessage, i18n } = this.props;
const logoSrc = logo ? `data:image/jpeg;${logo}` : logoImg;
const {
authenticationError,
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
// passing. Attempting to use BrandName in the template literal results
// in failing tests.
const brandName = BrandName;
if (isAuthenticated) {
if (isLoading) {
return null;
}
if (isAuthenticated()) {
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 (
<LoginPage
brandImgSrc={logoSrc}
brandImgSrc={logo}
brandImgAlt={alt || brandName}
loginTitle={i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)}
textContent={loginInfo}
>
<LoginForm
className={errorMessage && 'pf-m-error'}
className={(authenticationError || validationError) ? 'pf-m-error' : ''}
usernameLabel={i18n._(t`Username`)}
passwordLabel={i18n._(t`Password`)}
showHelperText={!isInputValid || !!errorMessage}
helperText={errorMessage || i18n._(t`Invalid username or password. Please try again.`)}
showHelperText={(authenticationError || validationError)}
helperText={helperText}
usernameValue={username}
passwordValue={password}
isValidUsername={isInputValid}
isValidPassword={isInputValid}
onChangeUsername={this.onChangeUsername}
onChangePassword={this.onChangePassword}
onLoginButtonClick={this.onLoginButtonClick}
isValidUsername={!validationError}
isValidPassword={!validationError}
onChangeUsername={this.handleChangeUsername}
onChangePassword={this.handleChangePassword}
onLoginButtonClick={this.handleLoginButtonClick}
/>
</LoginPage>
);
@ -109,4 +148,4 @@ class AWXLogin extends Component {
}
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 { Config } from '../../contexts/Config';
import { NetworkProvider } from '../../contexts/Network';
import { withRootDialog } from '../../contexts/RootDialog';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import OrganizationsList from './screens/OrganizationsList';
@ -49,7 +46,7 @@ class Organizations extends Component {
}
render () {
const { match, history, location, setRootDialogMessage, i18n } = this.props;
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
@ -66,34 +63,17 @@ class Organizations extends Component {
/>
<Route
path={`${match.path}/:id`}
render={({ match: newRouteMatch }) => (
<NetworkProvider
handle404={() => {
history.replace('/organizations');
setRootDialogMessage({
title: '404',
bodyText: (
<Fragment>
{i18n._(t`Cannot find organization with ID`)}
<strong>{` ${newRouteMatch.params.id}`}</strong>
.
</Fragment>
),
variant: 'warning'
});
}}
>
<Config>
{({ me }) => (
<Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
</NetworkProvider>
render={() => (
<Config>
{({ me }) => (
<Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
)}
/>
<Route
@ -109,4 +89,4 @@ class Organizations extends Component {
}
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 { withNetwork } from '../../../contexts/Network';
import { InstanceGroupsAPI } from '../../../api';
const getInstanceGroups = async (params) => InstanceGroupsAPI.read(params);
@ -66,4 +64,4 @@ InstanceGroupsLookup.defaultProps = {
tooltip: '',
};
export default withI18n()(withNetwork(InstanceGroupsLookup));
export default withI18n()(InstanceGroupsLookup);

View File

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

View File

@ -2,13 +2,17 @@ import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../../../../components/AlertModal';
import PaginatedDataList, { ToolbarAddButton } from '../../../../components/PaginatedDataList';
import DataListToolbar from '../../../../components/DataListToolbar';
import OrganizationAccessItem from '../../components/OrganizationAccessItem';
import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal';
import AddResourceRole from '../../../../components/AddRole/AddResourceRole';
import { withNetwork } from '../../../../contexts/Network';
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
import {
getQSConfig,
encodeQueryString,
parseNamespacedQueryString
} from '../../../../util/qs';
import { Organization } from '../../../../types';
import { OrganizationsAPI, TeamsAPI, UsersAPI } from '../../../../api';
@ -25,183 +29,191 @@ class OrganizationAccess extends React.Component {
constructor (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 = {
isLoading: false,
isInitialized: false,
isAddModalOpen: false,
error: null,
itemCount: 0,
accessRecords: [],
roleToDelete: null,
roleToDeleteAccessRecord: null,
contentError: false,
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 () {
this.readOrgAccessList();
this.loadAccessList();
}
componentDidUpdate (prevProps) {
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 () {
const { organization, handleHttpError, location } = this.props;
this.setState({ isLoading: true });
async loadAccessList () {
const { organization, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ contentError: false, contentLoading: true });
try {
const { data } = await OrganizationsAPI.readAccessList(
organization.id,
parseNamespacedQueryString(QS_CONFIG, location.search)
);
this.setState({
itemCount: data.count || 0,
accessRecords: data.results || [],
isLoading: false,
isInitialized: true,
});
const {
data: {
results: accessRecords = [],
count: itemCount = 0
}
} = await OrganizationsAPI.readAccessList(organization.id, params);
this.setState({ itemCount, accessRecords });
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
confirmRemoveRole (role, accessRecord) {
handleDeleteOpen (deletionRole, deletionRecord) {
this.setState({ deletionRole, deletionRecord });
}
handleDeleteCancel () {
this.setState({ deletionRole: null, deletionRecord: null });
}
handleDeleteErrorClose () {
this.setState({
roleToDelete: role,
roleToDeleteAccessRecord: accessRecord,
deletionError: false,
deletionRecord: null,
deletionRole: null
});
}
cancelRemoveRole () {
this.setState({
roleToDelete: null,
roleToDeleteAccessRecord: null
});
}
async handleDeleteConfirm () {
const { deletionRole, deletionRecord } = this.state;
async removeRole () {
const { handleHttpError } = this.props;
const { roleToDelete: role, roleToDeleteAccessRecord: accessRecord } = this.state;
if (!role || !accessRecord) {
if (!deletionRole || !deletionRecord) {
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 {
if (type === 'teams') {
await TeamsAPI.disassociateRole(role.team_id, role.id);
} else {
await UsersAPI.disassociateRole(accessRecord.id, role.id);
}
await promise.then(this.loadAccessList);
this.setState({
isLoading: false,
roleToDelete: null,
roleToDeleteAccessRecord: null,
deletionRole: null,
deletionRecord: null
});
this.readOrgAccessList();
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
this.setState({
contentLoading: false,
deletionError: true
});
}
}
toggleAddModal () {
const { isAddModalOpen } = this.state;
this.setState({
isAddModalOpen: !isAddModalOpen,
});
handleAddClose () {
this.setState({ isAddModalOpen: false });
}
handleSuccessfulRoleAdd () {
this.toggleAddModal();
this.readOrgAccessList();
handleAddOpen () {
this.setState({ isAddModalOpen: true });
}
handleAddSuccess () {
this.setState({ isAddModalOpen: false });
this.loadAccessList();
}
render () {
const { organization, i18n } = this.props;
const {
isLoading,
isInitialized,
accessRecords,
contentError,
contentLoading,
deletionRole,
deletionRecord,
deletionError,
itemCount,
isAddModalOpen,
accessRecords,
roleToDelete,
roleToDeleteAccessRecord,
error,
} = this.state;
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 (
<Fragment>
{isLoading && (<div>Loading...</div>)}
{roleToDelete && (
<DeleteRoleConfirmationModal
role={roleToDelete}
username={roleToDeleteAccessRecord.username}
onCancel={this.cancelRemoveRole}
onConfirm={this.removeRole}
/>
)}
{isInitialized && (
<PaginatedDataList
items={accessRecords}
itemCount={itemCount}
itemName="role"
qsConfig={QS_CONFIG}
toolbarColumns={[
{ name: i18n._(t`Name`), key: 'first_name', isSortable: true },
{ name: i18n._(t`Username`), key: 'username', isSortable: true },
{ name: i18n._(t`Last Name`), key: 'last_name', isSortable: true },
]}
renderToolbar={(props) => (
<DataListToolbar
{...props}
additionalControls={canEdit ? [
<ToolbarAddButton key="add" onClick={this.toggleAddModal} />
] : null}
/>
)}
renderItem={accessRecord => (
<OrganizationAccessItem
key={accessRecord.id}
accessRecord={accessRecord}
onRoleDelete={this.confirmRemoveRole}
/>
)}
/>
)}
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={accessRecords}
itemCount={itemCount}
itemName="role"
qsConfig={QS_CONFIG}
toolbarColumns={[
{ name: i18n._(t`Name`), key: 'first_name', isSortable: true },
{ name: i18n._(t`Username`), key: 'username', isSortable: true },
{ name: i18n._(t`Last Name`), key: 'last_name', isSortable: true },
]}
renderToolbar={(props) => (
<DataListToolbar
{...props}
additionalControls={canEdit ? [
<ToolbarAddButton key="add" onClick={this.handleAddOpen} />
] : null}
/>
)}
renderItem={accessRecord => (
<OrganizationAccessItem
key={accessRecord.id}
accessRecord={accessRecord}
onRoleDelete={this.handleDeleteOpen}
/>
)}
/>
{isAddModalOpen && (
<AddResourceRole
onClose={this.toggleAddModal}
onSave={this.handleSuccessfulRoleAdd}
onClose={this.handleAddClose}
onSave={this.handleAddSuccess}
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>
);
}
}
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 { CardBody as PFCardBody, Button } from '@patternfly/react-core';
import styled from 'styled-components';
import { DetailList, Detail } from '../../../../components/DetailList';
import { withNetwork } from '../../../../contexts/Network';
import { ChipGroup, Chip } from '../../../../components/Chip';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { OrganizationsAPI } from '../../../../api';
const CardBody = styled(PFCardBody)`
@ -18,8 +20,9 @@ class OrganizationDetail extends Component {
super(props);
this.state = {
contentError: false,
contentLoading: true,
instanceGroups: [],
error: false
};
this.loadInstanceGroups = this.loadInstanceGroups.bind(this);
}
@ -29,25 +32,23 @@ class OrganizationDetail extends Component {
}
async loadInstanceGroups () {
const {
handleHttpError,
match
} = this.props;
const { match: { params: { id } } } = this.props;
this.setState({ contentLoading: true });
try {
const {
data
} = await OrganizationsAPI.readInstanceGroups(match.params.id);
this.setState({
instanceGroups: [...data.results]
});
const { data: { results = [] } } = await OrganizationsAPI.readInstanceGroups(id);
this.setState({ instanceGroups: [...results] });
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
this.setState({ contentError: true });
} finally {
this.setState({ contentLoading: false });
}
}
render () {
const {
error,
contentLoading,
contentError,
instanceGroups,
} = this.state;
@ -65,6 +66,14 @@ class OrganizationDetail extends Component {
i18n
} = this.props;
if (contentLoading) {
return (<ContentLoading />);
}
if (contentError) {
return (<ContentError />);
}
return (
<CardBody>
<DetailList>
@ -116,10 +125,9 @@ class OrganizationDetail extends Component {
</Button>
</div>
)}
{error ? 'error!' : ''}
</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 OrganizationForm from '../../components/OrganizationForm';
import { Config } from '../../../../contexts/Config';
import { withNetwork } from '../../../../contexts/Network';
import { OrganizationsAPI } from '../../../../api';
class OrganizationEdit extends Component {
@ -22,13 +22,13 @@ class OrganizationEdit extends Component {
}
async handleSubmit (values, groupsToAssociate, groupsToDisassociate) {
const { organization, handleHttpError } = this.props;
const { organization } = this.props;
try {
await OrganizationsAPI.update(organization.id, values);
await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate);
this.handleSuccess();
} catch (err) {
handleHttpError(err) || this.setState({ error: err });
this.setState({ error: err });
}
}
@ -43,8 +43,7 @@ class OrganizationEdit extends Component {
}
async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) {
const { organization, handleHttpError } = this.props;
const { organization } = this.props;
try {
await Promise.all(
groupsToAssociate.map(id => OrganizationsAPI.associateInstanceGroup(organization.id, id))
@ -55,7 +54,7 @@ class OrganizationEdit extends Component {
)
);
} catch (err) {
handleHttpError(err) || this.setState({ error: err });
this.setState({ error: err });
}
}
@ -90,4 +89,4 @@ OrganizationEdit.contextTypes = {
};
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 { number, shape, func, string, bool } from 'prop-types';
import { number, shape, string, bool } from 'prop-types';
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 NotificationListItem from '../../../../components/NotificationsList/NotificationListItem';
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
@ -22,194 +25,159 @@ const COLUMNS = [
class OrganizationNotifications extends Component {
constructor (props) {
super(props);
this.readNotifications = this.readNotifications.bind(this);
this.readSuccessesAndErrors = this.readSuccessesAndErrors.bind(this);
this.toggleNotification = this.toggleNotification.bind(this);
this.state = {
isInitialized: false,
isLoading: false,
error: null,
contentError: false,
contentLoading: true,
toggleError: false,
toggleLoading: false,
itemCount: 0,
notifications: [],
successTemplateIds: [],
errorTemplateIds: [],
};
this.handleNotificationToggle = this.handleNotificationToggle.bind(this);
this.handleNotificationErrorClose = this.handleNotificationErrorClose.bind(this);
this.loadNotifications = this.loadNotifications.bind(this);
}
componentDidMount () {
this.readNotifications();
this.loadNotifications();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readNotifications();
this.loadNotifications();
}
}
async readNotifications () {
const { id, handleHttpError, location } = this.props;
async loadNotifications () {
const { id, location } = this.props;
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 () {
const { handleHttpError, id } = this.props;
const { notifications } = this.state;
if (!notifications.length) {
return;
}
const ids = notifications.map(n => n.id).join(',');
this.setState({ contentError: false, contentLoading: true });
try {
const successTemplatesPromise = OrganizationsAPI.readNotificationTemplatesSuccess(
id,
{ id__in: ids }
);
const errorTemplatesPromise = OrganizationsAPI.readNotificationTemplatesError(
id,
{ id__in: ids }
);
const {
data: {
count: itemCount = 0,
results: notifications = [],
}
} = await OrganizationsAPI.readNotificationTemplates(id, params);
const { data: successTemplates } = await successTemplatesPromise;
const { data: errorTemplates } = await errorTemplatesPromise;
let idMatchParams;
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({
itemCount,
notifications,
successTemplateIds: successTemplates.results.map(s => s.id),
errorTemplateIds: errorTemplates.results.map(e => e.id),
});
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
} catch {
this.setState({ contentError: true });
} finally {
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) => {
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;
this.setState({ toggleLoading: true });
try {
await OrganizationsAPI.associateNotificationTemplatesSuccess(id, notificationId);
this.setState(prevState => ({
successTemplateIds: [...prevState.successTemplateIds, notificationId]
}));
await OrganizationsAPI.updateNotificationTemplateAssociation(
id,
notificationId,
status,
!isCurrentlyOn
);
this.setState(stateUpdateFunction);
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
this.setState({ toggleError: true });
} finally {
this.setState({ toggleLoading: false });
}
}
async disassociateSuccess (notificationId) {
const { id, handleHttpError } = this.props;
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 });
}
handleNotificationErrorClose () {
this.setState({ toggleError: false });
}
render () {
const { canToggleNotifications } = this.props;
const { canToggleNotifications, i18n } = this.props;
const {
notifications,
contentError,
contentLoading,
toggleError,
toggleLoading,
itemCount,
isLoading,
isInitialized,
error,
notifications,
successTemplateIds,
errorTemplateIds,
} = this.state;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return (
<Fragment>
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<PaginatedDataList
items={notifications}
itemCount={itemCount}
itemName="notification"
qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS}
renderItem={(notification) => (
<NotificationListItem
key={notification.id}
notification={notification}
detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={canToggleNotifications}
toggleNotification={this.toggleNotification}
errorTurnedOn={errorTemplateIds.includes(notification.id)}
successTurnedOn={successTemplateIds.includes(notification.id)}
/>
)}
/>
)}
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={notifications}
itemCount={itemCount}
itemName="notification"
qsConfig={QS_CONFIG}
toolbarColumns={COLUMNS}
renderItem={(notification) => (
<NotificationListItem
key={notification.id}
notification={notification}
detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={canToggleNotifications && !toggleLoading}
toggleNotification={this.handleNotificationToggle}
errorTurnedOn={errorTemplateIds.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>
);
}
@ -218,11 +186,10 @@ class OrganizationNotifications extends Component {
OrganizationNotifications.propTypes = {
id: number.isRequired,
canToggleNotifications: bool.isRequired,
handleHttpError: func.isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
};
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 { withRouter } from 'react-router-dom';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import { getQSConfig, parseNamespacedQueryString } from '../../../../util/qs';
import { withNetwork } from '../../../../contexts/Network';
import { OrganizationsAPI } from '../../../../api';
const QS_CONFIG = getQSConfig('team', {
@ -16,32 +15,32 @@ class OrganizationTeams extends React.Component {
constructor (props) {
super(props);
this.readOrganizationTeamsList = this.readOrganizationTeamsList.bind(this);
this.loadOrganizationTeamsList = this.loadOrganizationTeamsList.bind(this);
this.state = {
isInitialized: false,
isLoading: false,
error: null,
contentError: false,
contentLoading: true,
itemCount: 0,
teams: [],
};
}
componentDidMount () {
this.readOrganizationTeamsList();
this.loadOrganizationTeamsList();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readOrganizationTeamsList();
this.loadOrganizationTeamsList();
}
}
async readOrganizationTeamsList () {
const { id, handleHttpError, location } = this.props;
async loadOrganizationTeamsList () {
const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ isLoading: true, error: null });
this.setState({ contentLoading: true, contentError: false });
try {
const {
data: { count = 0, results = [] },
@ -49,38 +48,25 @@ class OrganizationTeams extends React.Component {
this.setState({
itemCount: count,
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 () {
const { teams, itemCount, isLoading, isInitialized, error } = this.state;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
const { contentError, contentLoading, teams, itemCount } = this.state;
return (
<Fragment>
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<PaginatedDataList
items={teams}
itemCount={itemCount}
itemName="team"
qsConfig={QS_CONFIG}
/>
)}
</Fragment>
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={teams}
itemCount={itemCount}
itemName="team"
qsConfig={QS_CONFIG}
/>
);
}
}
@ -90,4 +76,4 @@ OrganizationTeams.propTypes = {
};
export { OrganizationTeams as _OrganizationTeams };
export default withNetwork(withRouter(OrganizationTeams));
export default withRouter(OrganizationTeams);

View File

@ -12,7 +12,6 @@ import {
} from '@patternfly/react-core';
import { Config } from '../../../contexts/Config';
import { withNetwork } from '../../../contexts/Network';
import CardCloseButton from '../../../components/CardCloseButton';
import OrganizationForm from '../components/OrganizationForm';
import { OrganizationsAPI } from '../../../api';
@ -20,29 +19,20 @@ import { OrganizationsAPI } from '../../../api';
class OrganizationAdd extends React.Component {
constructor (props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleSuccess = this.handleSuccess.bind(this);
this.state = {
error: '',
};
this.state = { error: '' };
}
async handleSubmit (values, groupsToAssociate) {
const { handleHttpError } = this.props;
const { history } = this.props;
try {
const { data: response } = await OrganizationsAPI.create(values);
try {
await Promise.all(groupsToAssociate.map(id => OrganizationsAPI
.associateInstanceGroup(response.id, id)));
this.handleSuccess(response.id);
} catch (err) {
handleHttpError(err) || this.setState({ error: err });
}
} catch (err) {
this.setState({ error: err });
await Promise.all(groupsToAssociate.map(id => OrganizationsAPI
.associateInstanceGroup(response.id, id)));
history.push(`/organizations/${response.id}`);
} catch (error) {
this.setState({ error });
}
}
@ -51,11 +41,6 @@ class OrganizationAdd extends React.Component {
history.push('/organizations');
}
handleSuccess (id) {
const { history } = this.props;
history.push(`/organizations/${id}`);
}
render () {
const { error } = this.state;
const { i18n } = this.props;
@ -94,4 +79,4 @@ OrganizationAdd.contextTypes = {
};
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -8,13 +8,13 @@ import {
PageSectionVariants,
} from '@patternfly/react-core';
import { withNetwork } from '../../../contexts/Network';
import PaginatedDataList, {
ToolbarDeleteButton,
ToolbarAddButton
} from '../../../components/PaginatedDataList';
import DataListToolbar from '../../../components/DataListToolbar';
import OrganizationListItem from '../components/OrganizationListItem';
import AlertModal from '../../../components/AlertModal';
import { getQSConfig, parseNamespacedQueryString } from '../../../util/qs';
import { OrganizationsAPI } from '../../../api';
@ -29,29 +29,30 @@ class OrganizationsList extends Component {
super(props);
this.state = {
error: null,
isLoading: true,
isInitialized: false,
contentLoading: true,
contentError: false,
deletionError: false,
organizations: [],
selected: []
selected: [],
itemCount: 0,
actions: null,
};
this.handleSelectAll = this.handleSelectAll.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.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.loadOrganizations = this.loadOrganizations.bind(this);
}
componentDidMount () {
this.fetchOptionsOrganizations();
this.fetchOrganizations();
this.loadOrganizations();
}
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.fetchOrganizations();
this.loadOrganizations();
}
}
@ -72,63 +73,54 @@ class OrganizationsList extends Component {
}
}
handleDeleteErrorClose () {
this.setState({ deletionError: false });
}
async handleOrgDelete () {
const { selected } = this.state;
const { handleHttpError } = this.props;
let errorHandled;
this.setState({ contentLoading: true, deletionError: false });
try {
await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id)));
this.setState({
selected: []
});
this.setState({ selected: [] });
} catch (err) {
errorHandled = handleHttpError(err);
this.setState({ deletionError: true });
} finally {
if (!errorHandled) {
this.fetchOrganizations();
}
await this.loadOrganizations();
}
}
async fetchOrganizations () {
const { handleHttpError, location } = this.props;
async loadOrganizations () {
const { location } = this.props;
const { actions: cachedActions } = this.state;
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 {
const { data } = await OrganizationsAPI.read(params);
const { count, results } = data;
const stateToUpdate = {
const [{ data: { count, results } }, { data: { actions } }] = await promises;
this.setState({
actions,
itemCount: count,
organizations: results,
selected: [],
isLoading: false,
isInitialized: true,
};
this.setState(stateToUpdate);
});
} catch (err) {
handleHttpError(err) || this.setState({ error: true, isLoading: false });
}
}
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 });
this.setState(({ contentError: true }));
} finally {
this.setState({ isLoading: false });
this.setState({ contentLoading: false });
}
}
@ -137,23 +129,26 @@ class OrganizationsList extends Component {
medium,
} = PageSectionVariants;
const {
canAdd,
actions,
itemCount,
error,
isLoading,
isInitialized,
contentError,
contentLoading,
deletionError,
selected,
organizations
organizations,
} = this.state;
const { match, i18n } = this.props;
const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length === organizations.length;
return (
<PageSection variant={medium}>
<Card>
{isInitialized && (
<Fragment>
<PageSection variant={medium}>
<Card>
<PaginatedDataList
contentError={contentError}
contentLoading={contentLoading}
items={organizations}
itemCount={itemCount}
itemName="organization"
@ -196,14 +191,20 @@ class OrganizationsList extends Component {
: null
}
/>
)}
{ isLoading ? <div>loading...</div> : '' }
{ error ? <div>error</div> : '' }
</Card>
</PageSection>
</Card>
</PageSection>
<AlertModal
isOpen={deletionError}
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 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 { t } from '@lingui/macro';
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 TemplatesList from './TemplatesList';
@ -21,32 +19,12 @@ class Templates extends Component {
}
render () {
const { match, history, setRootDialogMessage, i18n } = this.props;
const { match } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<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
path={`${match.path}`}
render={() => (
@ -60,4 +38,4 @@ class Templates extends Component {
}
export { Templates as _Templates };
export default withI18n()(withRootDialog(withRouter(Templates)));
export default withI18n()(withRouter(Templates));

View File

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