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

@@ -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}