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:
parent
534418c81a
commit
e72f0bcfd4
@ -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
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
`;
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}());
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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(
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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 }
|
||||
} }
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
`;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
233
src/App.jsx
233
src/App.jsx
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
25
src/components/ContentEmpty.jsx
Normal file
25
src/components/ContentEmpty.jsx
Normal 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);
|
26
src/components/ContentError.jsx
Normal file
26
src/components/ContentError.jsx
Normal 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);
|
19
src/components/ContentLoading.jsx
Normal file
19
src/components/ContentLoading.jsx
Normal 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);
|
@ -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));
|
||||
|
@ -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)));
|
@ -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} />),
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
373
src/index.jsx
373
src/index.jsx
@ -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>
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
|
@ -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 };
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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
8
src/util/auth.js
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user