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

@@ -1,88 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import { shape, number, string, bool, func } from 'prop-types';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Link
} from 'react-router-dom';
import {
Badge,
Switch
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { Badge, Switch, DataListItem, DataListCell } from '@patternfly/react-core';
class NotificationListItem extends React.Component {
render () {
const {
canToggleNotifications,
itemId,
name,
notificationType,
detailUrl,
successTurnedOn,
errorTurnedOn,
toggleNotification
} = this.props;
const capText = {
textTransform: 'capitalize'
};
function NotificationListItem (props) {
const {
canToggleNotifications,
notification,
detailUrl,
successTurnedOn,
errorTurnedOn,
toggleNotification
} = props;
const capText = {
textTransform: 'capitalize'
};
return (
<I18n>
{({ i18n }) => (
<li key={itemId} className="pf-c-data-list__item">
<div className="pf-c-data-list__cell" style={{ display: 'flex' }}>
<Link
to={{
pathname: detailUrl
}}
style={{ marginRight: '1.5em' }}
>
<b>{name}</b>
</Link>
<Badge
style={capText}
isRead
>
{notificationType}
</Badge>
</div>
<div className="pf-c-data-list__cell" style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Switch
label={i18n._(t`Successful`)}
isChecked={successTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(itemId, successTurnedOn, 'success')}
aria-label={i18n._(t`Notification success toggle`)}
/>
<Switch
label={i18n._(t`Failure`)}
isChecked={errorTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(itemId, errorTurnedOn, 'error')}
aria-label={i18n._(t`Notification failure toggle`)}
/>
</div>
</li>
)}
</I18n>
);
}
return (
<I18n>
{({ i18n }) => (
<DataListItem
aria-labelledby={`items-list-item-${notification.id}`}
key={notification.id}
>
<DataListCell>
<Link
to={{
pathname: detailUrl
}}
style={{ marginRight: '1.5em' }}
>
<b id={`items-list-item-${notification.id}`}>{notification.name}</b>
</Link>
<Badge
style={capText}
isRead
>
{notification.notification_type}
</Badge>
</DataListCell>
<DataListCell alignRight>
<Switch
id={`notification-${notification.id}-success-toggle`}
label={i18n._(t`Successful`)}
isChecked={successTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(
notification.id,
successTurnedOn,
'success'
)}
aria-label={i18n._(t`Toggle notification success`)}
/>
<Switch
id={`notification-${notification.id}-error-toggle`}
label={i18n._(t`Failure`)}
isChecked={errorTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(
notification.id,
errorTurnedOn,
'error'
)}
aria-label={i18n._(t`Toggle notification failure`)}
/>
</DataListCell>
</DataListItem>
)}
</I18n>
);
}
NotificationListItem.propTypes = {
canToggleNotifications: PropTypes.bool.isRequired,
detailUrl: PropTypes.string.isRequired,
errorTurnedOn: PropTypes.bool,
itemId: PropTypes.number.isRequired,
name: PropTypes.string,
notificationType: PropTypes.string.isRequired,
successTurnedOn: PropTypes.bool,
toggleNotification: PropTypes.func.isRequired,
notification: shape({
id: number.isRequired,
canToggleNotifications: bool.isRequired,
name: string.isRequired,
notification_type: string.isRequired,
}).isRequired,
detailUrl: string.isRequired,
errorTurnedOn: bool,
successTurnedOn: bool,
toggleNotification: func.isRequired,
};
NotificationListItem.defaultProps = {
errorTurnedOn: false,
name: null,
successTurnedOn: false,
};

View File

@@ -1,351 +0,0 @@
import React, {
Component,
Fragment
} from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { 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 { withNetwork } from '../../contexts/Network';
import DataListToolbar from '../DataListToolbar';
import NotificationListItem from './NotificationListItem';
import Pagination from '../Pagination';
import { parseQueryString } from '../../util/qs';
class Notifications 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);
const { page, page_size } = this.getQueryParams();
this.state = {
page,
page_size,
sortedColumnKey: 'name',
sortOrder: 'ascending',
count: null,
error: null,
loading: true,
results: [],
selected: [],
successTemplateIds: [],
errorTemplateIds: []
};
this.handleSearch = this.handleSearch.bind(this);
this.getQueryParams = this.getQueryParams.bind(this);
this.handleSort = this.handleSort.bind(this);
this.handleSetPage = this.handleSetPage.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this);
this.toggleNotification = this.toggleNotification.bind(this);
this.createError = this.createError.bind(this);
this.createSuccess = this.createSuccess.bind(this);
this.readNotifications = this.readNotifications.bind(this);
}
componentDidMount () {
const queryParams = this.getQueryParams();
this.readNotifications(queryParams);
}
getQueryParams (overrides = {}) {
const { location } = this.props;
const { search } = location;
const searchParams = parseQueryString(search.substring(1));
return Object.assign({}, this.defaultParams, searchParams, overrides);
}
handleSort = (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.readNotifications(queryParams);
};
handleSetPage = (pageNumber, pageSize) => {
const page = parseInt(pageNumber, 10);
const page_size = parseInt(pageSize, 10);
const queryParams = this.getQueryParams({ page, page_size });
this.readNotifications(queryParams);
};
handleSelectAll = isSelected => {
const { results } = this.state;
const selected = isSelected ? results.map(o => o.id) : [];
this.setState({ selected });
};
toggleNotification = (id, isCurrentlyOn, status) => {
if (status === 'success') {
this.createSuccess(id, isCurrentlyOn);
} else if (status === 'error') {
this.createError(id, isCurrentlyOn);
}
};
handleSearch () {
const { sortedColumnKey, sortOrder } = this.state;
this.handleSort(sortedColumnKey, sortOrder);
}
async createError (id, isCurrentlyOn) {
const { onCreateError, match, handleHttpError } = this.props;
const postParams = { id };
let errorHandled;
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await onCreateError(match.params.id, postParams);
} catch (err) {
errorHandled = handleHttpError(err);
if (!errorHandled) {
this.setState({ error: true });
}
} finally {
if (!errorHandled) {
if (isCurrentlyOn) {
// Remove it from state
this.setState((prevState) => ({
errorTemplateIds: prevState.errorTemplateIds.filter((templateId) => templateId !== id)
}));
} else {
// Add it to state
this.setState(prevState => ({
errorTemplateIds: [...prevState.errorTemplateIds, id]
}));
}
}
}
}
async createSuccess (id, isCurrentlyOn) {
const { onCreateSuccess, match, handleHttpError } = this.props;
const postParams = { id };
let errorHandled;
if (isCurrentlyOn) {
postParams.disassociate = true;
}
try {
await onCreateSuccess(match.params.id, postParams);
} catch (err) {
errorHandled = handleHttpError(err);
if (!errorHandled) {
this.setState({ error: true });
}
} finally {
if (!errorHandled) {
if (isCurrentlyOn) {
// Remove it from state
this.setState((prevState) => ({
successTemplateIds: prevState.successTemplateIds
.filter((templateId) => templateId !== id)
}));
} else {
// Add it to state
this.setState(prevState => ({
successTemplateIds: [...prevState.successTemplateIds, id]
}));
}
}
}
}
async readNotifications (queryParams) {
const { noInitialResults } = this.state;
const { onReadNotifications, onReadSuccess, onReadError, match, handleHttpError } = 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);
}
this.setState({ error: false, loading: true });
try {
const { data } = await onReadNotifications(match.params.id, queryParams);
const { count, results } = data;
const pageCount = Math.ceil(count / page_size);
const stateToUpdate = {
count,
page,
pageCount,
page_size,
sortOrder,
sortedColumnKey,
results,
noInitialResults,
selected: []
};
// This is in place to track whether or not the initial request
// return any results. If it did not, we show the empty state.
// This will become problematic once search is in play because
// the first load may have query params (think bookmarked search)
if (typeof noInitialResults === 'undefined') {
stateToUpdate.noInitialResults = results.length === 0;
}
this.setState(stateToUpdate);
const notificationTemplateIds = results
.map(notificationTemplate => notificationTemplate.id)
.join(',');
let successTemplateIds = [];
let errorTemplateIds = [];
if (results.length > 0) {
const successTemplatesPromise = onReadSuccess(match.params.id, {
id__in: notificationTemplateIds
});
const errorTemplatesPromise = onReadError(match.params.id, {
id__in: notificationTemplateIds
});
const successTemplatesResult = await successTemplatesPromise;
const errorTemplatesResult = await errorTemplatesPromise;
successTemplateIds = successTemplatesResult.data.results
.map(successTemplate => successTemplate.id);
errorTemplateIds = errorTemplatesResult.data.results
.map(errorTemplate => errorTemplate.id);
}
this.setState({
successTemplateIds,
errorTemplateIds,
loading: false
});
} catch (err) {
handleHttpError(err) || this.setState({ error: true, loading: false });
}
}
render () {
const {
count,
error,
loading,
page,
pageCount,
page_size,
sortedColumnKey,
sortOrder,
results,
noInitialResults,
selected,
successTemplateIds,
errorTemplateIds
} = this.state;
const { canToggleNotifications } = this.props;
return (
<Fragment>
{noInitialResults && (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>No Notifications Found</Trans>
</Title>
<EmptyStateBody>
<Trans>Please add a notification template to populate this list</Trans>
</EmptyStateBody>
</EmptyState>
)}
{(
typeof noInitialResults !== 'undefined'
&& !noInitialResults
&& !loading
&& !error
) && (
<Fragment>
<DataListToolbar
isAllSelected={selected.length === results.length}
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
onSearch={this.handleSearch}
onSort={this.handleSort}
onSelectAll={this.handleSelectAll}
/>
<I18n>
{({ i18n }) => (
<ul className="pf-c-data-list" aria-label={i18n._(t`Organizations List`)}>
{results.map(o => (
<NotificationListItem
key={o.id}
itemId={o.id}
name={o.name}
notificationType={o.notification_type}
detailUrl={`/notifications/${o.id}`}
toggleNotification={this.toggleNotification}
errorTurnedOn={errorTemplateIds.includes(o.id)}
successTurnedOn={successTemplateIds.includes(o.id)}
canToggleNotifications={canToggleNotifications}
/>
))}
</ul>
)}
</I18n>
<Pagination
count={count}
page={page}
pageCount={pageCount}
page_size={page_size}
onSetPage={this.handleSetPage}
/>
</Fragment>
)}
{loading ? <div>loading...</div> : ''}
{error ? <div>error</div> : ''}
</Fragment>
);
}
}
Notifications.propTypes = {
canToggleNotifications: PropTypes.bool.isRequired,
onReadError: PropTypes.func.isRequired,
onReadNotifications: PropTypes.func.isRequired,
onReadSuccess: PropTypes.func.isRequired,
onCreateError: PropTypes.func.isRequired,
onCreateSuccess: PropTypes.func.isRequired,
};
export { Notifications as _Notifications };
export default withRouter(withNetwork(Notifications));

View File

@@ -0,0 +1,230 @@
import React, { Fragment } from 'react';
import PropTypes, { arrayOf, shape, string, bool } 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 '../Pagination';
import DataListToolbar from '../DataListToolbar';
import { encodeQueryString, parseQueryString } from '../../util/qs';
import { pluralize, getArticle, ucFirst } from '../../util/strings';
const detailWrapperStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)',
};
const detailLabelStyle = {
fontWeight: '700',
lineHeight: '24px',
marginRight: '20px',
};
class PaginatedDataList extends React.Component {
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 [queryParams.order_by.substr(1), 'descending'];
}
return [queryParams.order_by, 'ascending'];
}
handleSetPage (pageNumber, pageSize) {
this.pushHistoryState({
page: pageNumber,
page_size: pageSize,
});
}
handleSort (sortedColumnKey, sortOrder) {
this.pushHistoryState({
order_by: sortOrder === 'ascending' ? sortedColumnKey : `-${sortedColumnKey}`,
page: null,
});
}
pushHistoryState (newParams) {
const { history } = this.props;
const { pathname, search } = history.location;
const currentParams = parseQueryString(search);
const qs = encodeQueryString({
...currentParams,
...newParams
});
history.push(`${pathname}?${qs}`);
}
getPluralItemName () {
const { itemName, itemNamePlural } = this.props;
return itemNamePlural || `${itemName}s`;
}
render () {
const {
items,
itemCount,
queryParams,
renderItem,
toolbarColumns,
additionalControls,
itemName,
itemNamePlural,
} = this.props;
const { error } = this.state;
const [orderBy, sortOrder] = this.getSortOrder();
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
)}
{items.length === 0 ? (
<EmptyState>
<EmptyStateIcon icon={CubesIcon} />
<Title size="lg">
<Trans>
No
{' '}
{ucFirst(itemNamePlural || pluralize(itemName))}
{' '}
Found
</Trans>
</Title>
<EmptyStateBody>
<Trans>
Please add
{' '}
{getArticle(itemName)}
{' '}
{itemName}
{' '}
to populate this list
</Trans>
</EmptyStateBody>
</EmptyState>
) : (
<Fragment>
<DataListToolbar
sortedColumnKey={orderBy}
sortOrder={sortOrder}
columns={toolbarColumns}
onSearch={() => { }}
onSort={this.handleSort}
showAdd={!!additionalControls}
add={additionalControls}
/>
<DataList aria-label={i18n._(t`${ucFirst(pluralize(itemName))} List`)}>
{items.map(item => (renderItem ? renderItem(item) : (
<DataListItem
aria-labelledby={`items-list-item-${item.id}`}
key={item.id}
>
<DataListCell>
<TextContent style={detailWrapperStyle}>
<Link to={{ pathname: item.url }}>
<Text
id="items-list-item"
component={TextVariants.h6}
style={detailLabelStyle}
>
<span id={`items-list-item-${item.id}`}>
{item.name}
</span>
</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,
});
const QueryParams = PropTypes.shape({
page: PropTypes.number,
page_size: PropTypes.number,
order_by: PropTypes.string,
});
PaginatedDataList.propTypes = {
items: PropTypes.arrayOf(Item).isRequired,
itemCount: PropTypes.number.isRequired,
itemName: PropTypes.string,
itemNamePlural: PropTypes.string,
// TODO: determine this internally but pass in defaults?
queryParams: QueryParams.isRequired,
renderItem: PropTypes.func,
toolbarColumns: arrayOf(shape({
name: string.isRequired,
key: string.isRequired,
isSortable: bool,
})),
additionalControls: PropTypes.node,
};
PaginatedDataList.defaultProps = {
renderItem: null,
toolbarColumns: [
{ name: i18nMark('Name'), key: 'name', isSortable: true },
],
additionalControls: null,
itemName: 'item',
itemNamePlural: '',
};
export { PaginatedDataList as _PaginatedDataList };
export default withRouter(PaginatedDataList);

View File

@@ -0,0 +1,3 @@
import PaginatedDataList from './PaginatedDataList';
export default PaginatedDataList;

View File

@@ -36,7 +36,7 @@ class Search extends React.Component {
const { columns } = this.props;
const { innerText } = target;
const [{ key: searchKey }] = columns.filter(({ name }) => name === innerText);
const { key: searchKey } = columns.find(({ name }) => name === innerText);
this.setState({ isSearchDropdownOpen: false, searchKey });
}
@@ -62,7 +62,7 @@ class Search extends React.Component {
searchValue,
} = this.state;
const [{ name: searchColumnName }] = columns.filter(({ key }) => key === searchKey);
const { name: searchColumnName } = columns.find(({ key }) => key === searchKey);
const searchDropdownItems = columns
.filter(({ key }) => key !== searchKey)