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:
parent
a0d115a703
commit
47c695f43b
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -0,0 +1,11 @@
|
||||||
|
.ProposalItem {
|
||||||
|
& h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
& .ant-tag {
|
||||||
|
vertical-align: text-top;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
|
@ -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);
|
||||||
|
};
|
|
@ -21,7 +21,8 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"src/*": ["./*"],
|
"src/*": ["./*"],
|
||||||
"components/*": ["./components/*"],
|
"components/*": ["./components/*"],
|
||||||
"styles/*": ["./styles/*"]
|
"styles/*": ["./styles/*"],
|
||||||
|
"util/*": ["./util/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*"],
|
"include": ["./src/**/*"],
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
440
admin/yarn.lock
440
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 ###
|
|
@ -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.")
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
// simulate non-fullscreen template margins
|
||||||
|
.Preview {
|
||||||
|
margin: @template-space-top @template-space-sides;
|
||||||
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 you’re ready to publish your proposal? Once you’ve done so, you
|
Are you sure you're ready to submit your proposal for approval? Once you’ve
|
||||||
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>
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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">
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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%;
|
|
@ -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> </span>}
|
<h1 className="Proposal-top-main-title">
|
||||||
</h1>
|
{proposal ? proposal.title : <span> </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 = () => {
|
||||||
|
|
|
@ -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 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue