Merge latest develop in.

This commit is contained in:
Will O'Beirne 2019-01-09 16:57:15 -05:00
commit 5a922cefee
No known key found for this signature in database
GPG Key ID: 44C190DB5DEAF9F6
74 changed files with 2658 additions and 708 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

@ -8,7 +8,9 @@ import store from './store';
import Login from 'components/Login';
import Home from 'components/Home';
import Users from 'components/Users';
import Emails from 'components/Emails';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
import 'styles/style.less';
@ -28,7 +30,9 @@ 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} />
<Route path="/emails/:type?" component={Emails} />
</Switch>
)}
</Template>

View File

@ -0,0 +1,91 @@
.Example {
&-section {
margin-bottom: 3rem;
padding-bottom: 3rem;
border-bottom: 1px solid rgba(#000, 0.3);
&:last-child {
border: none;
}
&-title {
font-size: 1.2rem;
}
}
// Values taken directly from gmail, that's why they're weird!
&-inbox {
display: flex;
justify-content: space-between;
align-items: center;
background: #FFF;
color: #202124;
border: 1px solid rgba(100,121,143,0.122);
height: 40px;
font-family: Roboto,RobotoDraft,Helvetica,Arial,sans-serif;
&-left {
display: flex;
width: 270px;
padding-left: 18px;
padding-right: 32px;
&-icon {
width: 20px;
height: 20px;
background-position: center;
background-size: 20px;
opacity: 0.2;
margin-right: 10px;
&.is-checkbox {
background-image: url(https://www.gstatic.com/images/icons/material/system/2x/check_box_outline_blank_black_20dp.png);
}
&.is-favorite {
background-image: url(https://www.gstatic.com/images/icons/material/system/2x/star_border_black_20dp.png);
}
}
&-sender {
font-size: 14px;
letter-spacing: 0.2px;
}
}
&-subject {
letter-spacing: 0.2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
strong {
font-size: 14px;
font-weight: 700;
}
small {
font-size: 14px;
color: #5f6368;
}
}
&-time {
width: 115px;
padding-right: 18px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.3px;
text-align: right;
}
}
&-text {
pre {
padding: 1rem;
background: #FAFAFA;
border: 1px solid #CCC;
border-radius: 4px;
}
}
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { Spin } from 'antd';
import moment from 'moment';
import { EmailExample } from '../../types';
import './Example.less';
interface Props {
email?: EmailExample;
}
export default class Example extends React.Component<Props> {
render() {
const { email } = this.props;
let content;
if (email) {
content = [
<div key="inbox" className="Example-section">
<h2 className="Example-section-title">Inbox</h2>
<div className="Example-inbox">
<div className="Example-inbox-left">
<div className="Example-inbox-left-icon is-checkbox" />
<div className="Example-inbox-left-icon is-favorite" />
<div className="Example-inbox-left-sender">Grant.io</div>
</div>
<div className="Example-inbox-subject">
<strong>{email.info.subject}</strong>
<small>
{' - '}
{email.info.preview}
</small>
</div>
<div className="Example-inbox-time">{moment().format('MMM Do')}</div>
</div>
</div>,
<div key="html" className="Example-section">
<h2 className="Example-section-title">HTML</h2>
<div className="Example-html">
<div
className="Example-html-content"
dangerouslySetInnerHTML={{ __html: email.html }}
/>
</div>
</div>,
<div key="text" className="Example-section">
<h2 className="Example-section-title">Text</h2>
<div className="Example-text">
<pre>{email.text}</pre>
</div>
</div>,
];
} else {
content = <Spin />;
}
return <div className="Example">{content}</div>;
}
}

View File

@ -0,0 +1,39 @@
interface Email {
id: string;
title: string;
description: string;
}
export default [
{
id: 'signup',
title: 'Signup',
description:
'Sent when the user first signs up, with instructions to confirm their email',
},
{
id: 'recover',
title: 'Password recovery',
description: 'For recovering a users forgotten password',
},
{
id: 'team_invite',
title: 'Proposal team invite',
description: 'Sent when a proposal creator sends an invite to a user',
},
{
id: 'proposal_approved',
title: 'Proposal approved',
description: 'Sent when an admin approves your submitted proposal',
},
{
id: 'proposal_rejected',
title: 'Proposal rejected',
description: 'Sent when an admin rejects your submitted proposal',
},
{
id: 'contribution_confirmed',
title: 'Contribution confirmed',
description: 'Sent after a contribution can be confirmed on chain',
},
] as Email[];

View File

@ -0,0 +1,34 @@
.Emails {
h1 {
font-size: 1.5rem;
a {
position: relative;
top: -0.25rem;
font-size: 0.8rem;
padding: 0 0.5rem;
}
}
&-email {
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid rgba(#000, 0.1);
border-radius: 4px;
&:hover {
border-color: #3498db;
}
h2 {
small {
opacity: 0.6;
}
}
p {
color: rgba(#000, 0.6);
margin: 0;
}
}
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Link } from 'react-router-dom';
import { Icon } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store';
import Example from './Example';
import EMAILS from './emails';
import './index.less';
type Props = RouteComponentProps<any>;
interface State {
examples: any;
}
class Emails extends React.Component<Props, State> {
state: State = {
examples: {},
};
componentDidMount() {
const { type } = this.props.match.params;
if (type && !store.emailExamples[type]) {
store.getEmailExample(type);
}
}
componentDidUpdate(prevProps: Props) {
const { type } = this.props.match.params;
const prevType = prevProps.match.params.type;
if (type && type !== prevType && !store.emailExamples[type]) {
store.getEmailExample(type);
}
}
render() {
const { type } = this.props.match.params;
let content;
if (type) {
content = <Example email={store.emailExamples[type]} />;
} else {
content = EMAILS.map(e => (
<Link key={e.id} to={`/emails/${e.id}`}>
<div className="Emails-email">
<h2>
{e.title} <small>({e.id})</small>
</h2>
<p>{e.description}</p>
</div>
</Link>
));
}
return (
<div className="Emails">
<h1>
{type && (
<Link to="/emails">
<Icon type="arrow-left" />
</Link>
)}
Emails
</h1>
{content}
</div>
);
}
}
export default withRouter(view(Emails));

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

@ -50,6 +50,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">proposals</span>
</Link>
</Menu.Item>
<Menu.Item key="/emails">
<Link to="/emails">
<Icon type="mail" />
<span className="nav-text">emails</span>
</Link>
</Menu.Item>
<Menu.Item key="logout" onClick={store.logout}>
<Icon type="logout" />
<span className="nav-text">logout</span>

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, EmailExample, 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,56 @@ 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;
}
async function getEmailExample(type: string) {
const { data } = await api.get(`/admin/email/example/${type}`);
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,
emailExamples: {} as { [type: string]: EmailExample },
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 +130,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 +149,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 +187,37 @@ 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;
},
async getEmailExample(type: string) {
try {
const example = await getEmailExample(type);
app.emailExamples = {
...app.emailExamples,
[type]: example,
};
} catch (e) {
handleApiError(e);
}
},
});
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;
@ -40,3 +55,13 @@ export interface User {
proposals: Proposal[];
comments: Comment[];
}
export interface EmailExample {
info: {
subject: string;
title: string;
preview: string;
};
html: string;
text: 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

@ -0,0 +1,51 @@
# Fake objects must be classes. Should stub out model properties.
class FakeUser(object):
id = 123
email_address = 'example@example.com'
display_name = 'Example User'
title = 'Email Example Dude'
class FakeProposal(object):
id = 123
title = 'Example proposal'
brief = 'This is an example proposal'
content = 'Example example example example'
class FakeContribution(object):
id = 123
amount = '123'
proposal_id = 123
user_id = 123
user = FakeUser()
proposal = FakeProposal()
contribution = FakeContribution()
example_email_args = {
'signup': {
'confirm_url': 'http://someconfirmurl.com',
},
'team_invite': {
'inviter': user,
'proposal': proposal,
'invite_url': 'http://someinviteurl.com',
},
'recover': {
'recover_url': 'http://somerecoveryurl.com',
},
'proposal_approved': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'admin_note': 'This proposal was the hottest stuff our team has seen yet. We look forward to throwing the fat stacks at you.',
},
'proposal_rejected': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'admin_note': 'We think that youve asked for too much money for the project youve proposed, and for such an inexperienced team. Feel free to change your target amount, or elaborate on why you need so much money, and try applying again.',
},
'contribution_confirmed': {
'proposal': proposal,
'contribution': contribution,
'tx_explorer_url': 'http://someblockexplorer.com/tx/271857129857192579125',
},
}

View File

@ -1,15 +1,17 @@
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
from grant.email.send import generate_email
from .example_emails import example_email_args
blueprint = Blueprint('admin', __name__, url_prefix='/api/v1/admin')
@ -34,7 +36,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 +45,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 +60,6 @@ def login(username, password):
@blueprint.route("/logout", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
def logout():
del session['username']
@ -68,20 +67,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 +90,6 @@ def delete_user(id):
@blueprint.route("/users", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def get_users():
@ -104,18 +104,56 @@ 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
@blueprint.route('/email/example/<type>', methods=['GET'])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def get_email_example(type):
return generate_email(type, example_email_args.get(type))

View File

@ -33,39 +33,72 @@ def recover_info(email_args):
'preview': 'Use the link to recover your account.'
}
def proposal_approved(email_args):
return {
'subject': 'Your proposal has been approved!',
'title': 'Your proposal has been approved',
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
}
def proposal_rejected(email_args):
return {
'subject': 'Your proposal has been rejected',
'title': 'Your proposal has been rejected',
'preview': '{} has been rejected'.format(email_args['proposal'].title),
}
def contribution_confirmed(email_args):
return {
'subject': 'Your contribution has been confirmed!',
'title': 'Contribution confirmed',
'preview': 'Your {} ZEC contribution to {} has been confirmed!'.format(
email_args['contribution'].amount,
email_args['proposal'].title
),
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info,
'recover': recover_info
'recover': recover_info,
'proposal_approved': proposal_approved,
'proposal_rejected': proposal_rejected,
'contribution_confirmed': contribution_confirmed,
}
def generate_email(type, email_args):
info = get_info_lookup[type](email_args)
body_text = render_template('emails/%s.txt' % (type), args=email_args)
body_html = render_template('emails/%s.html' % (type), args=email_args)
html = render_template('emails/template.html', args={
**default_template_args,
**info,
'body': Markup(body_html),
})
text = render_template('emails/template.txt', args={
**default_template_args,
**info,
'body': body_text,
})
return {
'info': info,
'html': html,
'text': text
}
def send_email(to, type, email_args):
if current_app and current_app.config.get("TESTING"):
return
try:
info = get_info_lookup[type](email_args)
body_text = render_template('emails/%s.txt' % (type), args=email_args)
body_html = render_template('emails/%s.html' % (type), args=email_args)
html = render_template('emails/template.html', args={
**default_template_args,
**info,
'body': Markup(body_html),
})
text = render_template('emails/template.txt', args={
**default_template_args,
**info,
'body': body_text,
})
email = generate_email(type, email_args)
res = mail.send_email(
to_email=to,
subject=info['subject'],
text=text,
html=html,
subject=email['info']['subject'],
text=email['text'],
html=email['html'],
)
print('Just sent an email to %s of type %s, response code: %s' % (to, type, res.status_code))
except Exception as e:

View File

@ -1,6 +1,6 @@
import datetime
from typing import List
from sqlalchemy import func
from sqlalchemy import func, or_
from functools import reduce
from grant.comment.models import Comment
@ -12,9 +12,11 @@ from grant.blockchain import blockchain_get
# Proposal states
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 stages
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
@ -144,6 +146,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)
@ -193,6 +198,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)
@ -201,10 +224,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
@ -234,25 +259,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):
@ -261,7 +301,11 @@ class ProposalSchema(ma.Schema):
# Fields to expose
fields = (
"stage",
"status",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"title",
"brief",
"proposal_id",
@ -279,6 +323,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")
@ -294,6 +340,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):
contributions = ProposalContribution.query \
.filter_by(proposal_id=obj.id, status=CONFIRMED) \
@ -304,10 +356,21 @@ class ProposalSchema(ma.Schema):
proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True)
user_proposal_schema = ProposalSchema(
only=["proposal_id", "title", "brief", "funded", "target", "date_created", "team"])
user_proposals_schema = ProposalSchema(
only=["proposal_id", "title", "brief", "funded", "target", "date_created", "team"], many=True)
user_fields = [
"proposal_id",
"status",
"title",
"brief",
"target",
"funded",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"team",
]
user_proposal_schema = ProposalSchema(only=user_fields)
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
class ProposalUpdateSchema(ma.Schema):
class Meta:

View File

@ -5,15 +5,16 @@ 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, internal_webhook
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url, from_zat
from .models import(
from .models import (
Proposal,
proposals_schema,
proposal_schema,
@ -24,10 +25,14 @@ from .models import(
proposal_team,
ProposalTeamInvite,
proposal_team_invite_schema,
proposal_proposal_contributions_schema,
CONFIRMED,
db,
DRAFT,
PENDING,
APPROVED,
REJECTED,
LIVE,
DELETED,
db
CONFIRMED,
)
import traceback
@ -39,6 +44,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
@ -99,13 +111,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()
)
@ -134,7 +146,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())
@ -185,14 +197,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

@ -0,0 +1,20 @@
<p style="margin: 0 0 20px;">
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has
been confirmed! <strong>{{ args.proposal.title}}</strong> has been updated
to reflect your funding, and your account will now show your contribution.
You can view your transaction below:
</p>
<p style="margin: 0 0 20px;">
<a
href="{{ args.tx_explorer_url }}"
target="_blank"
rel="nofollow noopener"
>
{{ args.tx_explorer_url }}
</a>
</p>
<p style="margin: 0;">
Thank you for your help in improving the ZCash ecosystem.
</p>

View File

@ -0,0 +1,7 @@
Your {{ args.contribution.amount }} ZEC contribution has been confirmed!
{{ args.proposal.title}} has been updated to reflect your funding, and your
account will now show your contribution. You can view your transaction here:
{{ args.tx_explorer_url }}
Thank you for your help in improving the ZCash ecosystem.

View File

@ -0,0 +1,34 @@
<p style="margin: 0;">
Congratulations on your approval! We look forward to seeing the support your
proposal receives. To get your campaign started, click below and follow the
instructions to publish your proposal.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the admin team was attached to your approval:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
<a
href="{{ args.proposal_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;"
>
Publish your proposal
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,11 @@
Congratulations on your approval! We look forward to seeing the support your
proposal receives. To start the fundraising (and the clock) go to the URL
below and publish your proposal.
{% if args.admin_note %}
A note from the admin team was attached to your approval:
> {{ args.admin_note }}
{% endif %}
{{ args.proposal_url }}

View File

@ -0,0 +1,19 @@
<p style="margin: 0;">
Your proposal has unfortunately been rejected. You're free to modify it
and try submitting again.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the admin team was attached to your rejection:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
Please note that repeated submissions without significant changes or with
content that doesn't match the platform guidelines may result in a removal
of your submission privileges.
</p>

View File

@ -0,0 +1,12 @@
Your proposal has unfortunately been rejected. You're free to modify it
and try submitting again.
{% if args.admin_note %}
A note from the admin team was attached to your rejection:
> {{ args.admin_note }}
{% endif %}
Please note that repeated submissions without significant changes or with
content that doesn't match the platform guidelines may result in a removal
of your submission privileges.

View File

@ -24,8 +24,7 @@
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;"
>
{% if args.user %} See invitation {% else %} Get started {% endif
%}
{% if args.user %} See invitation {% else %} Get started {% endif %}
</a>
</td>
</tr>

View File

@ -1 +1,10 @@
U invited
Youve been invited by {{ args.inviter.display_name }} to join the team for
{{ args.proposal.title or 'Untitled Project' }}, a project on Grant.io! If
you want to accept the invitation, continue to the URL below.
{{ args.invite_url }}
{% if not args.user %}
It looks like you don't yet have a Grant.io account, so you'll need to sign
up first before you can join the team.
{% endif %}

View File

@ -1,4 +1,4 @@
{{ body }}
{{ args.body }}
===============

View File

@ -10,9 +10,12 @@ from grant.proposal.models import (
invites_with_proposal_schema,
ProposalContribution,
user_proposal_contributions_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
@ -54,9 +57,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)
@ -72,6 +76,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

@ -1,8 +1,8 @@
"""empty message
Revision ID: d7b8a546917a
Revision ID: 4af29f8b2143
Revises:
Create Date: 2019-01-08 11:58:11.938479
Create Date: 2019-01-09 16:35:34.349666
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd7b8a546917a'
revision = '4af29f8b2143'
down_revision = None
branch_labels = None
depends_on = None
@ -27,6 +27,9 @@ def upgrade():
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('date_approved', sa.DateTime(), nullable=True),
sa.Column('date_published', sa.DateTime(), nullable=True),
sa.Column('reject_reason', sa.String(length=255), nullable=True),
sa.Column('target', sa.String(length=255), nullable=False),
sa.Column('payout_address', sa.String(length=255), nullable=False),
sa.Column('deadline_duration', sa.Integer(), nullable=False),

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

@ -52,6 +52,7 @@ export function getUser(address: string): Promise<{ data: User }> {
withProposals: true,
withComments: true,
withFunded: true,
withPending: true,
},
})
.then(res => {
@ -160,10 +161,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 ProfileContribution from './ProfileContribution';
import ProfileComment from './ProfileComment';
@ -24,7 +24,7 @@ import Exception from 'pages/exception';
import './style.less';
interface StateProps {
usersMap: UsersState['map'];
usersMap: AppState['users']['map'];
authUser: AppState['auth']['user'];
}
@ -70,7 +70,8 @@ class Profile extends React.Component<Props> {
return <Exception code="404" />;
}
const { proposals, contributions, comments, invites } = user;
const { proposals, pendingProposals, contributions, comments, invites } = user;
const nonePending = pendingProposals.length === 0;
const noneCreated = proposals.length === 0;
const noneFunded = contributions.length === 0;
const noneCommented = comments.length === 0;
@ -97,6 +98,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', proposals.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';
@ -54,6 +54,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
// TODO: Get values from proposal
console.warn('TODO: Get isFrozen from proposal data');
const isFrozen = false;
const isLive = proposal.status === STATUS.LIVE;
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
@ -61,12 +62,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';
@ -19,7 +21,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;
@ -55,9 +57,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') {
@ -90,37 +93,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) || ''}
@ -130,34 +181,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}
@ -171,48 +224,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 proposalId={proposal.proposalId} />
</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">
<ContributorsTab proposalId={proposal.proposalId} />
</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

@ -6,6 +6,8 @@ import {
fetchUserInvites as apiFetchUserInvites,
putInviteResponse,
deleteProposalContribution,
deleteProposalDraft,
putProposalPublish,
} from 'api/api';
import { Dispatch } from 'redux';
import { cleanClone } from 'utils/helpers';
@ -102,3 +104,25 @@ export function deleteContribution(userId: string | number, contributionId: stri
},
};
}
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

@ -4,21 +4,22 @@ import types 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[];
proposals: UserProposal[];
contributions: UserContribution[];
comments: UserComment[];
isFetchingInvites: boolean;
hasFetchedInvites: boolean;
fetchErrorInvites: number | null;
fetchErrorInvites: string | null;
invites: TeamInviteWithResponse[];
}
@ -42,6 +43,7 @@ export const INITIAL_USER_STATE: UserState = {
fetchError: null,
isUpdating: false,
updateError: null,
pendingProposals: [],
proposals: [],
contributions: [],
comments: [],
@ -59,12 +61,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:
@ -80,7 +80,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:
@ -98,7 +98,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:
@ -116,7 +116,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:
@ -134,7 +134,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:
@ -147,7 +147,7 @@ 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,
});
// delete contribution
case types.DELETE_CONTRIBUTION:
@ -156,6 +156,12 @@ export default (state = INITIAL_STATE, action: any) => {
c => c.id !== payload.contributionId
),
});
// 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;
@ -177,6 +183,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 = {
proposals: [proposal, ...state.map[userId].proposals],
};
return updateUserState(withoutPending, userId, userUpdates);
}
function updateTeamInvite(
state: UsersState,
userid: string | number,

View File

@ -20,6 +20,12 @@ enum UsersActions {
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
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,8 +12,15 @@ export function formatUserForPost(user: User) {
}
export function formatUserFromGet(user: UserState) {
console.log(user.proposals);
user.proposals = user.proposals.map(formatProposalFromGet);
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.proposals = user.proposals.map(bnUserProp);
user.contributions = user.contributions.map(c => {
console.log(c.amount);
c.amount = toZat(c.amount as any as string);

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,6 @@
import { Zat } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants';
import {
CreateMilestone,
Update,
User,
Comment,
ContributionWithUser,
} from 'types';
import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
import { ProposalMilestone } from './milestone';
export interface TeamInvite {
@ -39,9 +33,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 +43,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
funded: Zat;
percentFunded: number;
milestones: ProposalMilestone[];
datePublished: number;
}
export interface TeamInviteWithProposal extends TeamInvite {
@ -74,9 +69,24 @@ export interface ProposalContributions {
export interface UserProposal {
proposalId: number;
status: STATUS;
title: string;
brief: string;
team: User[];
funded: Zat;
target: Zat;
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',
}