diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 7a88c05a..9db9693e 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -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 { ) : ( - + + diff --git a/admin/src/components/Back/index.less b/admin/src/components/Back/index.less new file mode 100644 index 00000000..ffdfef4f --- /dev/null +++ b/admin/src/components/Back/index.less @@ -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; + } +} diff --git a/admin/src/components/Back/index.tsx b/admin/src/components/Back/index.tsx new file mode 100644 index 00000000..92698540 --- /dev/null +++ b/admin/src/components/Back/index.tsx @@ -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 = p => ( +

+ + + + {p.text} +

+); + +export default Back; diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index a8ac1026..37e602c8 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -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; @@ -155,6 +156,7 @@ class ProposalDetailNaked extends React.Component { return (
+

{p.title}

{/* MAIN */} @@ -198,9 +200,9 @@ class ProposalDetailNaked extends React.Component { {/* TEAM */} {p.team.map(t => ( - - {t.displayName} - +
+ {t.displayName} +
))}
{/* TODO: contributors here? */} diff --git a/admin/src/components/Proposals/ProposalItem.tsx b/admin/src/components/Proposals/ProposalItem.tsx index 527b9679..97b7068c 100644 --- a/admin/src/components/Proposals/ProposalItem.tsx +++ b/admin/src/components/Proposals/ProposalItem.tsx @@ -19,7 +19,7 @@ class ProposalItemNaked extends React.Component { const deleteAction = ( diff --git a/admin/src/components/Proposals/index.tsx b/admin/src/components/Proposals/index.tsx index 1f50411a..de0d374f 100644 --- a/admin/src/components/Proposals/index.tsx +++ b/admin/src/components/Proposals/index.tsx @@ -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 { } 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 ( -
-
- proposals {id}{' '} -
- -
- ); - } else { - return `could not find proposal: ${id}`; - } - } + const loading = !proposalsFetched || proposalsFetching; const statusFilterMenu = ( @@ -99,16 +73,14 @@ class ProposalsNaked extends React.Component { )}
)} - {proposalsFetching && 'Fetching proposals...'} - {proposalsFetched && - !proposalsFetching && ( - } - /> - )} + + } + /> ); } diff --git a/admin/src/components/UserDetail/index.less b/admin/src/components/UserDetail/index.less new file mode 100644 index 00000000..df276d66 --- /dev/null +++ b/admin/src/components/UserDetail/index.less @@ -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; + } + } +} diff --git a/admin/src/components/UserDetail/index.tsx b/admin/src/components/UserDetail/index.tsx new file mode 100644 index 00000000..85fa189a --- /dev/null +++ b/admin/src/components/UserDetail/index.tsx @@ -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; + +const STATE = {}; + +type State = typeof STATE; + +class UserDetailNaked extends React.Component { + 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 = () => ( + + + + ); + + const renderDeetItem = (name: string, val: any) => ( +
+ {name} +
{(val && val) || 'n/a'}
+
+ ); + + return ( +
+ +

+ + + {u.displayName} - {u.emailAddress} + +

+ + {/* MAIN */} + + {/* DETAILS */} + + {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) => ( +
+ {renderDeetItem( + `socialMedias[${idx}]`, + <> + {sm.service}/{sm.username} {sm.url} + , + )} +
+ ))) || + renderDeetItem('socialMedias', '')} + {renderDeetItem( + 'avatar.imageUrl', + u.avatar && {u.avatar.imageUrl}, + )} +
+ + {/* PROPOSALS */} + + ( + + view + , + ]} + > + + + )} + /> + + + {/* CONTRIBUTIONS */} + + ( + + + {c.amount} + ZEC to{' '} + + {c.proposal.title} + {' '} + on {formatDateSeconds(c.dateCreated)} +
+ } + description={ + <> + id: {c.id}, status: {c.status} + + } + /> + + )} + /> + + + {/* COMMENTS */} + + ( + + +
+ on{' '} + + {c.proposal && c.proposal.title} + {' '} + at {formatDateMs(c.dateCreated)} +
+ + + } + /> +
+ )} + /> +
+ + {/* JSON */} + +
{JSON.stringify(u, null, 4)}
+
+ + + + {/* SIDE */} + + {/* ACTIONS */} + {renderDelete()} + + + + ); + } + + 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; diff --git a/admin/src/components/Users/UserItem.less b/admin/src/components/Users/UserItem.less new file mode 100644 index 00000000..fb80d651 --- /dev/null +++ b/admin/src/components/Users/UserItem.less @@ -0,0 +1,2 @@ +.UserItem { +} diff --git a/admin/src/components/Users/UserItem.tsx b/admin/src/components/Users/UserItem.tsx new file mode 100644 index 00000000..273d6c86 --- /dev/null +++ b/admin/src/components/Users/UserItem.tsx @@ -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 { + render() { + const p = this.props; + + const deleteAction = ( + +
delete
+
+ ); + const viewAction = view; + const actions = [viewAction, deleteAction]; + + return ( + + + } + title={p.displayName} + description={p.emailAddress} + /> + + ); + } + private handleDelete = () => { + store.deleteUser(this.props.userid); + }; +} + +const UserItem = view(UserItemNaked); +export default UserItem; diff --git a/admin/src/components/Users/index.less b/admin/src/components/Users/index.less index cf19170b..1d8904a5 100644 --- a/admin/src/components/Users/index.less +++ b/admin/src/components/Users/index.less @@ -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; + margin-bottom: 0.5rem; + & > * { + margin-right: 0.5rem; + } } - &-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; - 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; } } diff --git a/admin/src/components/Users/index.tsx b/admin/src/components/Users/index.tsx index 6d9f5d95..6dd35a0b 100644 --- a/admin/src/components/Users/index.tsx +++ b/admin/src/components/Users/index.tsx @@ -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; @@ -16,123 +15,25 @@ class UsersNaked extends React.Component { } 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 ( -
-
- users {id}{' '} -
- -
- ); - } else { - return `could not find user: ${id}`; - } - } + const { users, usersFetched, usersFetching } = store; + const loading = !usersFetched || usersFetching; return (
- {users.length === 0 &&
no users
} - {users.length > 0 && users.map(u => )} + } + />
); } } -// tslint:disable-next-line:max-classes-per-file -class UserItemNaked extends React.Component { - state = { - showProposals: false, - activeProposal: '', - showDelete: false, - }; - render() { - const u = this.props; - return ( -
-
-
- - {' '} - -
- } - title="Permanently delete user?" - trigger="click" - visible={this.state.showDelete} - onVisibleChange={showDelete => this.setState({ showDelete })} - > -
-
- {u.avatar ? : 'n/a'} -
-
- -
- - - - - - - {u.proposals.map(p => ( -
- {p.title} ( - {p.proposalId}) -
- ))} -
- } - /> - TODO: comments} - /> - - - ); - } - private handleDelete = () => { - store.deleteUser(this.props.userid); - }; -} -const UserItem = view(UserItemNaked); - const Users = withRouter(view(UsersNaked)); export default Users; diff --git a/admin/src/store.ts b/admin/src/store.ts index 63e273e1..816493b3 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -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) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 94e50ebc..06597c35 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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 { diff --git a/admin/src/util/time.ts b/admin/src/util/time.ts index dc84df25..d436f28d 100644 --- a/admin/src/util/time.ts +++ b/admin/src/util/time.ts @@ -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); +}; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index cacbba8c..ce16c156 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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/', 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/', 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/', methods=['GET']) @cross_origin(supports_credentials=True) @endpoint.api() diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index 073a1dd0..bbd0ea99 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -74,7 +74,6 @@ class UserCommentSchema(ma.Schema): "ProposalSchema", exclude=[ "comments", - "contributions", "team", "milestones", "content",