update content loading and error handling

unwind error handling

use auth cookie as source of truth, fetch config only when authenticated
This commit is contained in:
Jake McDermott
2019-05-09 15:59:43 -04:00
parent 534418c81a
commit e72f0bcfd4
50 changed files with 4721 additions and 4724 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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