Proposal Approval Process (#39)

* endpoints and model support for proposal approval

* admin test + proposal approval tests

* GET user/<id> withPending support

* basic withPending suport for Profile

* change create publish to sumbit for approval

* admin proposal filter by status + some refactoring

* admin: update antd

* backend: admin get single proposal + populate date_approved

* admin: rework Proposals + support approval

* backend: approval process updates

* admin: review count on home + cosmetic

* frontend: proposal approval flow

* Profile ZEC/ZAT adjustments

* fix regression in formatUserFromGet + update error type in users/reducers

* fix merge tsc issues

* publish warning vebiage change

* fix ssr fetchProposal 404 hang bug

* proposals/<id> - limit status non-LIVE to team member, exclude DELETED

* various adjustments to Proposal based on `status`

* remove comments

* Proposal statuses to banner style + fix up CreateFlow - Preview mode

* Proposal tsc fix
This commit is contained in:
AMStrix 2019-01-09 12:23:08 -06:00 committed by William O'Beirne
parent a0d115a703
commit 47c695f43b
57 changed files with 2124 additions and 675 deletions

View File

@ -48,7 +48,7 @@
"@types/webpack": "4.4.17", "@types/webpack": "4.4.17",
"@types/webpack-env": "^1.13.6", "@types/webpack-env": "^1.13.6",
"ant-design-pro": "2.0.0", "ant-design-pro": "2.0.0",
"antd": "3.9.3", "antd": "3.12.1",
"axios": "^0.18.0", "axios": "^0.18.0",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-loader": "^8.0.2", "babel-loader": "^8.0.2",

View File

@ -9,6 +9,7 @@ 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 Proposals from 'components/Proposals'; import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
import 'styles/style.less'; import 'styles/style.less';
@ -28,7 +29,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?" exact={true} component={Users} />
<Route path="/proposals/:id?" component={Proposals} /> <Route path="/proposals/:id" component={ProposalDetail} />
<Route path="/proposals" component={Proposals} />
</Switch> </Switch>
)} )}
</Template> </Template>

View File

@ -3,7 +3,12 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
& > div { &-actionItems {
margin-bottom: 0.5rem; margin-bottom: 3rem;
font-size: 1rem;
.anticon {
color: #ffaa00;
}
} }
} }

View File

@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { Divider, Icon } from 'antd';
import { view } from 'react-easy-state'; import { view } from 'react-easy-state';
import store from '../../store'; import store from '../../store';
import './index.less'; import './index.less';
import { Link } from 'react-router-dom';
class Home extends React.Component { class Home extends React.Component {
componentDidMount() { componentDidMount() {
@ -9,11 +11,21 @@ class Home extends React.Component {
} }
render() { render() {
const { userCount, proposalCount } = store.stats; const { userCount, proposalCount, proposalPendingCount } = store.stats;
return ( return (
<div className="Home"> <div className="Home">
<h1>Home</h1> {!!proposalPendingCount && (
<div>isLoggedIn: {JSON.stringify(store.isLoggedIn)}</div> <div className="Home-actionItems">
<Divider orientation="left">Action Items</Divider>
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
proposals waiting for review.{' '}
<Link to="/proposals?status=PENDING">Click here</Link> to view them.
</div>
</div>
)}
<Divider orientation="left">Stats</Divider>
<div>user count: {userCount}</div> <div>user count: {userCount}</div>
<div>proposal count: {proposalCount}</div> <div>proposal count: {proposalCount}</div>
</div> </div>

View File

@ -0,0 +1,145 @@
.Markdown {
line-height: 1.7;
font-family: 'Nunito Sans', 'Helvetica Neue', Arial, sans-serif;
h1,
h2,
h3,
h4,
h5,
h6 {
display: block;
font-weight: bold;
margin-top: 0;
}
h1 {
font-size: 2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
h2 {
font-size: 1.8rem;
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
h3 {
font-size: 1.6rem;
margin-bottom: 1.5rem;
}
h4 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
h5 {
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
h6 {
font-size: 1.1rem;
margin-bottom: 0rem;
}
ul,
ol {
padding-left: 30px;
font-size: 1.05rem;
}
ul {
list-style: circle;
}
ol {
list-style: decimal;
}
dl {
dt {
text-decoration: underline;
}
dd {
padding-left: 1rem;
margin-bottom: 1rem;
}
}
img {
max-width: 100%;
}
hr {
margin: 3rem 0;
border-bottom: 2px solid #eee;
}
code,
pre {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
}
code {
padding: 0.2rem 0.25rem;
margin: 0;
font-size: 90%;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 2px;
&:before,
&:after {
display: none;
}
}
pre {
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 2px;
font-size: 1rem;
code {
padding: 0;
font-size: inherit;
background: none;
border: none;
border-radius: 0;
}
}
table {
display: block;
width: 100%;
border-spacing: 0;
border-collapse: collapse;
margin-bottom: 2rem;
th,
td {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
}
th {
font-size: 1.1rem;
font-weight: bold;
}
td {
font-size: 1rem;
}
}
blockquote {
margin: 0 0 1rem;
padding: 0 0 0 1rem;
color: #777;
border-left: 4px solid rgba(0, 0, 0, 0.08);
> :last-child {
margin: 0;
}
}
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import classnames from 'classnames';
import { mdToHtml } from 'util/md';
import './index.less';
interface Props extends React.HTMLAttributes<HTMLDivElement> {
source: string;
}
export default class Markdown extends React.PureComponent<Props> {
render() {
const { source, ...rest } = this.props;
const html = mdToHtml(source);
// TS types seem to be fighting over react prop defs for div
const divProps = rest as any;
return (
<div
{...divProps}
className={classnames('Markdown', divProps.className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
}

View File

@ -0,0 +1,26 @@
.ProposalDetail {
h1 {
font-size: 1.5rem;
}
&-deet {
position: relative;
margin-bottom: 0.6rem;
& > span {
font-size: 0.7rem;
position: absolute;
top: 0.85rem;
}
}
& .ant-card,
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
}

View File

@ -0,0 +1,237 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Row, Col, Card, Alert, Button, Collapse, Popconfirm, Modal, Input } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
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 Markdown from 'components/Markdown';
type Props = RouteComponentProps<any>;
const STATE = {
showRejectModal: false,
rejectReason: '',
};
type State = typeof STATE;
class ProposalDetailNaked extends React.Component<Props, State> {
state = STATE;
rejectInput: null | TextArea = null;
componentDidMount() {
this.loadDetail();
}
render() {
const id = this.getIdFromQuery();
const { proposalDetail: p, proposalDetailFetching, proposalDetailApproving } = store;
const { rejectReason, showRejectModal } = this.state;
if (!p || (p && p.proposalId !== id) || proposalDetailFetching) {
return 'loading proposal...';
}
const renderDelete = () => (
<Popconfirm
onConfirm={this.handleDelete}
title="Delete proposal?"
okText="delete"
cancelText="cancel"
>
<Button icon="delete" block>
Delete
</Button>
</Popconfirm>
);
const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
<Alert
showIcon
type="success"
message={`Approved on ${formatDateSeconds(p.dateApproved)}`}
description={`
This proposal has been approved and will become live when a team-member
publishes it.
`}
/>
);
const rejectModal = (
<Modal
visible={showRejectModal}
title="Reject this proposal"
onOk={this.handleReject}
onCancel={() => this.setState({ showRejectModal: false })}
okButtonProps={{
disabled: rejectReason.length === 0,
loading: proposalDetailApproving,
}}
cancelButtonProps={{
loading: proposalDetailApproving,
}}
>
Please provide a reason ({!!rejectReason.length && `${rejectReason.length}/`}
250 chars max):
<Input.TextArea
ref={ta => (this.rejectInput = ta)}
rows={4}
maxLength={250}
required={true}
value={rejectReason}
onChange={e => {
this.setState({ rejectReason: e.target.value });
}}
/>
</Modal>
);
const renderReview = () =>
p.status === PROPOSAL_STATUS.PENDING && (
<Alert
showIcon
type="warning"
message="Review Pending"
description={
<div>
<p>Please review this proposal and render your judgment.</p>
<Button
loading={store.proposalDetailApproving}
icon="check"
type="primary"
onClick={this.handleApprove}
>
Approve
</Button>
<Button
loading={store.proposalDetailApproving}
icon="close"
type="danger"
onClick={() => {
this.setState({ showRejectModal: true });
// hacky way of waiting for modal to render in before focus
setTimeout(() => {
if (this.rejectInput) this.rejectInput.focus();
}, 200);
}}
>
Reject
</Button>
{rejectModal}
</div>
}
/>
);
const renderRejected = () =>
p.status === PROPOSAL_STATUS.REJECTED && (
<Alert
showIcon
type="error"
message="Rejected"
description={
<div>
<p>
This proposal has been rejected. The team will be able to re-submit it for
approval should they desire to do so.
</p>
<b>Reason:</b>
<br />
<i>{p.rejectReason}</i>
</div>
}
/>
);
const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet">
<span>{name}</span>
{val}
</div>
);
return (
<div className="ProposalDetail">
<h1>{p.title}</h1>
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
{renderApproved()}
{renderReview()}
{renderRejected()}
<Collapse defaultActiveKey={['brief', 'content']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
</Collapse.Panel>
<Collapse.Panel key="content" header="content">
<Markdown source={p.content} />
</Collapse.Panel>
{/* TODO - comments, milestones, updates &etc. */}
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(p, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
</Col>
{/* RIGHT SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card size="small">
{renderDelete()}
{/* TODO - other actions */}
</Card>
{/* DETAILS */}
<Card title="details" size="small">
{renderDeetItem('id', p.proposalId)}
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
{renderDeetItem('status', p.status)}
{renderDeetItem('category', p.category)}
{renderDeetItem('target', p.target)}
</Card>
{/* TEAM */}
<Card title="Team" size="small">
{p.team.map(t => (
<Link key={t.userid} to={`/users/${t.userid}`}>
{t.displayName}
</Link>
))}
</Card>
{/* TODO: contributors here? */}
</Col>
</Row>
</div>
);
}
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
private loadDetail = () => {
store.fetchProposalDetail(this.getIdFromQuery());
};
private handleDelete = () => {
if (!store.proposalDetail) return;
store.deleteProposal(store.proposalDetail.proposalId);
};
private handleApprove = () => {
store.approveProposal(true);
};
private handleReject = async () => {
await store.approveProposal(false, this.state.rejectReason);
this.setState({ showRejectModal: false });
};
}
const ProposalDetail = withRouter(view(ProposalDetailNaked));
export default ProposalDetail;

View File

@ -0,0 +1,11 @@
.ProposalItem {
& h1 {
font-size: 1.4rem;
margin-bottom: 0;
& .ant-tag {
vertical-align: text-top;
margin-top: 0.2rem;
}
}
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Popconfirm, Tag, Tooltip, List } from 'antd';
import { Link } from 'react-router-dom';
import store from 'src/store';
import { Proposal } from 'src/types';
import { getStatusById } from './STATUSES';
import { formatDateSeconds } from 'src/util/time';
import './ProposalItem.less';
class ProposalItemNaked extends React.Component<Proposal> {
state = {
showDelete: false,
};
render() {
const p = this.props;
const status = getStatusById(p.status);
const deleteAction = (
<Popconfirm
onConfirm={this.handleDelete}
title="Permanently delete proposal?"
okText="delete"
cancelText="cancel"
>
<div>delete</div>
</Popconfirm>
);
const viewAction = <Link to={`/proposals/${p.proposalId}`}>view</Link>;
const actions = [viewAction, deleteAction];
return (
<List.Item key={p.proposalId} className="ProposalItem" actions={actions}>
<div>
<h1>
{p.title || '(no title)'}{' '}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
</h1>
<div>Created: {formatDateSeconds(p.dateCreated)}</div>
<div>{p.brief}</div>
</div>
</List.Item>
);
}
private handleDelete = () => {
store.deleteProposal(this.props.proposalId);
};
}
const ProposalItem = view(ProposalItemNaked);
export default ProposalItem;

View File

@ -0,0 +1,65 @@
import { PROPOSAL_STATUS } from 'src/types';
export interface ProposalStatusSoT {
id: PROPOSAL_STATUS;
filterDisplay: string;
tagDisplay: string;
tagColor: string;
hint: string;
}
const STATUSES: ProposalStatusSoT[] = [
{
id: PROPOSAL_STATUS.APPROVED,
filterDisplay: 'Status: approved',
tagDisplay: 'Approved',
tagColor: '#afd500',
hint: 'Proposal has been approved and is awaiting being published by user.',
},
{
id: PROPOSAL_STATUS.DELETED,
filterDisplay: 'Status: deleted',
tagDisplay: 'Deleted',
tagColor: '#bebebe',
hint: 'Proposal has been deleted and is not visible on the platform.',
},
{
id: PROPOSAL_STATUS.DRAFT,
filterDisplay: 'Status: draft',
tagDisplay: 'Draft',
tagColor: '#8d8d8d',
hint: 'Proposal is being created by the user.',
},
{
id: PROPOSAL_STATUS.LIVE,
filterDisplay: 'Status: live',
tagDisplay: 'Live',
tagColor: '#108ee9',
hint: 'Proposal is live on the platform.',
},
{
id: PROPOSAL_STATUS.PENDING,
filterDisplay: 'Status: pending',
tagDisplay: 'Awaiting Approval',
tagColor: '#ffaa00',
hint: 'User is waiting for admin to approve or reject this Proposal.',
},
{
id: PROPOSAL_STATUS.REJECTED,
filterDisplay: 'Status: rejected',
tagDisplay: 'Approval Rejected',
tagColor: '#eb4118',
hint:
'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
},
];
export const getStatusById = (id: PROPOSAL_STATUS) => {
const result = STATUSES.find(s => s.id === id);
if (!result) {
throw Error(`getStatusById: could not find status for '${id}'`);
}
return result;
};
export default STATUSES;

View File

@ -1,108 +1,16 @@
@controls-height: 40px;
.Proposals { .Proposals {
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; margin-right: 0.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1); }
position: fixed;
top: 0;
right: 0;
left: 216px;
z-index: 5;
background: white;
} }
&-proposal { &-filters {
display: flex; margin-bottom: 0.5rem;
padding-bottom: 1rem; }
border-bottom: 1px solid rgb(214, 214, 214);
margin-bottom: 1rem;
&-controls { &-list {
margin: 0 0.5rem 0.2rem 0; margin-top: 1rem;
background: rgba(0, 0, 0, 0.1);
padding: 0.1rem;
border-radius: 0.5rem;
width: fit-content;
}
&-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;
}
}
&-body {
margin: 0;
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 1rem;
max-height: 600px;
overflow-y: scroll;
& img {
max-width: 100%;
}
ul,
ol {
padding-left: 30px;
font-size: 1.05rem;
}
ul {
list-style: circle;
}
ol {
list-style: decimal;
}
}
&-milestones {
display: flex;
flex-flow: wrap;
& > div {
width: 316px;
margin: 0 1rem 1rem 0;
& > div > span {
display: inline-block;
margin-left: 0.3rem;
font-size: 0.8rem;
opacity: 0.7;
}
}
}
&-comments {
border-left: 1rem solid rgba(0, 0, 0, 0.1);
& > div {
margin: 0.2rem 0 0.2rem 0.4rem;
}
}
} }
} }

View File

@ -1,34 +1,40 @@
import React from 'react'; import React from 'react';
import { view } from 'react-easy-state'; import qs from 'query-string';
import { Icon, Button, Popover } from 'antd'; import { uniq, without } from 'lodash';
import { RouteComponentProps, withRouter } from 'react-router';
import Showdown from 'showdown';
import moment from 'moment';
import store from 'src/store';
import { Proposal } from 'src/types';
import './index.less';
import Field from 'components/Field';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { view } from 'react-easy-state';
import { Icon, Button, Dropdown, Menu, Tag, List } from 'antd';
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';
const showdownConverter = new Showdown.Converter({ interface Query {
simplifiedAutoLink: true, status: PROPOSAL_STATUS[];
tables: true, }
strikethrough: true,
disableForced4SpacesIndentedSublists: true,
openLinksInNewWindow: true,
excludeTrailingPunctuationFromURLs: true,
});
type Props = RouteComponentProps<any>; type Props = RouteComponentProps<any>;
class ProposalsNaked extends React.Component<Props> { const STATE = {
statusFilters: [] as PROPOSAL_STATUS[],
};
type State = typeof STATE;
class ProposalsNaked extends React.Component<Props, State> {
state = STATE;
componentDidMount() { componentDidMount() {
store.fetchProposals(); this.setStateFromQueryString();
this.fetchProposals();
} }
render() { render() {
const id = Number(this.props.match.params.id); const id = Number(this.props.match.params.id);
const { proposals, proposalsFetched } = store; const { proposals, proposalsFetching, proposalsFetched } = store;
const { statusFilters } = this.state;
if (!proposalsFetched) { if (!proposalsFetched) {
return 'loading proposals...'; return 'loading proposals...';
@ -55,141 +61,113 @@ class ProposalsNaked extends React.Component<Props> {
} }
} }
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
{STATUSES.map(f => (
<Menu.Item key={f.id}>{f.filterDisplay}</Menu.Item>
))}
</Menu>
);
return ( return (
<div className="Proposals"> <div className="Proposals">
<div className="Proposals-controls"> <div className="Proposals-controls">
<Button title="refresh" icon="reload" onClick={() => store.fetchProposals()} /> <Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
<Button title="refresh" icon="reload" onClick={() => this.fetchProposals()} />
</div> </div>
{proposals.length === 0 && <div>no proposals</div>} {!!statusFilters.length && (
{proposals.length > 0 && <div className="Proposals-filters">
proposals.map(p => <ProposalItem key={p.proposalId} {...p} />)} Filters:{' '}
</div> {statusFilters.map(sf => (
); <Tag
} key={sf}
} onClose={() => this.handleFilterClose(sf)}
color={getStatusById(sf).tagColor}
// tslint:disable-next-line:max-classes-per-file closable
class ProposalItemNaked extends React.Component<Proposal> { >
state = { status: {sf}
showDelete: false, </Tag>
}; ))}
render() { {statusFilters.length > 1 && (
const p = this.props; <Tag key="clear" onClick={this.handleFilterClear}>
const body = showdownConverter.makeHtml(p.content); clear
return ( </Tag>
<div key={p.proposalId} className="Proposals-proposal"> )}
<div>
<div className="Proposals-proposal-controls">
<Popover
content={
<div>
<Button type="primary" onClick={this.handleDelete}>
delete {p.title}
</Button>{' '}
<Button onClick={() => this.setState({ showDelete: false })}>
cancel
</Button>
</div>
}
title="Permanently delete proposal?"
trigger="click"
visible={this.state.showDelete}
onVisibleChange={showDelete => this.setState({ showDelete })}
>
<Button icon="delete" shape="circle" size="small" title="delete" />
</Popover>
{/* TODO: implement disable payments on BE */}
<Button
icon="dollar"
shape="circle"
size="small"
title={false ? 'allow payments' : 'disable payments'}
type={false ? 'danger' : 'default'}
disabled={true}
/>
</div> </div>
<b>{p.title}</b> [{p.proposalId}]{p.proposalAddress}{' '} )}
<Field title="category" value={p.category} /> {proposalsFetching && 'Fetching proposals...'}
<Field title="dateCreated" value={p.dateCreated * 1000} isTime={true} /> {proposalsFetched &&
<Field title="stage" value={p.stage} /> !proposalsFetching && (
<Field <List
title={`team (${p.team.length})`} className="Proposals-list"
value={ bordered
<div> dataSource={proposals}
{p.team.map(u => ( renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
<div key={u.userid}> />
{u.displayName} ( )}
<Link to={`/users/${u.accountAddress}`}>{u.accountAddress}</Link>)
</div>
))}
</div>
}
/>
<Field
title={`comments (${p.comments.length})`}
value={<div>TODO: comments</div>}
/>
<Field
title={`body (${body.length}chr)`}
value={
<div
className="Proposals-proposal-body"
dangerouslySetInnerHTML={{ __html: body }}
/>
}
/>
<Field
title={`milestones (${p.milestones.length})`}
value={
<div className="Proposals-proposal-milestones">
{p.milestones.map((ms, idx) => (
<div key={idx}>
<div>
<b>
{idx}. {ms.title}
</b>
<span>(title)</span>
</div>
<div>
{moment(ms.dateCreated).format('YYYY/MM/DD h:mm a')}
<span>(dateCreated)</span>
</div>
<div>
{moment(ms.dateEstimated).format('YYYY/MM/DD h:mm a')}
<span>(dateEstimated)</span>
</div>
<div>
{ms.stage}
<span>(stage)</span>
</div>
<div>
{JSON.stringify(ms.immediatePayout)}
<span>(immediatePayout)</span>
</div>
<div>
{ms.payoutPercent}
<span>(payoutPercent)</span>
</div>
<div>
{ms.content}
<span>(body)</span>
</div>
{/* <small>content</small>
<div>{ms.content}</div> */}
</div>
))}
</div>
}
/>
</div>
</div> </div>
); );
} }
private handleDelete = () => {
store.deleteProposal(this.props.proposalId); private fetchProposals = () => {
const statusFilters = this.getParsedQuery().status;
store.fetchProposals(statusFilters);
};
private getParsedQuery = () => {
const parsed = qs.parse(this.props.history.location.search) as Query;
let statusFilters = parsed.status || [];
// qs.parse returns non-array for single item
statusFilters = Array.isArray(statusFilters) ? statusFilters : [statusFilters];
parsed.status = statusFilters;
return parsed;
};
private setStateFromQueryString = () => {
const statusFilters = this.getParsedQuery().status;
this.setState({ statusFilters });
};
private updateHistoryStateAndProposals = (queryStringArgs: Query) => {
this.props.history.push(`${this.props.match.url}?${qs.stringify(queryStringArgs)}`);
this.setStateFromQueryString();
this.fetchProposals();
};
private addStatusFilter = (statusFilter: PROPOSAL_STATUS) => {
const parsed = this.getParsedQuery();
parsed.status = uniq([statusFilter, ...parsed.status]);
this.updateHistoryStateAndProposals(parsed);
};
private removeStatusFilter = (statusFilter: PROPOSAL_STATUS) => {
const parsed = this.getParsedQuery();
parsed.status = without(parsed.status, statusFilter);
this.updateHistoryStateAndProposals(parsed);
};
private clearStatusFilters = () => {
const parsed = this.getParsedQuery();
parsed.status = [];
this.updateHistoryStateAndProposals(parsed);
};
private handleFilterClick = (e: ClickParam) => {
this.addStatusFilter(e.key as PROPOSAL_STATUS);
};
private handleFilterClose = (filter: PROPOSAL_STATUS) => {
this.removeStatusFilter(filter);
};
private handleFilterClear = () => {
this.clearStatusFilters();
}; };
} }
const ProposalItem = view(ProposalItemNaked);
const Proposals = withRouter(view(ProposalsNaked)); const Proposals = withRouter(view(ProposalsNaked));
export default Proposals; export default Proposals;

View File

@ -5,8 +5,8 @@ import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom'; 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 './index.less';
import Field from 'components/Field'; import Field from 'components/Field';
import './index.less';
type Props = RouteComponentProps<any>; type Props = RouteComponentProps<any>;
@ -16,7 +16,7 @@ class UsersNaked extends React.Component<Props> {
} }
render() { render() {
const id = this.props.match.params.id; const id = parseInt(this.props.match.params.id, 10);
const { users, usersFetched } = store; const { users, usersFetched } = store;
if (!usersFetched) { if (!usersFetched) {
@ -24,7 +24,7 @@ class UsersNaked extends React.Component<Props> {
} }
if (id) { if (id) {
const singleUser = users.find(u => u.accountAddress === id); const singleUser = users.find(u => u.userid === id);
if (singleUser) { if (singleUser) {
return ( return (
<div className="Users"> <div className="Users">
@ -102,7 +102,6 @@ class UserItemNaked extends React.Component<User> {
<Field title="displayName" value={u.displayName} /> <Field title="displayName" value={u.displayName} />
<Field title="title" value={u.title} /> <Field title="title" value={u.title} />
<Field title="emailAddress" value={u.emailAddress} /> <Field title="emailAddress" value={u.emailAddress} />
<Field title="accountAddress" value={u.accountAddress} />
<Field title="userid" value={u.userid} /> <Field title="userid" value={u.userid} />
<Field <Field
title="avatar.imageUrl" title="avatar.imageUrl"
@ -130,7 +129,7 @@ class UserItemNaked extends React.Component<User> {
); );
} }
private handleDelete = () => { private handleDelete = () => {
store.deleteUser(this.props.accountAddress); store.deleteUser(this.props.userid);
}; };
} }
const UserItem = view(UserItemNaked); const UserItem = view(UserItemNaked);

View File

@ -1,6 +1,6 @@
import { store } from 'react-easy-state'; import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { User, Proposal } from './types'; import { User, Proposal, PROPOSAL_STATUS } from './types';
// API // API
const api = axios.create({ const api = axios.create({
@ -36,13 +36,20 @@ async function fetchUsers() {
return data; return data;
} }
async function deleteUser(id: 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;
} }
async function fetchProposals() { async function fetchProposals(statusFilters?: PROPOSAL_STATUS[]) {
const { data } = await api.get('/admin/proposals'); const { data } = await api.get('/admin/proposals', {
params: { statusFilters },
});
return data;
}
async function fetchProposalDetail(id: number) {
const { data } = await api.get(`/admin/proposals/${id}`);
return data; return data;
} }
@ -51,25 +58,50 @@ async function deleteProposal(id: number) {
return data; return data;
} }
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
isApprove,
rejectReason,
});
return data;
}
// STORE // STORE
const app = store({ const app = store({
hasCheckedLogin: false, hasCheckedLogin: false,
isLoggedIn: false, isLoggedIn: false,
loginError: '', loginError: '',
generalError: [] as string[], generalError: [] as string[],
statsFetched: false,
statsFetching: false,
stats: { stats: {
userCount: -1, userCount: 0,
proposalCount: -1, proposalCount: 0,
proposalPendingCount: 0,
}, },
usersFetched: false, usersFetched: false,
users: [] as User[], users: [] as User[],
proposalsFetching: false,
proposalsFetched: false, proposalsFetched: false,
proposals: [] as Proposal[], proposals: [] as Proposal[],
proposalDetailFetching: false,
proposalDetail: null as null | Proposal,
proposalDetailApproving: false,
removeGeneralError(i: number) { removeGeneralError(i: number) {
app.generalError.splice(i, 1); app.generalError.splice(i, 1);
}, },
updateProposalInStore(p: Proposal) {
const index = app.proposals.findIndex(x => x.proposalId === p.proposalId);
if (index > -1) {
app.proposals[index] = p;
}
if (app.proposalDetail && app.proposalDetail.proposalId === p.proposalId) {
app.proposalDetail = p;
}
},
async checkLogin() { async checkLogin() {
app.isLoggedIn = await checkLogin(); app.isLoggedIn = await checkLogin();
app.hasCheckedLogin = true; app.hasCheckedLogin = true;
@ -92,11 +124,14 @@ const app = store({
}, },
async fetchStats() { async fetchStats() {
app.statsFetching = true;
try { try {
app.stats = await fetchStats(); app.stats = await fetchStats();
app.statsFetched = true;
} catch (e) { } catch (e) {
handleApiError(e); handleApiError(e);
} }
app.statsFetching = false;
}, },
async fetchUsers() { async fetchUsers() {
@ -108,26 +143,34 @@ const app = store({
} }
}, },
async deleteUser(id: string) { async deleteUser(id: string | number) {
try { try {
await deleteUser(id); await deleteUser(id);
app.users = app.users.filter(u => u.accountAddress !== id && u.emailAddress !== id); app.users = app.users.filter(u => u.userid !== id && u.emailAddress !== id);
} catch (e) { } catch (e) {
handleApiError(e); handleApiError(e);
} }
}, },
async fetchProposals() { async fetchProposals(statusFilters?: PROPOSAL_STATUS[]) {
app.proposalsFetching = true;
try { try {
app.proposals = await fetchProposals(); app.proposals = await fetchProposals(statusFilters);
app.proposalsFetched = true; app.proposalsFetched = true;
// for (const p of app.proposals) {
// TODO: partial populate contributorList
// await app.populateProposalContract(p.proposalId);
// }
} catch (e) { } catch (e) {
handleApiError(e); handleApiError(e);
} }
app.proposalsFetching = false;
},
async fetchProposalDetail(id: number) {
app.proposalDetailFetching = true;
try {
app.proposalDetail = await fetchProposalDetail(id);
} catch (e) {
handleApiError(e);
}
app.proposalDetailFetching = false;
}, },
async deleteProposal(id: number) { async deleteProposal(id: number) {
@ -138,6 +181,25 @@ const app = store({
handleApiError(e); handleApiError(e);
} }
}, },
async approveProposal(isApprove: boolean, rejectReason?: string) {
if (!app.proposalDetail) {
(x => {
app.generalError.push(x);
console.error(x);
})('store.approveProposal(): Expected proposalDetail to be populated!');
return;
}
app.proposalDetailApproving = true;
try {
const { proposalId } = app.proposalDetail;
const res = await approveProposal(proposalId, isApprove, rejectReason);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailApproving = false;
},
}); });
function handleApiError(e: AxiosError) { function handleApiError(e: AxiosError) {

View File

@ -11,10 +11,23 @@ export interface Milestone {
stage: string; stage: string;
title: string; title: string;
} }
// NOTE: sync with backend/grant/proposal/models.py STATUSES
export enum PROPOSAL_STATUS {
DRAFT = 'DRAFT',
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
LIVE = 'LIVE',
DELETED = 'DELETED',
}
export interface Proposal { export interface Proposal {
proposalId: number; proposalId: number;
brief: string;
status: PROPOSAL_STATUS;
proposalAddress: string; proposalAddress: string;
dateCreated: number; dateCreated: number;
dateApproved: number;
datePublished: number;
title: string; title: string;
content: string; content: string;
stage: string; stage: string;
@ -23,6 +36,8 @@ export interface Proposal {
team: User[]; team: User[];
comments: Comment[]; comments: Comment[];
contractStatus: string; contractStatus: string;
target: string;
rejectReason: string;
} }
export interface Comment { export interface Comment {
commentId: string; commentId: string;

14
admin/src/util/md.ts Normal file
View File

@ -0,0 +1,14 @@
import Showdown from 'showdown';
const showdownConverter = new Showdown.Converter({
simplifiedAutoLink: true,
tables: true,
strikethrough: true,
disableForced4SpacesIndentedSublists: true,
openLinksInNewWindow: true,
excludeTrailingPunctuationFromURLs: true,
});
export const mdToHtml = (text: string) => {
return showdownConverter.makeHtml(text);
};

7
admin/src/util/time.ts Normal file
View File

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

View File

@ -21,7 +21,8 @@
"paths": { "paths": {
"src/*": ["./*"], "src/*": ["./*"],
"components/*": ["./components/*"], "components/*": ["./components/*"],
"styles/*": ["./styles/*"] "styles/*": ["./styles/*"],
"util/*": ["./util/*"]
} }
}, },
"include": ["./src/**/*"], "include": ["./src/**/*"],

View File

@ -115,6 +115,7 @@ module.exports = {
src: path.resolve(__dirname, 'src'), src: path.resolve(__dirname, 'src'),
components: path.resolve(__dirname, 'src/components'), components: path.resolve(__dirname, 'src/components'),
styles: path.resolve(__dirname, 'src/styles'), styles: path.resolve(__dirname, 'src/styles'),
util: path.resolve(__dirname, 'src/util'),
}, },
}, },
plugins: [ plugins: [

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
from functools import wraps from functools import wraps
from flask import Blueprint, g, session from flask import Blueprint, g, session, request
from flask_yoloapi import endpoint, parameter from flask_yoloapi import endpoint, parameter
from hashlib import sha256 from hashlib import sha256
from uuid import uuid4 from uuid import uuid4
from flask_cors import CORS, cross_origin from flask_cors import CORS, cross_origin
from sqlalchemy import func 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
from grant.proposal.models import Proposal, proposals_schema from grant.proposal.models import Proposal, proposals_schema, proposal_schema, PENDING
from grant.comment.models import Comment, comments_schema from grant.comment.models import Comment, comments_schema
@ -34,7 +34,6 @@ def auth_required(f):
@blueprint.route("/checklogin", methods=["GET"]) @blueprint.route("/checklogin", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
def loggedin(): def loggedin():
if 'username' in session: if 'username' in session:
@ -44,7 +43,6 @@ def loggedin():
@blueprint.route("/login", methods=["POST"]) @blueprint.route("/login", methods=["POST"])
@cross_origin(supports_credentials=True)
@endpoint.api( @endpoint.api(
parameter('username', type=str, required=False), parameter('username', type=str, required=False),
parameter('password', type=str, required=False), parameter('password', type=str, required=False),
@ -60,7 +58,6 @@ def login(username, password):
@blueprint.route("/logout", methods=["GET"]) @blueprint.route("/logout", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
def logout(): def logout():
del session['username'] del session['username']
@ -68,20 +65,22 @@ def logout():
@blueprint.route("/stats", methods=["GET"]) @blueprint.route("/stats", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
@auth_required @auth_required
def stats(): def stats():
user_count = db.session.query(func.count(User.id)).scalar() user_count = db.session.query(func.count(User.id)).scalar()
proposal_count = db.session.query(func.count(Proposal.id)).scalar() proposal_count = db.session.query(func.count(Proposal.id)).scalar()
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
.filter(Proposal.status == PENDING) \
.scalar()
return { return {
"userCount": user_count, "userCount": user_count,
"proposalCount": proposal_count "proposalCount": proposal_count,
"proposalPendingCount": proposal_pending_count,
} }
@blueprint.route('/users/<id>', methods=['DELETE']) @blueprint.route('/users/<id>', methods=['DELETE'])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
@auth_required @auth_required
def delete_user(id): def delete_user(id):
@ -89,7 +88,6 @@ def delete_user(id):
@blueprint.route("/users", methods=["GET"]) @blueprint.route("/users", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
@auth_required @auth_required
def get_users(): def get_users():
@ -104,18 +102,48 @@ def get_users():
@blueprint.route("/proposals", methods=["GET"]) @blueprint.route("/proposals", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
@auth_required @auth_required
def get_proposals(): def get_proposals():
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all() # endpoint.api doesn't seem to handle GET query array input
status_filters = request.args.getlist('statusFilters[]')
or_filter = or_(Proposal.status == v for v in status_filters)
proposals = Proposal.query.filter(or_filter) \
.order_by(Proposal.date_created.desc()) \
.all()
# TODO: return partial data for list
dumped_proposals = proposals_schema.dump(proposals) dumped_proposals = proposals_schema.dump(proposals)
return dumped_proposals return dumped_proposals
@blueprint.route('/proposals/<id>', methods=['GET'])
@endpoint.api()
@auth_required
def get_proposal(id):
proposal = Proposal.query.filter(Proposal.id == id).first()
if proposal:
return proposal_schema.dump(proposal)
return {"message": "Could not find proposal with id %s" % id}, 404
@blueprint.route('/proposals/<id>', methods=['DELETE']) @blueprint.route('/proposals/<id>', methods=['DELETE'])
@cross_origin(supports_credentials=True)
@endpoint.api() @endpoint.api()
@auth_required @auth_required
def delete_proposal(id): def delete_proposal(id):
return {"message": "Not implemented."}, 400 return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
@endpoint.api(
parameter('isApprove', type=bool, required=True),
parameter('rejectReason', type=str, required=False)
)
@auth_required
def approve_proposal(id, is_approve, reject_reason=None):
proposal = Proposal.query.filter_by(id=id).first()
if proposal:
proposal.approve_pending(is_approve, reject_reason)
db.session.commit()
return proposal_schema.dump(proposal)
return {"message": "Not implemented."}, 400

View File

@ -1,6 +1,6 @@
import datetime import datetime
from typing import List from typing import List
from sqlalchemy import func from sqlalchemy import func, or_
from grant.comment.models import Comment from grant.comment.models import Comment
from grant.extensions import ma, db from grant.extensions import ma, db
@ -9,9 +9,11 @@ from grant.utils.exceptions import ValidationException
DRAFT = 'DRAFT' DRAFT = 'DRAFT'
PENDING = 'PENDING' PENDING = 'PENDING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE' LIVE = 'LIVE'
DELETED = 'DELETED' DELETED = 'DELETED'
STATUSES = [DRAFT, PENDING, LIVE, DELETED] STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED]
FUNDING_REQUIRED = 'FUNDING_REQUIRED' FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED' COMPLETED = 'COMPLETED'
@ -114,6 +116,9 @@ class Proposal(db.Model):
stage = db.Column(db.String(255), nullable=False) stage = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False) category = db.Column(db.String(255), nullable=False)
date_approved = db.Column(db.DateTime)
date_published = db.Column(db.DateTime)
reject_reason = db.Column(db.String(255))
# Payment info # Payment info
target = db.Column(db.String(255), nullable=False) target = db.Column(db.String(255), nullable=False)
@ -163,6 +168,24 @@ class Proposal(db.Model):
if category and category not in CATEGORIES: if category and category not in CATEGORIES:
raise ValidationException("Category {} not in {}".format(category, CATEGORIES)) raise ValidationException("Category {} not in {}".format(category, CATEGORIES))
def validate_publishable(self):
# Require certain fields
# TODO: I'm an idiot, make this a loop.
if not self.title:
raise ValidationException("Proposal must have a title")
if not self.content:
raise ValidationException("Proposal must have content")
if not self.brief:
raise ValidationException("Proposal must have a brief")
if not self.category:
raise ValidationException("Proposal must have a category")
if not self.target:
raise ValidationException("Proposal must have a target amount")
if not self.payout_address:
raise ValidationException("Proposal must have a payout address")
# Then run through regular validation
Proposal.validate(vars(self))
@staticmethod @staticmethod
def create(**kwargs): def create(**kwargs):
Proposal.validate(kwargs) Proposal.validate(kwargs)
@ -171,10 +194,12 @@ class Proposal(db.Model):
) )
@staticmethod @staticmethod
def get_by_user(user): def get_by_user(user, statuses=[LIVE]):
status_filter = or_(Proposal.status == v for v in statuses)
return Proposal.query \ return Proposal.query \
.join(proposal_team) \ .join(proposal_team) \
.filter(proposal_team.c.user_id == user.id) \ .filter(proposal_team.c.user_id == user.id) \
.filter(status_filter) \
.all() .all()
@staticmethod @staticmethod
@ -204,25 +229,40 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration self.deadline_duration = deadline_duration
Proposal.validate(vars(self)) Proposal.validate(vars(self))
def publish(self): def submit_for_approval(self):
# Require certain fields self.validate_publishable()
# TODO: I'm an idiot, make this a loop. allowed_statuses = [DRAFT, REJECTED]
if not self.title: # specific validation
raise ValidationException("Proposal must have a title") if self.status not in allowed_statuses:
if not self.content: raise ValidationException("Proposal status must be {} or {} to submit for approval".format(DRAFT, REJECTED))
raise ValidationException("Proposal must have content")
if not self.brief:
raise ValidationException("Proposal must have a brief")
if not self.category:
raise ValidationException("Proposal must have a category")
if not self.target:
raise ValidationException("Proposal must have a target amount")
if not self.payout_address:
raise ValidationException("Proposal must have a payout address")
# Then run through regular validation self.status = PENDING
Proposal.validate(vars(self))
self.status = 'LIVE' def approve_pending(self, is_approve, reject_reason=None):
self.validate_publishable()
# specific validation
if not self.status == PENDING:
raise ValidationException("Proposal status must be {} to approve or reject".format(PENDING))
if is_approve:
self.status = APPROVED
self.date_approved = datetime.datetime.now()
# TODO: send approval email
else:
if not reject_reason:
raise ValidationException("Please provide a reason for rejecting the proposal")
self.status = REJECTED
self.reject_reason = reject_reason
# TODO: send rejection email
def publish(self):
self.validate_publishable()
# specific validation
if not self.status == APPROVED:
raise ValidationException("Proposal status must be {}".format(APPROVED))
self.date_published = datetime.datetime.now()
self.status = LIVE
class ProposalSchema(ma.Schema): class ProposalSchema(ma.Schema):
@ -231,7 +271,11 @@ class ProposalSchema(ma.Schema):
# Fields to expose # Fields to expose
fields = ( fields = (
"stage", "stage",
"status",
"date_created", "date_created",
"date_approved",
"date_published",
"reject_reason",
"title", "title",
"brief", "brief",
"proposal_id", "proposal_id",
@ -250,6 +294,8 @@ class ProposalSchema(ma.Schema):
) )
date_created = ma.Method("get_date_created") date_created = ma.Method("get_date_created")
date_approved = ma.Method("get_date_approved")
date_published = ma.Method("get_date_published")
proposal_id = ma.Method("get_proposal_id") proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded") funded = ma.Method("get_funded")
@ -266,6 +312,12 @@ class ProposalSchema(ma.Schema):
def get_date_created(self, obj): def get_date_created(self, obj):
return dt_to_unix(obj.date_created) return dt_to_unix(obj.date_created)
def get_date_approved(self, obj):
return dt_to_unix(obj.date_approved) if obj.date_approved else None
def get_date_published(self, obj):
return dt_to_unix(obj.date_published) if obj.date_published else None
def get_funded(self, obj): def get_funded(self, obj):
# TODO: Add up all contributions and return that # TODO: Add up all contributions and return that
return "0" return "0"
@ -382,13 +434,20 @@ class UserProposalSchema(ma.Schema):
# Fields to expose # Fields to expose
fields = ( fields = (
"proposal_id", "proposal_id",
"status",
"title", "title",
"brief", "brief",
"target",
"funded",
"date_created", "date_created",
"date_approved",
"date_published",
"reject_reason",
"team", "team",
) )
date_created = ma.Method("get_date_created") date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id") proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded")
team = ma.Nested("UserSchema", many=True) team = ma.Nested("UserSchema", many=True)
def get_proposal_id(self, obj): def get_proposal_id(self, obj):
@ -397,6 +456,10 @@ class UserProposalSchema(ma.Schema):
def get_date_created(self, obj): def get_date_created(self, obj):
return dt_to_unix(obj.date_created) * 1000 return dt_to_unix(obj.date_created) * 1000
def get_funded(self, obj):
# TODO: Add up all contributions and return that
return "0"
user_proposal_schema = UserProposalSchema() user_proposal_schema = UserProposalSchema()
user_proposals_schema = UserProposalSchema(many=True) user_proposals_schema = UserProposalSchema(many=True)

View File

@ -5,12 +5,13 @@ import ast
from flask import Blueprint, g from flask import Blueprint, g
from flask_yoloapi import endpoint, parameter from flask_yoloapi import endpoint, parameter
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_
from grant.comment.models import Comment, comment_schema, comments_schema from grant.comment.models import Comment, comment_schema, comments_schema
from grant.milestone.models import Milestone from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar from grant.user.models import User, SocialMedia, Avatar
from grant.email.send import send_email from grant.email.send import send_email
from grant.utils.auth import requires_auth, requires_team_member_auth from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user
from grant.utils.exceptions import ValidationException from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url from grant.utils.misc import is_email, make_url
from .models import( from .models import(
@ -24,7 +25,13 @@ from .models import(
proposal_team, proposal_team,
ProposalTeamInvite, ProposalTeamInvite,
proposal_team_invite_schema, proposal_team_invite_schema,
db db,
DRAFT,
PENDING,
APPROVED,
REJECTED,
LIVE,
DELETED
) )
import traceback import traceback
@ -36,6 +43,13 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
def get_proposal(proposal_id): def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first() proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal: if proposal:
if proposal.status != LIVE:
if proposal.status == DELETED:
return {"message": "Proposal was deleted"}, 404
authed_user = get_authed_user()
team_ids = list(x.id for x in proposal.team)
if not authed_user or authed_user.id not in team_ids:
return {"message": "User cannot view this proposal"}, 404
return proposal_schema.dump(proposal) return proposal_schema.dump(proposal)
else: else:
return {"message": "No proposal matching id"}, 404 return {"message": "No proposal matching id"}, 404
@ -96,13 +110,13 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
def get_proposals(stage): def get_proposals(stage):
if stage: if stage:
proposals = ( proposals = (
Proposal.query.filter_by(status="LIVE", stage=stage) Proposal.query.filter_by(status=LIVE, stage=stage)
.order_by(Proposal.date_created.desc()) .order_by(Proposal.date_created.desc())
.all() .all()
) )
else: else:
proposals = ( proposals = (
Proposal.query.filter_by(status="LIVE") Proposal.query.filter_by(status=LIVE)
.order_by(Proposal.date_created.desc()) .order_by(Proposal.date_created.desc())
.all() .all()
) )
@ -131,7 +145,7 @@ def make_proposal_draft():
def get_proposal_drafts(): def get_proposal_drafts():
proposals = ( proposals = (
Proposal.query Proposal.query
.filter_by(status="DRAFT") .filter(or_(Proposal.status == DRAFT, Proposal.status == REJECTED))
.join(proposal_team) .join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id) .filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc()) .order_by(Proposal.date_created.desc())
@ -182,14 +196,29 @@ def update_proposal(milestones, proposal_id, **kwargs):
@blueprint.route("/<proposal_id>", methods=["DELETE"]) @blueprint.route("/<proposal_id>", methods=["DELETE"])
@requires_team_member_auth @requires_team_member_auth
@endpoint.api() @endpoint.api()
def delete_proposal_draft(proposal_id): def delete_proposal(proposal_id):
if g.current_proposal.status != 'DRAFT': deleteable_statuses = [DRAFT, PENDING, APPROVED, REJECTED]
return {"message": "Cannot delete non-draft proposals"}, 400 status = g.current_proposal.status
if status not in deleteable_statuses:
return {"message": "Cannot delete proposals with %s status" % status}, 400
db.session.delete(g.current_proposal) db.session.delete(g.current_proposal)
db.session.commit() db.session.commit()
return None, 202 return None, 202
@blueprint.route("/<proposal_id>/submit_for_approval", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def submit_for_approval_proposal(proposal_id):
try:
g.current_proposal.submit_for_approval()
except ValidationException as e:
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
db.session.add(g.current_proposal)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/publish", methods=["PUT"]) @blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth @requires_team_member_auth
@endpoint.api() @endpoint.api()

View File

@ -8,9 +8,12 @@ from grant.proposal.models import (
proposal_team, proposal_team,
ProposalTeamInvite, ProposalTeamInvite,
invites_with_proposal_schema, invites_with_proposal_schema,
user_proposals_schema user_proposals_schema,
PENDING,
APPROVED,
REJECTED
) )
from grant.utils.auth import requires_auth, requires_same_user_auth from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
from grant.email.models import EmailRecovery from grant.email.models import EmailRecovery
@ -52,9 +55,10 @@ def get_me():
@endpoint.api( @endpoint.api(
parameter("withProposals", type=bool, required=False), parameter("withProposals", type=bool, required=False),
parameter("withComments", type=bool, required=False), parameter("withComments", type=bool, required=False),
parameter("withFunded", type=bool, required=False) parameter("withFunded", type=bool, required=False),
parameter("withPending", type=bool, required=False)
) )
def get_user(user_id, with_proposals, with_comments, with_funded): def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
user = User.get_by_id(user_id) user = User.get_by_id(user_id)
if user: if user:
result = user_schema.dump(user) result = user_schema.dump(user)
@ -70,6 +74,11 @@ def get_user(user_id, with_proposals, with_comments, with_funded):
comments = Comment.get_by_user(user) comments = Comment.get_by_user(user)
comments_dump = user_comments_schema.dump(comments) comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump result["comments"] = comments_dump
authed_user = get_authed_user()
if with_pending and authed_user and authed_user.id == user.id:
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
pending_dump = user_proposals_schema.dump(pending)
result["pendingProposals"] = pending_dump
return result return result
else: else:
message = "User with id matching {} not found".format(user_id) message = "User with id matching {} not found".format(user_id)

View File

@ -12,6 +12,10 @@ from ..proposal.models import Proposal
from ..user.models import User from ..user.models import User
def get_authed_user():
return current_user if current_user.is_authenticated else None
def requires_auth(f): def requires_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):

View File

@ -0,0 +1,32 @@
"""empty message
Revision ID: 2f30fb7d656e
Revises: 3ffceaeb996a
Create Date: 2019-01-04 13:31:32.851145
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2f30fb7d656e'
down_revision = '3ffceaeb996a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('date_approved', sa.DateTime(), nullable=True))
op.add_column('proposal', sa.Column('date_published', sa.DateTime(), nullable=True))
op.add_column('proposal', sa.Column('reject_reason', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('proposal', 'reject_reason')
op.drop_column('proposal', 'date_published')
op.drop_column('proposal', 'date_approved')
# ### end Alembic commands ###

View File

View File

@ -0,0 +1,87 @@
import json
from mock import patch
from grant.proposal.models import Proposal, APPROVED, REJECTED, PENDING, DRAFT
from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal, test_user
plaintext_mock_password = "p4ssw0rd"
mock_admin_auth = {
"username": "admin",
"password": "20cc8f433a1d6400aed9850504c33bfe51ace17ed15d62b0e046b9d7bc4b893b",
"salt": "s4lt"
}
class TestAdminAPI(BaseProposalCreatorConfig):
@patch.dict('grant.admin.views.admin_auth', mock_admin_auth)
def login_admin(self):
return self.app.post(
"/api/v1/admin/login",
data={
"username": mock_admin_auth["username"],
"password": plaintext_mock_password
}
)
def test_login(self):
resp = self.login_admin()
self.assert200(resp)
def test_checklogin_loggedin(self):
self.login_admin()
resp = self.app.get("/api/v1/admin/checklogin")
self.assert200(resp)
self.assertTrue(resp.json["isLoggedIn"])
def test_checklogin_loggedout(self):
resp = self.app.get("/api/v1/admin/checklogin")
self.assert200(resp)
self.assertFalse(resp.json["isLoggedIn"])
def test_logout(self):
self.login_admin()
resp = self.app.get("/api/v1/admin/logout")
self.assert200(resp)
self.assertFalse(resp.json["isLoggedIn"])
cl_resp = self.app.get("/api/v1/admin/checklogin")
self.assertFalse(cl_resp.json["isLoggedIn"])
def test_get_users(self):
self.login_admin()
resp = self.app.get("/api/v1/admin/users")
self.assert200(resp)
# 2 users created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json), 2)
def test_get_proposals(self):
self.login_admin()
resp = self.app.get("/api/v1/admin/proposals")
self.assert200(resp)
# 2 proposals created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json), 2)
def test_approve_proposal(self):
self.login_admin()
# submit for approval (performed by end-user)
self.proposal.submit_for_approval()
# approve
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data={"isApprove": True}
)
self.assert200(resp)
self.assertEqual(resp.json["status"], APPROVED)
def test_reject_proposal(self):
self.login_admin()
# submit for approval (performed by end-user)
self.proposal.submit_for_approval()
# reject
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data={"isApprove": False, "rejectReason": "Funnzies."}
)
self.assert200(resp)
self.assertEqual(resp.json["status"], REJECTED)
self.assertEqual(resp.json["rejectReason"], "Funnzies.")

View File

@ -1,7 +1,7 @@
import json import json
from mock import patch from mock import patch
from grant.proposal.models import Proposal from grant.proposal.models import Proposal, APPROVED, PENDING, DRAFT
from ..config import BaseProposalCreatorConfig from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal, test_user from ..test_data import test_proposal, test_user
@ -64,16 +64,47 @@ class TestProposalAPI(BaseProposalCreatorConfig):
) )
self.assert404(resp) self.assert404(resp)
def test_publish_proposal_draft(self): # /submit_for_approval
def test_proposal_draft_submit_for_approval(self):
self.login_default_user() self.login_default_user()
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert200(resp)
def test_no_auth_proposal_draft_submit_for_approval(self):
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert401(resp)
def test_invalid_proposal_draft_submit_for_approval(self):
self.login_default_user()
resp = self.app.put("/api/v1/proposals/12345/submit_for_approval")
self.assert404(resp)
def test_invalid_status_proposal_draft_submit_for_approval(self):
self.login_default_user()
self.proposal.status = PENDING # should be DRAFT
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert400(resp)
# /publish
def test_publish_proposal_approved(self):
self.login_default_user()
# submit for approval, then approve
self.proposal.submit_for_approval()
self.proposal.approve_pending(True) # admin action
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert200(resp) self.assert200(resp)
def test_no_auth_publish_proposal_draft(self): def test_no_auth_publish_proposal(self):
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert401(resp) self.assert401(resp)
def test_invalid_proposal_publish_proposal_draft(self): def test_invalid_proposal_publish_proposal(self):
self.login_default_user() self.login_default_user()
resp = self.app.put("/api/v1/proposals/12345/publish") resp = self.app.put("/api/v1/proposals/12345/publish")
self.assert404(resp) self.assert404(resp)
def test_invalid_status_proposal_publish_proposal(self):
self.login_default_user()
self.proposal.status = PENDING # should be APPROVED
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert400(resp)

View File

@ -48,6 +48,7 @@ export function getUser(address: string): Promise<{ data: User }> {
withProposals: true, withProposals: true,
withComments: true, withComments: true,
withFunded: true, withFunded: true,
withPending: true,
}, },
}) })
.then(res => { .then(res => {
@ -156,10 +157,21 @@ export function putProposal(proposal: ProposalDraft): Promise<{ data: ProposalDr
return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest); return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest);
} }
export async function putProposalPublish( export async function putProposalSubmitForApproval(
proposal: ProposalDraft, proposal: ProposalDraft,
): Promise<{ data: Proposal }> { ): Promise<{ data: Proposal }> {
return axios.put(`/api/v1/proposals/${proposal.proposalId}/publish`).then(res => { return axios
.put(`/api/v1/proposals/${proposal.proposalId}/submit_for_approval`)
.then(res => {
res.data = formatProposalFromGet(res.data);
return res;
});
}
export async function putProposalPublish(
proposalId: number,
): Promise<{ data: Proposal }> {
return axios.put(`/api/v1/proposals/${proposalId}/publish`).then(res => {
res.data = formatProposalFromGet(res.data); res.data = formatProposalFromGet(res.data);
return res; return res;
}); });

View File

@ -36,27 +36,30 @@ class CreateFinal extends React.Component<Props> {
</div> </div>
</div> </div>
); );
} else } else if (submittedProposal) {
if (submittedProposal) {
content = ( content = (
<div className="CreateFinal-message is-success"> <div className="CreateFinal-message is-success">
<Icon type="check-circle" /> <Icon type="check-circle" />
<div className="CreateFinal-message-text"> <div className="CreateFinal-message-text">
Your proposal has been submitted! Check your{' '}
<Link to={`/profile`}>profile's</Link> pending proposals tab to check it's
status.
</div>
{/* TODO - remove or rework depending on design choices */}
{/* <div className="CreateFinal-message-text">
Your proposal has been submitted!{' '} Your proposal has been submitted!{' '}
<Link to={`/proposals/${submittedProposal.proposalUrlId}`}> <Link to={`/proposals/${submittedProposal.proposalUrlId}`}>
Click here Click here
</Link> </Link>
{' '}to check it out. {' '}to check it out.
</div> </div> */}
</div> </div>
); );
} else { } else {
content = ( content = (
<div className="CreateFinal-loader"> <div className="CreateFinal-loader">
<Spin size="large" /> <Spin size="large" />
<div className="CreateFinal-loader-text"> <div className="CreateFinal-loader-text">Submitting your proposal...</div>
Submitting your proposal...
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,6 @@
@import '~styles/variables.less';
// simulate non-fullscreen template margins
.Preview {
margin: @template-space-top @template-space-sides;
}

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Alert } from 'antd';
import { ProposalDetail } from 'components/Proposal'; import { ProposalDetail } from 'components/Proposal';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { makeProposalPreviewFromDraft } from 'modules/create/utils'; import { makeProposalPreviewFromDraft } from 'modules/create/utils';
import { ProposalDraft } from 'types'; import { ProposalDraft } from 'types';
import './Preview.less';
interface StateProps { interface StateProps {
form: ProposalDraft; form: ProposalDraft;
@ -17,14 +17,7 @@ class CreateFlowPreview extends React.Component<Props> {
const { form } = this.props; const { form } = this.props;
const proposal = makeProposalPreviewFromDraft(form); const proposal = makeProposalPreviewFromDraft(form);
return ( return (
<> <div className="Preview">
<Alert
style={{ margin: '-1rem 0 2rem', textAlign: 'center' }}
message="This is a preview of your proposal. It has not yet been published."
type="info"
showIcon={false}
banner
/>
<ProposalDetail <ProposalDetail
user={null} user={null}
proposalId={0} proposalId={0}
@ -32,7 +25,7 @@ class CreateFlowPreview extends React.Component<Props> {
proposal={proposal} proposal={proposal}
isPreview isPreview
/> />
</> </div>
); );
} }
} }

View File

@ -18,9 +18,9 @@ export default class PublishWarningModal extends React.Component<Props> {
return ( return (
<Modal <Modal
title={<>Confirm publish</>} title={<>Confirm submit for approval</>}
visible={isVisible} visible={isVisible}
okText="Confirm publish" okText="Confirm submit"
cancelText="Never mind" cancelText="Never mind"
onOk={handlePublish} onOk={handlePublish}
onCancel={handleClose} onCancel={handleClose}
@ -38,15 +38,14 @@ export default class PublishWarningModal extends React.Component<Props> {
<li key={w}>{w}</li> <li key={w}>{w}</li>
))} ))}
</ul> </ul>
<p>You can still publish, despite these warnings.</p> <p>You can still submit, despite these warnings.</p>
</> </>
} }
/> />
)} )}
<p> <p>
Are you sure youre ready to publish your proposal? Once youve done so, you Are you sure you're ready to submit your proposal for approval? Once youve
won't be able to change certain fields such as: target amount, payout address, done so, you won't be able to edit it.
team, & deadline.
</p> </p>
</div> </div>
</Modal> </Modal>

View File

@ -213,11 +213,11 @@ class CreateFlow extends React.Component<Props, State> {
</button> </button>
<button <button
className="CreateFlow-footer-button is-primary" className="CreateFlow-footer-button is-primary"
key="publish" key="submit"
onClick={this.openPublishWarning} onClick={this.openPublishWarning}
disabled={this.checkFormErrors()} disabled={this.checkFormErrors()}
> >
Publish Submit
</button> </button>
</> </>
) : ( ) : (

View File

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { List, Button, Divider, Spin, Popconfirm, message } from 'antd'; import { List, Button, Divider, Spin, Popconfirm, message } from 'antd';
import Placeholder from 'components/Placeholder'; import Placeholder from 'components/Placeholder';
import { ProposalDraft } from 'types'; import { ProposalDraft, STATUS } from 'types';
import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions'; import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import './style.less'; import './style.less';
@ -96,7 +96,12 @@ class DraftList extends React.Component<Props, State> {
<Spin tip="deleting..." spinning={deletingId === d.proposalId}> <Spin tip="deleting..." spinning={deletingId === d.proposalId}>
<List.Item actions={actions}> <List.Item actions={actions}>
<List.Item.Meta <List.Item.Meta
title={d.title || <em>Untitled proposal</em>} title={
<>
{d.title || <em>Untitled proposal</em>}
{d.status === STATUS.REJECTED && <em> (rejected)</em>}
</>
}
description={d.brief || <em>No description</em>} description={d.brief || <em>No description</em>}
/> />
</List.Item> </List.Item>

View File

@ -0,0 +1,71 @@
@import '~styles/variables.less';
@small-query: ~'(max-width: 640px)';
.ProfilePending {
display: flex;
padding-bottom: 1.2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 1rem;
&:last-child {
border-bottom: none;
padding-bottom: none;
}
@media @small-query {
flex-direction: column;
padding-bottom: 0.6rem;
}
&-title {
font-size: 1.2rem;
font-weight: 600;
color: inherit;
display: block;
margin-bottom: 0.1rem;
}
&-block {
flex: 1 0 0%;
&:last-child {
margin-left: 1.2rem;
flex: 0 0 0%;
min-width: 15rem;
@media @small-query {
margin-left: 0;
margin-top: 0.6rem;
}
}
&.is-actions {
display: flex;
justify-content: flex-end;
align-items: center;
& button + button,
a + button {
margin-left: 0.5rem;
}
}
}
.ant-tag {
vertical-align: text-top;
}
&-status {
margin-bottom: 0.6rem;
& q {
display: block;
margin: 0.5rem;
font-style: italic;
}
& small {
opacity: 0.6;
}
}
}

View File

@ -0,0 +1,154 @@
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, message, Tag } from 'antd';
import { UserProposal, STATUS } from 'types';
import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
import './ProfilePending.less';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
interface OwnProps {
proposal: UserProposal;
onPublish: (id: UserProposal['proposalId']) => void;
}
interface StateProps {
user: AppState['auth']['user'];
}
interface DispatchProps {
deletePendingProposal: typeof deletePendingProposal;
publishPendingProposal: typeof publishPendingProposal;
}
type Props = OwnProps & StateProps & DispatchProps;
const STATE = {
isDeleting: false,
isPublishing: false,
};
type State = typeof STATE;
class ProfilePending extends React.Component<Props, State> {
state = STATE;
render() {
const { status, title, proposalId, rejectReason } = this.props.proposal;
const { isDeleting, isPublishing } = this.state;
const isDisableActions = isDeleting || isPublishing;
const st = {
[STATUS.APPROVED]: {
color: 'green',
tag: 'Approved',
blurb: <div>You may publish this proposal when you are ready.</div>,
},
[STATUS.REJECTED]: {
color: 'red',
tag: 'Rejected',
blurb: (
<>
<div>This proposal was rejected for the following reason:</div>
<q>{rejectReason}</q>
<div>You may edit this proposal and re-submit it for approval.</div>
</>
),
},
[STATUS.PENDING]: {
color: 'orange',
tag: 'Pending',
blurb: (
<div>
You will receive an email when this proposal has completed the review process.
</div>
),
},
} as { [key in STATUS]: { color: string; tag: string; blurb: ReactNode } };
return (
<div className="ProfilePending">
<div className="ProfilePending-block">
<Link to={`/proposals/${proposalId}`} className="ProfilePending-title">
{title} <Tag color={st[status].color}>{st[status].tag}</Tag>
</Link>
<div className={`ProfilePending-status is-${status.toLowerCase()}`}>
{st[status].blurb}
</div>
</div>
<div className="ProfilePending-block is-actions">
{STATUS.APPROVED === status && (
<Button
loading={isPublishing}
disabled={isDisableActions}
type="primary"
onClick={this.handlePublish}
>
Publish
</Button>
)}
{STATUS.REJECTED === status && (
<Link to={`/proposals/${proposalId}/edit`}>
<Button disabled={isDisableActions} type="primary">
Edit
</Button>
</Link>
)}
<Popconfirm
key="delete"
title="Are you sure?"
onConfirm={() => this.handleDelete()}
>
<Button type="default" disabled={isDisableActions} loading={isDeleting}>
Delete
</Button>
</Popconfirm>
</div>
</div>
);
}
private handlePublish = async () => {
const {
user,
proposal: { proposalId },
onPublish,
} = this.props;
if (!user) return;
this.setState({ isPublishing: true });
try {
await this.props.publishPendingProposal(user.userid, proposalId);
onPublish(proposalId);
} catch (e) {
message.error(e.message || e.toString());
this.setState({ isPublishing: false });
}
};
private handleDelete = async () => {
const {
user,
proposal: { proposalId },
} = this.props;
if (!user) return;
this.setState({ isDeleting: true });
try {
await this.props.deletePendingProposal(user.userid, proposalId);
message.success('Proposal deleted.');
} catch (e) {
message.error(e.message || e.toString());
this.setState({ isDeleting: false });
}
};
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
user: state.auth.user,
}),
{
deletePendingProposal,
publishPendingProposal,
},
)(ProfilePending);

View File

@ -0,0 +1,54 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Modal } from 'antd';
import { UserProposal } from 'types';
import ProfilePending from './ProfilePending';
interface OwnProps {
proposals: UserProposal[];
}
type Props = OwnProps;
const STATE = {
publishedId: null as null | UserProposal['proposalId'],
};
type State = typeof STATE;
class ProfilePendingList extends React.Component<Props, State> {
state = STATE;
render() {
const { proposals } = this.props;
const { publishedId } = this.state;
return (
<>
{proposals.map(p => (
<ProfilePending
key={p.proposalId}
proposal={p}
onPublish={this.handlePublish}
/>
))}
<Modal
title="Proposal Published"
visible={!!publishedId}
footer={null}
onCancel={() => this.setState({ publishedId: null })}
>
<div>
Your proposal is live!{' '}
<Link to={`/proposals/${publishedId}`}>Click here</Link> to check it out.
</div>
</Modal>
</>
);
}
private handlePublish = (publishedId: UserProposal['proposalId']) => {
this.setState({ publishedId });
};
}
export default ProfilePendingList;

View File

@ -11,8 +11,7 @@ interface OwnProps {
export default class Profile extends React.Component<OwnProps> { export default class Profile extends React.Component<OwnProps> {
render() { render() {
const { title, brief, team, funded, target, proposalId } = this.props.proposal; const { title, brief, team, proposalId, funded, target } = this.props.proposal;
return ( return (
<div className="ProfileProposal"> <div className="ProfileProposal">
<div className="ProfileProposal-block"> <div className="ProfileProposal-block">

View File

@ -9,12 +9,12 @@ import {
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { compose } from 'recompose'; import { compose } from 'recompose';
import { Spin, Tabs, Badge } from 'antd'; import { Spin, Tabs, Badge } from 'antd';
import { UsersState } from 'modules/users/reducers';
import { usersActions } from 'modules/users'; import { usersActions } from 'modules/users';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import HeaderDetails from 'components/HeaderDetails'; import HeaderDetails from 'components/HeaderDetails';
import ProfileUser from './ProfileUser'; import ProfileUser from './ProfileUser';
import ProfileEdit from './ProfileEdit'; import ProfileEdit from './ProfileEdit';
import ProfilePendingList from './ProfilePendingList';
import ProfileProposal from './ProfileProposal'; import ProfileProposal from './ProfileProposal';
import ProfileComment from './ProfileComment'; import ProfileComment from './ProfileComment';
import ProfileInvite from './ProfileInvite'; import ProfileInvite from './ProfileInvite';
@ -23,7 +23,7 @@ import Exception from 'pages/exception';
import './style.less'; import './style.less';
interface StateProps { interface StateProps {
usersMap: UsersState['map']; usersMap: AppState['users']['map'];
authUser: AppState['auth']['user']; authUser: AppState['auth']['user'];
} }
@ -69,7 +69,14 @@ class Profile extends React.Component<Props> {
return <Exception code="404" />; return <Exception code="404" />;
} }
const { createdProposals, fundedProposals, comments, invites } = user; const {
pendingProposals,
createdProposals,
fundedProposals,
comments,
invites,
} = user;
const nonePending = pendingProposals.length === 0;
const noneCreated = createdProposals.length === 0; const noneCreated = createdProposals.length === 0;
const noneFunded = fundedProposals.length === 0; const noneFunded = fundedProposals.length === 0;
const noneCommented = comments.length === 0; const noneCommented = comments.length === 0;
@ -96,6 +103,22 @@ class Profile extends React.Component<Props> {
/> />
</Switch> </Switch>
<Tabs> <Tabs>
{isAuthedUser && (
<Tabs.TabPane
tab={TabTitle('Pending', pendingProposals.length)}
key="pending"
>
<div>
{nonePending && (
<Placeholder
title="No pending proposals"
subtitle="You do not have any proposals awaiting approval."
/>
)}
<ProfilePendingList proposals={pendingProposals} />
</div>
</Tabs.TabPane>
)}
<Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created"> <Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created">
<div> <div>
{noneCreated && ( {noneCreated && (

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Spin, Form, Input, Button, Icon } from 'antd'; import { Spin, Form, Input, Button, Icon } from 'antd';
import { Proposal } from 'types'; import { Proposal, STATUS } from 'types';
import classnames from 'classnames'; import classnames from 'classnames';
import { fromZat } from 'utils/units'; import { fromZat } from 'utils/units';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -83,6 +83,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
console.warn('TODO: Get deadline and isFrozen from proposal data'); console.warn('TODO: Get deadline and isFrozen from proposal data');
const deadline = 0; const deadline = 0;
const isFrozen = false; const isFrozen = false;
const isLive = proposal.status === STATUS.LIVE;
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen; const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview; const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
@ -90,12 +91,14 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
content = ( content = (
<React.Fragment> <React.Fragment>
<div className="ProposalCampaignBlock-info"> {isLive && (
<div className="ProposalCampaignBlock-info-label">Started</div> <div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-value"> <div className="ProposalCampaignBlock-info-label">Started</div>
{moment(proposal.dateCreated * 1000).fromNow()} <div className="ProposalCampaignBlock-info-value">
{moment(proposal.datePublished * 1000).fromNow()}
</div>
</div> </div>
</div> )}
<div className="ProposalCampaignBlock-info"> <div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Category</div> <div className="ProposalCampaignBlock-info-label">Category</div>
<div className="ProposalCampaignBlock-info-value"> <div className="ProposalCampaignBlock-info-value">

View File

@ -7,6 +7,18 @@
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
&-banner {
text-align: center;
margin-top: -@template-space-top;
margin-left: -@template-space-sides;
margin-right: -@template-space-sides;
margin-bottom: 1rem;
.ant-alert {
padding-top: 1rem;
}
}
&-top { &-top {
display: flex; display: flex;
width: 100%; width: 100%;

View File

@ -1,13 +1,15 @@
import React from 'react'; import React, { ReactNode } from 'react';
import { compose } from 'recompose'; import { compose } from 'recompose';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import Markdown from 'components/Markdown'; import Markdown from 'components/Markdown';
import { proposalActions } from 'modules/proposals'; import { proposalActions } from 'modules/proposals';
import { bindActionCreators, Dispatch } from 'redux'; import { bindActionCreators, Dispatch } from 'redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { Proposal } from 'types'; import { Proposal, STATUS } from 'types';
import { getProposal } from 'modules/proposals/selectors'; import { getProposal } from 'modules/proposals/selectors';
import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd'; import { Spin, Tabs, Icon, Dropdown, Menu, Button, Alert } from 'antd';
import { AlertProps } from 'antd/lib/alert';
import CampaignBlock from './CampaignBlock'; import CampaignBlock from './CampaignBlock';
import TeamBlock from './TeamBlock'; import TeamBlock from './TeamBlock';
import Milestones from './Milestones'; import Milestones from './Milestones';
@ -20,7 +22,7 @@ import CancelModal from './CancelModal';
import classnames from 'classnames'; import classnames from 'classnames';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import SocialShare from 'components/SocialShare'; import SocialShare from 'components/SocialShare';
import './style.less'; import './index.less';
interface OwnProps { interface OwnProps {
proposalId: number; proposalId: number;
@ -56,9 +58,10 @@ export class ProposalDetail extends React.Component<Props, State> {
}; };
componentDidMount() { componentDidMount() {
if (!this.props.proposal) { // always refresh from server
this.props.fetchProposal(this.props.proposalId); this.props.fetchProposal(this.props.proposalId);
} else {
if (this.props.proposal) {
this.checkBodyOverflow(); this.checkBodyOverflow();
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -91,37 +94,85 @@ export class ProposalDetail extends React.Component<Props, State> {
if (!proposal) { if (!proposal) {
return <Spin />; return <Spin />;
} else { }
const deadline = 0; // TODO: Use actual date for deadline
// TODO: isTrustee - determine rework to isAdmin?
// for now: check if authed user in member of proposal team
const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid));
const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding
const isProposalActive = !hasBeenFunded && deadline > Date.now();
const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet
const adminMenu = isTrustee && ( const deadline = 0; // TODO: Use actual date for deadline
<Menu> // TODO: isTrustee - determine rework to isAdmin?
<Menu.Item onClick={this.openUpdateModal}>Post an Update</Menu.Item> // for now: check if authed user in member of proposal team
<Menu.Item const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid));
onClick={() => alert('Sorry, not yet implemented!')} const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding
disabled={!isProposalActive} const isProposalActive = !hasBeenFunded && deadline > Date.now();
> const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet
Edit proposal const isLive = proposal.status === STATUS.LIVE;
</Menu.Item>
<Menu.Item
style={{ color: canCancel ? '#e74c3c' : undefined }}
onClick={this.openCancelModal}
disabled={!canCancel}
>
Cancel proposal
</Menu.Item>
</Menu>
);
return ( const adminMenu = (
<div className="Proposal"> <Menu>
<div className="Proposal-top"> <Menu.Item disabled={!isLive} onClick={this.openUpdateModal}>
Post an Update
</Menu.Item>
<Menu.Item
onClick={() => alert('Sorry, not yet implemented!')}
disabled={!isProposalActive}
>
Edit proposal
</Menu.Item>
<Menu.Item
style={{ color: canCancel ? '#e74c3c' : undefined }}
onClick={this.openCancelModal}
disabled={!canCancel}
>
Cancel proposal
</Menu.Item>
</Menu>
);
// BANNER
const statusBanner = {
[STATUS.PENDING]: {
blurb: (
<>
Your proposal is being reviewed and is only visible to the team. You will get
an email when it is complete.
</>
),
type: 'warning',
},
[STATUS.APPROVED]: {
blurb: (
<>
Your proposal has been approved! It is currently only visible to the team.
Visit your <Link to="/profile">profile - pending</Link> tab to publish.
</>
),
type: 'success',
},
[STATUS.REJECTED]: {
blurb: (
<>
Your proposal was rejected and is only visible to the team. Visit your{' '}
<Link to="/profile">profile - pending</Link> tab for more information.
</>
),
type: 'error',
},
} as { [key in STATUS]: { blurb: ReactNode; type: AlertProps['type'] } };
let banner = statusBanner[proposal.status];
if (isPreview) {
banner = {
blurb: 'This is a preview of your proposal. It has not yet been published.',
type: 'info',
};
}
return (
<div className="Proposal">
{banner && (
<div className="Proposal-banner">
<Alert type={banner.type} message={banner.blurb} showIcon={false} banner />
</div>
)}
<div className="Proposal-top">
{isLive && (
<div className="Proposal-top-social"> <div className="Proposal-top-social">
<SocialShare <SocialShare
url={(typeof window !== 'undefined' && window.location.href) || ''} url={(typeof window !== 'undefined' && window.location.href) || ''}
@ -131,34 +182,36 @@ export class ProposalDetail extends React.Component<Props, State> {
} needs funding on Grant.io! Come help make this proposal a reality by funding it.`} } needs funding on Grant.io! Come help make this proposal a reality by funding it.`}
/> />
</div> </div>
<div className="Proposal-top-main"> )}
<h1 className="Proposal-top-main-title"> <div className="Proposal-top-main">
{proposal ? proposal.title : <span>&nbsp;</span>} <h1 className="Proposal-top-main-title">
</h1> {proposal ? proposal.title : <span>&nbsp;</span>}
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}> </h1>
<div <div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
id={bodyId} <div
className={classnames({ id={bodyId}
['Proposal-top-main-block-bodyText']: true, className={classnames({
['is-expanded']: isBodyExpanded, ['Proposal-top-main-block-bodyText']: true,
})} ['is-expanded']: isBodyExpanded,
> })}
{proposal ? ( >
<Markdown source={proposal.content} /> {proposal ? (
) : ( <Markdown source={proposal.content} />
<Spin size="large" /> ) : (
)} <Spin size="large" />
</div>
{showExpand && (
<button
className="Proposal-top-main-block-bodyExpand"
onClick={this.expandBody}
>
Read more <Icon type="arrow-down" style={{ fontSize: '0.7rem' }} />
</button>
)} )}
</div> </div>
{isTrustee && ( {showExpand && (
<button
className="Proposal-top-main-block-bodyExpand"
onClick={this.expandBody}
>
Read more <Icon type="arrow-down" style={{ fontSize: '0.7rem' }} />
</button>
)}
</div>
{isLive &&
isTrustee && (
<div className="Proposal-top-main-menu"> <div className="Proposal-top-main-menu">
<Dropdown <Dropdown
overlay={adminMenu} overlay={adminMenu}
@ -172,48 +225,46 @@ export class ProposalDetail extends React.Component<Props, State> {
</Dropdown> </Dropdown>
</div> </div>
)} )}
</div>
<div className="Proposal-top-side">
<CampaignBlock proposal={proposal} isPreview={isPreview} />
<TeamBlock proposal={proposal} />
</div>
</div> </div>
<div className="Proposal-top-side">
{proposal && ( <CampaignBlock proposal={proposal} isPreview={!isLive} />
<Tabs> <TeamBlock proposal={proposal} />
<Tabs.TabPane tab="Milestones" key="milestones"> </div>
<div style={{ marginTop: '1.5rem', padding: '0 2rem' }}>
<Milestones proposal={proposal} />
</div>
</Tabs.TabPane>
<Tabs.TabPane tab="Discussion" key="discussions" disabled={isPreview}>
<CommentsTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
<Tabs.TabPane tab="Updates" key="updates" disabled={isPreview}>
<UpdatesTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
<Tabs.TabPane tab="Contributors" key="contributors">
<ContributorsTab />
</Tabs.TabPane>
</Tabs>
)}
{isTrustee && (
<>
<UpdateModal
proposalId={proposal.proposalId}
isVisible={isUpdateOpen}
handleClose={this.closeUpdateModal}
/>
<CancelModal
proposal={proposal}
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
</>
)}
</div> </div>
);
} <Tabs>
<Tabs.TabPane tab="Milestones" key="milestones">
<div style={{ marginTop: '1.5rem', padding: '0 2rem' }}>
<Milestones proposal={proposal} />
</div>
</Tabs.TabPane>
<Tabs.TabPane tab="Discussion" key="discussions" disabled={!isLive}>
<CommentsTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
<UpdatesTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
<ContributorsTab />
</Tabs.TabPane>
</Tabs>
{isTrustee && (
<>
<UpdateModal
proposalId={proposal.proposalId}
isVisible={isUpdateOpen}
handleClose={this.closeUpdateModal}
/>
<CancelModal
proposal={proposal}
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
</>
)}
</div>
);
} }
private expandBody = () => { private expandBody = () => {

View File

@ -9,7 +9,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
flex: 1; flex: 1;
padding: 0 2.5rem; padding: 0 @template-space-sides;
.is-fullscreen & { .is-fullscreen & {
padding: 0; padding: 0;
@ -19,8 +19,8 @@
width: 100%; width: 100%;
max-width: @max-content-width; max-width: @max-content-width;
margin: 0 auto; margin: 0 auto;
padding-top: 2.5rem; padding-top: @template-space-top;
padding-bottom: 2.5rem; padding-bottom: @template-space-top;
min-height: 280px; min-height: 280px;
.is-fullscreen & { .is-fullscreen & {
@ -28,7 +28,7 @@
padding-bottom: 0; padding-bottom: 0;
max-width: none; max-width: none;
} }
.is-centered & { .is-centered & {
align-self: center; align-self: center;
} }
@ -41,4 +41,4 @@
} }
} }
} }
} }

View File

@ -1,10 +1,7 @@
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { ProposalDraft } from 'types'; import { ProposalDraft } from 'types';
// import { AppState } from 'store/reducers';
import types, { CreateDraftOptions } from './types'; import types, { CreateDraftOptions } from './types';
import { putProposal, putProposalPublish } from 'api/api'; import { putProposal, putProposalSubmitForApproval } from 'api/api';
// type GetState = () => AppState;
export function initializeForm(proposalId: number) { export function initializeForm(proposalId: number) {
return { return {
@ -50,12 +47,12 @@ export function submitProposal(form: ProposalDraft) {
dispatch({ type: types.SUBMIT_PROPOSAL_PENDING }); dispatch({ type: types.SUBMIT_PROPOSAL_PENDING });
try { try {
await putProposal(form); await putProposal(form);
const res = await putProposalPublish(form); const res = await putProposalSubmitForApproval(form);
dispatch({ dispatch({
type: types.SUBMIT_PROPOSAL_FULFILLED, type: types.SUBMIT_PROPOSAL_FULFILLED,
payload: res.data, payload: res.data,
}); });
} catch(err) { } catch (err) {
dispatch({ dispatch({
type: types.SUBMIT_PROPOSAL_REJECTED, type: types.SUBMIT_PROPOSAL_REJECTED,
payload: err.message || err.toString(), payload: err.message || err.toString(),

View File

@ -24,6 +24,10 @@ export interface CreateState {
submittedProposal: Proposal | null; submittedProposal: Proposal | null;
isSubmitting: boolean; isSubmitting: boolean;
submitError: string | null; submitError: string | null;
publishedProposal: Proposal | null;
isPublishing: boolean;
publishError: string | null;
} }
export const INITIAL_STATE: CreateState = { export const INITIAL_STATE: CreateState = {
@ -49,6 +53,10 @@ export const INITIAL_STATE: CreateState = {
submittedProposal: null, submittedProposal: null,
isSubmitting: false, isSubmitting: false,
submitError: null, submitError: null,
publishedProposal: null,
isPublishing: false,
publishError: null,
}; };
export default function createReducer( export default function createReducer(
@ -162,7 +170,7 @@ export default function createReducer(
isDeletingDraft: false, isDeletingDraft: false,
deleteDraftError: action.payload, deleteDraftError: action.payload,
}; };
case types.SUBMIT_PROPOSAL_PENDING: case types.SUBMIT_PROPOSAL_PENDING:
return { return {
...state, ...state,
@ -180,7 +188,7 @@ export default function createReducer(
...state, ...state,
submitError: action.payload, submitError: action.payload,
isSubmitting: false, isSubmitting: false,
} };
} }
return state; return state;
} }

View File

@ -1,4 +1,4 @@
import { ProposalDraft, CreateMilestone } from 'types'; import { ProposalDraft, CreateMilestone, STATUS } from 'types';
import { User } from 'types'; import { User } from 'types';
import { getAmountError } from 'utils/validators'; import { getAmountError } from 'utils/validators';
import { MILESTONE_STATE, Proposal } from 'types'; import { MILESTONE_STATE, Proposal } from 'types';
@ -170,17 +170,17 @@ export function proposalToContractData(form: ProposalDraft): any {
} }
// This is kind of a disgusting function, sorry. // This is kind of a disgusting function, sorry.
export function makeProposalPreviewFromDraft( export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
draft: ProposalDraft,
): Proposal {
const target = parseFloat(draft.target); const target = parseFloat(draft.target);
return { return {
proposalId: 0, proposalId: 0,
status: STATUS.DRAFT,
proposalUrlId: '0-title', proposalUrlId: '0-title',
proposalAddress: '0x0', proposalAddress: '0x0',
payoutAddress: '0x0', payoutAddress: '0x0',
dateCreated: Date.now(), dateCreated: Date.now(),
datePublished: Date.now(),
title: draft.title, title: draft.title,
brief: draft.brief, brief: draft.brief,
content: draft.content, content: draft.content,

View File

@ -5,6 +5,8 @@ import {
updateUser as apiUpdateUser, updateUser as apiUpdateUser,
fetchUserInvites as apiFetchUserInvites, fetchUserInvites as apiFetchUserInvites,
putInviteResponse, putInviteResponse,
deleteProposalDraft,
putProposalPublish,
} from 'api/api'; } from 'api/api';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { cleanClone } from 'utils/helpers'; import { cleanClone } from 'utils/helpers';
@ -89,3 +91,25 @@ export function respondToInvite(
} }
}; };
} }
export function deletePendingProposal(userId: number, proposalId: number) {
return async (dispatch: Dispatch<any>) => {
await dispatch({
type: types.USER_DELETE_PROPOSAL,
payload: deleteProposalDraft(proposalId).then(_ => ({ userId, proposalId })),
});
};
}
export function publishPendingProposal(userId: number, proposalId: number) {
return async (dispatch: Dispatch<any>) => {
await dispatch({
type: types.USER_PUBLISH_PROPOSAL,
payload: putProposalPublish(proposalId).then(res => ({
userId,
proposalId,
proposal: res.data,
})),
});
};
}

View File

@ -5,21 +5,22 @@ import { User } from 'types';
export interface TeamInviteWithResponse extends TeamInviteWithProposal { export interface TeamInviteWithResponse extends TeamInviteWithProposal {
isResponding: boolean; isResponding: boolean;
respondError: number | null; respondError: string | null;
} }
export interface UserState extends User { export interface UserState extends User {
isFetching: boolean; isFetching: boolean;
hasFetched: boolean; hasFetched: boolean;
fetchError: number | null; fetchError: string | null;
isUpdating: boolean; isUpdating: boolean;
updateError: number | null; updateError: string | null;
pendingProposals: UserProposal[];
createdProposals: UserProposal[]; createdProposals: UserProposal[];
fundedProposals: UserProposal[]; fundedProposals: UserProposal[];
comments: UserComment[]; comments: UserComment[];
isFetchingInvites: boolean; isFetchingInvites: boolean;
hasFetchedInvites: boolean; hasFetchedInvites: boolean;
fetchErrorInvites: number | null; fetchErrorInvites: string | null;
invites: TeamInviteWithResponse[]; invites: TeamInviteWithResponse[];
} }
@ -43,6 +44,7 @@ export const INITIAL_USER_STATE: UserState = {
fetchError: null, fetchError: null,
isUpdating: false, isUpdating: false,
updateError: null, updateError: null,
pendingProposals: [],
createdProposals: [], createdProposals: [],
fundedProposals: [], fundedProposals: [],
comments: [], comments: [],
@ -60,12 +62,10 @@ export default (state = INITIAL_STATE, action: any) => {
const { payload } = action; const { payload } = action;
const userFetchId = payload && payload.userFetchId; const userFetchId = payload && payload.userFetchId;
const invites = payload && payload.invites; const invites = payload && payload.invites;
const errorStatus = const errorMessage =
(payload && (payload && payload.error && (payload.error.message || payload.error.toString())) ||
payload.error && null;
payload.error.response &&
payload.error.response.status) ||
999;
switch (action.type) { switch (action.type) {
// fetch // fetch
case types.FETCH_USER_PENDING: case types.FETCH_USER_PENDING:
@ -81,7 +81,7 @@ export default (state = INITIAL_STATE, action: any) => {
return updateUserState(state, userFetchId, { return updateUserState(state, userFetchId, {
isFetching: false, isFetching: false,
hasFetched: true, hasFetched: true,
fetchError: errorStatus, fetchError: errorMessage,
}); });
// update // update
case types.UPDATE_USER_PENDING: case types.UPDATE_USER_PENDING:
@ -99,7 +99,7 @@ export default (state = INITIAL_STATE, action: any) => {
case types.UPDATE_USER_REJECTED: case types.UPDATE_USER_REJECTED:
return updateUserState(state, payload.user.userid, { return updateUserState(state, payload.user.userid, {
isUpdating: false, isUpdating: false,
updateError: errorStatus, updateError: errorMessage,
}); });
// invites // invites
case types.FETCH_USER_INVITES_PENDING: case types.FETCH_USER_INVITES_PENDING:
@ -117,7 +117,7 @@ export default (state = INITIAL_STATE, action: any) => {
return updateUserState(state, userFetchId, { return updateUserState(state, userFetchId, {
isFetchingInvites: false, isFetchingInvites: false,
hasFetchedInvites: true, hasFetchedInvites: true,
fetchErrorInvites: errorStatus, fetchErrorInvites: errorMessage,
}); });
// invites // invites
case types.FETCH_USER_INVITES_PENDING: case types.FETCH_USER_INVITES_PENDING:
@ -135,7 +135,7 @@ export default (state = INITIAL_STATE, action: any) => {
return updateUserState(state, userFetchId, { return updateUserState(state, userFetchId, {
isFetchingInvites: false, isFetchingInvites: false,
hasFetchedInvites: true, hasFetchedInvites: true,
fetchErrorInvites: errorStatus, fetchErrorInvites: errorMessage,
}); });
// invite response // invite response
case types.RESPOND_TO_INVITE_PENDING: case types.RESPOND_TO_INVITE_PENDING:
@ -148,8 +148,14 @@ export default (state = INITIAL_STATE, action: any) => {
case types.RESPOND_TO_INVITE_REJECTED: case types.RESPOND_TO_INVITE_REJECTED:
return updateTeamInvite(state, payload.userId, payload.inviteId, { return updateTeamInvite(state, payload.userId, payload.inviteId, {
isResponding: false, isResponding: false,
respondError: errorStatus, respondError: errorMessage,
}); });
// proposal delete
case types.USER_DELETE_PROPOSAL_FULFILLED:
return removePendingProposal(state, payload.userId, payload.proposalId);
// proposal publish
case types.USER_PUBLISH_PROPOSAL_FULFILLED:
return updatePublishedProposal(state, payload.userId, payload.proposal);
// default // default
default: default:
return state; return state;
@ -171,6 +177,32 @@ function updateUserState(
}; };
} }
function removePendingProposal(
state: UsersState,
userId: string | number,
proposalId: number,
) {
const pendingProposals = state.map[userId].pendingProposals.filter(
p => p.proposalId !== proposalId,
);
const userUpdates = {
pendingProposals,
};
return updateUserState(state, userId, userUpdates);
}
function updatePublishedProposal(
state: UsersState,
userId: string | number,
proposal: UserProposal,
) {
const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
const userUpdates = {
createdProposals: [proposal, ...state.map[userId].createdProposals],
};
return updateUserState(withoutPending, userId, userUpdates);
}
function updateTeamInvite( function updateTeamInvite(
state: UsersState, state: UsersState,
userid: string | number, userid: string | number,

View File

@ -18,6 +18,12 @@ enum UsersActions {
RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING', RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING',
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED', RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED', RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',
USER_PUBLISH_PROPOSAL = 'USER_PUBLISH_PROPOSAL',
USER_PUBLISH_PROPOSAL_FULFILLED = 'USER_PUBLISH_PROPOSAL_FULFILLED',
} }
export default UsersActions; export default UsersActions;

View File

@ -1,26 +1,29 @@
// Override ant less variables // Override ant less variables
@primary-color: #530EEC; @primary-color: #530eec;
@link-color: rgba(@primary-color, 0.8); @link-color: rgba(@primary-color, 0.8);
@success-color: #52c41a; @success-color: #52c41a;
@warning-color: #faad14; @warning-color: #faad14;
@error-color: #f5222d; @error-color: #f5222d;
@info-color: #1890ff; @info-color: #1890ff;
@processing-color: @primary-color; @processing-color: @primary-color;
@normal-color: #DDD; @normal-color: #ddd;
@heading-color: #221F1F; @heading-color: #221f1f;
@text-color: rgba(#221F1F, .8); @text-color: rgba(#221f1f, 0.8);
@text-color-secondary : rgba(#221F1F, .6); @text-color-secondary : rgba(#221F1F, .6);
@border-color: rgba(229, 231, 235, 100); @border-color: rgba(229, 231, 235, 100);
@font-family: 'Nunito Sans', 'Helvetica Neue', Arial, sans-serif; @font-family: 'Nunito Sans', 'Helvetica Neue', Arial, sans-serif;
@code-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; @code-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
// Custom variables // Custom variables
@primary-color-dark: #3F05BE; @primary-color-dark: #3f05be;
@primary-color-light: #692CEC; @primary-color-light: #692cec;
@max-content-width: 1440px; @max-content-width: 1440px;
@tablet-query: ~'(max-width: 900px)'; @tablet-query: ~'(max-width: 900px)';
@mobile-query: ~'(max-width: 600px)'; @mobile-query: ~'(max-width: 600px)';
@tiny-query: ~'(max-width: 360px)'; @tiny-query: ~'(max-width: 360px)';
@template-space-top: 2.5rem;
@template-space-sides: 2.5rem;

View File

@ -12,11 +12,14 @@ export function formatUserForPost(user: User) {
} }
export function formatUserFromGet(user: UserState) { export function formatUserFromGet(user: UserState) {
const bnUserProp = (p: UserProposal) => { const bnUserProp = (p: any) => {
p.funded = new BN(p.funded); p.funded = toZat(p.funded);
p.target = new BN(p.target); p.target = toZat(p.target);
return p; return p;
}; };
if (user.pendingProposals) {
user.pendingProposals = user.pendingProposals.map(bnUserProp);
}
user.createdProposals = user.createdProposals.map(bnUserProp); user.createdProposals = user.createdProposals.map(bnUserProp);
user.fundedProposals = user.fundedProposals.map(bnUserProp); user.fundedProposals = user.fundedProposals.map(bnUserProp);
return user; return user;

View File

@ -15,7 +15,8 @@ const pathActions = [
action: (match: RegExpMatchArray, store: Store) => { action: (match: RegExpMatchArray, store: Store) => {
const proposalId = extractProposalIdFromUrl(match[1]); const proposalId = extractProposalIdFromUrl(match[1]);
if (proposalId) { if (proposalId) {
return store.dispatch<any>(fetchProposal(proposalId)); // return null for errors (404 most likely)
return store.dispatch<any>(fetchProposal(proposalId)).catch(() => null);
} }
}, },
}, },

View File

@ -4,6 +4,7 @@ import {
MILESTONE_STATE, MILESTONE_STATE,
Proposal, Proposal,
ProposalMilestone, ProposalMilestone,
STATUS,
} from 'types'; } from 'types';
import { PROPOSAL_CATEGORY } from 'api/constants'; import { PROPOSAL_CATEGORY } from 'api/constants';
import BN from 'bn.js'; import BN from 'bn.js';
@ -97,7 +98,9 @@ export function generateProposal({
return ts.slice(rand).join(''); return ts.slice(rand).join('');
}; };
const genMilestone = (overrides: Partial<ProposalMilestone> = {}): ProposalMilestone => { const genMilestone = (
overrides: Partial<ProposalMilestone> = {},
): ProposalMilestone => {
const now = new Date(); const now = new Date();
if (overrides.index) { if (overrides.index) {
const estimate = new Date(now.setMonth(now.getMonth() + overrides.index)); const estimate = new Date(now.setMonth(now.getMonth() + overrides.index));
@ -140,10 +143,12 @@ export function generateProposal({
const proposal: Proposal = { const proposal: Proposal = {
proposalId: 12345, proposalId: 12345,
status: STATUS.DRAFT,
proposalUrlId: '12345-crowdfund-title', proposalUrlId: '12345-crowdfund-title',
proposalAddress: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710', proposalAddress: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710',
payoutAddress: 'z123', payoutAddress: 'z123',
dateCreated: created / 1000, dateCreated: created / 1000,
datePublished: created / 1000,
deadlineDuration: 86400 * 60, deadlineDuration: 86400 * 60,
target: amountBn, target: amountBn,
funded: fundedBn, funded: fundedBn,

View File

@ -1,12 +1,7 @@
import BN from 'bn.js'; import BN from 'bn.js';
import { Zat } from 'utils/units'; import { Zat } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants'; import { PROPOSAL_CATEGORY } from 'api/constants';
import { import { CreateMilestone, Update, User, Comment } from 'types';
CreateMilestone,
Update,
User,
Comment,
} from 'types';
import { ProposalMilestone } from './milestone'; import { ProposalMilestone } from './milestone';
export interface TeamInvite { export interface TeamInvite {
@ -39,9 +34,9 @@ export interface ProposalDraft {
milestones: CreateMilestone[]; milestones: CreateMilestone[];
team: User[]; team: User[];
invites: TeamInvite[]; invites: TeamInvite[];
status: STATUS;
} }
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> { export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
proposalAddress: string; proposalAddress: string;
proposalUrlId: string; proposalUrlId: string;
@ -49,6 +44,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
funded: BN; funded: BN;
percentFunded: number; percentFunded: number;
milestones: ProposalMilestone[]; milestones: ProposalMilestone[];
datePublished: number;
} }
export interface TeamInviteWithProposal extends TeamInvite { export interface TeamInviteWithProposal extends TeamInvite {
@ -68,9 +64,24 @@ export interface ProposalUpdates {
export interface UserProposal { export interface UserProposal {
proposalId: number; proposalId: number;
status: STATUS;
title: string; title: string;
brief: string; brief: string;
team: User[];
funded: BN; funded: BN;
target: BN; target: BN;
dateCreated: number;
dateApproved: number;
datePublished: number;
team: User[];
rejectReason: string;
}
// NOTE: sync with backend/grant/proposal/models.py STATUSES
export enum STATUS {
DRAFT = 'DRAFT',
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
LIVE = 'LIVE',
DELETED = 'DELETED',
} }