158 paginated data list (#180)

* working: rename OrganizationTeamsList to PaginatedDataList

* convert org notifications list fully to PaginatedDataList

* update NotificationList tests

* refactor org access to use PaginatedDataList

* update tests for org access refactor; fix pagination & sorting

* restore Add Role functionality to Org roles

* fix displayed text when list of items is empty

* preserve query params when navigating through pagination

* fix bugs after RBAC rebase

* fix lint errors, fix add org access button
This commit is contained in:
Keith Grant
2019-04-29 10:08:50 -04:00
committed by GitHub
parent 3c06c97c32
commit 9d66b583b7
36 changed files with 4133 additions and 1427 deletions

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { func, string } from 'prop-types';
import { Button } from '@patternfly/react-core';
import { I18n, i18nMark } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import AlertModal from '../../../components/AlertModal';
import { Role } from '../../../types';
class DeleteRoleConfirmationModal extends React.Component {
static propTypes = {
role: Role.isRequired,
username: string,
onCancel: func.isRequired,
onConfirm: func.isRequired,
}
static defaultProps = {
username: '',
}
isTeamRole () {
const { role } = this.props;
return typeof role.team_id !== 'undefined';
}
render () {
const { role, username, onCancel, onConfirm } = this.props;
const title = `Remove ${this.isTeamRole() ? 'Team' : 'User'} Access`;
return (
<I18n>
{({ i18n }) => (
<AlertModal
variant="danger"
title={i18nMark(title)}
isOpen
onClose={this.hideWarning}
actions={[
<Button
key="delete"
variant="danger"
aria-label="Confirm delete"
onClick={onConfirm}
>
{i18n._(t`Delete`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={onCancel}>
{i18n._(t`Cancel`)}
</Button>
]}
>
{this.isTeamRole() ? (
<Trans>
Are you sure you want to remove
<b>{` ${role.name} `}</b>
access from
<b>{` ${role.team_name}`}</b>
? Doing so affects all members of the team.
<br />
<br />
If you
<b><i> only </i></b>
want to remove access for this particular user, please remove them from the team.
</Trans>
) : (
<Trans>
Are you sure you want to remove
<b>{` ${role.name} `}</b>
access from
<b>{` ${username}`}</b>
?
</Trans>
)}
</AlertModal>
)}
</I18n>
);
}
}
export default DeleteRoleConfirmationModal;

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { func } from 'prop-types';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListCell,
Text,
TextContent,
TextVariants,
Chip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { AccessRecord } from '../../../types';
import BasicChip from '../../../components/BasicChip/BasicChip';
const userRolesWrapperStyle = {
display: 'flex',
flexWrap: 'wrap',
};
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
const detailValueStyle = {
lineHeight: '28px',
overflow: 'visible',
};
/* TODO: does PF offer any sort of <dl> treatment for this? */
const Detail = ({ label, value, url, customStyles }) => {
let detail = null;
if (value) {
detail = (
<TextContent style={{ ...detailWrapperStyle, ...customStyles }}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
)}
<Text component={TextVariants.p} style={detailValueStyle}>{value}</Text>
</TextContent>
);
}
return detail;
};
class OrganizationAccessItem extends React.Component {
static propTypes = {
accessRecord: AccessRecord.isRequired,
onRoleDelete: func.isRequired,
};
getRoleLists () {
const { accessRecord } = this.props;
const teamRoles = [];
const userRoles = [];
function sort (item) {
const { role } = item;
if (role.team_id) {
teamRoles.push(role);
} else {
userRoles.push(role);
}
}
accessRecord.summary_fields.direct_access.map(sort);
accessRecord.summary_fields.indirect_access.map(sort);
return [teamRoles, userRoles];
}
render () {
const { accessRecord, onRoleDelete } = this.props;
const [teamRoles, userRoles] = this.getRoleLists();
return (
<I18n>
{({ i18n }) => (
<DataListItem aria-labelledby="access-list-item" key={accessRecord.id}>
<DataListCell>
{accessRecord.username && (
<TextContent style={detailWrapperStyle}>
{accessRecord.url ? (
<Link to={{ pathname: accessRecord.url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>
{accessRecord.username}
</Text>
</Link>
) : (
<Text component={TextVariants.h6} style={detailLabelStyle}>
{accessRecord.username}
</Text>
)}
</TextContent>
)}
{accessRecord.first_name || accessRecord.last_name ? (
<Detail
label={i18n._(t`Name`)}
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
url={null}
customStyles={null}
/>
) : (
null
)}
</DataListCell>
<DataListCell>
{userRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>
{i18n._(t`User Roles`)}
</Text>
{userRoles.map(role => (
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => { onRoleDelete(role, accessRecord); }}
>
{role.name}
</Chip>
) : (
<BasicChip key={role.id}>
{role.name}
</BasicChip>
)
))}
</ul>
)}
{teamRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>
{i18n._(t`Team Roles`)}
</Text>
{teamRoles.map(role => (
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => { onRoleDelete(role, accessRecord); }}
>
{role.name}
</Chip>
) : (
<BasicChip key={role.id}>
{role.name}
</BasicChip>
)
))}
</ul>
)}
</DataListCell>
</DataListItem>
)}
</I18n>
);
}
}
export default OrganizationAccessItem;

View File

@@ -1,486 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
DataList, DataListItem, DataListCell, Text,
TextContent, TextVariants, Chip, Button
} from '@patternfly/react-core';
import {
PlusIcon,
} from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import {
Link,
withRouter
} from 'react-router-dom';
import { withNetwork } from '../../../contexts/Network';
import AlertModal from '../../../components/AlertModal';
import BasicChip from '../../../components/BasicChip/BasicChip';
import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar';
import AddResourceRole from '../../../components/AddRole/AddResourceRole';
import {
parseQueryString,
} from '../../../util/qs';
const userRolesWrapperStyle = {
display: 'flex',
flexWrap: 'wrap',
};
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
const detailValueStyle = {
lineHeight: '28px',
overflow: 'visible',
};
const Detail = ({ label, value, url, customStyles }) => {
let detail = null;
if (value) {
detail = (
<TextContent style={{ ...detailWrapperStyle, ...customStyles }}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
)}
<Text component={TextVariants.p} style={detailValueStyle}>{value}</Text>
</TextContent>
);
}
return detail;
};
const UserName = ({ value, url }) => {
let username = null;
if (value) {
username = (
<TextContent style={detailWrapperStyle}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{value}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{value}</Text>
)}
</TextContent>
);
}
return username;
};
class OrganizationAccessList extends React.Component {
columns = [
{ name: i18nMark('Name'), key: 'first_name', isSortable: true },
{ name: i18nMark('Username'), key: 'username', isSortable: true },
{ name: i18nMark('Last Name'), key: 'last_name', isSortable: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'first_name',
};
constructor (props) {
super(props);
const { page, page_size } = this.getQueryParams();
this.state = {
page,
page_size,
count: 0,
sortOrder: 'ascending',
sortedColumnKey: 'username',
showWarning: false,
warningTitle: '',
warningMsg: '',
deleteType: '',
deleteRoleId: null,
deleteResourceId: null,
results: [],
isModalOpen: false
};
this.fetchOrgAccessList = this.fetchOrgAccessList.bind(this);
this.onSetPage = this.onSetPage.bind(this);
this.onSort = this.onSort.bind(this);
this.getQueryParams = this.getQueryParams.bind(this);
this.removeAccessRole = this.removeAccessRole.bind(this);
this.handleWarning = this.handleWarning.bind(this);
this.hideWarning = this.hideWarning.bind(this);
this.confirmDelete = this.confirmDelete.bind(this);
this.handleModalToggle = this.handleModalToggle.bind(this);
this.handleSuccessfulRoleAdd = this.handleSuccessfulRoleAdd.bind(this);
}
componentDidMount () {
const queryParams = this.getQueryParams();
try {
this.fetchOrgAccessList(queryParams);
} catch (error) {
this.setState({ error });
}
}
onSetPage (pageNumber, pageSize) {
const { sortOrder, sortedColumnKey } = this.state;
const page = parseInt(pageNumber, 10);
const page_size = parseInt(pageSize, 10);
let order_by = sortedColumnKey;
// Preserve sort order when paginating
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
const queryParams = this.getQueryParams({ page, page_size, order_by });
this.fetchOrgAccessList(queryParams);
}
onSort (sortedColumnKey, sortOrder) {
const { page_size } = this.state;
let order_by = sortedColumnKey;
if (sortOrder === 'descending') {
order_by = `-${order_by}`;
}
const queryParams = this.getQueryParams({ order_by, page_size });
this.fetchOrgAccessList(queryParams);
}
getQueryParams (overrides = {}) {
const { history } = this.props;
const { search } = history.location;
const searchParams = parseQueryString(search.substring(1));
return Object.assign({}, this.defaultParams, searchParams, overrides);
}
async fetchOrgAccessList (queryParams) {
const { match, getAccessList } = this.props;
const { page, page_size, order_by } = queryParams;
let sortOrder = 'ascending';
let sortedColumnKey = order_by;
if (order_by.startsWith('-')) {
sortOrder = 'descending';
sortedColumnKey = order_by.substring(1);
}
try {
const { data:
{ count = 0, results = [] }
} = await getAccessList(match.params.id, queryParams);
const pageCount = Math.ceil(count / page_size);
const stateToUpdate = {
count,
page,
pageCount,
page_size,
sortOrder,
sortedColumnKey,
results,
};
results.forEach((result) => {
// Separate out roles into user roles or team roles
// based on whether or not a team_id attribute is present
const teamRoles = [];
const userRoles = [];
Object.values(result.summary_fields).forEach(field => {
if (field.length > 0) {
field.forEach(item => {
const { role } = item;
if (role.team_id) {
teamRoles.push(role);
} else {
userRoles.push(role);
}
});
}
});
result.teamRoles = teamRoles;
result.userRoles = userRoles;
});
this.setState(stateToUpdate);
} catch (error) {
this.setState({ error });
}
}
async removeAccessRole (roleId, resourceId, type) {
const { removeRole, handleHttpError } = this.props;
const url = `/api/v2/${type}/${resourceId}/roles/`;
try {
await removeRole(url, roleId);
const queryParams = this.getQueryParams();
await this.fetchOrgAccessList(queryParams);
this.setState({ showWarning: false });
} catch (error) {
handleHttpError(error) || this.setState({ error });
}
}
handleWarning (roleName, roleId, resourceName, resourceId, type) {
let warningTitle;
let warningMsg;
if (type === 'users') {
warningTitle = i18nMark('Remove User Access');
warningMsg = (
<Trans>
Are you sure you want to remove
<b>{` ${roleName} `}</b>
access from
<strong>{` ${resourceName}`}</strong>
?
</Trans>
);
}
if (type === 'teams') {
warningTitle = i18nMark('Remove Team Access');
warningMsg = (
<Trans>
Are you sure you want to remove
<b>{` ${roleName} `}</b>
access from
<b>{` ${resourceName}`}</b>
? Doing so affects all members of the team.
<br />
<br />
If you
<b><i> only </i></b>
want to remove access for this particular user, please remove them from the team.
</Trans>
);
}
this.setState({
showWarning: true,
warningMsg,
warningTitle,
deleteType: type,
deleteRoleId: roleId,
deleteResourceId: resourceId
});
}
handleSuccessfulRoleAdd () {
this.handleModalToggle();
const queryParams = this.getQueryParams();
try {
this.fetchOrgAccessList(queryParams);
} catch (error) {
this.setState({ error });
}
}
handleModalToggle () {
this.setState((prevState) => ({
isModalOpen: !prevState.isModalOpen,
}));
}
hideWarning () {
this.setState({ showWarning: false });
}
confirmDelete () {
const { deleteType, deleteResourceId, deleteRoleId } = this.state;
this.removeAccessRole(deleteRoleId, deleteResourceId, deleteType);
}
render () {
const {
results,
error,
count,
page_size,
pageCount,
page,
sortedColumnKey,
sortOrder,
warningMsg,
warningTitle,
showWarning,
isModalOpen
} = this.state;
const {
api,
organization
} = this.props;
return (
<I18n>
{({ i18n }) => (
<Fragment>
{!error && results.length <= 0 && (
<h1>Loading...</h1> // TODO: replace with proper loading state
)}
{error && results.length <= 0 && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{results.length > 0 && (
<Fragment>
<DataListToolbar
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
onSearch={() => { }}
onSort={this.onSort}
showAdd={organization.summary_fields.user_capabilities.edit}
add={(
<Fragment>
<Button
variant="primary"
aria-label={i18n._(t`Add Access Role`)}
onClick={this.handleModalToggle}
>
<PlusIcon />
</Button>
{isModalOpen && (
<AddResourceRole
onClose={this.handleModalToggle}
onSave={this.handleSuccessfulRoleAdd}
api={api}
roles={organization.summary_fields.object_roles}
/>
)}
</Fragment>
)}
/>
{showWarning && (
<AlertModal
variant="danger"
title={warningTitle}
isOpen={showWarning}
onClose={this.hideWarning}
actions={[
<Button key="delete" variant="danger" aria-label="Confirm delete" onClick={this.confirmDelete}>{i18n._(t`Delete`)}</Button>,
<Button key="cancel" variant="secondary" onClick={this.hideWarning}>{i18n._(t`Cancel`)}</Button>
]}
>
{warningMsg}
</AlertModal>
)}
<DataList aria-label={i18n._(t`Access List`)}>
{results.map(result => (
<DataListItem aria-labelledby={i18n._(t`access-list-item`)} key={result.id}>
<DataListCell>
<UserName
value={result.username}
url={result.url}
/>
{result.first_name || result.last_name ? (
<Detail
label={i18n._(t`Name`)}
value={`${result.first_name} ${result.last_name}`}
url={null}
customStyles={null}
/>
) : (
null
)}
</DataListCell>
<DataListCell>
<Detail
label=" "
value=" "
url={null}
customStyles={null}
/>
{result.userRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`User Roles`)}</Text>
{result.userRoles.map(role => (
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => this.handleWarning(role.name, role.id, result.username, result.id, 'users')}
>
{role.name}
</Chip>
) : (
<BasicChip
key={role.id}
>
{role.name}
</BasicChip>
)
))}
</ul>
)}
{result.teamRoles.length > 0 && (
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`Team Roles`)}</Text>
{result.teamRoles.map(role => (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => this.handleWarning(role.name, role.id, role.team_name, role.team_id, 'teams')}
>
{role.name}
</Chip>
))}
</ul>
)}
</DataListCell>
</DataListItem>
))}
</DataList>
<Pagination
count={count}
page={page}
pageCount={pageCount}
page_size={page_size}
onSetPage={this.onSetPage}
/>
</Fragment>
)}
</Fragment>
)}
</I18n>
);
}
}
OrganizationAccessList.propTypes = {
api: PropTypes.shape().isRequired,
getAccessList: PropTypes.func.isRequired,
organization: PropTypes.shape().isRequired,
removeRole: PropTypes.func.isRequired
};
export { OrganizationAccessList as _OrganizationAccessList };
export default withRouter(withNetwork(OrganizationAccessList));

View File

@@ -1,173 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
DataList,
DataListItem,
DataListCell,
Text,
TextContent,
TextVariants,
Title,
EmptyState,
EmptyStateIcon,
EmptyStateBody,
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
import { I18n, i18nMark } from '@lingui/react';
import { Trans, t } from '@lingui/macro';
import { withRouter, Link } from 'react-router-dom';
import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar';
import { encodeQueryString } from '../../../util/qs';
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
class OrganizationTeamsList extends React.Component {
columns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'name',
};
constructor (props) {
super(props);
this.state = {
error: null,
};
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSort = this.handleSort.bind(this);
}
getPageCount () {
const { itemCount, queryParams: { page_size } } = this.props;
return Math.ceil(itemCount / page_size);
}
getSortOrder () {
const { queryParams } = this.props;
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
return 'descending';
}
return 'ascending';
}
handleSetPage (pageNumber, pageSize) {
this.pushHistoryState({
page: pageNumber,
page_size: pageSize,
});
}
handleSort (sortedColumnKey, sortOrder) {
this.pushHistoryState({
order_by: sortOrder === 'ascending' ? sortedColumnKey : `-${sortedColumnKey}`,
});
}
pushHistoryState (params) {
const { history } = this.props;
const { pathname } = history.location;
const qs = encodeQueryString(params);
history.push(`${pathname}?${qs}`);
}
render () {
const { teams, itemCount, queryParams } = this.props;
const { error } = this.state;
return (
<I18n>
{({ i18n }) => (
<Fragment>
{error && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{teams.length === 0 ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>No Teams Found</Trans>
</Title>
<EmptyStateBody>
<Trans>Please add a team to populate this list</Trans>
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
<DataListToolbar
sortedColumnKey={queryParams.sort_by}
sortOrder={this.getSortOrder()}
columns={this.columns}
onSearch={() => { }}
onSort={this.handleSort}
/>
<DataList aria-label={i18n._(t`Teams List`)}>
{teams.map(({ url, id, name }) => (
<DataListItem aria-labelledby={i18n._(t`teams-list-item`)} key={id}>
<DataListCell>
<TextContent style={detailWrapperStyle}>
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{name}</Text>
</Link>
</TextContent>
</DataListCell>
</DataListItem>
))}
</DataList>
<Pagination
count={itemCount}
page={queryParams.page}
pageCount={this.getPageCount()}
page_size={queryParams.page_size}
onSetPage={this.handleSetPage}
/>
</Fragment>
)}
</Fragment>
)}
</I18n>
);
}
}
const Item = PropTypes.shape({
id: PropTypes.number.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
});
const QueryParams = PropTypes.shape({
page: PropTypes.number,
page_size: PropTypes.number,
order_by: PropTypes.string,
});
OrganizationTeamsList.propTypes = {
teams: PropTypes.arrayOf(Item).isRequired,
itemCount: PropTypes.number.isRequired,
queryParams: QueryParams.isRequired
};
export { OrganizationTeamsList as _OrganizationTeamsList };
export default withRouter(OrganizationTeamsList);

View File

@@ -216,23 +216,20 @@ class Organization extends Component {
)}
/>
)}
<Route
path="/organizations/:id/access"
render={() => (
<OrganizationAccess
organization={organization}
/>
)}
/>
{organization && (
<Route
path="/organizations/:id/access"
render={() => (
<OrganizationAccess
organization={organization}
/>
)}
/>
)}
<Route
path="/organizations/:id/teams"
render={() => (
<OrganizationTeams
id={Number(match.params.id)}
match={match}
location={location}
history={history}
/>
<OrganizationTeams id={Number(match.params.id)} />
)}
/>
{canSeeNotificationsTab && (
@@ -240,12 +237,17 @@ class Organization extends Component {
path="/organizations/:id/notifications"
render={() => (
<OrganizationNotifications
id={Number(match.params.id)}
canToggleNotifications={canToggleNotifications}
/>
)}
/>
)}
{organization && <NotifyAndRedirect to={`/organizations/${match.params.id}/details`} />}
{organization && (
<NotifyAndRedirect
to={`/organizations/${match.params.id}/details`}
/>
)}
</Switch>
{error ? 'error!' : ''}
{loading ? 'loading...' : ''}

View File

@@ -1,37 +1,223 @@
import React from 'react';
import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { I18n, i18nMark } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { PlusIcon } from '@patternfly/react-icons';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import OrganizationAccessItem from '../../components/OrganizationAccessItem';
import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal';
import AddResourceRole from '../../../../components/AddRole/AddResourceRole';
import { withNetwork } from '../../../../contexts/Network';
import { parseQueryString } from '../../../../util/qs';
import { Organization } from '../../../../types';
import OrganizationAccessList from '../../components/OrganizationAccessList';
const DEFAULT_QUERY_PARAMS = {
page: 1,
page_size: 5,
order_by: 'first_name',
};
class OrganizationAccess extends React.Component {
static propTypes = {
organization: Organization.isRequired,
};
constructor (props) {
super(props);
this.getOrgAccessList = this.getOrgAccessList.bind(this);
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,
};
}
getOrgAccessList (id, params) {
const { api } = this.props;
return api.getOrganizationAccessList(id, params);
componentDidMount () {
this.readOrgAccessList();
}
removeRole (url, id) {
const { api } = this.props;
return api.disassociate(url, id);
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readOrgAccessList();
}
}
async readOrgAccessList () {
const { organization, api, handleHttpError } = this.props;
this.setState({ isLoading: true });
try {
const { data } = await api.getOrganizationAccessList(
organization.id,
this.getQueryParams()
);
this.setState({
itemCount: data.count || 0,
accessRecords: data.results || [],
isLoading: false,
isInitialized: true,
});
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
getQueryParams () {
const { location } = this.props;
const searchParams = parseQueryString(location.search.substring(1));
return {
...DEFAULT_QUERY_PARAMS,
...searchParams,
};
}
confirmRemoveRole (role, accessRecord) {
this.setState({
roleToDelete: role,
roleToDeleteAccessRecord: accessRecord,
});
}
cancelRemoveRole () {
this.setState({
roleToDelete: null,
roleToDeleteAccessRecord: null
});
}
async removeRole () {
const { api, handleHttpError } = this.props;
const { roleToDelete: role, roleToDeleteAccessRecord: accessRecord } = this.state;
if (!role || !accessRecord) {
return;
}
const type = typeof role.team_id === 'undefined' ? 'users' : 'teams';
this.setState({ isLoading: true });
try {
if (type === 'teams') {
await api.disassociateTeamRole(role.team_id, role.id);
} else {
await api.disassociateUserRole(accessRecord.id, role.id);
}
this.setState({
isLoading: false,
roleToDelete: null,
roleToDeleteAccessRecord: null,
});
this.readOrgAccessList();
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
toggleAddModal () {
const { isAddModalOpen } = this.state;
this.setState({
isAddModalOpen: !isAddModalOpen,
});
}
handleSuccessfulRoleAdd () {
this.toggleAddModal();
this.readOrgAccessList();
}
render () {
const { organization } = this.props;
const { api, organization } = this.props;
const {
isLoading,
isInitialized,
itemCount,
isAddModalOpen,
accessRecords,
roleToDelete,
roleToDeleteAccessRecord,
error,
} = this.state;
const canEdit = organization.summary_fields.user_capabilities.edit;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return (
<OrganizationAccessList
getAccessList={this.getOrgAccessList}
removeRole={this.removeRole}
organization={organization}
/>
<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"
queryParams={this.getQueryParams()}
toolbarColumns={[
{ name: i18nMark('Name'), key: 'first_name', isSortable: true },
{ name: i18nMark('Username'), key: 'username', isSortable: true },
{ name: i18nMark('Last Name'), key: 'last_name', isSortable: true },
]}
additionalControls={canEdit ? (
<I18n>
{({ i18n }) => (
<Button
variant="primary"
aria-label={i18n._(t`Add Access Role`)}
onClick={this.toggleAddModal}
>
<PlusIcon />
</Button>
)}
</I18n>
) : null}
renderItem={accessRecord => (
<OrganizationAccessItem
key={accessRecord.id}
accessRecord={accessRecord}
onRoleDelete={this.confirmRemoveRole}
/>
)}
/>
)}
{isAddModalOpen && (
<AddResourceRole
onClose={this.toggleAddModal}
onSave={this.handleSuccessfulRoleAdd}
api={api}
roles={organization.summary_fields.object_roles}
/>
)}
</Fragment>
);
}
}
export default withNetwork(OrganizationAccess);
export { OrganizationAccess as _OrganizationAccess };
export default withNetwork(withRouter(OrganizationAccess));

View File

@@ -1,62 +1,224 @@
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import { number, shape, func, string, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withNetwork } from '../../../../contexts/Network';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import NotificationListItem from '../../../../components/NotificationsList/NotificationListItem';
import { parseQueryString } from '../../../../util/qs';
import NotificationsList from '../../../../components/NotificationsList/Notifications.list';
const DEFAULT_QUERY_PARAMS = {
page: 1,
page_size: 5,
order_by: 'name',
};
class OrganizationNotifications extends Component {
constructor (props) {
super(props);
this.readOrgNotifications = this.readOrgNotifications.bind(this);
this.readOrgNotificationSuccess = this.readOrgNotificationSuccess.bind(this);
this.readOrgNotificationError = this.readOrgNotificationError.bind(this);
this.createOrgNotificationSuccess = this.createOrgNotificationSuccess.bind(this);
this.createOrgNotificationError = this.createOrgNotificationError.bind(this);
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,
itemCount: 0,
notifications: [],
successTemplateIds: [],
errorTemplateIds: [],
};
}
readOrgNotifications (id, reqParams) {
const { api } = this.props;
return api.getOrganizationNotifications(id, reqParams);
componentDidMount () {
this.readNotifications();
}
readOrgNotificationSuccess (id, reqParams) {
const { api } = this.props;
return api.getOrganizationNotificationSuccess(id, reqParams);
componentDidUpdate (prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.readNotifications();
}
}
readOrgNotificationError (id, reqParams) {
const { api } = this.props;
return api.getOrganizationNotificationError(id, reqParams);
getQueryParams () {
const { location } = this.props;
const searchParams = parseQueryString(location.search.substring(1));
return {
...DEFAULT_QUERY_PARAMS,
...searchParams,
};
}
createOrgNotificationSuccess (id, data) {
const { api } = this.props;
return api.createOrganizationNotificationSuccess(id, data);
async readNotifications () {
const { api, handleHttpError, id } = this.props;
const params = this.getQueryParams();
this.setState({ isLoading: true });
try {
const { data } = await api.getOrganizationNotifications(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,
});
}
}
createOrgNotificationError (id, data) {
const { api } = this.props;
return api.createOrganizationNotificationError(id, data);
async readSuccessesAndErrors () {
const { api, handleHttpError, id } = this.props;
const { notifications } = this.state;
if (!notifications.length) {
return;
}
const ids = notifications.map(n => n.id).join(',');
try {
const successTemplatesPromise = api.getOrganizationNotificationSuccess(
id,
{ id__in: ids }
);
const errorTemplatesPromise = api.getOrganizationNotificationError(
id,
{ id__in: ids }
);
const { data: successTemplates } = await successTemplatesPromise;
const { data: errorTemplates } = await errorTemplatesPromise;
this.setState({
successTemplateIds: successTemplates.results.map(s => s.id),
errorTemplateIds: errorTemplates.results.map(e => e.id),
});
} catch (error) {
handleHttpError(error) || this.setState({
error,
isLoading: false,
});
}
}
toggleNotification = (notificationId, isCurrentlyOn, status) => {
if (status === 'success') {
this.createSuccess(notificationId, isCurrentlyOn);
} else if (status === 'error') {
this.createError(notificationId, isCurrentlyOn);
}
};
async createSuccess (notificationId, isCurrentlyOn) {
const { id, api, handleHttpError } = this.props;
const postParams = { id: notificationId };
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await api.createOrganizationNotificationSuccess(id, postParams);
if (isCurrentlyOn) {
this.setState((prevState) => ({
successTemplateIds: prevState.successTemplateIds
.filter((templateId) => templateId !== notificationId)
}));
} else {
this.setState(prevState => ({
successTemplateIds: [...prevState.successTemplateIds, notificationId]
}));
}
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
}
async createError (notificationId, isCurrentlyOn) {
const { id, api, handleHttpError } = this.props;
const postParams = { id: notificationId };
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await api.createOrganizationNotificationError(id, postParams);
if (isCurrentlyOn) {
this.setState((prevState) => ({
errorTemplateIds: prevState.errorTemplateIds
.filter((templateId) => templateId !== notificationId)
}));
} else {
this.setState(prevState => ({
errorTemplateIds: [...prevState.errorTemplateIds, notificationId]
}));
}
} catch (err) {
handleHttpError(err) || this.setState({ error: true });
}
}
render () {
const { canToggleNotifications } = this.props;
const {
canToggleNotifications
} = this.props;
notifications,
itemCount,
isLoading,
isInitialized,
error,
successTemplateIds,
errorTemplateIds,
} = this.state;
if (error) {
// TODO: better error state
return <div>{error.message}</div>;
}
// TODO: better loading state
return (
<NotificationsList
canToggleNotifications={canToggleNotifications}
onCreateError={this.createOrgNotificationError}
onCreateSuccess={this.createOrgNotificationSuccess}
onReadError={this.readOrgNotificationError}
onReadNotifications={this.readOrgNotifications}
onReadSuccess={this.readOrgNotificationSuccess}
/>
<Fragment>
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<PaginatedDataList
items={notifications}
itemCount={itemCount}
itemName="notification"
queryParams={this.getQueryParams()}
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)}
/>
)}
/>
)}
</Fragment>
);
}
}
OrganizationNotifications.propTypes = {
id: number.isRequired,
canToggleNotifications: bool.isRequired,
handleHttpError: func.isRequired,
api: shape({
getOrganizationNotifications: func.isRequired,
getOrganizationNotificationSuccess: func.isRequired,
getOrganizationNotificationError: func.isRequired,
createOrganizationNotificationSuccess: func.isRequired,
createOrganizationNotificationError: func.isRequired,
}).isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
};
export { OrganizationNotifications as _OrganizationNotifications };
export default withNetwork(OrganizationNotifications);
export default withNetwork(withRouter(OrganizationNotifications));

View File

@@ -1,7 +1,7 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import OrganizationTeamsList from '../../components/OrganizationTeamsList';
import PaginatedDataList from '../../../../components/PaginatedDataList';
import { parseQueryString } from '../../../../util/qs';
import { withNetwork } from '../../../../contexts/Network';
@@ -62,10 +62,9 @@ class OrganizationTeams extends React.Component {
isInitialized: true,
});
} catch (error) {
handleHttpError(error) && this.setState({
handleHttpError(error) || this.setState({
error,
isLoading: false,
isInitialized: true,
});
}
}
@@ -83,9 +82,10 @@ class OrganizationTeams extends React.Component {
<Fragment>
{isLoading && (<div>Loading...</div>)}
{isInitialized && (
<OrganizationTeamsList
teams={teams}
<PaginatedDataList
items={teams}
itemCount={itemCount}
itemName="team"
queryParams={this.getQueryParams()}
/>
)}

View File

@@ -34,19 +34,19 @@ import {
parseQueryString,
} from '../../../util/qs';
const COLUMNS = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: true, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true },
];
const DEFAULT_PARAMS = {
page: 1,
page_size: 5,
order_by: 'name',
};
class OrganizationsList extends Component {
columns = [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
{ name: i18nMark('Modified'), key: 'modified', isSortable: true, isNumeric: true },
{ name: i18nMark('Created'), key: 'created', isSortable: true, isNumeric: true },
];
defaultParams = {
page: 1,
page_size: 5,
order_by: 'name',
};
constructor (props) {
super(props);
@@ -141,7 +141,7 @@ class OrganizationsList extends Component {
const searchParams = parseQueryString(search.substring(1));
return Object.assign({}, this.defaultParams, searchParams, overrides);
return Object.assign({}, DEFAULT_PARAMS, searchParams, overrides);
}
handleCloseOrgDeleteModal () {
@@ -333,7 +333,7 @@ class OrganizationsList extends Component {
isAllSelected={selected.length === results.length}
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
columns={COLUMNS}
onSearch={this.onSearch}
onSort={this.onSort}
onSelectAll={this.onSelectAll}