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:
parent
48912c95cc
commit
c0557a9fa6
|
@ -8,6 +8,7 @@ import store from './store';
|
||||||
import Login from 'components/Login';
|
import Login from 'components/Login';
|
||||||
import Home from 'components/Home';
|
import Home from 'components/Home';
|
||||||
import Users from 'components/Users';
|
import Users from 'components/Users';
|
||||||
|
import UserDetail from 'components/UserDetail';
|
||||||
import Emails from 'components/Emails';
|
import Emails from 'components/Emails';
|
||||||
import Proposals from 'components/Proposals';
|
import Proposals from 'components/Proposals';
|
||||||
import ProposalDetail from 'components/ProposalDetail';
|
import ProposalDetail from 'components/ProposalDetail';
|
||||||
|
@ -29,7 +30,8 @@ class Routes extends React.Component<Props> {
|
||||||
) : (
|
) : (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" exact={true} component={Home} />
|
<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/:id" component={ProposalDetail} />
|
||||||
<Route path="/proposals" component={Proposals} />
|
<Route path="/proposals" component={Proposals} />
|
||||||
<Route path="/emails/:type?" component={Emails} />
|
<Route path="/emails/:type?" component={Emails} />
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -7,8 +7,9 @@ import store from 'src/store';
|
||||||
import { formatDateSeconds } from 'util/time';
|
import { formatDateSeconds } from 'util/time';
|
||||||
import { PROPOSAL_STATUS } from 'src/types';
|
import { PROPOSAL_STATUS } from 'src/types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import './index.less';
|
import Back from 'components/Back';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
type Props = RouteComponentProps<any>;
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
@ -155,6 +156,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ProposalDetail">
|
<div className="ProposalDetail">
|
||||||
|
<Back to="/proposals" text="Proposals" />
|
||||||
<h1>{p.title}</h1>
|
<h1>{p.title}</h1>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{/* MAIN */}
|
{/* MAIN */}
|
||||||
|
@ -198,9 +200,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{/* TEAM */}
|
{/* TEAM */}
|
||||||
<Card title="Team" size="small">
|
<Card title="Team" size="small">
|
||||||
{p.team.map(t => (
|
{p.team.map(t => (
|
||||||
<Link key={t.userid} to={`/users/${t.userid}`}>
|
<div key={t.userid}>
|
||||||
{t.displayName}
|
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
{/* TODO: contributors here? */}
|
{/* TODO: contributors here? */}
|
||||||
|
|
|
@ -19,7 +19,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
||||||
const deleteAction = (
|
const deleteAction = (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
onConfirm={this.handleDelete}
|
onConfirm={this.handleDelete}
|
||||||
title="Permanently delete proposal?"
|
title="Are you sure?"
|
||||||
okText="delete"
|
okText="delete"
|
||||||
cancelText="cancel"
|
cancelText="cancel"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import qs from 'query-string';
|
import qs from 'query-string';
|
||||||
import { uniq, without } from 'lodash';
|
import { uniq, without } from 'lodash';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { Icon, Button, Dropdown, Menu, Tag, List } from 'antd';
|
import { Icon, Button, Dropdown, Menu, Tag, List } from 'antd';
|
||||||
|
import { ClickParam } from 'antd/lib/menu';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import ProposalItem from './ProposalItem';
|
import ProposalItem from './ProposalItem';
|
||||||
import { PROPOSAL_STATUS, Proposal } from 'src/types';
|
import { PROPOSAL_STATUS, Proposal } from 'src/types';
|
||||||
import STATUSES, { getStatusById } from './STATUSES';
|
import STATUSES, { getStatusById } from './STATUSES';
|
||||||
import { ClickParam } from 'antd/lib/menu';
|
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
interface Query {
|
interface Query {
|
||||||
|
@ -32,34 +31,9 @@ class ProposalsNaked extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const id = Number(this.props.match.params.id);
|
|
||||||
const { proposals, proposalsFetching, proposalsFetched } = store;
|
const { proposals, proposalsFetching, proposalsFetched } = store;
|
||||||
const { statusFilters } = this.state;
|
const { statusFilters } = this.state;
|
||||||
|
const loading = !proposalsFetched || proposalsFetching;
|
||||||
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 statusFilterMenu = (
|
const statusFilterMenu = (
|
||||||
<Menu onClick={this.handleFilterClick}>
|
<Menu onClick={this.handleFilterClick}>
|
||||||
|
@ -99,16 +73,14 @@ class ProposalsNaked extends React.Component<Props, State> {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{proposalsFetching && 'Fetching proposals...'}
|
|
||||||
{proposalsFetched &&
|
|
||||||
!proposalsFetching && (
|
|
||||||
<List
|
<List
|
||||||
className="Proposals-list"
|
className="Proposals-list"
|
||||||
bordered
|
bordered
|
||||||
dataSource={proposals}
|
dataSource={proposals}
|
||||||
|
loading={loading}
|
||||||
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
|
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
||||||
|
.UserItem {
|
||||||
|
}
|
|
@ -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;
|
|
@ -1,57 +1,12 @@
|
||||||
@controls-height: 40px;
|
|
||||||
|
|
||||||
.Users {
|
.Users {
|
||||||
margin-top: @controls-height + 0.5rem;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-controls {
|
&-controls {
|
||||||
height: @controls-height;
|
margin-bottom: 0.5rem;
|
||||||
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-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
& img {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& button {
|
&-list {
|
||||||
cursor: pointer;
|
margin-top: 1rem;
|
||||||
margin: 0 0.3rem 0 0;
|
|
||||||
outline: none !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { Button, Popover, Icon } from 'antd';
|
import { Button, List } from 'antd';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { User } from 'src/types';
|
import { User } from 'src/types';
|
||||||
import Field from 'components/Field';
|
import UserItem from './UserItem';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
type Props = RouteComponentProps<any>;
|
type Props = RouteComponentProps<any>;
|
||||||
|
@ -16,123 +15,25 @@ class UsersNaked extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const id = parseInt(this.props.match.params.id, 10);
|
const { users, usersFetched, usersFetching } = store;
|
||||||
const { users, usersFetched } = store;
|
const loading = !usersFetched || usersFetching;
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Users">
|
<div className="Users">
|
||||||
<div className="Users-controls">
|
<div className="Users-controls">
|
||||||
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
|
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
|
||||||
</div>
|
</div>
|
||||||
{users.length === 0 && <div>no users</div>}
|
<List
|
||||||
{users.length > 0 && users.map(u => <UserItem key={u.userid} {...u} />)}
|
className="Users-list"
|
||||||
|
bordered
|
||||||
|
dataSource={users}
|
||||||
|
loading={loading}
|
||||||
|
renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
|
||||||
|
/>
|
||||||
</div>
|
</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));
|
const Users = withRouter(view(UsersNaked));
|
||||||
export default Users;
|
export default Users;
|
||||||
|
|
|
@ -36,6 +36,11 @@ async function fetchUsers() {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUserDetail(id: number) {
|
||||||
|
const { data } = await api.get(`/admin/users/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteUser(id: number | string) {
|
async function deleteUser(id: number | string) {
|
||||||
const { data } = await api.delete('/admin/users/' + id);
|
const { data } = await api.delete('/admin/users/' + id);
|
||||||
return data;
|
return data;
|
||||||
|
@ -84,14 +89,20 @@ const app = store({
|
||||||
proposalCount: 0,
|
proposalCount: 0,
|
||||||
proposalPendingCount: 0,
|
proposalPendingCount: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
usersFetching: false,
|
||||||
usersFetched: false,
|
usersFetched: false,
|
||||||
users: [] as User[],
|
users: [] as User[],
|
||||||
|
userDetailFetching: false,
|
||||||
|
userDetail: null as null | User,
|
||||||
|
|
||||||
proposalsFetching: false,
|
proposalsFetching: false,
|
||||||
proposalsFetched: false,
|
proposalsFetched: false,
|
||||||
proposals: [] as Proposal[],
|
proposals: [] as Proposal[],
|
||||||
proposalDetailFetching: false,
|
proposalDetailFetching: false,
|
||||||
proposalDetail: null as null | Proposal,
|
proposalDetail: null as null | Proposal,
|
||||||
proposalDetailApproving: false,
|
proposalDetailApproving: false,
|
||||||
|
|
||||||
emailExamples: {} as { [type: string]: EmailExample },
|
emailExamples: {} as { [type: string]: EmailExample },
|
||||||
|
|
||||||
removeGeneralError(i: number) {
|
removeGeneralError(i: number) {
|
||||||
|
@ -141,12 +152,24 @@ const app = store({
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchUsers() {
|
async fetchUsers() {
|
||||||
|
app.usersFetching = true;
|
||||||
try {
|
try {
|
||||||
app.users = await fetchUsers();
|
app.users = await fetchUsers();
|
||||||
app.usersFetched = true;
|
app.usersFetched = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(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) {
|
async deleteUser(id: string | number) {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
// backend
|
// backend
|
||||||
export interface SocialMedia {
|
export interface SocialMedia {
|
||||||
socialMediaLink: string;
|
url: string;
|
||||||
|
service: string;
|
||||||
|
username: string;
|
||||||
}
|
}
|
||||||
export interface Milestone {
|
export interface Milestone {
|
||||||
content: string;
|
content: string;
|
||||||
|
@ -41,9 +43,20 @@ export interface Proposal {
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
dateCreated: string;
|
proposalId: Proposal['proposalId'];
|
||||||
|
proposal?: Proposal;
|
||||||
|
dateCreated: number;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
export interface Contribution {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
txId: null | string;
|
||||||
|
amount: string;
|
||||||
|
dateCreated: number;
|
||||||
|
user: User;
|
||||||
|
proposal: Proposal;
|
||||||
|
}
|
||||||
export interface User {
|
export interface User {
|
||||||
accountAddress: string;
|
accountAddress: string;
|
||||||
avatar: null | { imageUrl: string };
|
avatar: null | { imageUrl: string };
|
||||||
|
@ -54,6 +67,7 @@ export interface User {
|
||||||
userid: number;
|
userid: number;
|
||||||
proposals: Proposal[];
|
proposals: Proposal[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
|
contributions: Contribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailExample {
|
export interface EmailExample {
|
||||||
|
|
|
@ -5,3 +5,7 @@ const DATE_FMT_STRING = 'MM/DD/YYYY h:mm a';
|
||||||
export const formatDateSeconds = (s: number) => {
|
export const formatDateSeconds = (s: number) => {
|
||||||
return moment(s * 1000).format(DATE_FMT_STRING);
|
return moment(s * 1000).format(DATE_FMT_STRING);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatDateMs = (s: number) => {
|
||||||
|
return moment(s).format(DATE_FMT_STRING);
|
||||||
|
};
|
||||||
|
|
|
@ -7,9 +7,16 @@ from flask_cors import CORS, cross_origin
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
from grant.user.models import User, users_schema
|
from grant.user.models import User, users_schema, user_schema
|
||||||
from grant.proposal.models import Proposal, proposals_schema, proposal_schema, PENDING
|
from grant.proposal.models import (
|
||||||
from grant.comment.models import Comment, comments_schema
|
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 grant.email.send import generate_email
|
||||||
from .example_emails import example_email_args
|
from .example_emails import example_email_args
|
||||||
|
|
||||||
|
@ -82,6 +89,9 @@ def stats():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# USERS
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/users/<id>', methods=['DELETE'])
|
@blueprint.route('/users/<id>', methods=['DELETE'])
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
@auth_required
|
@auth_required
|
||||||
|
@ -95,12 +105,28 @@ def delete_user(id):
|
||||||
def get_users():
|
def get_users():
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
result = users_schema.dump(users)
|
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 = Proposal.query.filter(Proposal.team.any(id=user['userid'])).all()
|
||||||
user['proposals'] = proposals_schema.dump(user_proposals)
|
user['proposals'] = proposals_schema.dump(user_proposals)
|
||||||
user_comments = Comment.query.filter(Comment.user_id == user['userid']).all()
|
user_comments = Comment.get_by_user(user_db)
|
||||||
user['comments'] = comments_schema.dump(user_comments)
|
user['comments'] = user_comments_schema.dump(user_comments)
|
||||||
return result
|
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"])
|
@blueprint.route("/proposals", methods=["GET"])
|
||||||
|
@ -151,6 +177,9 @@ def approve_proposal(id, is_approve, reject_reason=None):
|
||||||
return {"message": "Not implemented."}, 400
|
return {"message": "Not implemented."}, 400
|
||||||
|
|
||||||
|
|
||||||
|
# EMAIL
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/email/example/<type>', methods=['GET'])
|
@blueprint.route('/email/example/<type>', methods=['GET'])
|
||||||
@cross_origin(supports_credentials=True)
|
@cross_origin(supports_credentials=True)
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
|
|
|
@ -74,7 +74,6 @@ class UserCommentSchema(ma.Schema):
|
||||||
"ProposalSchema",
|
"ProposalSchema",
|
||||||
exclude=[
|
exclude=[
|
||||||
"comments",
|
"comments",
|
||||||
"contributions",
|
|
||||||
"team",
|
"team",
|
||||||
"milestones",
|
"milestones",
|
||||||
"content",
|
"content",
|
||||||
|
|
Loading…
Reference in New Issue