Admin - rework user list/user detail (#80)

* fix UserCommentSchema to no longer exclude contributions (no longer on ProposalSchema)

* return more detail for /admin/users/<id>

* admin: add UserDetail + refactoring

* remove unused state from UserItem.tsx
This commit is contained in:
AMStrix 2019-01-16 23:01:29 -06:00 committed by William O'Beirne
parent 48912c95cc
commit c0557a9fa6
17 changed files with 445 additions and 215 deletions

View File

@ -8,6 +8,7 @@ import store from './store';
import Login from 'components/Login';
import Home from 'components/Home';
import Users from 'components/Users';
import UserDetail from 'components/UserDetail';
import Emails from 'components/Emails';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
@ -29,7 +30,8 @@ class Routes extends React.Component<Props> {
) : (
<Switch>
<Route path="/" exact={true} component={Home} />
<Route path="/users/:id?" exact={true} component={Users} />
<Route path="/users/:id" component={UserDetail} />
<Route path="/users" component={Users} />
<Route path="/proposals/:id" component={ProposalDetail} />
<Route path="/proposals" component={Proposals} />
<Route path="/emails/:type?" component={Emails} />

View File

@ -0,0 +1,10 @@
h1.Back {
font-size: 1.5rem !important;
a {
position: relative;
top: -0.25rem;
font-size: 0.8rem;
padding: 0 0.5rem;
}
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Icon } from 'antd';
import './index.less';
interface Props {
to: string;
text: string;
}
const Back: React.SFC<Props> = p => (
<h1 className="Back">
<Link to={p.to}>
<Icon type="arrow-left" />
</Link>
{p.text}
</h1>
);
export default Back;

View File

@ -7,8 +7,9 @@ import store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { PROPOSAL_STATUS } from 'src/types';
import { Link } from 'react-router-dom';
import './index.less';
import Back from 'components/Back';
import Markdown from 'components/Markdown';
import './index.less';
type Props = RouteComponentProps<any>;
@ -155,6 +156,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return (
<div className="ProposalDetail">
<Back to="/proposals" text="Proposals" />
<h1>{p.title}</h1>
<Row gutter={16}>
{/* MAIN */}
@ -198,9 +200,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{/* TEAM */}
<Card title="Team" size="small">
{p.team.map(t => (
<Link key={t.userid} to={`/users/${t.userid}`}>
{t.displayName}
</Link>
<div key={t.userid}>
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
</div>
))}
</Card>
{/* TODO: contributors here? */}

View File

@ -19,7 +19,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
const deleteAction = (
<Popconfirm
onConfirm={this.handleDelete}
title="Permanently delete proposal?"
title="Are you sure?"
okText="delete"
cancelText="cancel"
>

View File

@ -1,15 +1,14 @@
import React from 'react';
import qs from 'query-string';
import { uniq, without } from 'lodash';
import { Link } from 'react-router-dom';
import { view } from 'react-easy-state';
import { Icon, Button, Dropdown, Menu, Tag, List } from 'antd';
import { ClickParam } from 'antd/lib/menu';
import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store';
import ProposalItem from './ProposalItem';
import { PROPOSAL_STATUS, Proposal } from 'src/types';
import STATUSES, { getStatusById } from './STATUSES';
import { ClickParam } from 'antd/lib/menu';
import './index.less';
interface Query {
@ -32,34 +31,9 @@ class ProposalsNaked extends React.Component<Props, State> {
}
render() {
const id = Number(this.props.match.params.id);
const { proposals, proposalsFetching, proposalsFetched } = store;
const { statusFilters } = this.state;
if (!proposalsFetched) {
return 'loading proposals...';
}
if (id) {
const singleProposal = proposals.find(p => p.proposalId === id);
if (singleProposal) {
return (
<div className="Proposals">
<div className="Proposals-controls">
<Link to="/proposals">proposals</Link> <Icon type="right" /> {id}{' '}
<Button
title="refresh"
icon="reload"
onClick={() => store.fetchProposals()}
/>
</div>
<ProposalItem key={singleProposal.proposalId} {...singleProposal} />
</div>
);
} else {
return `could not find proposal: ${id}`;
}
}
const loading = !proposalsFetched || proposalsFetching;
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
@ -99,16 +73,14 @@ class ProposalsNaked extends React.Component<Props, State> {
)}
</div>
)}
{proposalsFetching && 'Fetching proposals...'}
{proposalsFetched &&
!proposalsFetching && (
<List
className="Proposals-list"
bordered
dataSource={proposals}
loading={loading}
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
/>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
.UserDetail {
h1 {
font-size: 1.2rem;
margin-bottom: 1rem;
& > * + * {
margin-left: 0.5rem;
}
}
&-comment {
color: black;
margin-left: 1rem;
}
&-deet {
position: relative;
margin-bottom: 0.9rem;
& > div {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
& > span {
font-size: 0.7rem;
position: absolute;
bottom: -0.7rem;
}
}
& .ant-card,
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
}

View File

@ -0,0 +1,207 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Row, Col, Card, Button, Collapse, Popconfirm, Avatar, List } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
import { Proposal, Comment, Contribution } from 'src/types';
import { formatDateSeconds, formatDateMs } from 'util/time';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import './index.less';
import Markdown from 'components/Markdown';
type Props = RouteComponentProps<any>;
const STATE = {};
type State = typeof STATE;
class UserDetailNaked extends React.Component<Props, State> {
state = STATE;
rejectInput: null | TextArea = null;
componentDidMount() {
this.loadDetail();
}
render() {
const id = this.getIdFromQuery();
const { userDetail: u, userDetailFetching } = store;
if (!u || (u && u.userid !== id) || userDetailFetching) {
return 'loading user...';
}
const renderDelete = () => (
<Popconfirm
onConfirm={this.handleDelete}
title="Delete user?"
okText="delete"
cancelText="cancel"
>
<Button icon="delete" block>
Delete
</Button>
</Popconfirm>
);
const renderDeetItem = (name: string, val: any) => (
<div className="UserDetail-deet">
<span>{name}</span>
<div>{(val && val) || 'n/a'}</div>
</div>
);
return (
<div className="UserDetail">
<Back to="/users" text="Users" />
<h1>
<Avatar
size="default"
shape="square"
icon="user"
src={(u.avatar && u.avatar.imageUrl) || ''}
/>
<span>
{u.displayName} - {u.emailAddress}
</span>
</h1>
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
{/* DETAILS */}
<Card title="details" size="small">
{renderDeetItem('userid', u.userid)}
{renderDeetItem('displayName', u.displayName)}
{renderDeetItem('emailAddress', u.emailAddress)}
{renderDeetItem('title', u.title)}
{(u.socialMedias.length > 0 &&
u.socialMedias.map((sm, idx) => (
<div key={sm.service}>
{renderDeetItem(
`socialMedias[${idx}]`,
<>
{sm.service}/{sm.username} <a href={sm.url}>{sm.url}</a>
</>,
)}
</div>
))) ||
renderDeetItem('socialMedias', '')}
{renderDeetItem(
'avatar.imageUrl',
u.avatar && <a href={u.avatar.imageUrl}>{u.avatar.imageUrl}</a>,
)}
</Card>
<Collapse defaultActiveKey={['proposals', 'contributions']}>
{/* PROPOSALS */}
<Collapse.Panel
key="proposals"
header={`proposals (${u.proposals.length})`}
>
<List
size="small"
dataSource={u.proposals}
renderItem={(p: Proposal) => (
<List.Item
actions={[
<Link key="view" to={`/proposals/${p.proposalId}`}>
view
</Link>,
]}
>
<List.Item.Meta title={p.title} description={p.brief} />
</List.Item>
)}
/>
</Collapse.Panel>
{/* CONTRIBUTIONS */}
<Collapse.Panel
key="contributions"
header={`contributions (${u.contributions.length})`}
>
<List
size="small"
dataSource={u.contributions}
renderItem={(c: Contribution) => (
<List.Item>
<List.Item.Meta
title={
<div>
<b>{c.amount}</b>
ZEC to{' '}
<Link to={`/proposals/${c.proposal.proposalId}`}>
{c.proposal.title}
</Link>{' '}
on {formatDateSeconds(c.dateCreated)}
</div>
}
description={
<>
id: {c.id}, status: {c.status}
</>
}
/>
</List.Item>
)}
/>
</Collapse.Panel>
{/* COMMENTS */}
<Collapse.Panel key="comments" header={`comments (${u.comments.length})`}>
<List
size="small"
dataSource={u.comments}
renderItem={(c: Comment) => (
<List.Item>
<List.Item.Meta
description={
<>
<div>
on{' '}
<Link to={`/proposals/${c.proposalId}`}>
{c.proposal && c.proposal.title}
</Link>{' '}
at {formatDateMs(c.dateCreated)}
</div>
<Markdown source={c.content} className="UserDetail-comment" />
</>
}
/>
</List.Item>
)}
/>
</Collapse.Panel>
{/* JSON */}
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(u, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
</Col>
{/* SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card size="small">{renderDelete()}</Card>
</Col>
</Row>
</div>
);
}
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
private loadDetail = () => {
store.fetchUserDetail(this.getIdFromQuery());
};
private handleDelete = () => {
if (!store.userDetail) return;
store.deleteUser(store.userDetail.userid);
};
}
const UserDetail = withRouter(view(UserDetailNaked));
export default UserDetail;

View File

@ -0,0 +1,2 @@
.UserItem {
}

View File

@ -0,0 +1,48 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Popconfirm, List, Avatar } from 'antd';
import { Link } from 'react-router-dom';
import store from 'src/store';
import { User } from 'src/types';
import './UserItem.less';
class UserItemNaked extends React.Component<User> {
render() {
const p = this.props;
const deleteAction = (
<Popconfirm
onConfirm={this.handleDelete}
title="Are you sure?"
okText="delete"
cancelText="cancel"
>
<div>delete</div>
</Popconfirm>
);
const viewAction = <Link to={`/users/${p.userid}`}>view</Link>;
const actions = [viewAction, deleteAction];
return (
<List.Item key={p.userid} className="UserItem" actions={actions}>
<List.Item.Meta
avatar={
<Avatar
shape="square"
icon="user"
src={(p.avatar && p.avatar.imageUrl) || ''}
/>
}
title={p.displayName}
description={p.emailAddress}
/>
</List.Item>
);
}
private handleDelete = () => {
store.deleteUser(this.props.userid);
};
}
const UserItem = view(UserItemNaked);
export default UserItem;

View File

@ -1,57 +1,12 @@
@controls-height: 40px;
.Users {
margin-top: @controls-height + 0.5rem;
h1 {
font-size: 1.5rem;
}
&-controls {
height: @controls-height;
padding: 0.25rem 1rem;
margin-left: -1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
right: 0;
left: 216px;
z-index: 5;
background: white;
}
&-user {
display: flex;
padding-bottom: 1rem;
border-bottom: 1px solid rgb(214, 214, 214);
margin-bottom: 1rem;
&-controls {
margin: 0 0.5rem 0.5rem 0;
background: rgba(0, 0, 0, 0.1);
padding: 0.1rem;
border-radius: 0.5rem;
}
&-img {
width: 100px;
height: 100px;
margin-bottom: 0.5rem;
& > * {
margin-right: 0.5rem;
background: rgba(0, 0, 0, 0.1);
& img {
width: 100%;
}
}
& button {
cursor: pointer;
margin: 0 0.3rem 0 0;
outline: none !important;
&:hover {
color: #1890ff;
}
}
&-list {
margin-top: 1rem;
}
}

View File

@ -1,11 +1,10 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Button, Popover, Icon } from 'antd';
import { Button, List } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import store from 'src/store';
import { User } from 'src/types';
import Field from 'components/Field';
import UserItem from './UserItem';
import './index.less';
type Props = RouteComponentProps<any>;
@ -16,123 +15,25 @@ class UsersNaked extends React.Component<Props> {
}
render() {
const id = parseInt(this.props.match.params.id, 10);
const { users, usersFetched } = store;
if (!usersFetched) {
return 'loading users...';
}
if (id) {
const singleUser = users.find(u => u.userid === id);
if (singleUser) {
return (
<div className="Users">
<div className="Users-controls">
<Link to="/users">users</Link> <Icon type="right" /> {id}{' '}
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
</div>
<UserItem key={singleUser.userid} {...singleUser} />
</div>
);
} else {
return `could not find user: ${id}`;
}
}
const { users, usersFetched, usersFetching } = store;
const loading = !usersFetched || usersFetching;
return (
<div className="Users">
<div className="Users-controls">
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
</div>
{users.length === 0 && <div>no users</div>}
{users.length > 0 && users.map(u => <UserItem key={u.userid} {...u} />)}
<List
className="Users-list"
bordered
dataSource={users}
loading={loading}
renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
/>
</div>
);
}
}
// tslint:disable-next-line:max-classes-per-file
class UserItemNaked extends React.Component<User> {
state = {
showProposals: false,
activeProposal: '',
showDelete: false,
};
render() {
const u = this.props;
return (
<div key={u.userid} className="Users-user">
<div>
<div className="Users-user-controls">
<Popover
content={
<div>
<Button type="primary" onClick={this.handleDelete}>
delete {u.emailAddress}
</Button>{' '}
<Button onClick={() => this.setState({ showDelete: false })}>
cancel
</Button>
</div>
}
title="Permanently delete user?"
trigger="click"
visible={this.state.showDelete}
onVisibleChange={showDelete => this.setState({ showDelete })}
>
<Button icon="delete" shape="circle" size="small" title="delete" />
</Popover>
{/* TODO: implement silence user on BE */}
<Button
icon="notification"
shape="circle"
size="small"
title={false ? 'allow commenting' : 'disable commenting'}
type={false ? 'danger' : 'default'}
disabled={true}
/>
</div>
<div className="Users-user-img">
{u.avatar ? <img src={u.avatar.imageUrl} /> : 'n/a'}
</div>
</div>
<div>
<Field title="displayName" value={u.displayName} />
<Field title="title" value={u.title} />
<Field title="emailAddress" value={u.emailAddress} />
<Field title="userid" value={u.userid} />
<Field
title="avatar.imageUrl"
value={(u.avatar && u.avatar.imageUrl) || 'n/a'}
/>
<Field
title={`proposals (${u.proposals.length})`}
value={
<div className="Users-user-proposals">
{u.proposals.map(p => (
<div key={p.proposalId}>
{p.title} (
<Link to={`/proposals/${p.proposalId}`}>{p.proposalId}</Link>)
</div>
))}
</div>
}
/>
<Field
title={`comments (${u.comments.length})`}
value={<div>TODO: comments</div>}
/>
</div>
</div>
);
}
private handleDelete = () => {
store.deleteUser(this.props.userid);
};
}
const UserItem = view(UserItemNaked);
const Users = withRouter(view(UsersNaked));
export default Users;

View File

@ -36,6 +36,11 @@ async function fetchUsers() {
return data;
}
async function fetchUserDetail(id: number) {
const { data } = await api.get(`/admin/users/${id}`);
return data;
}
async function deleteUser(id: number | string) {
const { data } = await api.delete('/admin/users/' + id);
return data;
@ -84,14 +89,20 @@ const app = store({
proposalCount: 0,
proposalPendingCount: 0,
},
usersFetching: false,
usersFetched: false,
users: [] as User[],
userDetailFetching: false,
userDetail: null as null | User,
proposalsFetching: false,
proposalsFetched: false,
proposals: [] as Proposal[],
proposalDetailFetching: false,
proposalDetail: null as null | Proposal,
proposalDetailApproving: false,
emailExamples: {} as { [type: string]: EmailExample },
removeGeneralError(i: number) {
@ -141,12 +152,24 @@ const app = store({
},
async fetchUsers() {
app.usersFetching = true;
try {
app.users = await fetchUsers();
app.usersFetched = true;
} catch (e) {
handleApiError(e);
}
app.usersFetching = false;
},
async fetchUserDetail(id: number) {
app.userDetailFetching = true;
try {
app.userDetail = await fetchUserDetail(id);
} catch (e) {
handleApiError(e);
}
app.userDetailFetching = false;
},
async deleteUser(id: string | number) {

View File

@ -1,6 +1,8 @@
// backend
export interface SocialMedia {
socialMediaLink: string;
url: string;
service: string;
username: string;
}
export interface Milestone {
content: string;
@ -41,9 +43,20 @@ export interface Proposal {
}
export interface Comment {
commentId: string;
dateCreated: string;
proposalId: Proposal['proposalId'];
proposal?: Proposal;
dateCreated: number;
content: string;
}
export interface Contribution {
id: number;
status: string;
txId: null | string;
amount: string;
dateCreated: number;
user: User;
proposal: Proposal;
}
export interface User {
accountAddress: string;
avatar: null | { imageUrl: string };
@ -54,6 +67,7 @@ export interface User {
userid: number;
proposals: Proposal[];
comments: Comment[];
contributions: Contribution[];
}
export interface EmailExample {

View File

@ -5,3 +5,7 @@ const DATE_FMT_STRING = 'MM/DD/YYYY h:mm a';
export const formatDateSeconds = (s: number) => {
return moment(s * 1000).format(DATE_FMT_STRING);
};
export const formatDateMs = (s: number) => {
return moment(s).format(DATE_FMT_STRING);
};

View File

@ -7,9 +7,16 @@ from flask_cors import CORS, cross_origin
from sqlalchemy import func, or_
from grant.extensions import db
from grant.user.models import User, users_schema
from grant.proposal.models import Proposal, proposals_schema, proposal_schema, PENDING
from grant.comment.models import Comment, comments_schema
from grant.user.models import User, users_schema, user_schema
from grant.proposal.models import (
Proposal,
ProposalContribution,
proposals_schema,
proposal_schema,
user_proposal_contributions_schema,
PENDING
)
from grant.comment.models import Comment, comments_schema, user_comments_schema
from grant.email.send import generate_email
from .example_emails import example_email_args
@ -82,6 +89,9 @@ def stats():
}
# USERS
@blueprint.route('/users/<id>', methods=['DELETE'])
@endpoint.api()
@auth_required
@ -95,12 +105,28 @@ def delete_user(id):
def get_users():
users = User.query.all()
result = users_schema.dump(users)
for user in result:
return result
@blueprint.route('/users/<id>', methods=['GET'])
@endpoint.api()
@auth_required
def get_user(id):
user_db = User.query.filter(User.id == id).first()
if user_db:
user = user_schema.dump(user_db)
user_proposals = Proposal.query.filter(Proposal.team.any(id=user['userid'])).all()
user['proposals'] = proposals_schema.dump(user_proposals)
user_comments = Comment.query.filter(Comment.user_id == user['userid']).all()
user['comments'] = comments_schema.dump(user_comments)
return result
user_comments = Comment.get_by_user(user_db)
user['comments'] = user_comments_schema.dump(user_comments)
contributions = ProposalContribution.get_by_userid(user_db.id)
contributions_dump = user_proposal_contributions_schema.dump(contributions)
user["contributions"] = contributions_dump
return user
return {"message": f"Could not find user with id {id}"}, 404
# PROPOSALS
@blueprint.route("/proposals", methods=["GET"])
@ -151,6 +177,9 @@ def approve_proposal(id, is_approve, reject_reason=None):
return {"message": "Not implemented."}, 400
# EMAIL
@blueprint.route('/email/example/<type>', methods=['GET'])
@cross_origin(supports_credentials=True)
@endpoint.api()

View File

@ -74,7 +74,6 @@ class UserCommentSchema(ma.Schema):
"ProposalSchema",
exclude=[
"comments",
"contributions",
"team",
"milestones",
"content",