From e72f0bcfd48aad652eae22530a68d31f5318088e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 9 May 2019 15:59:43 -0400 Subject: [PATCH] update content loading and error handling unwind error handling use auth cookie as source of truth, fetch config only when authenticated --- CONTRIBUTING.md | 10 + README.md | 2 +- __tests__/App.test.jsx | 54 +- .../__snapshots__/enzymeHelpers.test.jsx.snap | 14 +- __tests__/components/Lookup.test.jsx | 3 - .../components/NotifyAndRedirect.test.jsx | 19 - __tests__/enzymeHelpers.jsx | 50 +- __tests__/enzymeHelpers.test.jsx | 72 +- __tests__/pages/Login.test.jsx | 306 +- .../Organizations/Organizations.test.jsx | 2 + .../Organization/Organization.test.jsx | 271 +- .../Organization/OrganizationAccess.test.jsx | 107 +- .../Organization/OrganizationDetail.test.jsx | 138 +- .../Organization/OrganizationEdit.test.jsx | 3 - .../OrganizationNotifications.test.jsx | 37 +- .../Organization/OrganizationTeams.test.jsx | 25 +- .../OrganizationAccess.test.jsx.snap | 227 +- .../OrganizationNotifications.test.jsx.snap | 5485 +++++++++-------- .../screens/OrganizationAdd.test.jsx | 73 +- .../screens/OrganizationsList.test.jsx | 50 +- .../pages/Templates/TemplatesList.test.jsx | 70 +- src/App.jsx | 233 +- src/RootProvider.jsx | 12 +- src/api/mixins/Notifications.mixin.js | 30 + src/components/AddRole/AddResourceRole.jsx | 3 +- src/components/ContentEmpty.jsx | 25 + src/components/ContentError.jsx | 26 + src/components/ContentLoading.jsx | 19 + src/components/Lookup/Lookup.jsx | 9 +- src/components/NotifyAndRedirect.jsx | 41 - .../PaginatedDataList/PaginatedDataList.jsx | 137 +- src/contexts/Config.jsx | 139 +- src/contexts/Network.jsx | 80 - src/contexts/RootDialog.jsx | 54 - src/index.jsx | 373 +- src/pages/Login.jsx | 121 +- src/pages/Organizations/Organizations.jsx | 46 +- .../components/InstanceGroupsLookup.jsx | 4 +- .../components/OrganizationForm.jsx | 3 +- .../screens/Organization/Organization.jsx | 136 +- .../Organization/OrganizationAccess.jsx | 260 +- .../Organization/OrganizationDetail.jsx | 40 +- .../screens/Organization/OrganizationEdit.jsx | 13 +- .../OrganizationNotifications.jsx | 261 +- .../Organization/OrganizationTeams.jsx | 62 +- .../Organizations/screens/OrganizationAdd.jsx | 31 +- .../screens/OrganizationsList.jsx | 127 +- src/pages/Templates/Templates.jsx | 26 +- src/pages/Templates/TemplatesList.jsx | 108 +- src/util/auth.js | 8 + 50 files changed, 4721 insertions(+), 4724 deletions(-) delete mode 100644 __tests__/components/NotifyAndRedirect.test.jsx create mode 100644 src/components/ContentEmpty.jsx create mode 100644 src/components/ContentError.jsx create mode 100644 src/components/ContentLoading.jsx delete mode 100644 src/components/NotifyAndRedirect.jsx delete mode 100644 src/contexts/Network.jsx delete mode 100644 src/contexts/RootDialog.jsx create mode 100644 src/util/auth.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2384441812..00a8939685 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index d9ca9e479c..7f1ef077c8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index fb6af10158..0565ac23d2 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -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('', () => { + 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( ', () => { render={({ routeGroups }) => ( routeGroups.map(({ groupId }) => (
)) )} - /> + />, ); // page components @@ -54,12 +71,8 @@ describe('', () => { }); 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(, { context: { config } }); + const wrapper = mountWithContexts(); + wrapper.update(); // open about modal const aboutDropdown = 'Dropdown QuestionCircleIcon'; @@ -67,9 +80,11 @@ describe('', () => { 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('', () => { done(); }); - test('onNavToggle sets state.isNavOpen to opposite', () => { + test('handleNavToggle sets state.isNavOpen to opposite', () => { const appWrapper = mountWithContexts().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(, { - context: { network: { handleHttpError: () => {} } } - }).find('App'); - - appWrapper.instance().onLogout(); + const appWrapper = mountWithContexts().find('App'); + appWrapper.instance().handleLogout(); await asyncFlush(); expect(RootAPI.logout).toHaveBeenCalledTimes(1); - done(); }); }); diff --git a/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap b/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap index a1f251ed2d..6e8daa0552 100644 --- a/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap +++ b/__tests__/__snapshots__/enzymeHelpers.test.jsx.snap @@ -2,20 +2,16 @@ exports[`mountWithContexts injected ConfigProvider should mount and render with custom Config value 1`] = ` - -
- Fizz - 1.1 -
-
+
+ Fizz + 1.1 +
`; exports[`mountWithContexts injected ConfigProvider should mount and render with default values 1`] = ` - -
- +
`; diff --git a/__tests__/components/Lookup.test.jsx b/__tests__/components/Lookup.test.jsx index 9fee58c795..eb7ccd98d7 100644 --- a/__tests__/components/Lookup.test.jsx +++ b/__tests__/components/Lookup.test.jsx @@ -19,7 +19,6 @@ describe('', () => { getItems={() => { }} columns={mockColumns} sortedColumnKey="name" - handleHttpError={() => {}} /> ); }); @@ -34,7 +33,6 @@ describe('', () => { getItems={() => ({ data: { results: [{ name: 'test instance', id: 1 }] } })} columns={mockColumns} sortedColumnKey="name" - handleHttpError={() => {}} /> ).find('Lookup'); @@ -57,7 +55,6 @@ describe('', () => { getItems={() => { }} columns={mockColumns} sortedColumnKey="name" - handleHttpError={() => {}} /> ).find('Lookup'); expect(spy).not.toHaveBeenCalled(); diff --git a/__tests__/components/NotifyAndRedirect.test.jsx b/__tests__/components/NotifyAndRedirect.test.jsx deleted file mode 100644 index df4845359f..0000000000 --- a/__tests__/components/NotifyAndRedirect.test.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { mountWithContexts } from '../enzymeHelpers'; - -import { _NotifyAndRedirect } from '../../src/components/NotifyAndRedirect'; - -describe('', () => { - 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(); - }); -}); diff --git a/__tests__/enzymeHelpers.jsx b/__tests__/enzymeHelpers.jsx index b7114e9cdf..1ba808266d 100644 --- a/__tests__/enzymeHelpers.jsx +++ b/__tests__/enzymeHelpers.jsx @@ -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 ( - - <_NetworkProvider value={network}> - - {component} - - - + + {component} + ); } } @@ -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); }()); diff --git a/__tests__/enzymeHelpers.test.jsx b/__tests__/enzymeHelpers.test.jsx index 6c8c16a99a..0b830e8634 100644 --- a/__tests__/enzymeHelpers.test.jsx +++ b/__tests__/enzymeHelpers.test.jsx @@ -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 }) => ( -
- {title} - -
- ); - const Bar = withRootDialog(Foo); - const wrapper = mountWithContexts(); - - 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 }) => ( -
- {title} - -
- ); - const Bar = withRootDialog(Foo); - const wrapper = mountWithContexts(, { 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 (
{text}
); - } - - const wrapper = mountWithContexts( - - ); - 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(
); 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(); } }); diff --git a/__tests__/pages/Login.test.jsx b/__tests__/pages/Login.test.jsx index 6489869b77..8edbf7a318 100644 --- a/__tests__/pages/Login.test.jsx +++ b/__tests__/pages/Login.test.jsx @@ -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('', () => { - 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(, { 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( + 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(); - 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + true} /> + ); + await waitForElement(loginWrapper, 'Redirect', (el) => el.length === 1); + await waitForElement(loginWrapper, 'Redirect', (el) => el.props().to === '/'); + done(); }); }); diff --git a/__tests__/pages/Organizations/Organizations.test.jsx b/__tests__/pages/Organizations/Organizations.test.jsx index b772b462d0..28c6cf9f1a 100644 --- a/__tests__/pages/Organizations/Organizations.test.jsx +++ b/__tests__/pages/Organizations/Organizations.test.jsx @@ -2,6 +2,8 @@ import React from 'react'; import { mountWithContexts } from '../../enzymeHelpers'; import Organizations from '../../../src/pages/Organizations/Organizations'; +jest.mock('../../../src/api'); + describe('', () => { test('initially renders succesfully', () => { mountWithContexts( diff --git a/__tests__/pages/Organizations/screens/Organization/Organization.test.jsx b/__tests__/pages/Organizations/screens/Organization/Organization.test.jsx index 95787ba4e2..43116b80a0 100644 --- a/__tests__/pages/Organizations/screens/Organization/Organization.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/Organization.test.jsx @@ -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('', () => { - 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('', () => { test('initially renders succesfully', () => { - mountWithContexts(); + OrganizationsAPI.readDetail.mockResolvedValue(mockDetails); + OrganizationsAPI.read.mockImplementation(getOrganizations); + mountWithContexts( {}} 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( - {}} - />, - { - 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( {}} 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( {}} 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(); }); }); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx index 64b358c78f..ae7ce91ae3 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx @@ -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('', () => { - const network = {}; const organization = { id: 1, name: 'Default', @@ -64,7 +62,9 @@ describe('', () => { }; beforeEach(() => { - OrganizationsAPI.readAccessList.mockReturnValue({ data }); + OrganizationsAPI.readAccessList.mockResolvedValue({ data }); + TeamsAPI.disassociateRole.mockResolvedValue({}); + UsersAPI.disassociateRole.mockResolvedValue({}); }); afterEach(() => { @@ -72,31 +72,21 @@ describe('', () => { }); test('initially renders succesfully', () => { - const wrapper = mountWithContexts( - , - { context: { network } } - ); + const wrapper = mountWithContexts(); expect(wrapper.find('OrganizationAccess')).toMatchSnapshot(); }); - test('should fetch and display access records on mount', async () => { - const wrapper = mountWithContexts( - , - { 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(); + 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( - , - { context: { network } } - ); + test('should open confirmation dialog when deleting role', async (done) => { + const wrapper = mountWithContexts(); await sleep(0); wrapper.update(); @@ -105,18 +95,16 @@ describe('', () => { 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( - , - { context: { network } } - ); + it('should close dialog when cancel button clicked', async (done) => { + const wrapper = mountWithContexts(); await sleep(0); wrapper.update(); const button = wrapper.find('ChipButton').at(0); @@ -125,55 +113,50 @@ describe('', () => { 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( - , - { context: { network } } - ); + it('should delete user role', async (done) => { + const wrapper = mountWithContexts(); + 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( - , - { context: { network } } - ); + it('should delete team role', async (done) => { + const wrapper = mountWithContexts(); + 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(); }); }); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx index ca7676db42..6e9f675388 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx @@ -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('', () => { - const mockDetails = { + const mockOrganization = { name: 'Foo', description: 'Bar', custom_virtualenv: 'Fizz', @@ -19,107 +19,75 @@ describe('', () => { } } }; + 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( - - ); + mountWithContexts(); }); test('should request instance groups from api', () => { - mountWithContexts( - , { context: { - network: { handleHttpError: () => {} } - } } - ).find('OrganizationDetail'); - + mountWithContexts(); 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( - , { context: { - network: { handleHttpError: () => {} } - } } - ).find('OrganizationDetail'); - - await OrganizationsAPI.readInstanceGroups(); - expect(wrapper.state().instanceGroups).toEqual(mockInstanceGroups); - }); - - test('should render Details', async () => { - const wrapper = mountWithContexts( - + ); - - 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( - - ).find('OrganizationDetail'); - const editButton = wrapper.find('Button'); - expect((editButton).prop('to')).toBe('/organizations/undefined/edit'); + test('should render Details', async (done) => { + const wrapper = mountWithContexts(); + 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(); + 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( - - ).find('OrganizationDetail'); - - const editLink = wrapper - .findWhere(node => node.props().to === '/organizations/undefined/edit'); - expect(editLink.length).toBe(0); + const wrapper = mountWithContexts(); + await waitForElement(wrapper, 'OrganizationDetail'); + expect(wrapper.find('OrganizationDetail Button').length).toBe(0); + done(); }); }); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx index 2b2d2eeea8..25df2eed0a 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx @@ -37,7 +37,6 @@ describe('', () => { organization={mockData} />, { context: { network: { api, - handleHttpError: () => {} } } } ); @@ -57,7 +56,6 @@ describe('', () => { organization={mockData} />, { context: { network: { api, - handleHttpError: () => {} } } } ); @@ -84,7 +82,6 @@ describe('', () => { />, { context: { network: { api: { api }, - handleHttpError: () => {} }, router: { history } } } diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx index bda435749e..8c29971496 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationNotifications.test.jsx @@ -8,7 +8,6 @@ jest.mock('../../../../../src/api'); describe('', () => { let data; - const network = {}; beforeEach(() => { data = { @@ -40,8 +39,7 @@ describe('', () => { test('initially renders succesfully', async () => { const wrapper = mountWithContexts( - , - { context: { network } } + ); await sleep(0); wrapper.update(); @@ -50,10 +48,7 @@ describe('', () => { test('should render list fetched of items', async () => { const wrapper = mountWithContexts( - , - { - context: { network } - } + ); await sleep(0); wrapper.update(); @@ -71,10 +66,7 @@ describe('', () => { test('should enable success notification', async () => { const wrapper = mountWithContexts( - , - { - context: { network } - } + ); await sleep(0); wrapper.update(); @@ -84,7 +76,7 @@ describe('', () => { ).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('', () => { test('should enable error notification', async () => { const wrapper = mountWithContexts( - , - { - context: { network } - } + ); await sleep(0); wrapper.update(); @@ -107,7 +96,7 @@ describe('', () => { ).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('', () => { test('should disable success notification', async () => { const wrapper = mountWithContexts( - , - { - context: { network } - } + ); await sleep(0); wrapper.update(); @@ -130,7 +116,7 @@ describe('', () => { ).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('', () => { test('should disable error notification', async () => { const wrapper = mountWithContexts( - , - { - context: { network } - } + ); await sleep(0); wrapper.update(); @@ -153,7 +136,7 @@ describe('', () => { ).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( diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx index 3f4321009d..fbe4323863 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationTeams.test.jsx @@ -20,22 +20,21 @@ const listData = { } }; -beforeEach(() => { - OrganizationsAPI.readTeams.mockResolvedValue(listData); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - describe('', () => { + 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('', () => { , { context: { - network: {} } - } + /> ).find('OrganizationTeams'); expect(OrganizationsAPI.readTeams).toHaveBeenCalledWith(1, { page: 1, @@ -61,9 +58,7 @@ describe('', () => { , { context: { - network: { handleHttpError: () => {} } } - } + /> ); await sleep(0); diff --git a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap index f520ba2c1e..2439871af1 100644 --- a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap +++ b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationAccess.test.jsx.snap @@ -2,7 +2,6 @@ exports[` initially renders succesfully 1`] = ` initially renders succesfully 1`] = ` } } > -
- Loading... -
+ + + + + + + + + +
+ +

+ Loading... +

+
+
+
+
+
+
+
+
+
+
+
+ <_default + isOpen={false} + onClose={[Function]} + title="Error!" + variant="danger" + > + + } + > + + + +
`; diff --git a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap index 56b09fb904..82257e5a6e 100644 --- a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap +++ b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap @@ -2,153 +2,245 @@ exports[` initially renders succesfully 1`] = ` - - - - - + - - - + - - - + + initially renders succesfully 1`] = ` }, ] } + onSearch={[Function]} + onSort={[Function]} + sortOrder="ascending" + sortedColumnKey="name" > - - + initially renders succesfully 1`] = ` }, ] } + i18n={"/i18n/"} + isAllSelected={false} + isCompact={false} + noLeftMargin={false} + onCompact={null} + onExpand={null} + onSearch={[Function]} + onSelectAll={null} + onSort={[Function]} + showSelectAll={false} + sortOrder="ascending" + sortedColumnKey="name" > - + - - +
- + -
- - + -
- + initially renders succesfully 1`] = ` } forwardedRef={null} > -
- - - -
- - - -
- - Modified - , - - Created - , - ] - } - isOpen={false} - onSelect={[Function]} - onToggle={[Function]} - toggle={ - - Name - - } - > - - Modified - , - - Created - , - ] - } - forwardedComponent={ - Object { - "$$typeof": Symbol(react.forward_ref), - "attrs": Array [], - "componentStyle": ComponentStyle { - "componentId": "Search__Dropdown-sc-1dwuww3-2", - "isStatic": true, - "lastClassName": "kcnywV", - "rules": Array [ - "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", - ], - }, - "displayName": "Search__Dropdown", - "foldedComponentIds": Array [], - "render": [Function], - "styledComponentId": "Search__Dropdown-sc-1dwuww3-2", - "target": [Function], - "toString": [Function], - "warnTooManyClasses": [Function], - "withComponent": [Function], - } - } - forwardedRef={null} - isOpen={false} - onSelect={[Function]} - onToggle={[Function]} - toggle={ - - Name - - } - > - - Modified - , - - Created - , - ] - } - isOpen={false} - isPlain={false} - onSelect={[Function]} - onToggle={[Function]} - position="left" - toggle={ - - Name - - } - > -
- - -
- } - > - - -
- } - > - - - -
- -
- - - - - - - - - - - - - - -
- - - -
- -
- - -
- - - - - -
-
-
-
- - - -
- -
- - - - initially renders succesfully 1`] = ` isOpen={false} onSelect={[Function]} onToggle={[Function]} - style={ - Object { - "marginRight": "20px", - } - } toggle={ initially renders succesfully 1`] = ` "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "componentId": "Sort__Dropdown-sc-21g5aw-0", + "componentId": "Search__Dropdown-sc-1dwuww3-2", "isStatic": true, - "lastClassName": "kdSQuN", + "lastClassName": "kcnywV", "rules": Array [ "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", ], }, - "displayName": "Sort__Dropdown", + "displayName": "Search__Dropdown", "foldedComponentIds": Array [], "render": [Function], - "styledComponentId": "Sort__Dropdown-sc-21g5aw-0", + "styledComponentId": "Search__Dropdown-sc-1dwuww3-2", "target": [Function], "toString": [Function], "warnTooManyClasses": [Function], @@ -1209,16 +585,11 @@ exports[` initially renders succesfully 1`] = ` isOpen={false} onSelect={[Function]} onToggle={[Function]} - style={ - Object { - "marginRight": "20px", - } - } toggle={ initially renders succesfully 1`] = ` } > initially renders succesfully 1`] = ` onSelect={[Function]} onToggle={[Function]} position="left" - style={ - Object { - "marginRight": "20px", - } - } toggle={ initially renders succesfully 1`] = ` } >
initially renders succesfully 1`] = ` onToggle={[Function]} parentRef={
- - + + + + + + + + initially renders succesfully 1`] = ` } forwardedRef={null} onClick={[Function]} - variant="plain" + variant="tertiary" > - - - - -
- - - :not(:first-child){margin-left:20px;}", - ], - }, - "displayName": "DataListToolbar__AdditionalControlsWrapper", - "foldedComponentIds": Array [], - "render": [Function], - "styledComponentId": "DataListToolbar__AdditionalControlsWrapper-ajzso8-5", - "target": "div", - "toString": [Function], - "warnTooManyClasses": [Function], - "withComponent": [Function], - } - } - forwardedRef={null} - > -
- - -
+ +
+ + + +
+
-
+ + +
+ + + + + +
+
- -
- -
- - - - - - -
    - - - - -
  • - -
    + + + - - + +
    + + + + + Modified + , + + Created + , + ] + } + isOpen={false} + onSelect={[Function]} + onToggle={[Function]} + style={ + Object { + "marginRight": "20px", + } + } + toggle={ + + Name + + } + > + + Modified + , + + Created + , + ] + } + forwardedComponent={ + Object { + "$$typeof": Symbol(react.forward_ref), + "attrs": Array [], + "componentStyle": ComponentStyle { + "componentId": "Sort__Dropdown-sc-21g5aw-0", + "isStatic": true, + "lastClassName": "kdSQuN", + "rules": Array [ + "&&&{> button{min-height:30px;min-width:70px;height:30px;padding:0 10px;margin:0px;> span{width:auto;}> svg{margin:0px;padding-top:3px;padding-left:3px;}}}", + ], + }, + "displayName": "Sort__Dropdown", + "foldedComponentIds": Array [], + "render": [Function], + "styledComponentId": "Sort__Dropdown-sc-21g5aw-0", + "target": [Function], + "toString": [Function], + "warnTooManyClasses": [Function], + "withComponent": [Function], + } + } + forwardedRef={null} + isOpen={false} + onSelect={[Function]} + onToggle={[Function]} + style={ + Object { + "marginRight": "20px", + } + } + toggle={ + + Name + + } + > + + Modified + , + + Created + , + ] + } + isOpen={false} + isPlain={false} + onSelect={[Function]} + onToggle={[Function]} + position="left" + style={ + Object { + "marginRight": "20px", + } + } + toggle={ + + Name + + } + > +
    + + +
    + } + > + + +
    + } + > + + + +
    + + + + + + + + + + + + +
+ + + :not(:first-child){margin-left:20px;}", + ], + }, + "displayName": "DataListToolbar__AdditionalControlsWrapper", + "foldedComponentIds": Array [], + "render": [Function], + "styledComponentId": "DataListToolbar__AdditionalControlsWrapper-ajzso8-5", + "target": "div", + "toString": [Function], + "warnTooManyClasses": [Function], + "withComponent": [Function], + } + } + forwardedRef={null} + > +
+ + +
+
+ +
+ + + +
+ + + + + + +
    + + + + +
  • + +
    + + + + Notification one + + + + email + + , + + + + , + ] + } + key=".0" + rowid="items-list-item-1" + > +
    + + + +
    + + - - Notification one - - - + + Notification one + + + + + + + - email - - , - + + email + + + + +
    +
    +
    +
    + + + +
    + - - + + + + + + + - , - ] - } - key=".0" - rowid="items-list-item-1" - > -
    - - - -
    - - - - - - Notification one - - - - - - - - - - email - - - - -
    -
    -
    -
    - - - -
    - - - - - - - - - + + - -
    -
    -
    -
    -
    - -
    - -
  • -
    -
    -
    -
    - + + + + + + + + + + + + + + + + + + + + + + - - - -
  • + + + Notification two + + + + email + + , + + + + , + ] + } + key=".0" + rowid="items-list-item-2" > - -
    - - + +
    + + - - Notification two - - - + + Notification two + + + + + + + - email - - , - + + email + + + + +
    +
    + + + + + +
    + - - + + + + + + + - , - ] - } - key=".0" - rowid="items-list-item-2" - > -
    - - - -
    - - - - - - Notification two - - - - - - - - - - email - - - - -
    -
    -
    -
    - - - -
    - - - - - - - - - + + - -
    -
    -
    -
    -
    - -
    - -
  • -
    -
    -
    -
    -
-
- <_default + Failure + + + + + + + + + + + + + + + + + + + + + + <_default + itemCount={2} + onPerPageSelect={[Function]} + onSetPage={[Function]} + page={1} + perPage={5} + perPageOptions={ + Array [ + Object { + "title": "5", + "value": 5, + }, + Object { + "title": "10", + "value": 10, + }, + Object { + "title": "20", + "value": 20, + }, + Object { + "title": "50", + "value": 50, + }, + ] + } + variant="bottom" + > + + + initially renders succesfully 1`] = ` }, ] } + titles={ + Object { + "currPage": "Current page", + "items": "items", + "itemsPerPage": "Items per page", + "optionsToggle": "Select", + "pages": "pages", + "paginationTitle": "Pagination", + "perPageSuffix": "per page", + "toFirstPage": "Go to first page", + "toLastPage": "Go to last page", + "toNextPage": "Go to next page", + "toPreviousPage": "Go to previous page", + } + } variant="bottom" > - - +
- initially renders succesfully 1`] = ` }, ] } - titles={ - Object { - "currPage": "Current page", - "items": "items", - "itemsPerPage": "Items per page", - "optionsToggle": "Select", - "pages": "pages", - "paginationTitle": "Pagination", - "perPageSuffix": "per page", - "toFirstPage": "Go to first page", - "toLastPage": "Go to last page", - "toNextPage": "Go to next page", - "toPreviousPage": "Go to previous page", - } - } - variant="bottom" + perPageSuffix="per page" + toggleTemplate={[Function]} + widgetId="pagination-options-menu" > - - - - -
+ + + - + + + + + +
+ +
- -
-
-
- - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + <_default + isOpen={false} + onClose={[Function]} + title="Error!" + variant="danger" + > + + } + > + + + + + + + + + `; diff --git a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx index 69f55fdbcf..e1f6c9c730 100644 --- a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx @@ -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('', () => { - let networkProviderValue; - - beforeEach(() => { - networkProviderValue = { - handleHttpError: () => {} - }; - }); - test('handleSubmit should post to api', () => { - const wrapper = mountWithContexts(, { - context: { network: networkProviderValue } - }); + const wrapper = mountWithContexts(); const updatedOrgData = { name: 'new name', description: 'new description', @@ -35,9 +23,10 @@ describe('', () => { const history = { push: jest.fn(), }; - const wrapper = mountWithContexts(, { - context: { router: { history } } - }); + const wrapper = mountWithContexts( + , + { 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('', () => { const history = { push: jest.fn(), }; - const wrapper = mountWithContexts(, { - context: { router: { history } } - }); + const wrapper = mountWithContexts( + , + { 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('', () => { description: 'new description', custom_virtualenv: 'Buzz', }; - OrganizationsAPI.create.mockReturnValueOnce({ + OrganizationsAPI.create.mockResolvedValueOnce({ data: { id: 5, related: { @@ -73,24 +63,23 @@ describe('', () => { ...orgData, } }); - const wrapper = mountWithContexts(, { - context: { router: { history }, network: networkProviderValue } - }); - wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [], []); - await sleep(0); + const wrapper = mountWithContexts( + , + { 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(, { - 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('', () => { ...orgData, } }); - wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []); - await sleep(0); + const wrapper = mountWithContexts(); + 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(, { - context: { network: networkProviderValue, config } - }).find('AnsibleSelect'); + const wrapper = mountWithContexts( + , + { context: { config } } + ).find('AnsibleSelect'); expect(wrapper.find('FormSelect')).toHaveLength(1); expect(wrapper.find('FormSelectOption')).toHaveLength(2); }); @@ -120,9 +112,10 @@ describe('', () => { const config = { custom_virtualenvs: [], }; - const wrapper = mountWithContexts(, { - context: { network: networkProviderValue, config } - }).find('AnsibleSelect'); + const wrapper = mountWithContexts( + , + { context: { config } } + ).find('AnsibleSelect'); expect(wrapper.find('FormSelect')).toHaveLength(0); }); }); diff --git a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx index c3e38f3a30..90ed0743b4 100644 --- a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx @@ -59,25 +59,13 @@ const mockAPIOrgsList = { describe('', () => { let wrapper; - let api; - - beforeEach(() => { - api = { - getOrganizations: () => {}, - destroyOrganization: jest.fn(), - }; - }); test('initially renders succesfully', () => { - mountWithContexts( - - ); + mountWithContexts(); }); test('Puts 1 selected Org in state when handleSelect is called.', () => { - wrapper = mountWithContexts( - - ).find('OrganizationsList'); + wrapper = mountWithContexts().find('OrganizationsList'); wrapper.setState({ organizations: mockAPIOrgsList.data.results, @@ -91,9 +79,7 @@ describe('', () => { }); test('Puts all Orgs in state when handleSelectAll is called.', () => { - wrapper = mountWithContexts( - - ); + wrapper = mountWithContexts(); const list = wrapper.find('OrganizationsList'); list.setState({ organizations: mockAPIOrgsList.data.results, @@ -108,16 +94,7 @@ describe('', () => { }); test('api is called to delete Orgs for each org in selected.', () => { - const fetchOrganizations = jest.fn(() => wrapper.find('OrganizationsList').setState({ - organizations: [] - })); - wrapper = mountWithContexts( - , { - context: { network: { api } } - } - ); + wrapper = mountWithContexts(); const component = wrapper.find('OrganizationsList'); wrapper.find('OrganizationsList').setState({ organizations: mockAPIOrgsList.data.results, @@ -130,14 +107,10 @@ describe('', () => { 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( - , { - context: { network: { api } } - } - ); + wrapper = mountWithContexts(); wrapper.find('OrganizationsList').setState({ organizations: mockAPIOrgsList.data.results, itemCount: 3, @@ -153,13 +126,9 @@ describe('', () => { const history = createMemoryHistory({ initialEntries: ['organizations?order_by=name&page=1&page_size=5'], }); - const handleError = jest.fn(); wrapper = mountWithContexts( - , { - context: { - router: { history }, network: { api, handleHttpError: handleError } - } - } + , + { context: { router: { history } } } ); await wrapper.setState({ organizations: mockAPIOrgsList.data.results, @@ -173,6 +142,5 @@ describe('', () => { wrapper.update(); const component = wrapper.find('OrganizationsList'); component.instance().handleOrgDelete(); - expect(handleError).toHaveBeenCalled(); }); }); diff --git a/__tests__/pages/Templates/TemplatesList.test.jsx b/__tests__/pages/Templates/TemplatesList.test.jsx index aa071c29ed..38ed7263f8 100644 --- a/__tests__/pages/Templates/TemplatesList.test.jsx +++ b/__tests__/pages/Templates/TemplatesList.test.jsx @@ -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('', () => { + beforeEach(() => { + UnifiedJobTemplatesAPI.read.mockResolvedValue({ + data: { + count: mockTemplates.length, + results: mockTemplates + } + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + test('initially renders succesfully', () => { mountWithContexts( ', () => { /> ); }); + test('Templates are retrieved from the api and the components finishes loading', async (done) => { - const readTemplates = jest.spyOn(_TemplatesList.prototype, 'readUnifiedJobTemplates'); - - const wrapper = mountWithContexts().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(); + 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(); - - 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(); - - 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(); }); }); diff --git a/src/App.jsx b/src/App.jsx index 75afb25e96..5e1ff9a28e 100644 --- a/src/App.jsx +++ b/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 = ( + } + logoProps={{ href: '/' }} + toolbar={( + + )} + /> + ); + + const sidebar = ( + + + {routeGroups.map( + ({ groupId, groupTitle, routes }) => ( + + ) + )} + + + )} + /> + ); return ( - - {({ ansible_version, version, me }) => ( - - {({ - title, - bodyText, - variant = 'info', - clearRootDialogMessage - }) => ( - - {(title || bodyText) && ( - - {i18n._(t`Close`)} - - ]} - > - {bodyText} - - )} - } - logoProps={{ href: '/' }} - toolbar={( - - )} - /> - )} - sidebar={( - - - {routeGroups.map( - ({ groupId, groupTitle, routes }) => ( - - ) - )} - - - )} - /> - )} - > - {render && render({ routeGroups })} - - - - )} - - )} - + + + + {render({ routeGroups })} + + + + + {i18n._(t`Failed to retrieve configuration.`)} + + ); } } export { App as _App }; -export default withI18n()(withNetwork(App)); +export default withI18n()(App); diff --git a/src/RootProvider.jsx b/src/RootProvider.jsx index 1f334c3b4c..9165ae4d23 100644 --- a/src/RootProvider.jsx +++ b/src/RootProvider.jsx @@ -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} > - - - - {children} - - - + {children} ); diff --git a/src/api/mixins/Notifications.mixin.js b/src/api/mixins/Notifications.mixin.js index 4672b9989b..bbc9048ad2 100644 --- a/src/api/mixins/Notifications.mixin.js +++ b/src/api/mixins/Notifications.mixin.js @@ -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; diff --git a/src/components/AddRole/AddResourceRole.jsx b/src/components/AddRole/AddResourceRole.jsx index a282521750..d9dc7642f5 100644 --- a/src/components/AddRole/AddResourceRole.jsx +++ b/src/components/AddRole/AddResourceRole.jsx @@ -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); diff --git a/src/components/ContentEmpty.jsx b/src/components/ContentEmpty.jsx new file mode 100644 index 0000000000..c234845898 --- /dev/null +++ b/src/components/ContentEmpty.jsx @@ -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 = '' }) => ( + + + + {title || i18n._(t`No items found.`)} + + + {message} + + +); + +export { ContentEmpty as _ContentEmpty }; +export default withI18n()(ContentEmpty); diff --git a/src/components/ContentError.jsx b/src/components/ContentError.jsx new file mode 100644 index 0000000000..b5721cc988 --- /dev/null +++ b/src/components/ContentError.jsx @@ -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 }) => ( + + + + {i18n._(t`Something went wrong...`)} + + + {i18n._(t`There was an error loading this content. Please reload the page.`)} + + +); + +export { ContentError as _ContentError }; +export default withI18n()(ContentError); diff --git a/src/components/ContentLoading.jsx b/src/components/ContentLoading.jsx new file mode 100644 index 0000000000..050ec82cb1 --- /dev/null +++ b/src/components/ContentLoading.jsx @@ -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 }) => ( + + + {i18n._(t`Loading...`)} + + +); + +export { ContentLoading as _ContentLoading }; +export default withI18n()(ContentLoading); diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx index 999bcb9e04..9d69ba7049 100644 --- a/src/components/Lookup/Lookup.jsx +++ b/src/components/Lookup/Lookup.jsx @@ -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)); diff --git a/src/components/NotifyAndRedirect.jsx b/src/components/NotifyAndRedirect.jsx deleted file mode 100644 index 19a18ea62c..0000000000 --- a/src/components/NotifyAndRedirect.jsx +++ /dev/null @@ -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: ( - {i18n._(t`Cannot find route ${({location.pathname})}.`)} - ), - variant: 'warning' - }); - - return ( - - ); -}; - -export { NotifyAndRedirect as _NotifyAndRedirect }; -export default withI18n()(withRootDialog(withRouter(NotifyAndRedirect))); diff --git a/src/components/PaginatedDataList/PaginatedDataList.jsx b/src/components/PaginatedDataList/PaginatedDataList.jsx index 8c82774e65..ee59abec79 100644 --- a/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -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 ( - - {error && ( - -
{error.message}
- {error.response && ( -
{error.response.data.detail}
- )} -
// TODO: replace with proper error handling - )} - {items.length === 0 ? ( - + 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 = (); + } else if (contentError) { + Content = (); + } else if (items.length <= 0) { + Content = (); + } else { + Content = ({items.map(renderItem)}); + } + + if (items.length <= 0) { + return ( + + {emptyStateControls && ( {emptyStateControls} + )} + {emptyStateControls && (
- - - - {i18n._(t`No ${ucFirst(itemNamePlural || pluralize(itemName))} Found `)} - - - {i18n._(t`Please add ${ucFirst(itemNamePlural || pluralize(itemName))} to populate this list `)} - - - - ) : ( - - {renderToolbar({ - sortedColumnKey: orderBy, - sortOrder, - columns, - onSearch: () => { }, - onSort: this.handleSort, - })} - - {items.map(item => (renderItem ? renderItem(item) : ( - - )))} - - - - )} + )} + {Content} + + ); + } + + return ( + + {renderToolbar({ + sortedColumnKey: orderBy, + sortOrder, + columns, + onSearch: () => { }, + onSort: this.handleSort, + })} + {Content} + ); } @@ -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) => (), renderToolbar: (props) => (), }; diff --git a/src/contexts/Config.jsx b/src/contexts/Config.jsx index 7660ff4771..231e6f8301 100644 --- a/src/contexts/Config.jsx +++ b/src/contexts/Config.jsx @@ -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 ( - - {children} - - ); - } -} - -export const ConfigProvider = withNetwork(Provider); - -export const Config = ({ children }) => ( - {value => children(value)} -); +export const ConfigProvider = ConfigContext.Provider; +export const Config = ConfigContext.Consumer; diff --git a/src/contexts/Network.jsx b/src/contexts/Network.jsx deleted file mode 100644 index 84628aba5e..0000000000 --- a/src/contexts/Network.jsx +++ /dev/null @@ -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 ( - - {children} - - ); - } -} - -export { Provider as _NetworkProvider }; -export const NetworkProvider = withI18n()(withRootDialog(withRouter(Provider))); - -export function withNetwork (Child) { - return (props) => ( - - {context => } - - ); -} diff --git a/src/contexts/RootDialog.jsx b/src/contexts/RootDialog.jsx deleted file mode 100644 index d393da068d..0000000000 --- a/src/contexts/RootDialog.jsx +++ /dev/null @@ -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 ( - - {children} - - ); - } -} - -export const RootDialog = ({ children }) => ( - - {value => children(value)} - -); - -export function withRootDialog (Child) { - return (props) => ( - - {context => } - - ); -} diff --git a/src/index.jsx b/src/index.jsx index 62a1f72e86..e30b1a5b2c 100644 --- a/src/index.jsx +++ b/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 = () => (); + const removeTrailingSlash = ( + ( + + )} + /> + ); + const loginRoutes = ( + + {removeTrailingSlash} + ( + + )} + /> + + + ); + return render( {({ i18n }) => ( - - ( - - )} - /> - ( - - {({ custom_logo, custom_login_info, fetchMe, updateConfig }) => ( - - )} - - )} - /> - } /> - ( - ( - - {routeGroups + {!isAuthenticated() ? loginRoutes : ( + + {removeTrailingSlash} + + + ( + ( + routeGroups .reduce((allRoutes, { routes }) => allRoutes.concat(routes), []) .map(({ component: PageComponent, path }) => ( )) - .concat([ - - ])} - - )} - /> - )} - /> - + )} + /> + )} + /> + + )} )} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 39a12c1969..6a7ac25efa 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -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 (); } + 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 ( ); @@ -109,4 +148,4 @@ class AWXLogin extends Component { } export { AWXLogin as _AWXLogin }; -export default withI18n()(withNetwork(withRootDialog(withRouter(AWXLogin)))); +export default withI18n()(withRouter(AWXLogin)); diff --git a/src/pages/Organizations/Organizations.jsx b/src/pages/Organizations/Organizations.jsx index 00962dd43a..702876c78e 100644 --- a/src/pages/Organizations/Organizations.jsx +++ b/src/pages/Organizations/Organizations.jsx @@ -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 { /> ( - { - history.replace('/organizations'); - setRootDialogMessage({ - title: '404', - bodyText: ( - - {i18n._(t`Cannot find organization with ID`)} - {` ${newRouteMatch.params.id}`} - . - - ), - variant: 'warning' - }); - }} - > - - {({ me }) => ( - - )} - - + render={() => ( + + {({ me }) => ( + + )} + )} /> InstanceGroupsAPI.read(params); @@ -66,4 +64,4 @@ InstanceGroupsLookup.defaultProps = { tooltip: '', }; -export default withI18n()(withNetwork(InstanceGroupsLookup)); +export default withI18n()(InstanceGroupsLookup); diff --git a/src/pages/Organizations/components/OrganizationForm.jsx b/src/pages/Organizations/components/OrganizationForm.jsx index 5e99e7d484..a17efbace7 100644 --- a/src/pages/Organizations/components/OrganizationForm.jsx +++ b/src/pages/Organizations/components/OrganizationForm.jsx @@ -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)); diff --git a/src/pages/Organizations/screens/Organization/Organization.jsx b/src/pages/Organizations/screens/Organization/Organization.jsx index 931736b3d5..71100980f7 100644 --- a/src/pages/Organizations/screens/Organization/Organization.jsx +++ b/src/pages/Organizations/screens/Organization/Organization.jsx @@ -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 ? '' : ( - - -
- - -
-
- - - ) + + +
+ + +
+
+ + ); + + if (!isInitialized) { + cardHeader = null; + } + if (!match) { cardHeader = null; } @@ -161,10 +161,20 @@ class Organization extends Component { cardHeader = null; } + if (!contentLoading && contentError) { + return ( + + + + + + ); + } + return ( - { cardHeader } + {cardHeader} )} - {organization && ( - - )} - {error ? 'error!' : ''} - {loading ? 'loading...' : ''} ); } } -export default withI18n()(withNetwork(withRouter(Organization))); + +export default withI18n()(withRouter(Organization)); export { Organization as _Organization }; diff --git a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx index 2e654d347f..f26e9c0b2b 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx @@ -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
{error.message}
; - } - // TODO: better loading state return ( - {isLoading && (
Loading...
)} - {roleToDelete && ( - - )} - {isInitialized && ( - ( - - ] : null} - /> - )} - renderItem={accessRecord => ( - - )} - /> - )} + ( + + ] : null} + /> + )} + renderItem={accessRecord => ( + + )} + /> {isAddModalOpen && ( )} + {isDeleteModalOpen && ( + + )} + + {i18n._(t`Failed to delete role`)} +
); } } export { OrganizationAccess as _OrganizationAccess }; -export default withI18n()(withNetwork(withRouter(OrganizationAccess))); +export default withI18n()(withRouter(OrganizationAccess)); diff --git a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx index 6aafd74b45..34e44af61c 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx @@ -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 (); + } + + if (contentError) { + return (); + } + return ( @@ -116,10 +125,9 @@ class OrganizationDetail extends Component {
)} - {error ? 'error!' : ''} ); } } -export default withI18n()(withRouter(withNetwork(OrganizationDetail))); +export default withI18n()(withRouter(OrganizationDetail)); diff --git a/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx b/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx index 677dfe94f3..258b052c79 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx @@ -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); diff --git a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx index 6fd667f0d5..54274d5f2e 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx @@ -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
{error.message}
; - } - // TODO: better loading state return ( - {isLoading && (
Loading...
)} - {isInitialized && ( - ( - - )} - /> - )} + ( + + )} + /> + + {i18n._(t`Failed to toggle notification.`)} +
); } @@ -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)); diff --git a/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx b/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx index 5f0cd85e8d..afa0dac525 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationTeams.jsx @@ -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
{error.message}
; - } - - // TODO: better loading state + const { contentError, contentLoading, teams, itemCount } = this.state; return ( - - {isLoading && (
Loading...
)} - {isInitialized && ( - - )} -
+ ); } } @@ -90,4 +76,4 @@ OrganizationTeams.propTypes = { }; export { OrganizationTeams as _OrganizationTeams }; -export default withNetwork(withRouter(OrganizationTeams)); +export default withRouter(OrganizationTeams); diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index d5a537a633..a4faed6058 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -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)); diff --git a/src/pages/Organizations/screens/OrganizationsList.jsx b/src/pages/Organizations/screens/OrganizationsList.jsx index c8bd1350c1..7d72efba76 100644 --- a/src/pages/Organizations/screens/OrganizationsList.jsx +++ b/src/pages/Organizations/screens/OrganizationsList.jsx @@ -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 ( - - - {isInitialized && ( + + + - )} - { isLoading ?
loading...
: '' } - { error ?
error
: '' } -
-
+
+
+ + {i18n._(t`Failed to delete one or more organizations.`)} + + ); } } export { OrganizationsList as _OrganizationsList }; -export default withI18n()(withNetwork(withRouter(OrganizationsList))); +export default withI18n()(withRouter(OrganizationsList)); diff --git a/src/pages/Templates/Templates.jsx b/src/pages/Templates/Templates.jsx index 4a0e9d6470..bcbd27bdec 100644 --- a/src/pages/Templates/Templates.jsx +++ b/src/pages/Templates/Templates.jsx @@ -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 ( - ( - { - history.replace('/templates'); - setRootDialogMessage({ - title: '404', - bodyText: ( - - {i18n._(t`Cannot find template with ID`)} - {` ${newRouteMatch.params.id}`} - - ), - variant: 'warning' - }); - }} - /> - )} - /> ( @@ -60,4 +38,4 @@ class Templates extends Component { } export { Templates as _Templates }; -export default withI18n()(withRootDialog(withRouter(Templates))); +export default withI18n()(withRouter(Templates)); diff --git a/src/pages/Templates/TemplatesList.jsx b/src/pages/Templates/TemplatesList.jsx index e45c2d5aa4..673c940567 100644 --- a/src/pages/Templates/TemplatesList.jsx +++ b/src/pages/Templates/TemplatesList.jsx @@ -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 ( - {isInitialized && ( - ( - - )} - renderItem={(template) => ( - this.handleSelect(template)} - isSelected={selected.some(row => row.id === template.id)} - /> - )} - /> - )} - {isLoading ?
loading....
: ''} - {error ?
error
: '' } + ( + + )} + renderItem={(template) => ( + this.handleSelect(template)} + isSelected={selected.some(row => row.id === template.id)} + /> + )} + />
); } } + export { TemplatesList as _TemplatesList }; -export default withI18n()(withNetwork(withRouter(TemplatesList))); +export default withI18n()(withRouter(TemplatesList)); diff --git a/src/util/auth.js b/src/util/auth.js new file mode 100644 index 0000000000..f83d1797b3 --- /dev/null +++ b/src/util/auth.js @@ -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; +}