Proposal Approval Process (#39)

* endpoints and model support for proposal approval

* admin test + proposal approval tests

* GET user/<id> withPending support

* basic withPending suport for Profile

* change create publish to sumbit for approval

* admin proposal filter by status + some refactoring

* admin: update antd

* backend: admin get single proposal + populate date_approved

* admin: rework Proposals + support approval

* backend: approval process updates

* admin: review count on home + cosmetic

* frontend: proposal approval flow

* Profile ZEC/ZAT adjustments

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

* fix merge tsc issues

* publish warning vebiage change

* fix ssr fetchProposal 404 hang bug

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

* various adjustments to Proposal based on `status`

* remove comments

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

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

View File

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

View File

@ -9,6 +9,7 @@ import Login from 'components/Login';
import Home from 'components/Home';
import Users from 'components/Users';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
import 'styles/style.less';
@ -28,7 +29,8 @@ class Routes extends React.Component<Props> {
<Switch>
<Route path="/" exact={true} component={Home} />
<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>
)}
</Template>

View File

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

View File

@ -1,7 +1,9 @@
import React from 'react';
import { Divider, Icon } from 'antd';
import { view } from 'react-easy-state';
import store from '../../store';
import './index.less';
import { Link } from 'react-router-dom';
class Home extends React.Component {
componentDidMount() {
@ -9,11 +11,21 @@ class Home extends React.Component {
}
render() {
const { userCount, proposalCount } = store.stats;
const { userCount, proposalCount, proposalPendingCount } = store.stats;
return (
<div className="Home">
<h1>Home</h1>
<div>isLoggedIn: {JSON.stringify(store.isLoggedIn)}</div>
{!!proposalPendingCount && (
<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>proposal count: {proposalCount}</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,108 +1,16 @@
@controls-height: 40px;
.Proposals {
margin-top: @controls-height + 0.5rem;
h1 {
font-size: 1.5rem;
}
&-controls {
height: @controls-height;
padding: 0.25rem 1rem;
margin-left: -1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
right: 0;
left: 216px;
z-index: 5;
background: white;
margin-bottom: 0.5rem;
& > * {
margin-right: 0.5rem;
}
}
&-proposal {
display: flex;
padding-bottom: 1rem;
border-bottom: 1px solid rgb(214, 214, 214);
margin-bottom: 1rem;
&-filters {
margin-bottom: 0.5rem;
}
&-controls {
margin: 0 0.5rem 0.2rem 0;
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;
}
}
&-list {
margin-top: 1rem;
}
}

View File

@ -1,34 +1,40 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Icon, Button, Popover } from 'antd';
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 qs from 'query-string';
import { uniq, without } from 'lodash';
import { Link } from 'react-router-dom';
import { view } from 'react-easy-state';
import { Icon, Button, Dropdown, Menu, Tag, List } from 'antd';
import { 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({
simplifiedAutoLink: true,
tables: true,
strikethrough: true,
disableForced4SpacesIndentedSublists: true,
openLinksInNewWindow: true,
excludeTrailingPunctuationFromURLs: true,
});
interface Query {
status: PROPOSAL_STATUS[];
}
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() {
store.fetchProposals();
this.setStateFromQueryString();
this.fetchProposals();
}
render() {
const id = Number(this.props.match.params.id);
const { proposals, proposalsFetched } = store;
const { proposals, proposalsFetching, proposalsFetched } = store;
const { statusFilters } = this.state;
if (!proposalsFetched) {
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 (
<div className="Proposals">
<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>
{proposals.length === 0 && <div>no proposals</div>}
{proposals.length > 0 &&
proposals.map(p => <ProposalItem key={p.proposalId} {...p} />)}
</div>
);
}
}
// tslint:disable-next-line:max-classes-per-file
class ProposalItemNaked extends React.Component<Proposal> {
state = {
showDelete: false,
};
render() {
const p = this.props;
const body = showdownConverter.makeHtml(p.content);
return (
<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}
/>
{!!statusFilters.length && (
<div className="Proposals-filters">
Filters:{' '}
{statusFilters.map(sf => (
<Tag
key={sf}
onClose={() => this.handleFilterClose(sf)}
color={getStatusById(sf).tagColor}
closable
>
status: {sf}
</Tag>
))}
{statusFilters.length > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
<b>{p.title}</b> [{p.proposalId}]{p.proposalAddress}{' '}
<Field title="category" value={p.category} />
<Field title="dateCreated" value={p.dateCreated * 1000} isTime={true} />
<Field title="stage" value={p.stage} />
<Field
title={`team (${p.team.length})`}
value={
<div>
{p.team.map(u => (
<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>
)}
{proposalsFetching && 'Fetching proposals...'}
{proposalsFetched &&
!proposalsFetching && (
<List
className="Proposals-list"
bordered
dataSource={proposals}
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
/>
)}
</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));
export default Proposals;

View File

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

View File

@ -1,6 +1,6 @@
import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios';
import { User, Proposal } from './types';
import { User, Proposal, PROPOSAL_STATUS } from './types';
// API
const api = axios.create({
@ -36,13 +36,20 @@ async function fetchUsers() {
return data;
}
async function deleteUser(id: string) {
async function deleteUser(id: number | string) {
const { data } = await api.delete('/admin/users/' + id);
return data;
}
async function fetchProposals() {
const { data } = await api.get('/admin/proposals');
async function fetchProposals(statusFilters?: PROPOSAL_STATUS[]) {
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;
}
@ -51,25 +58,50 @@ async function deleteProposal(id: number) {
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
const app = store({
hasCheckedLogin: false,
isLoggedIn: false,
loginError: '',
generalError: [] as string[],
statsFetched: false,
statsFetching: false,
stats: {
userCount: -1,
proposalCount: -1,
userCount: 0,
proposalCount: 0,
proposalPendingCount: 0,
},
usersFetched: false,
users: [] as User[],
proposalsFetching: false,
proposalsFetched: false,
proposals: [] as Proposal[],
proposalDetailFetching: false,
proposalDetail: null as null | Proposal,
proposalDetailApproving: false,
removeGeneralError(i: number) {
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() {
app.isLoggedIn = await checkLogin();
app.hasCheckedLogin = true;
@ -92,11 +124,14 @@ const app = store({
},
async fetchStats() {
app.statsFetching = true;
try {
app.stats = await fetchStats();
app.statsFetched = true;
} catch (e) {
handleApiError(e);
}
app.statsFetching = false;
},
async fetchUsers() {
@ -108,26 +143,34 @@ const app = store({
}
},
async deleteUser(id: string) {
async deleteUser(id: string | number) {
try {
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) {
handleApiError(e);
}
},
async fetchProposals() {
async fetchProposals(statusFilters?: PROPOSAL_STATUS[]) {
app.proposalsFetching = true;
try {
app.proposals = await fetchProposals();
app.proposals = await fetchProposals(statusFilters);
app.proposalsFetched = true;
// for (const p of app.proposals) {
// TODO: partial populate contributorList
// await app.populateProposalContract(p.proposalId);
// }
} catch (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) {
@ -138,6 +181,25 @@ const app = store({
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) {

View File

@ -11,10 +11,23 @@ export interface Milestone {
stage: 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 {
proposalId: number;
brief: string;
status: PROPOSAL_STATUS;
proposalAddress: string;
dateCreated: number;
dateApproved: number;
datePublished: number;
title: string;
content: string;
stage: string;
@ -23,6 +36,8 @@ export interface Proposal {
team: User[];
comments: Comment[];
contractStatus: string;
target: string;
rejectReason: string;
}
export interface Comment {
commentId: string;

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

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
from functools import wraps
from flask import Blueprint, g, session
from flask import Blueprint, g, session, request
from flask_yoloapi import endpoint, parameter
from hashlib import sha256
from uuid import uuid4
from flask_cors import CORS, cross_origin
from sqlalchemy import func
from sqlalchemy import func, or_
from grant.extensions import db
from grant.user.models import User, users_schema
from grant.proposal.models import Proposal, proposals_schema
from grant.proposal.models import Proposal, proposals_schema, proposal_schema, PENDING
from grant.comment.models import Comment, comments_schema
@ -34,7 +34,6 @@ def auth_required(f):
@blueprint.route("/checklogin", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
def loggedin():
if 'username' in session:
@ -44,7 +43,6 @@ def loggedin():
@blueprint.route("/login", methods=["POST"])
@cross_origin(supports_credentials=True)
@endpoint.api(
parameter('username', type=str, required=False),
parameter('password', type=str, required=False),
@ -60,7 +58,6 @@ def login(username, password):
@blueprint.route("/logout", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
def logout():
del session['username']
@ -68,20 +65,22 @@ def logout():
@blueprint.route("/stats", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def stats():
user_count = db.session.query(func.count(User.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 {
"userCount": user_count,
"proposalCount": proposal_count
"proposalCount": proposal_count,
"proposalPendingCount": proposal_pending_count,
}
@blueprint.route('/users/<id>', methods=['DELETE'])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def delete_user(id):
@ -89,7 +88,6 @@ def delete_user(id):
@blueprint.route("/users", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def get_users():
@ -104,18 +102,48 @@ def get_users():
@blueprint.route("/proposals", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
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)
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'])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def delete_proposal(id):
return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
@endpoint.api(
parameter('isApprove', type=bool, required=True),
parameter('rejectReason', type=str, required=False)
)
@auth_required
def approve_proposal(id, is_approve, reject_reason=None):
proposal = Proposal.query.filter_by(id=id).first()
if proposal:
proposal.approve_pending(is_approve, reject_reason)
db.session.commit()
return proposal_schema.dump(proposal)
return {"message": "Not implemented."}, 400

View File

@ -1,6 +1,6 @@
import datetime
from typing import List
from sqlalchemy import func
from sqlalchemy import func, or_
from grant.comment.models import Comment
from grant.extensions import ma, db
@ -9,9 +9,11 @@ from grant.utils.exceptions import ValidationException
DRAFT = 'DRAFT'
PENDING = 'PENDING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
DELETED = 'DELETED'
STATUSES = [DRAFT, PENDING, LIVE, DELETED]
STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED]
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
@ -114,6 +116,9 @@ class Proposal(db.Model):
stage = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, 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
target = db.Column(db.String(255), nullable=False)
@ -163,6 +168,24 @@ class Proposal(db.Model):
if category and category not in 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
def create(**kwargs):
Proposal.validate(kwargs)
@ -171,10 +194,12 @@ class Proposal(db.Model):
)
@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 \
.join(proposal_team) \
.filter(proposal_team.c.user_id == user.id) \
.filter(status_filter) \
.all()
@staticmethod
@ -204,25 +229,40 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration
Proposal.validate(vars(self))
def publish(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")
def submit_for_approval(self):
self.validate_publishable()
allowed_statuses = [DRAFT, REJECTED]
# specific validation
if self.status not in allowed_statuses:
raise ValidationException("Proposal status must be {} or {} to submit for approval".format(DRAFT, REJECTED))
# Then run through regular validation
Proposal.validate(vars(self))
self.status = 'LIVE'
self.status = PENDING
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):
@ -231,7 +271,11 @@ class ProposalSchema(ma.Schema):
# Fields to expose
fields = (
"stage",
"status",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"title",
"brief",
"proposal_id",
@ -250,6 +294,8 @@ class ProposalSchema(ma.Schema):
)
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")
funded = ma.Method("get_funded")
@ -266,6 +312,12 @@ class ProposalSchema(ma.Schema):
def get_date_created(self, obj):
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):
# TODO: Add up all contributions and return that
return "0"
@ -382,13 +434,20 @@ class UserProposalSchema(ma.Schema):
# Fields to expose
fields = (
"proposal_id",
"status",
"title",
"brief",
"target",
"funded",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"team",
)
date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded")
team = ma.Nested("UserSchema", many=True)
def get_proposal_id(self, obj):
@ -397,6 +456,10 @@ class UserProposalSchema(ma.Schema):
def get_date_created(self, obj):
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_proposals_schema = UserProposalSchema(many=True)

View File

@ -5,12 +5,13 @@ import ast
from flask import Blueprint, g
from flask_yoloapi import endpoint, parameter
from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_
from grant.comment.models import Comment, comment_schema, comments_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
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.misc import is_email, make_url
from .models import(
@ -24,7 +25,13 @@ from .models import(
proposal_team,
ProposalTeamInvite,
proposal_team_invite_schema,
db
db,
DRAFT,
PENDING,
APPROVED,
REJECTED,
LIVE,
DELETED
)
import traceback
@ -36,6 +43,13 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
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)
else:
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):
if stage:
proposals = (
Proposal.query.filter_by(status="LIVE", stage=stage)
Proposal.query.filter_by(status=LIVE, stage=stage)
.order_by(Proposal.date_created.desc())
.all()
)
else:
proposals = (
Proposal.query.filter_by(status="LIVE")
Proposal.query.filter_by(status=LIVE)
.order_by(Proposal.date_created.desc())
.all()
)
@ -131,7 +145,7 @@ def make_proposal_draft():
def get_proposal_drafts():
proposals = (
Proposal.query
.filter_by(status="DRAFT")
.filter(or_(Proposal.status == DRAFT, Proposal.status == REJECTED))
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
@ -182,14 +196,29 @@ def update_proposal(milestones, proposal_id, **kwargs):
@blueprint.route("/<proposal_id>", methods=["DELETE"])
@requires_team_member_auth
@endpoint.api()
def delete_proposal_draft(proposal_id):
if g.current_proposal.status != 'DRAFT':
return {"message": "Cannot delete non-draft proposals"}, 400
def delete_proposal(proposal_id):
deleteable_statuses = [DRAFT, PENDING, APPROVED, REJECTED]
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.commit()
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"])
@requires_team_member_auth
@endpoint.api()

View File

@ -8,9 +8,12 @@ from grant.proposal.models import (
proposal_team,
ProposalTeamInvite,
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.social import verify_social, get_social_login_url, VerifySocialException
from grant.email.models import EmailRecovery
@ -52,9 +55,10 @@ def get_me():
@endpoint.api(
parameter("withProposals", 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)
if 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_dump = user_comments_schema.dump(comments)
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
else:
message = "User with id matching {} not found".format(user_id)

View File

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

View File

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

View File

View File

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

View File

@ -1,7 +1,7 @@
import json
from mock import patch
from grant.proposal.models import Proposal
from grant.proposal.models import Proposal, APPROVED, PENDING, DRAFT
from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal, test_user
@ -64,16 +64,47 @@ class TestProposalAPI(BaseProposalCreatorConfig):
)
self.assert404(resp)
def test_publish_proposal_draft(self):
# /submit_for_approval
def test_proposal_draft_submit_for_approval(self):
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))
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))
self.assert401(resp)
def test_invalid_proposal_publish_proposal_draft(self):
def test_invalid_proposal_publish_proposal(self):
self.login_default_user()
resp = self.app.put("/api/v1/proposals/12345/publish")
self.assert404(resp)
def test_invalid_status_proposal_publish_proposal(self):
self.login_default_user()
self.proposal.status = PENDING # should be APPROVED
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert400(resp)

View File

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

View File

@ -36,27 +36,30 @@ class CreateFinal extends React.Component<Props> {
</div>
</div>
);
} else
if (submittedProposal) {
} else if (submittedProposal) {
content = (
<div className="CreateFinal-message is-success">
<Icon type="check-circle" />
<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!{' '}
<Link to={`/proposals/${submittedProposal.proposalUrlId}`}>
Click here
</Link>
{' '}to check it out.
</div>
</div> */}
</div>
);
} else {
content = (
<div className="CreateFinal-loader">
<Spin size="large" />
<div className="CreateFinal-loader-text">
Submitting your proposal...
</div>
<div className="CreateFinal-loader-text">Submitting your proposal...</div>
</div>
);
}

View File

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

View File

@ -1,10 +1,10 @@
import React from 'react';
import { connect } from 'react-redux';
import { Alert } from 'antd';
import { ProposalDetail } from 'components/Proposal';
import { AppState } from 'store/reducers';
import { makeProposalPreviewFromDraft } from 'modules/create/utils';
import { ProposalDraft } from 'types';
import './Preview.less';
interface StateProps {
form: ProposalDraft;
@ -17,14 +17,7 @@ class CreateFlowPreview extends React.Component<Props> {
const { form } = this.props;
const proposal = makeProposalPreviewFromDraft(form);
return (
<>
<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
/>
<div className="Preview">
<ProposalDetail
user={null}
proposalId={0}
@ -32,7 +25,7 @@ class CreateFlowPreview extends React.Component<Props> {
proposal={proposal}
isPreview
/>
</>
</div>
);
}
}

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { List, Button, Divider, Spin, Popconfirm, message } from 'antd';
import Placeholder from 'components/Placeholder';
import { ProposalDraft } from 'types';
import { ProposalDraft, STATUS } from 'types';
import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions';
import { AppState } from 'store/reducers';
import './style.less';
@ -96,7 +96,12 @@ class DraftList extends React.Component<Props, State> {
<Spin tip="deleting..." spinning={deletingId === d.proposalId}>
<List.Item actions={actions}>
<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>}
/>
</List.Item>

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,12 @@ import {
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { Spin, Tabs, Badge } from 'antd';
import { UsersState } from 'modules/users/reducers';
import { usersActions } from 'modules/users';
import { AppState } from 'store/reducers';
import HeaderDetails from 'components/HeaderDetails';
import ProfileUser from './ProfileUser';
import ProfileEdit from './ProfileEdit';
import ProfilePendingList from './ProfilePendingList';
import ProfileProposal from './ProfileProposal';
import ProfileComment from './ProfileComment';
import ProfileInvite from './ProfileInvite';
@ -23,7 +23,7 @@ import Exception from 'pages/exception';
import './style.less';
interface StateProps {
usersMap: UsersState['map'];
usersMap: AppState['users']['map'];
authUser: AppState['auth']['user'];
}
@ -69,7 +69,14 @@ class Profile extends React.Component<Props> {
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 noneFunded = fundedProposals.length === 0;
const noneCommented = comments.length === 0;
@ -96,6 +103,22 @@ class Profile extends React.Component<Props> {
/>
</Switch>
<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">
<div>
{noneCreated && (

View File

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

View File

@ -7,6 +7,18 @@
max-width: 1280px;
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 {
display: flex;
width: 100%;

View File

@ -1,13 +1,15 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { compose } from 'recompose';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import Markdown from 'components/Markdown';
import { proposalActions } from 'modules/proposals';
import { bindActionCreators, Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import { Proposal } from 'types';
import { Proposal, STATUS } from 'types';
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 TeamBlock from './TeamBlock';
import Milestones from './Milestones';
@ -20,7 +22,7 @@ import CancelModal from './CancelModal';
import classnames from 'classnames';
import { withRouter } from 'react-router';
import SocialShare from 'components/SocialShare';
import './style.less';
import './index.less';
interface OwnProps {
proposalId: number;
@ -56,9 +58,10 @@ export class ProposalDetail extends React.Component<Props, State> {
};
componentDidMount() {
if (!this.props.proposal) {
this.props.fetchProposal(this.props.proposalId);
} else {
// always refresh from server
this.props.fetchProposal(this.props.proposalId);
if (this.props.proposal) {
this.checkBodyOverflow();
}
if (typeof window !== 'undefined') {
@ -91,37 +94,85 @@ export class ProposalDetail extends React.Component<Props, State> {
if (!proposal) {
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 && (
<Menu>
<Menu.Item 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>
);
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 isLive = proposal.status === STATUS.LIVE;
return (
<div className="Proposal">
<div className="Proposal-top">
const adminMenu = (
<Menu>
<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">
<SocialShare
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.`}
/>
</div>
<div className="Proposal-top-main">
<h1 className="Proposal-top-main-title">
{proposal ? proposal.title : <span>&nbsp;</span>}
</h1>
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
<div
id={bodyId}
className={classnames({
['Proposal-top-main-block-bodyText']: true,
['is-expanded']: isBodyExpanded,
})}
>
{proposal ? (
<Markdown source={proposal.content} />
) : (
<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 className="Proposal-top-main">
<h1 className="Proposal-top-main-title">
{proposal ? proposal.title : <span>&nbsp;</span>}
</h1>
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
<div
id={bodyId}
className={classnames({
['Proposal-top-main-block-bodyText']: true,
['is-expanded']: isBodyExpanded,
})}
>
{proposal ? (
<Markdown source={proposal.content} />
) : (
<Spin size="large" />
)}
</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">
<Dropdown
overlay={adminMenu}
@ -172,48 +225,46 @@ export class ProposalDetail extends React.Component<Props, State> {
</Dropdown>
</div>
)}
</div>
<div className="Proposal-top-side">
<CampaignBlock proposal={proposal} isPreview={isPreview} />
<TeamBlock proposal={proposal} />
</div>
</div>
{proposal && (
<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={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 className="Proposal-top-side">
<CampaignBlock proposal={proposal} isPreview={!isLive} />
<TeamBlock proposal={proposal} />
</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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,12 @@ enum UsersActions {
RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING',
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
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;

View File

@ -1,26 +1,29 @@
// Override ant less variables
@primary-color: #530EEC;
@link-color: rgba(@primary-color, 0.8);
@primary-color: #530eec;
@link-color: rgba(@primary-color, 0.8);
@success-color: #52c41a;
@warning-color: #faad14;
@error-color: #f5222d;
@info-color: #1890ff;
@processing-color: @primary-color;
@normal-color: #DDD;
@heading-color: #221F1F;
@text-color: rgba(#221F1F, .8);
@normal-color: #ddd;
@heading-color: #221f1f;
@text-color: rgba(#221f1f, 0.8);
@text-color-secondary : rgba(#221F1F, .6);
@border-color: rgba(229, 231, 235, 100);
@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
@primary-color-dark: #3F05BE;
@primary-color-light: #692CEC;
@primary-color-dark: #3f05be;
@primary-color-light: #692cec;
@max-content-width: 1440px;
@tablet-query: ~'(max-width: 900px)';
@mobile-query: ~'(max-width: 600px)';
@tiny-query: ~'(max-width: 360px)';
@tiny-query: ~'(max-width: 360px)';
@template-space-top: 2.5rem;
@template-space-sides: 2.5rem;

View File

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

View File

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

View File

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

View File

@ -1,12 +1,7 @@
import BN from 'bn.js';
import { Zat } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants';
import {
CreateMilestone,
Update,
User,
Comment,
} from 'types';
import { CreateMilestone, Update, User, Comment } from 'types';
import { ProposalMilestone } from './milestone';
export interface TeamInvite {
@ -39,9 +34,9 @@ export interface ProposalDraft {
milestones: CreateMilestone[];
team: User[];
invites: TeamInvite[];
status: STATUS;
}
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
proposalAddress: string;
proposalUrlId: string;
@ -49,6 +44,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
funded: BN;
percentFunded: number;
milestones: ProposalMilestone[];
datePublished: number;
}
export interface TeamInviteWithProposal extends TeamInvite {
@ -68,9 +64,24 @@ export interface ProposalUpdates {
export interface UserProposal {
proposalId: number;
status: STATUS;
title: string;
brief: string;
team: User[];
funded: 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',
}