Display details about network errors in alert modal and content error components (#288)

Display details about network errors in alert modal and content error components
This commit is contained in:
Michael Abashian
2019-06-26 11:40:15 -04:00
committed by GitHub
parent a503529d05
commit 52851c57d8
26 changed files with 341 additions and 144 deletions

View File

@@ -17,6 +17,7 @@ import AlertModal from '@components/AlertModal';
import NavExpandableGroup from '@components/NavExpandableGroup'; import NavExpandableGroup from '@components/NavExpandableGroup';
import BrandLogo from '@components/BrandLogo'; import BrandLogo from '@components/BrandLogo';
import PageHeaderToolbar from '@components/PageHeaderToolbar'; import PageHeaderToolbar from '@components/PageHeaderToolbar';
import ErrorDetail from '@components/ErrorDetail';
import { ConfigProvider } from '@contexts/Config'; import { ConfigProvider } from '@contexts/Config';
const PageHeader = styled(PFPageHeader)` const PageHeader = styled(PFPageHeader)`
@@ -48,7 +49,7 @@ class App extends Component {
version: null, version: null,
isAboutModalOpen: false, isAboutModalOpen: false,
isNavOpen, isNavOpen,
hasConfigError: false, configError: null
}; };
this.handleLogout = this.handleLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
@@ -81,7 +82,9 @@ class App extends Component {
} }
handleConfigErrorClose () { handleConfigErrorClose () {
this.setState({ hasConfigError: false }); this.setState({
configError: null
});
} }
async loadConfig () { async loadConfig () {
@@ -92,7 +95,7 @@ class App extends Component {
this.setState({ ansible_version, custom_virtualenvs, version, me }); this.setState({ ansible_version, custom_virtualenvs, version, me });
} catch (err) { } catch (err) {
this.setState({ hasConfigError: true }); this.setState({ configError: err });
} }
} }
@@ -104,7 +107,7 @@ class App extends Component {
isNavOpen, isNavOpen,
me, me,
version, version,
hasConfigError, configError
} = this.state; } = this.state;
const { const {
i18n, i18n,
@@ -170,12 +173,13 @@ class App extends Component {
onClose={this.handleAboutClose} onClose={this.handleAboutClose}
/> />
<AlertModal <AlertModal
isOpen={hasConfigError} isOpen={configError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={this.handleConfigErrorClose} onClose={this.handleConfigErrorClose}
> >
{i18n._(t`Failed to retrieve configuration.`)} {i18n._(t`Failed to retrieve configuration.`)}
<ErrorDetail error={configError} />
</AlertModal> </AlertModal>
</Fragment> </Fragment>
); );

View File

@@ -20,9 +20,13 @@ const getIcon = (variant) => {
return icon; return icon;
}; };
export default ({ variant, children, ...props }) => ( export default ({ variant, children, ...props }) => {
<Modal className={`awx-c-modal${variant && ` at-c-alertModal at-c-alertModal--${variant}`}`} {...props}> const { isOpen = null } = props;
{children} props.isOpen = Boolean(isOpen);
{getIcon(variant)} return (
</Modal> <Modal className={`awx-c-modal${variant && ` at-c-alertModal at-c-alertModal--${variant}`}`} {...props}>
); {children}
{getIcon(variant)}
</Modal>
);
};

View File

@@ -1,26 +1,40 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { import {
Title, Title,
EmptyState, EmptyState as PFEmptyState,
EmptyStateIcon, EmptyStateIcon,
EmptyStateBody EmptyStateBody
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { ExclamationTriangleIcon } from '@patternfly/react-icons'; import { ExclamationTriangleIcon } from '@patternfly/react-icons';
// TODO: Pass actual error as prop and display expandable details for network errors. import ErrorDetail from '@components/ErrorDetail';
const ContentError = ({ i18n }) => (
<EmptyState> const EmptyState = styled(PFEmptyState)`
<EmptyStateIcon icon={ExclamationTriangleIcon} /> width: var(--pf-c-empty-state--m-lg--MaxWidth);
<Title size="lg"> `;
{i18n._(t`Something went wrong...`)}
</Title> class ContentError extends React.Component {
<EmptyStateBody> render () {
{i18n._(t`There was an error loading this content. Please reload the page.`)} const { error, i18n } = this.props;
</EmptyStateBody> return (
</EmptyState> <EmptyState>
); <EmptyStateIcon icon={ExclamationTriangleIcon} />
<Title size="lg">
{i18n._(t`Something went wrong...`)}
</Title>
<EmptyStateBody>
{i18n._(t`There was an error loading this content. Please reload the page.`)}
</EmptyStateBody>
{error && (
<ErrorDetail error={error} />
)}
</EmptyState>
);
}
}
export { ContentError as _ContentError }; export { ContentError as _ContentError };
export default withI18n()(ContentError); export default withI18n()(ContentError);

View File

@@ -5,7 +5,15 @@ import ContentError from './ContentError';
describe('ContentError', () => { describe('ContentError', () => {
test('renders the expected content', () => { test('renders the expected content', () => {
const wrapper = mountWithContexts(<ContentError />); const wrapper = mountWithContexts(<ContentError error={new Error({
response: {
config: {
method: 'post'
},
data: 'An error occurred',
}
})}
/>);
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
}); });

View File

@@ -0,0 +1,95 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Card as PFCard,
CardBody as PFCardBody,
Expandable as PFExpandable
} from '@patternfly/react-core';
const Card = styled(PFCard)`
background-color: var(--pf-global--BackgroundColor--200);
overflow-wrap: break-word;
`;
const CardBody = styled(PFCardBody)`
max-height: 200px;
overflow: scroll;
`;
const Expandable = styled(PFExpandable)`
text-align: left;
`;
class ErrorDetail extends Component {
constructor (props) {
super(props);
this.state = {
isExpanded: false
};
this.handleToggle = this.handleToggle.bind(this);
this.renderNetworkError = this.renderNetworkError.bind(this);
this.renderStack = this.renderStack.bind(this);
}
handleToggle () {
const { isExpanded } = this.state;
this.setState({ isExpanded: !isExpanded });
}
renderNetworkError () {
const { error } = this.props;
const { response } = error;
const message = typeof response.data === 'string'
? response.data
: response.data.detail;
return (
<Fragment>
<CardBody>
{response.config.method.toUpperCase()}
{' '}
{response.config.url}
{' '}
<strong>
{response.status}
</strong>
</CardBody>
<CardBody>{message}</CardBody>
</Fragment>
);
}
renderStack () {
const { error } = this.props;
return (<CardBody>{error.stack}</CardBody>);
}
render () {
const { isExpanded } = this.state;
const { error, i18n } = this.props;
return (
<Expandable toggleText={i18n._(t`Details`)} onToggle={this.handleToggle} isExpanded={isExpanded}>
<Card>
{Object.prototype.hasOwnProperty.call(error, 'response')
? this.renderNetworkError()
: this.renderStack()
}
</Card>
</Expandable>
);
}
}
ErrorDetail.propTypes = {
error: PropTypes.instanceOf(Error).isRequired
};
export default withI18n()(ErrorDetail);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import ErrorDetail from './ErrorDetail';
describe('ErrorDetail', () => {
test('renders the expected content', () => {
const wrapper = mountWithContexts(<ErrorDetail error={new Error({
response: {
config: {
method: 'post'
},
data: 'An error occurred'
}
})}
/>);
expect(wrapper).toHaveLength(1);
});
});

View File

@@ -0,0 +1 @@
export { default } from './ErrorDetail';

View File

@@ -8,6 +8,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import { JobTemplatesAPI } from '@api'; import { JobTemplatesAPI } from '@api';
const StyledLaunchButton = styled(Button)` const StyledLaunchButton = styled(Button)`
@@ -28,7 +29,7 @@ class LaunchButton extends React.Component {
super(props); super(props);
this.state = { this.state = {
launchError: false, launchError: null,
promptError: false promptError: false
}; };
@@ -38,7 +39,7 @@ class LaunchButton extends React.Component {
} }
handleLaunchErrorClose () { handleLaunchErrorClose () {
this.setState({ launchError: false }); this.setState({ launchError: null });
} }
handlePromptErrorClose () { handlePromptErrorClose () {
@@ -55,8 +56,8 @@ class LaunchButton extends React.Component {
} else { } else {
this.setState({ promptError: true }); this.setState({ promptError: true });
} }
} catch (error) { } catch (err) {
this.setState({ launchError: true }); this.setState({ launchError: err });
} }
} }
@@ -89,6 +90,7 @@ class LaunchButton extends React.Component {
onClose={this.handleLaunchErrorClose} onClose={this.handleLaunchErrorClose}
> >
{i18n._(t`Failed to launch job.`)} {i18n._(t`Failed to launch job.`)}
<ErrorDetail error={launchError} />
</AlertModal> </AlertModal>
<AlertModal <AlertModal
isOpen={promptError} isOpen={promptError}

View File

@@ -43,7 +43,16 @@ describe('LaunchButton', () => {
done(); done();
}); });
test('displays error modal after unsuccessful launch', async (done) => { test('displays error modal after unsuccessful launch', async (done) => {
JobTemplatesAPI.launch.mockRejectedValue({}); JobTemplatesAPI.launch.mockRejectedValue(new Error({
response: {
config: {
method: 'post',
url: '/api/v2/job_templates/1/launch'
},
data: 'An error occurred',
status: 403
}
}));
const wrapper = mountWithContexts(<LaunchButton templateId={1} />); const wrapper = mountWithContexts(<LaunchButton templateId={1} />);
const launchButton = wrapper.find('LaunchButton__StyledLaunchButton'); const launchButton = wrapper.find('LaunchButton__StyledLaunchButton');
launchButton.simulate('click'); launchButton.simulate('click');

View File

@@ -72,7 +72,7 @@ class PaginatedDataList extends React.Component {
render () { render () {
const [orderBy, sortOrder] = this.getSortOrder(); const [orderBy, sortOrder] = this.getSortOrder();
const { const {
hasContentError, contentError,
hasContentLoading, hasContentLoading,
emptyStateControls, emptyStateControls,
items, items,
@@ -100,8 +100,8 @@ class PaginatedDataList extends React.Component {
let Content; let Content;
if (hasContentLoading && items.length <= 0) { if (hasContentLoading && items.length <= 0) {
Content = (<ContentLoading />); Content = (<ContentLoading />);
} else if (hasContentError) { } else if (contentError) {
Content = (<ContentError />); Content = (<ContentError error={contentError} />);
} else if (items.length <= 0) { } else if (items.length <= 0) {
Content = (<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />); Content = (<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />);
} else { } else {
@@ -174,12 +174,12 @@ PaginatedDataList.propTypes = {
showPageSizeOptions: PropTypes.bool, showPageSizeOptions: PropTypes.bool,
renderToolbar: PropTypes.func, renderToolbar: PropTypes.func,
hasContentLoading: PropTypes.bool, hasContentLoading: PropTypes.bool,
hasContentError: PropTypes.bool, contentError: PropTypes.shape(),
}; };
PaginatedDataList.defaultProps = { PaginatedDataList.defaultProps = {
hasContentLoading: false, hasContentLoading: false,
hasContentError: false, contentError: null,
toolbarColumns: [], toolbarColumns: [],
itemName: 'item', itemName: 'item',
itemNamePlural: '', itemNamePlural: '',

View File

@@ -19,7 +19,7 @@ class Job extends Component {
this.state = { this.state = {
job: null, job: null,
hasContentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
isInitialized: false isInitialized: false
}; };
@@ -46,13 +46,13 @@ class Job extends Component {
} = this.props; } = this.props;
const id = parseInt(match.params.id, 10); const id = parseInt(match.params.id, 10);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { data } = await JobsAPI.readDetail(id); const { data } = await JobsAPI.readDetail(id);
setBreadcrumb(data); setBreadcrumb(data);
this.setState({ job: data }); this.setState({ job: data });
} catch (error) { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -67,7 +67,7 @@ class Job extends Component {
const { const {
job, job,
hasContentError, contentError,
hasContentLoading, hasContentLoading,
isInitialized isInitialized
} = this.state; } = this.state;
@@ -103,11 +103,11 @@ class Job extends Component {
cardHeader = null; cardHeader = null;
} }
if (!hasContentLoading && hasContentError) { if (!hasContentLoading && contentError) {
return ( return (
<PageSection> <PageSection>
<Card className="awx-c-card"> <Card className="awx-c-card">
<ContentError /> <ContentError error={contentError} />
</Card> </Card>
</PageSection> </PageSection>
); );

View File

@@ -11,6 +11,7 @@ import {
import { UnifiedJobsAPI } from '@api'; import { UnifiedJobsAPI } from '@api';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import DatalistToolbar from '@components/DataListToolbar'; import DatalistToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarDeleteButton ToolbarDeleteButton
} from '@components/PaginatedDataList'; } from '@components/PaginatedDataList';
@@ -31,8 +32,8 @@ class JobList extends Component {
this.state = { this.state = {
hasContentLoading: true, hasContentLoading: true,
hasContentError: false, contentError: null,
hasDeletionError: false, deletionError: null,
selected: [], selected: [],
jobs: [], jobs: [],
itemCount: 0, itemCount: 0,
@@ -56,7 +57,7 @@ class JobList extends Component {
} }
handleDeleteErrorClose () { handleDeleteErrorClose () {
this.setState({ hasDeletionError: false }); this.setState({ deletionError: null });
} }
handleSelectAll (isSelected) { handleSelectAll (isSelected) {
@@ -76,11 +77,11 @@ class JobList extends Component {
async handleDelete () { async handleDelete () {
const { selected } = this.state; const { selected } = this.state;
this.setState({ hasContentLoading: true, hasDeletionError: false }); this.setState({ hasContentLoading: true });
try { try {
await Promise.all(selected.map(({ id }) => UnifiedJobsAPI.destroy(id))); await Promise.all(selected.map(({ id }) => UnifiedJobsAPI.destroy(id)));
} catch (err) { } catch (err) {
this.setState({ hasDeletionError: true }); this.setState({ deletionError: err });
} finally { } finally {
await this.loadJobs(); await this.loadJobs();
} }
@@ -90,7 +91,7 @@ class JobList extends Component {
const { location } = this.props; const { location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { data: { count, results } } = await UnifiedJobsAPI.read(params); const { data: { count, results } } = await UnifiedJobsAPI.read(params);
this.setState({ this.setState({
@@ -99,7 +100,7 @@ class JobList extends Component {
selected: [], selected: [],
}); });
} catch (err) { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -107,9 +108,9 @@ class JobList extends Component {
render () { render () {
const { const {
hasContentError, contentError,
hasContentLoading, hasContentLoading,
hasDeletionError, deletionError,
jobs, jobs,
itemCount, itemCount,
selected, selected,
@@ -125,7 +126,7 @@ class JobList extends Component {
<PageSection variant={medium}> <PageSection variant={medium}>
<Card> <Card>
<PaginatedDataList <PaginatedDataList
hasContentError={hasContentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={jobs} items={jobs}
itemCount={itemCount} itemCount={itemCount}
@@ -166,12 +167,13 @@ class JobList extends Component {
/> />
</Card> </Card>
<AlertModal <AlertModal
isOpen={hasDeletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose} onClose={this.handleDeleteErrorClose}
> >
{i18n._(t`Failed to delete one or more jobs.`)} {i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
</PageSection> </PageSection>
); );

View File

@@ -21,7 +21,7 @@ class Organization extends Component {
this.state = { this.state = {
organization: null, organization: null,
hasContentLoading: true, hasContentLoading: true,
hasContentError: false, contentError: null,
isInitialized: false, isInitialized: false,
isNotifAdmin: false, isNotifAdmin: false,
isAuditorOfThisOrg: false, isAuditorOfThisOrg: false,
@@ -50,7 +50,7 @@ class Organization extends Component {
} = this.props; } = this.props;
const id = parseInt(match.params.id, 10); const id = parseInt(match.params.id, 10);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const [{ data }, notifAdminRes, auditorRes, adminRes] = await Promise.all([ const [{ data }, notifAdminRes, auditorRes, adminRes] = await Promise.all([
OrganizationsAPI.readDetail(id), OrganizationsAPI.readDetail(id),
@@ -66,7 +66,7 @@ class Organization extends Component {
isAdminOfThisOrg: adminRes.data.results.length > 0 isAdminOfThisOrg: adminRes.data.results.length > 0
}); });
} catch (err) { } catch (err) {
this.setState(({ hasContentError: true })); this.setState(({ contentError: err }));
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -79,13 +79,13 @@ class Organization extends Component {
} = this.props; } = this.props;
const id = parseInt(match.params.id, 10); const id = parseInt(match.params.id, 10);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { data } = await OrganizationsAPI.readDetail(id); const { data } = await OrganizationsAPI.readDetail(id);
setBreadcrumb(data); setBreadcrumb(data);
this.setState({ organization: data }); this.setState({ organization: data });
} catch (err) { } catch (err) {
this.setState(({ hasContentError: true })); this.setState(({ contentError: err }));
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -102,7 +102,7 @@ class Organization extends Component {
const { const {
organization, organization,
hasContentError, contentError,
hasContentLoading, hasContentLoading,
isInitialized, isInitialized,
isNotifAdmin, isNotifAdmin,
@@ -162,11 +162,11 @@ class Organization extends Component {
cardHeader = null; cardHeader = null;
} }
if (!hasContentLoading && hasContentError) { if (!hasContentLoading && contentError) {
return ( return (
<PageSection> <PageSection>
<Card className="awx-c-card"> <Card className="awx-c-card">
<ContentError /> <ContentError error={contentError} />
</Card> </Card>
</PageSection> </PageSection>
); );

View File

@@ -7,6 +7,7 @@ import { OrganizationsAPI, TeamsAPI, UsersAPI } from '@api';
import AddResourceRole from '@components/AddRole/AddResourceRole'; import AddResourceRole from '@components/AddRole/AddResourceRole';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar'; import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, { ToolbarAddButton } from '@components/PaginatedDataList'; import PaginatedDataList, { ToolbarAddButton } from '@components/PaginatedDataList';
import { import {
getQSConfig, getQSConfig,
@@ -33,9 +34,9 @@ class OrganizationAccess extends React.Component {
super(props); super(props);
this.state = { this.state = {
accessRecords: [], accessRecords: [],
hasContentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
hasDeletionError: false, deletionError: null,
deletionRecord: null, deletionRecord: null,
deletionRole: null, deletionRole: null,
isAddModalOpen: false, isAddModalOpen: false,
@@ -70,7 +71,7 @@ class OrganizationAccess extends React.Component {
const { organization, location } = this.props; const { organization, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { const {
data: { data: {
@@ -79,8 +80,8 @@ class OrganizationAccess extends React.Component {
} }
} = await OrganizationsAPI.readAccessList(organization.id, params); } = await OrganizationsAPI.readAccessList(organization.id, params);
this.setState({ itemCount, accessRecords }); this.setState({ itemCount, accessRecords });
} catch (error) { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -96,7 +97,7 @@ class OrganizationAccess extends React.Component {
handleDeleteErrorClose () { handleDeleteErrorClose () {
this.setState({ this.setState({
hasDeletionError: false, deletionError: null,
deletionRecord: null, deletionRecord: null,
deletionRole: null deletionRole: null
}); });
@@ -123,10 +124,10 @@ class OrganizationAccess extends React.Component {
deletionRole: null, deletionRole: null,
deletionRecord: null deletionRecord: null
}); });
} catch (error) { } catch (err) {
this.setState({ this.setState({
hasContentLoading: false, hasContentLoading: false,
hasDeletionError: true deletionError: err
}); });
} }
} }
@@ -148,21 +149,21 @@ class OrganizationAccess extends React.Component {
const { organization, i18n } = this.props; const { organization, i18n } = this.props;
const { const {
accessRecords, accessRecords,
hasContentError, contentError,
hasContentLoading, hasContentLoading,
deletionRole, deletionRole,
deletionRecord, deletionRecord,
hasDeletionError, deletionError,
itemCount, itemCount,
isAddModalOpen, isAddModalOpen
} = this.state; } = this.state;
const canEdit = organization.summary_fields.user_capabilities.edit; const canEdit = organization.summary_fields.user_capabilities.edit;
const isDeleteModalOpen = !hasContentLoading && !hasDeletionError && deletionRole; const isDeleteModalOpen = !hasContentLoading && !deletionError && deletionRole;
return ( return (
<Fragment> <Fragment>
<PaginatedDataList <PaginatedDataList
hasContentError={hasContentError} error={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={accessRecords} items={accessRecords}
itemCount={itemCount} itemCount={itemCount}
@@ -205,12 +206,13 @@ class OrganizationAccess extends React.Component {
/> />
)} )}
<AlertModal <AlertModal
isOpen={hasDeletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose} onClose={this.handleDeleteErrorClose}
> >
{i18n._(t`Failed to delete role`)} {i18n._(t`Failed to delete role`)}
<ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
</Fragment> </Fragment>
); );

View File

@@ -84,7 +84,7 @@ describe('<OrganizationAccess />', () => {
await waitForElement(wrapper, 'OrganizationAccessItem', el => el.length === 2); await waitForElement(wrapper, 'OrganizationAccessItem', el => el.length === 2);
expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results); expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(data.results);
expect(wrapper.find('OrganizationAccess').state('hasContentLoading')).toBe(false); expect(wrapper.find('OrganizationAccess').state('hasContentLoading')).toBe(false);
expect(wrapper.find('OrganizationAccess').state('hasContentError')).toBe(false); expect(wrapper.find('OrganizationAccess').state('contentError')).toBe(null);
done(); done();
}); });

View File

@@ -34,7 +34,7 @@ exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
} }
> >
<WithI18n <WithI18n
hasContentError={false} error={null}
hasContentLoading={true} hasContentLoading={true}
itemCount={0} itemCount={0}
itemName="role" itemName="role"
@@ -80,7 +80,7 @@ exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
withHash={true} withHash={true}
> >
<withRouter(PaginatedDataList) <withRouter(PaginatedDataList)
hasContentError={false} error={null}
hasContentLoading={true} hasContentLoading={true}
i18n={"/i18n/"} i18n={"/i18n/"}
itemCount={0} itemCount={0}
@@ -124,7 +124,8 @@ exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
> >
<Route> <Route>
<PaginatedDataList <PaginatedDataList
hasContentError={false} contentError={null}
error={null}
hasContentLoading={true} hasContentLoading={true}
history={"/history/"} history={"/history/"}
i18n={"/i18n/"} i18n={"/i18n/"}
@@ -220,7 +221,7 @@ exports[`<OrganizationAccess /> initially renders succesfully 1`] = `
</I18n> </I18n>
</WithI18n> </WithI18n>
<_default <_default
isOpen={false} isOpen={null}
onClose={[Function]} onClose={[Function]}
title="Error!" title="Error!"
variant="danger" variant="danger"

View File

@@ -20,7 +20,7 @@ class OrganizationDetail extends Component {
super(props); super(props);
this.state = { this.state = {
hasContentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
instanceGroups: [], instanceGroups: [],
}; };
@@ -39,7 +39,7 @@ class OrganizationDetail extends Component {
const { data: { results = [] } } = await OrganizationsAPI.readInstanceGroups(id); const { data: { results = [] } } = await OrganizationsAPI.readInstanceGroups(id);
this.setState({ instanceGroups: [...results] }); this.setState({ instanceGroups: [...results] });
} catch (err) { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -48,7 +48,7 @@ class OrganizationDetail extends Component {
render () { render () {
const { const {
hasContentLoading, hasContentLoading,
hasContentError, contentError,
instanceGroups, instanceGroups,
} = this.state; } = this.state;
@@ -70,8 +70,8 @@ class OrganizationDetail extends Component {
return (<ContentLoading />); return (<ContentLoading />);
} }
if (hasContentError) { if (contentError) {
return (<ContentError />); return (<ContentError error={contentError} />);
} }
return ( return (

View File

@@ -11,6 +11,7 @@ import {
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar'; import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
@@ -31,8 +32,8 @@ class OrganizationsList extends Component {
this.state = { this.state = {
hasContentLoading: true, hasContentLoading: true,
hasContentError: false, contentError: null,
hasDeletionError: false, deletionError: null,
organizations: [], organizations: [],
selected: [], selected: [],
itemCount: 0, itemCount: 0,
@@ -75,17 +76,17 @@ class OrganizationsList extends Component {
} }
handleDeleteErrorClose () { handleDeleteErrorClose () {
this.setState({ hasDeletionError: false }); this.setState({ deletionError: null });
} }
async handleOrgDelete () { async handleOrgDelete () {
const { selected } = this.state; const { selected } = this.state;
this.setState({ hasContentLoading: true, hasDeletionError: false }); this.setState({ hasContentLoading: true });
try { try {
await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id))); await Promise.all(selected.map((org) => OrganizationsAPI.destroy(org.id)));
} catch (err) { } catch (err) {
this.setState({ hasDeletionError: true }); this.setState({ deletionError: err });
} finally { } finally {
await this.loadOrganizations(); await this.loadOrganizations();
} }
@@ -108,7 +109,7 @@ class OrganizationsList extends Component {
optionsPromise, optionsPromise,
]); ]);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const [{ data: { count, results } }, { data: { actions } }] = await promises; const [{ data: { count, results } }, { data: { actions } }] = await promises;
this.setState({ this.setState({
@@ -118,7 +119,7 @@ class OrganizationsList extends Component {
selected: [], selected: [],
}); });
} catch (err) { } catch (err) {
this.setState(({ hasContentError: true })); this.setState(({ contentError: err }));
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -131,9 +132,9 @@ class OrganizationsList extends Component {
const { const {
actions, actions,
itemCount, itemCount,
hasContentError, contentError,
hasContentLoading, hasContentLoading,
hasDeletionError, deletionError,
selected, selected,
organizations, organizations,
} = this.state; } = this.state;
@@ -147,7 +148,7 @@ class OrganizationsList extends Component {
<PageSection variant={medium}> <PageSection variant={medium}>
<Card> <Card>
<PaginatedDataList <PaginatedDataList
hasContentError={hasContentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={organizations} items={organizations}
itemCount={itemCount} itemCount={itemCount}
@@ -194,12 +195,13 @@ class OrganizationsList extends Component {
</Card> </Card>
</PageSection> </PageSection>
<AlertModal <AlertModal
isOpen={hasDeletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose} onClose={this.handleDeleteErrorClose}
> >
{i18n._(t`Failed to delete one or more organizations.`)} {i18n._(t`Failed to delete one or more organizations.`)}
<ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
</Fragment> </Fragment>
); );

View File

@@ -60,6 +60,20 @@ const mockAPIOrgsList = {
describe('<OrganizationsList />', () => { describe('<OrganizationsList />', () => {
let wrapper; let wrapper;
beforeEach(() => {
OrganizationsAPI.read = () => Promise.resolve({
data: {
count: 0,
results: []
}
});
OrganizationsAPI.readOptions = () => Promise.resolve({
data: {
actions: []
}
});
});
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<OrganizationsList />); mountWithContexts(<OrganizationsList />);
}); });
@@ -122,8 +136,17 @@ describe('<OrganizationsList />', () => {
expect(fetchOrgs).toBeCalled(); expect(fetchOrgs).toBeCalled();
}); });
test('error is shown when org not successfully deleted from api', async () => { test('error is shown when org not successfully deleted from api', async (done) => {
OrganizationsAPI.destroy = () => Promise.reject(); OrganizationsAPI.destroy.mockRejectedValue(new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/organizations/1'
},
data: 'An error occurred'
}
}));
wrapper = mountWithContexts(<OrganizationsList />); wrapper = mountWithContexts(<OrganizationsList />);
wrapper.find('OrganizationsList').setState({ wrapper.find('OrganizationsList').setState({
organizations: mockAPIOrgsList.data.results, organizations: mockAPIOrgsList.data.results,
@@ -133,5 +156,6 @@ describe('<OrganizationsList />', () => {
}); });
wrapper.find('ToolbarDeleteButton').prop('onDelete')(); wrapper.find('ToolbarDeleteButton').prop('onDelete')();
await waitForElement(wrapper, 'Modal', (el) => el.props().isOpen === true && el.props().title === 'Error!'); await waitForElement(wrapper, 'Modal', (el) => el.props().isOpen === true && el.props().title === 'Error!');
done();
}); });
}); });

View File

@@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import NotificationListItem from '@components/NotificationsList/NotificationListItem'; import NotificationListItem from '@components/NotificationsList/NotificationListItem';
import PaginatedDataList from '@components/PaginatedDataList'; import PaginatedDataList from '@components/PaginatedDataList';
import { getQSConfig, parseNamespacedQueryString } from '@util/qs'; import { getQSConfig, parseNamespacedQueryString } from '@util/qs';
@@ -26,9 +27,9 @@ class OrganizationNotifications extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.state = { this.state = {
hasContentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
toggleError: false, toggleError: null,
toggleLoading: false, toggleLoading: false,
itemCount: 0, itemCount: 0,
notifications: [], notifications: [],
@@ -55,7 +56,7 @@ class OrganizationNotifications extends Component {
const { id, location } = this.props; const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { const {
data: { data: {
@@ -85,8 +86,8 @@ class OrganizationNotifications extends Component {
successTemplateIds: successTemplates.results.map(s => s.id), successTemplateIds: successTemplates.results.map(s => s.id),
errorTemplateIds: errorTemplates.results.map(e => e.id), errorTemplateIds: errorTemplates.results.map(e => e.id),
}); });
} catch { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -125,20 +126,20 @@ class OrganizationNotifications extends Component {
); );
this.setState(stateUpdateFunction); this.setState(stateUpdateFunction);
} catch (err) { } catch (err) {
this.setState({ toggleError: true }); this.setState({ toggleError: err });
} finally { } finally {
this.setState({ toggleLoading: false }); this.setState({ toggleLoading: false });
} }
} }
handleNotificationErrorClose () { handleNotificationErrorClose () {
this.setState({ toggleError: false }); this.setState({ toggleError: null });
} }
render () { render () {
const { canToggleNotifications, i18n } = this.props; const { canToggleNotifications, i18n } = this.props;
const { const {
hasContentError, contentError,
hasContentLoading, hasContentLoading,
toggleError, toggleError,
toggleLoading, toggleLoading,
@@ -151,7 +152,7 @@ class OrganizationNotifications extends Component {
return ( return (
<Fragment> <Fragment>
<PaginatedDataList <PaginatedDataList
hasContentError={hasContentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={notifications} items={notifications}
itemCount={itemCount} itemCount={itemCount}
@@ -171,12 +172,13 @@ class OrganizationNotifications extends Component {
)} )}
/> />
<AlertModal <AlertModal
isOpen={toggleError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={this.handleNotificationErrorClose} onClose={this.handleNotificationErrorClose}
> >
{i18n._(t`Failed to toggle notification.`)} {i18n._(t`Failed to toggle notification.`)}
<ErrorDetail error={toggleError} />
</AlertModal> </AlertModal>
</Fragment> </Fragment>
); );

View File

@@ -39,7 +39,7 @@ exports[`<OrganizationNotifications /> initially renders succesfully 1`] = `
} }
> >
<WithI18n <WithI18n
hasContentError={false} contentError={null}
hasContentLoading={false} hasContentLoading={false}
itemCount={2} itemCount={2}
itemName="notification" itemName="notification"
@@ -101,7 +101,7 @@ exports[`<OrganizationNotifications /> initially renders succesfully 1`] = `
withHash={true} withHash={true}
> >
<withRouter(PaginatedDataList) <withRouter(PaginatedDataList)
hasContentError={false} contentError={null}
hasContentLoading={false} hasContentLoading={false}
i18n={"/i18n/"} i18n={"/i18n/"}
itemCount={2} itemCount={2}
@@ -161,7 +161,7 @@ exports[`<OrganizationNotifications /> initially renders succesfully 1`] = `
> >
<Route> <Route>
<PaginatedDataList <PaginatedDataList
hasContentError={false} contentError={null}
hasContentLoading={false} hasContentLoading={false}
history={"/history/"} history={"/history/"}
i18n={"/i18n/"} i18n={"/i18n/"}
@@ -3295,7 +3295,7 @@ exports[`<OrganizationNotifications /> initially renders succesfully 1`] = `
</I18n> </I18n>
</WithI18n> </WithI18n>
<_default <_default
isOpen={false} isOpen={null}
onClose={[Function]} onClose={[Function]}
title="Error!" title="Error!"
variant="danger" variant="danger"

View File

@@ -19,7 +19,7 @@ class OrganizationTeams extends React.Component {
this.loadOrganizationTeamsList = this.loadOrganizationTeamsList.bind(this); this.loadOrganizationTeamsList = this.loadOrganizationTeamsList.bind(this);
this.state = { this.state = {
hasContentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
itemCount: 0, itemCount: 0,
teams: [], teams: [],
@@ -41,7 +41,7 @@ class OrganizationTeams extends React.Component {
const { id, location } = this.props; const { id, location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ hasContentLoading: true, hasContentError: false }); this.setState({ hasContentLoading: true, contentError: null });
try { try {
const { const {
data: { count = 0, results = [] }, data: { count = 0, results = [] },
@@ -50,18 +50,18 @@ class OrganizationTeams extends React.Component {
itemCount: count, itemCount: count,
teams: results, teams: results,
}); });
} catch { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
} }
render () { render () {
const { hasContentError, hasContentLoading, teams, itemCount } = this.state; const { contentError, hasContentLoading, teams, itemCount } = this.state;
return ( return (
<PaginatedDataList <PaginatedDataList
hasContentError={hasContentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={teams} items={teams}
itemCount={itemCount} itemCount={itemCount}

View File

@@ -24,7 +24,7 @@ class JobTemplateDetail extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.state = { this.state = {
contentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
instanceGroups: [] instanceGroups: []
}; };
@@ -40,8 +40,8 @@ class JobTemplateDetail extends Component {
try { try {
const { data } = await JobTemplatesAPI.readInstanceGroups(match.params.id); const { data } = await JobTemplatesAPI.readInstanceGroups(match.params.id);
this.setState({ instanceGroups: [...data.results] }); this.setState({ instanceGroups: [...data.results] });
} catch { } catch (err) {
this.setState({ contentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -131,7 +131,7 @@ class JobTemplateDetail extends Component {
); );
if (contentError) { if (contentError) {
return (<ContentError />); return (<ContentError error={contentError} />);
} }
if (hasContentLoading) { if (hasContentLoading) {

View File

@@ -24,7 +24,7 @@ class Template extends Component {
super(props); super(props);
this.state = { this.state = {
hasContentError: false, contentError: null,
hasContentLoading: true, hasContentLoading: true,
template: null, template: null,
}; };
@@ -46,15 +46,13 @@ class Template extends Component {
const { setBreadcrumb, match } = this.props; const { setBreadcrumb, match } = this.props;
const { id } = match.params; const { id } = match.params;
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { data } = await JobTemplatesAPI.readDetail(id); const { data } = await JobTemplatesAPI.readDetail(id);
setBreadcrumb(data); setBreadcrumb(data);
this.setState({ this.setState({ template: data });
template: data, } catch (err) {
}); this.setState({ contentError: err });
} catch {
this.setState({ hasContentError: true });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -68,7 +66,7 @@ class Template extends Component {
match, match,
} = this.props; } = this.props;
const { const {
hasContentError, contentError,
hasContentLoading, hasContentLoading,
template template
} = this.state; } = this.state;
@@ -98,11 +96,11 @@ class Template extends Component {
cardHeader = null; cardHeader = null;
} }
if (!hasContentLoading && hasContentError) { if (!hasContentLoading && contentError) {
return ( return (
<PageSection> <PageSection>
<Card className="awx-c-card"> <Card className="awx-c-card">
<ContentError /> <ContentError error={contentError} />
</Card> </Card>
</PageSection> </PageSection>
); );

View File

@@ -15,6 +15,7 @@ import {
} from '@api'; } from '@api';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import DatalistToolbar from '@components/DataListToolbar'; import DatalistToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarDeleteButton ToolbarDeleteButton
} from '@components/PaginatedDataList'; } from '@components/PaginatedDataList';
@@ -37,8 +38,8 @@ class TemplatesList extends Component {
this.state = { this.state = {
hasContentLoading: true, hasContentLoading: true,
hasContentError: false, contentError: null,
hasDeletionError: false, deletionError: null,
selected: [], selected: [],
templates: [], templates: [],
itemCount: 0, itemCount: 0,
@@ -62,7 +63,7 @@ class TemplatesList extends Component {
} }
handleDeleteErrorClose () { handleDeleteErrorClose () {
this.setState({ hasDeletionError: false }); this.setState({ deletionError: null });
} }
handleSelectAll (isSelected) { handleSelectAll (isSelected) {
@@ -83,7 +84,7 @@ class TemplatesList extends Component {
async handleTemplateDelete () { async handleTemplateDelete () {
const { selected } = this.state; const { selected } = this.state;
this.setState({ hasContentLoading: true, hasDeletionError: false }); this.setState({ hasContentLoading: true });
try { try {
await Promise.all(selected.map(({ type, id }) => { await Promise.all(selected.map(({ type, id }) => {
let deletePromise; let deletePromise;
@@ -95,7 +96,7 @@ class TemplatesList extends Component {
return deletePromise; return deletePromise;
})); }));
} catch (err) { } catch (err) {
this.setState({ hasDeletionError: true }); this.setState({ deletionError: err });
} finally { } finally {
await this.loadTemplates(); await this.loadTemplates();
} }
@@ -105,7 +106,7 @@ class TemplatesList extends Component {
const { location } = this.props; const { location } = this.props;
const params = parseNamespacedQueryString(QS_CONFIG, location.search); const params = parseNamespacedQueryString(QS_CONFIG, location.search);
this.setState({ hasContentError: false, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { data: { count, results } } = await UnifiedJobTemplatesAPI.read(params); const { data: { count, results } } = await UnifiedJobTemplatesAPI.read(params);
this.setState({ this.setState({
@@ -114,7 +115,7 @@ class TemplatesList extends Component {
selected: [], selected: [],
}); });
} catch (err) { } catch (err) {
this.setState({ hasContentError: true }); this.setState({ contentError: err });
} finally { } finally {
this.setState({ hasContentLoading: false }); this.setState({ hasContentLoading: false });
} }
@@ -122,9 +123,9 @@ class TemplatesList extends Component {
render () { render () {
const { const {
hasContentError, contentError,
hasContentLoading, hasContentLoading,
hasDeletionError, deletionError,
templates, templates,
itemCount, itemCount,
selected, selected,
@@ -139,7 +140,7 @@ class TemplatesList extends Component {
<PageSection variant={medium}> <PageSection variant={medium}>
<Card> <Card>
<PaginatedDataList <PaginatedDataList
hasContentError={hasContentError} error={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={templates} items={templates}
itemCount={itemCount} itemCount={itemCount}
@@ -180,12 +181,13 @@ class TemplatesList extends Component {
/> />
</Card> </Card>
<AlertModal <AlertModal
isOpen={hasDeletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose} onClose={this.handleDeleteErrorClose}
> >
{i18n._(t`Failed to delete one or more template.`)} {i18n._(t`Failed to delete one or more template.`)}
<ErrorDetail error={deletionError} />
</AlertModal> </AlertModal>
</PageSection> </PageSection>
); );

View File

@@ -151,7 +151,15 @@ describe('<TemplatesList />', () => {
}); });
test('error is shown when template not successfully deleted from api', async () => { test('error is shown when template not successfully deleted from api', async () => {
JobTemplatesAPI.destroy = () => Promise.reject(); JobTemplatesAPI.destroy.mockRejectedValue(new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/job_templates/1'
},
data: 'An error occurred'
}
}));
const wrapper = mountWithContexts(<TemplatesList />); const wrapper = mountWithContexts(<TemplatesList />);
wrapper.find('TemplatesList').setState({ wrapper.find('TemplatesList').setState({
templates: mockTemplates, templates: mockTemplates,