Merge branch 'develop' into zcash-contributions
This commit is contained in:
commit
7ade6b5715
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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 user’s 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[];
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
|
@ -3,7 +3,12 @@
|
|||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
& > div {
|
||||
margin-bottom: 0.5rem;
|
||||
&-actionItems {
|
||||
margin-bottom: 3rem;
|
||||
font-size: 1rem;
|
||||
|
||||
.anticon {
|
||||
color: #ffaa00;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
.Markdown {
|
||||
line-height: 1.7;
|
||||
font-family: 'Nunito Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
h6 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 30px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
dl {
|
||||
dt {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
dd {
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 3rem 0;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.2rem 0.25rem;
|
||||
margin: 0;
|
||||
font-size: 90%;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 2px;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 2px;
|
||||
font-size: 1rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0 0 0 1rem;
|
||||
color: #777;
|
||||
border-left: 4px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
> :last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { mdToHtml } from 'util/md';
|
||||
import './index.less';
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
source: string;
|
||||
}
|
||||
|
||||
export default class Markdown extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { source, ...rest } = this.props;
|
||||
const html = mdToHtml(source);
|
||||
// TS types seem to be fighting over react prop defs for div
|
||||
const divProps = rest as any;
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={classnames('Markdown', divProps.className)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
.ProposalDetail {
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&-deet {
|
||||
position: relative;
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
& > span {
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
& .ant-card,
|
||||
.ant-alert,
|
||||
.ant-collapse {
|
||||
margin-bottom: 16px;
|
||||
|
||||
button + button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
import React from 'react';
|
||||
import { view } from 'react-easy-state';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
import { Row, Col, Card, Alert, Button, Collapse, Popconfirm, Modal, Input } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import store from 'src/store';
|
||||
import { formatDateSeconds } from 'util/time';
|
||||
import { PROPOSAL_STATUS } from 'src/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './index.less';
|
||||
import Markdown from 'components/Markdown';
|
||||
|
||||
type Props = RouteComponentProps<any>;
|
||||
|
||||
const STATE = {
|
||||
showRejectModal: false,
|
||||
rejectReason: '',
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
class ProposalDetailNaked extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
rejectInput: null | TextArea = null;
|
||||
componentDidMount() {
|
||||
this.loadDetail();
|
||||
}
|
||||
render() {
|
||||
const id = this.getIdFromQuery();
|
||||
const { proposalDetail: p, proposalDetailFetching, proposalDetailApproving } = store;
|
||||
const { rejectReason, showRejectModal } = this.state;
|
||||
|
||||
if (!p || (p && p.proposalId !== id) || proposalDetailFetching) {
|
||||
return 'loading proposal...';
|
||||
}
|
||||
|
||||
const renderDelete = () => (
|
||||
<Popconfirm
|
||||
onConfirm={this.handleDelete}
|
||||
title="Delete proposal?"
|
||||
okText="delete"
|
||||
cancelText="cancel"
|
||||
>
|
||||
<Button icon="delete" block>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
|
||||
const renderApproved = () =>
|
||||
p.status === PROPOSAL_STATUS.APPROVED && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="success"
|
||||
message={`Approved on ${formatDateSeconds(p.dateApproved)}`}
|
||||
description={`
|
||||
This proposal has been approved and will become live when a team-member
|
||||
publishes it.
|
||||
`}
|
||||
/>
|
||||
);
|
||||
|
||||
const rejectModal = (
|
||||
<Modal
|
||||
visible={showRejectModal}
|
||||
title="Reject this proposal"
|
||||
onOk={this.handleReject}
|
||||
onCancel={() => this.setState({ showRejectModal: false })}
|
||||
okButtonProps={{
|
||||
disabled: rejectReason.length === 0,
|
||||
loading: proposalDetailApproving,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
loading: proposalDetailApproving,
|
||||
}}
|
||||
>
|
||||
Please provide a reason ({!!rejectReason.length && `${rejectReason.length}/`}
|
||||
250 chars max):
|
||||
<Input.TextArea
|
||||
ref={ta => (this.rejectInput = ta)}
|
||||
rows={4}
|
||||
maxLength={250}
|
||||
required={true}
|
||||
value={rejectReason}
|
||||
onChange={e => {
|
||||
this.setState({ rejectReason: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderReview = () =>
|
||||
p.status === PROPOSAL_STATUS.PENDING && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="Review Pending"
|
||||
description={
|
||||
<div>
|
||||
<p>Please review this proposal and render your judgment.</p>
|
||||
<Button
|
||||
loading={store.proposalDetailApproving}
|
||||
icon="check"
|
||||
type="primary"
|
||||
onClick={this.handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
loading={store.proposalDetailApproving}
|
||||
icon="close"
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
this.setState({ showRejectModal: true });
|
||||
// hacky way of waiting for modal to render in before focus
|
||||
setTimeout(() => {
|
||||
if (this.rejectInput) this.rejectInput.focus();
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{rejectModal}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderRejected = () =>
|
||||
p.status === PROPOSAL_STATUS.REJECTED && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="Rejected"
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
This proposal has been rejected. The team will be able to re-submit it for
|
||||
approval should they desire to do so.
|
||||
</p>
|
||||
<b>Reason:</b>
|
||||
<br />
|
||||
<i>{p.rejectReason}</i>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDeetItem = (name: string, val: any) => (
|
||||
<div className="ProposalDetail-deet">
|
||||
<span>{name}</span>
|
||||
{val}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ProposalDetail">
|
||||
<h1>{p.title}</h1>
|
||||
<Row gutter={16}>
|
||||
{/* MAIN */}
|
||||
<Col span={18}>
|
||||
{renderApproved()}
|
||||
{renderReview()}
|
||||
{renderRejected()}
|
||||
<Collapse defaultActiveKey={['brief', 'content']}>
|
||||
<Collapse.Panel key="brief" header="brief">
|
||||
{p.brief}
|
||||
</Collapse.Panel>
|
||||
|
||||
<Collapse.Panel key="content" header="content">
|
||||
<Markdown source={p.content} />
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* TODO - comments, milestones, updates &etc. */}
|
||||
<Collapse.Panel key="json" header="json">
|
||||
<pre>{JSON.stringify(p, null, 4)}</pre>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Col>
|
||||
|
||||
{/* RIGHT SIDE */}
|
||||
<Col span={6}>
|
||||
{/* ACTIONS */}
|
||||
<Card size="small">
|
||||
{renderDelete()}
|
||||
{/* TODO - other actions */}
|
||||
</Card>
|
||||
|
||||
{/* DETAILS */}
|
||||
<Card title="details" size="small">
|
||||
{renderDeetItem('id', p.proposalId)}
|
||||
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
||||
{renderDeetItem('status', p.status)}
|
||||
{renderDeetItem('category', p.category)}
|
||||
{renderDeetItem('target', p.target)}
|
||||
</Card>
|
||||
|
||||
{/* TEAM */}
|
||||
<Card title="Team" size="small">
|
||||
{p.team.map(t => (
|
||||
<Link key={t.userid} to={`/users/${t.userid}`}>
|
||||
{t.displayName}
|
||||
</Link>
|
||||
))}
|
||||
</Card>
|
||||
{/* TODO: contributors here? */}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getIdFromQuery = () => {
|
||||
return Number(this.props.match.params.id);
|
||||
};
|
||||
|
||||
private loadDetail = () => {
|
||||
store.fetchProposalDetail(this.getIdFromQuery());
|
||||
};
|
||||
|
||||
private handleDelete = () => {
|
||||
if (!store.proposalDetail) return;
|
||||
store.deleteProposal(store.proposalDetail.proposalId);
|
||||
};
|
||||
|
||||
private handleApprove = () => {
|
||||
store.approveProposal(true);
|
||||
};
|
||||
|
||||
private handleReject = async () => {
|
||||
await store.approveProposal(false, this.state.rejectReason);
|
||||
this.setState({ showRejectModal: false });
|
||||
};
|
||||
}
|
||||
|
||||
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
||||
export default ProposalDetail;
|
|
@ -0,0 +1,11 @@
|
|||
.ProposalItem {
|
||||
& h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0;
|
||||
|
||||
& .ant-tag {
|
||||
vertical-align: text-top;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import { view } from 'react-easy-state';
|
||||
import { Popconfirm, Tag, Tooltip, List } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import store from 'src/store';
|
||||
import { Proposal } from 'src/types';
|
||||
import { getStatusById } from './STATUSES';
|
||||
import { formatDateSeconds } from 'src/util/time';
|
||||
import './ProposalItem.less';
|
||||
|
||||
class ProposalItemNaked extends React.Component<Proposal> {
|
||||
state = {
|
||||
showDelete: false,
|
||||
};
|
||||
render() {
|
||||
const p = this.props;
|
||||
const status = getStatusById(p.status);
|
||||
|
||||
const deleteAction = (
|
||||
<Popconfirm
|
||||
onConfirm={this.handleDelete}
|
||||
title="Permanently delete proposal?"
|
||||
okText="delete"
|
||||
cancelText="cancel"
|
||||
>
|
||||
<div>delete</div>
|
||||
</Popconfirm>
|
||||
);
|
||||
const viewAction = <Link to={`/proposals/${p.proposalId}`}>view</Link>;
|
||||
const actions = [viewAction, deleteAction];
|
||||
|
||||
return (
|
||||
<List.Item key={p.proposalId} className="ProposalItem" actions={actions}>
|
||||
<div>
|
||||
<h1>
|
||||
{p.title || '(no title)'}{' '}
|
||||
<Tooltip title={status.hint}>
|
||||
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
|
||||
</Tooltip>
|
||||
</h1>
|
||||
<div>Created: {formatDateSeconds(p.dateCreated)}</div>
|
||||
<div>{p.brief}</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
private handleDelete = () => {
|
||||
store.deleteProposal(this.props.proposalId);
|
||||
};
|
||||
}
|
||||
|
||||
const ProposalItem = view(ProposalItemNaked);
|
||||
export default ProposalItem;
|
|
@ -0,0 +1,65 @@
|
|||
import { PROPOSAL_STATUS } from 'src/types';
|
||||
|
||||
export interface ProposalStatusSoT {
|
||||
id: PROPOSAL_STATUS;
|
||||
filterDisplay: string;
|
||||
tagDisplay: string;
|
||||
tagColor: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const STATUSES: ProposalStatusSoT[] = [
|
||||
{
|
||||
id: PROPOSAL_STATUS.APPROVED,
|
||||
filterDisplay: 'Status: approved',
|
||||
tagDisplay: 'Approved',
|
||||
tagColor: '#afd500',
|
||||
hint: 'Proposal has been approved and is awaiting being published by user.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.DELETED,
|
||||
filterDisplay: 'Status: deleted',
|
||||
tagDisplay: 'Deleted',
|
||||
tagColor: '#bebebe',
|
||||
hint: 'Proposal has been deleted and is not visible on the platform.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.DRAFT,
|
||||
filterDisplay: 'Status: draft',
|
||||
tagDisplay: 'Draft',
|
||||
tagColor: '#8d8d8d',
|
||||
hint: 'Proposal is being created by the user.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.LIVE,
|
||||
filterDisplay: 'Status: live',
|
||||
tagDisplay: 'Live',
|
||||
tagColor: '#108ee9',
|
||||
hint: 'Proposal is live on the platform.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.PENDING,
|
||||
filterDisplay: 'Status: pending',
|
||||
tagDisplay: 'Awaiting Approval',
|
||||
tagColor: '#ffaa00',
|
||||
hint: 'User is waiting for admin to approve or reject this Proposal.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.REJECTED,
|
||||
filterDisplay: 'Status: rejected',
|
||||
tagDisplay: 'Approval Rejected',
|
||||
tagColor: '#eb4118',
|
||||
hint:
|
||||
'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
|
||||
},
|
||||
];
|
||||
|
||||
export const getStatusById = (id: PROPOSAL_STATUS) => {
|
||||
const result = STATUSES.find(s => s.id === id);
|
||||
if (!result) {
|
||||
throw Error(`getStatusById: could not find status for '${id}'`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default STATUSES;
|
|
@ -1,108 +1,16 @@
|
|||
@controls-height: 40px;
|
||||
|
||||
.Proposals {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import Showdown from 'showdown';
|
||||
|
||||
const showdownConverter = new Showdown.Converter({
|
||||
simplifiedAutoLink: true,
|
||||
tables: true,
|
||||
strikethrough: true,
|
||||
disableForced4SpacesIndentedSublists: true,
|
||||
openLinksInNewWindow: true,
|
||||
excludeTrailingPunctuationFromURLs: true,
|
||||
});
|
||||
|
||||
export const mdToHtml = (text: string) => {
|
||||
return showdownConverter.makeHtml(text);
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import moment from 'moment';
|
||||
|
||||
const DATE_FMT_STRING = 'MM/DD/YYYY h:mm a';
|
||||
|
||||
export const formatDateSeconds = (s: number) => {
|
||||
return moment(s * 1000).format(DATE_FMT_STRING);
|
||||
};
|
|
@ -21,7 +21,8 @@
|
|||
"paths": {
|
||||
"src/*": ["./*"],
|
||||
"components/*": ["./components/*"],
|
||||
"styles/*": ["./styles/*"]
|
||||
"styles/*": ["./styles/*"],
|
||||
"util/*": ["./util/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
|
|
|
@ -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: [
|
||||
|
|
440
admin/yarn.lock
440
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -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 you’ve asked for too much money for the project you’ve 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',
|
||||
},
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import datetime
|
||||
from typing import List
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from grant.comment.models import Comment
|
||||
from grant.extensions import ma, db
|
||||
|
@ -11,9 +11,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'
|
||||
|
@ -130,6 +132,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)
|
||||
|
@ -179,6 +184,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)
|
||||
|
@ -187,10 +210,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
|
||||
|
@ -220,25 +245,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):
|
||||
|
@ -247,7 +287,11 @@ class ProposalSchema(ma.Schema):
|
|||
# Fields to expose
|
||||
fields = (
|
||||
"stage",
|
||||
"status",
|
||||
"date_created",
|
||||
"date_approved",
|
||||
"date_published",
|
||||
"reject_reason",
|
||||
"title",
|
||||
"brief",
|
||||
"proposal_id",
|
||||
|
@ -266,6 +310,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")
|
||||
|
||||
|
@ -282,6 +328,12 @@ class ProposalSchema(ma.Schema):
|
|||
def get_date_created(self, obj):
|
||||
return dt_to_unix(obj.date_created)
|
||||
|
||||
def get_date_approved(self, obj):
|
||||
return dt_to_unix(obj.date_approved) if obj.date_approved else None
|
||||
|
||||
def get_date_published(self, obj):
|
||||
return dt_to_unix(obj.date_published) if obj.date_published else None
|
||||
|
||||
def get_funded(self, obj):
|
||||
# TODO: Add up all contributions and return that
|
||||
return "0"
|
||||
|
@ -402,13 +454,20 @@ class UserProposalSchema(ma.Schema):
|
|||
# Fields to expose
|
||||
fields = (
|
||||
"proposal_id",
|
||||
"status",
|
||||
"title",
|
||||
"brief",
|
||||
"target",
|
||||
"funded",
|
||||
"date_created",
|
||||
"date_approved",
|
||||
"date_published",
|
||||
"reject_reason",
|
||||
"team",
|
||||
)
|
||||
date_created = ma.Method("get_date_created")
|
||||
proposal_id = ma.Method("get_proposal_id")
|
||||
funded = ma.Method("get_funded")
|
||||
team = ma.Nested("UserSchema", many=True)
|
||||
|
||||
def get_proposal_id(self, obj):
|
||||
|
@ -417,6 +476,10 @@ class UserProposalSchema(ma.Schema):
|
|||
def get_date_created(self, obj):
|
||||
return dt_to_unix(obj.date_created) * 1000
|
||||
|
||||
def get_funded(self, obj):
|
||||
# TODO: Add up all contributions and return that
|
||||
return "0"
|
||||
|
||||
|
||||
user_proposal_schema = UserProposalSchema()
|
||||
user_proposals_schema = UserProposalSchema(many=True)
|
||||
|
|
|
@ -5,12 +5,13 @@ import ast
|
|||
from flask import Blueprint, g
|
||||
from flask_yoloapi import endpoint, parameter
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import or_
|
||||
|
||||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.user.models import User, SocialMedia, Avatar
|
||||
from grant.email.send import send_email
|
||||
from grant.utils.auth import requires_auth, requires_team_member_auth
|
||||
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.misc import is_email, make_url
|
||||
from .models import(
|
||||
|
@ -24,7 +25,13 @@ from .models import(
|
|||
proposal_team,
|
||||
ProposalTeamInvite,
|
||||
proposal_team_invite_schema,
|
||||
db
|
||||
db,
|
||||
DRAFT,
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
LIVE,
|
||||
DELETED
|
||||
)
|
||||
import traceback
|
||||
|
||||
|
@ -36,6 +43,13 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
|
|||
def get_proposal(proposal_id):
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
if proposal:
|
||||
if proposal.status != LIVE:
|
||||
if proposal.status == DELETED:
|
||||
return {"message": "Proposal was deleted"}, 404
|
||||
authed_user = get_authed_user()
|
||||
team_ids = list(x.id for x in proposal.team)
|
||||
if not authed_user or authed_user.id not in team_ids:
|
||||
return {"message": "User cannot view this proposal"}, 404
|
||||
return proposal_schema.dump(proposal)
|
||||
else:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
@ -96,13 +110,13 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
|||
def get_proposals(stage):
|
||||
if stage:
|
||||
proposals = (
|
||||
Proposal.query.filter_by(status="LIVE", stage=stage)
|
||||
Proposal.query.filter_by(status=LIVE, stage=stage)
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
proposals = (
|
||||
Proposal.query.filter_by(status="LIVE")
|
||||
Proposal.query.filter_by(status=LIVE)
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
)
|
||||
|
@ -131,7 +145,7 @@ def make_proposal_draft():
|
|||
def get_proposal_drafts():
|
||||
proposals = (
|
||||
Proposal.query
|
||||
.filter_by(status="DRAFT")
|
||||
.filter(or_(Proposal.status == DRAFT, Proposal.status == REJECTED))
|
||||
.join(proposal_team)
|
||||
.filter(proposal_team.c.user_id == g.current_user.id)
|
||||
.order_by(Proposal.date_created.desc())
|
||||
|
@ -182,14 +196,29 @@ def update_proposal(milestones, proposal_id, **kwargs):
|
|||
@blueprint.route("/<proposal_id>", methods=["DELETE"])
|
||||
@requires_team_member_auth
|
||||
@endpoint.api()
|
||||
def delete_proposal_draft(proposal_id):
|
||||
if g.current_proposal.status != 'DRAFT':
|
||||
return {"message": "Cannot delete non-draft proposals"}, 400
|
||||
def delete_proposal(proposal_id):
|
||||
deleteable_statuses = [DRAFT, PENDING, APPROVED, REJECTED]
|
||||
status = g.current_proposal.status
|
||||
if status not in deleteable_statuses:
|
||||
return {"message": "Cannot delete proposals with %s status" % status}, 400
|
||||
db.session.delete(g.current_proposal)
|
||||
db.session.commit()
|
||||
return None, 202
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/submit_for_approval", methods=["PUT"])
|
||||
@requires_team_member_auth
|
||||
@endpoint.api()
|
||||
def submit_for_approval_proposal(proposal_id):
|
||||
try:
|
||||
g.current_proposal.submit_for_approval()
|
||||
except ValidationException as e:
|
||||
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
|
||||
db.session.add(g.current_proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(g.current_proposal), 200
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
||||
@requires_team_member_auth
|
||||
@endpoint.api()
|
||||
|
|
|
@ -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>
|
|
@ -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.
|
|
@ -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>
|
|
@ -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 }}
|
|
@ -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>
|
|
@ -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.
|
|
@ -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>
|
||||
|
|
|
@ -1 +1,10 @@
|
|||
U invited
|
||||
You’ve 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 %}
|
|
@ -1,4 +1,4 @@
|
|||
{{ body }}
|
||||
{{ args.body }}
|
||||
|
||||
===============
|
||||
|
||||
|
|
|
@ -8,9 +8,12 @@ from grant.proposal.models import (
|
|||
proposal_team,
|
||||
ProposalTeamInvite,
|
||||
invites_with_proposal_schema,
|
||||
user_proposals_schema
|
||||
user_proposals_schema,
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
)
|
||||
from grant.utils.auth import requires_auth, requires_same_user_auth
|
||||
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
|
||||
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
||||
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
|
||||
from grant.email.models import EmailRecovery
|
||||
|
@ -52,9 +55,10 @@ def get_me():
|
|||
@endpoint.api(
|
||||
parameter("withProposals", type=bool, required=False),
|
||||
parameter("withComments", type=bool, required=False),
|
||||
parameter("withFunded", type=bool, required=False)
|
||||
parameter("withFunded", type=bool, required=False),
|
||||
parameter("withPending", type=bool, required=False)
|
||||
)
|
||||
def get_user(user_id, with_proposals, with_comments, with_funded):
|
||||
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
|
||||
user = User.get_by_id(user_id)
|
||||
if user:
|
||||
result = user_schema.dump(user)
|
||||
|
@ -70,6 +74,11 @@ def get_user(user_id, with_proposals, with_comments, with_funded):
|
|||
comments = Comment.get_by_user(user)
|
||||
comments_dump = user_comments_schema.dump(comments)
|
||||
result["comments"] = comments_dump
|
||||
authed_user = get_authed_user()
|
||||
if with_pending and authed_user and authed_user.id == user.id:
|
||||
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
|
||||
pending_dump = user_proposals_schema.dump(pending)
|
||||
result["pendingProposals"] = pending_dump
|
||||
return result
|
||||
else:
|
||||
message = "User with id matching {} not found".format(user_id)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 2f30fb7d656e
|
||||
Revises: 3ffceaeb996a
|
||||
Create Date: 2019-01-04 13:31:32.851145
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2f30fb7d656e'
|
||||
down_revision = '3ffceaeb996a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('proposal', sa.Column('date_approved', sa.DateTime(), nullable=True))
|
||||
op.add_column('proposal', sa.Column('date_published', sa.DateTime(), nullable=True))
|
||||
op.add_column('proposal', sa.Column('reject_reason', sa.String(length=255), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('proposal', 'reject_reason')
|
||||
op.drop_column('proposal', 'date_published')
|
||||
op.drop_column('proposal', 'date_approved')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,87 @@
|
|||
import json
|
||||
from mock import patch
|
||||
|
||||
from grant.proposal.models import Proposal, APPROVED, REJECTED, PENDING, DRAFT
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
from ..test_data import test_proposal, test_user
|
||||
|
||||
plaintext_mock_password = "p4ssw0rd"
|
||||
mock_admin_auth = {
|
||||
"username": "admin",
|
||||
"password": "20cc8f433a1d6400aed9850504c33bfe51ace17ed15d62b0e046b9d7bc4b893b",
|
||||
"salt": "s4lt"
|
||||
}
|
||||
|
||||
|
||||
class TestAdminAPI(BaseProposalCreatorConfig):
|
||||
@patch.dict('grant.admin.views.admin_auth', mock_admin_auth)
|
||||
def login_admin(self):
|
||||
return self.app.post(
|
||||
"/api/v1/admin/login",
|
||||
data={
|
||||
"username": mock_admin_auth["username"],
|
||||
"password": plaintext_mock_password
|
||||
}
|
||||
)
|
||||
|
||||
def test_login(self):
|
||||
resp = self.login_admin()
|
||||
self.assert200(resp)
|
||||
|
||||
def test_checklogin_loggedin(self):
|
||||
self.login_admin()
|
||||
resp = self.app.get("/api/v1/admin/checklogin")
|
||||
self.assert200(resp)
|
||||
self.assertTrue(resp.json["isLoggedIn"])
|
||||
|
||||
def test_checklogin_loggedout(self):
|
||||
resp = self.app.get("/api/v1/admin/checklogin")
|
||||
self.assert200(resp)
|
||||
self.assertFalse(resp.json["isLoggedIn"])
|
||||
|
||||
def test_logout(self):
|
||||
self.login_admin()
|
||||
resp = self.app.get("/api/v1/admin/logout")
|
||||
self.assert200(resp)
|
||||
self.assertFalse(resp.json["isLoggedIn"])
|
||||
cl_resp = self.app.get("/api/v1/admin/checklogin")
|
||||
self.assertFalse(cl_resp.json["isLoggedIn"])
|
||||
|
||||
def test_get_users(self):
|
||||
self.login_admin()
|
||||
resp = self.app.get("/api/v1/admin/users")
|
||||
self.assert200(resp)
|
||||
# 2 users created by BaseProposalCreatorConfig
|
||||
self.assertEqual(len(resp.json), 2)
|
||||
|
||||
def test_get_proposals(self):
|
||||
self.login_admin()
|
||||
resp = self.app.get("/api/v1/admin/proposals")
|
||||
self.assert200(resp)
|
||||
# 2 proposals created by BaseProposalCreatorConfig
|
||||
self.assertEqual(len(resp.json), 2)
|
||||
|
||||
def test_approve_proposal(self):
|
||||
self.login_admin()
|
||||
# submit for approval (performed by end-user)
|
||||
self.proposal.submit_for_approval()
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
||||
data={"isApprove": True}
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], APPROVED)
|
||||
|
||||
def test_reject_proposal(self):
|
||||
self.login_admin()
|
||||
# submit for approval (performed by end-user)
|
||||
self.proposal.submit_for_approval()
|
||||
# reject
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
||||
data={"isApprove": False, "rejectReason": "Funnzies."}
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], REJECTED)
|
||||
self.assertEqual(resp.json["rejectReason"], "Funnzies.")
|
|
@ -1,7 +1,7 @@
|
|||
import json
|
||||
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)
|
||||
|
|
|
@ -48,6 +48,7 @@ export function getUser(address: string): Promise<{ data: User }> {
|
|||
withProposals: true,
|
||||
withComments: true,
|
||||
withFunded: true,
|
||||
withPending: true,
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
|
@ -156,10 +157,21 @@ export function putProposal(proposal: ProposalDraft): Promise<{ data: ProposalDr
|
|||
return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest);
|
||||
}
|
||||
|
||||
export async function putProposalPublish(
|
||||
export async function putProposalSubmitForApproval(
|
||||
proposal: ProposalDraft,
|
||||
): Promise<{ data: Proposal }> {
|
||||
return axios.put(`/api/v1/proposals/${proposal.proposalId}/publish`).then(res => {
|
||||
return axios
|
||||
.put(`/api/v1/proposals/${proposal.proposalId}/submit_for_approval`)
|
||||
.then(res => {
|
||||
res.data = formatProposalFromGet(res.data);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export async function putProposalPublish(
|
||||
proposalId: number,
|
||||
): Promise<{ data: Proposal }> {
|
||||
return axios.put(`/api/v1/proposals/${proposalId}/publish`).then(res => {
|
||||
res.data = formatProposalFromGet(res.data);
|
||||
return res;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
@import '~styles/variables.less';
|
||||
|
||||
// simulate non-fullscreen template margins
|
||||
.Preview {
|
||||
margin: @template-space-top @template-space-sides;
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 you’re ready to publish your proposal? Once you’ve 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 you’ve
|
||||
done so, you won't be able to edit it.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
@import '~styles/variables.less';
|
||||
@small-query: ~'(max-width: 640px)';
|
||||
|
||||
.ProfilePending {
|
||||
display: flex;
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: none;
|
||||
}
|
||||
|
||||
@media @small-query {
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
display: block;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
&-block {
|
||||
flex: 1 0 0%;
|
||||
|
||||
&:last-child {
|
||||
margin-left: 1.2rem;
|
||||
flex: 0 0 0%;
|
||||
min-width: 15rem;
|
||||
|
||||
@media @small-query {
|
||||
margin-left: 0;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
& button + button,
|
||||
a + button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
&-status {
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
& q {
|
||||
display: block;
|
||||
margin: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
& small {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Popconfirm, message, Tag } from 'antd';
|
||||
import { UserProposal, STATUS } from 'types';
|
||||
import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
|
||||
import './ProfilePending.less';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
interface OwnProps {
|
||||
proposal: UserProposal;
|
||||
onPublish: (id: UserProposal['proposalId']) => void;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
user: AppState['auth']['user'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
deletePendingProposal: typeof deletePendingProposal;
|
||||
publishPendingProposal: typeof publishPendingProposal;
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
||||
const STATE = {
|
||||
isDeleting: false,
|
||||
isPublishing: false,
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
class ProfilePending extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
render() {
|
||||
const { status, title, proposalId, rejectReason } = this.props.proposal;
|
||||
const { isDeleting, isPublishing } = this.state;
|
||||
|
||||
const isDisableActions = isDeleting || isPublishing;
|
||||
|
||||
const st = {
|
||||
[STATUS.APPROVED]: {
|
||||
color: 'green',
|
||||
tag: 'Approved',
|
||||
blurb: <div>You may publish this proposal when you are ready.</div>,
|
||||
},
|
||||
[STATUS.REJECTED]: {
|
||||
color: 'red',
|
||||
tag: 'Rejected',
|
||||
blurb: (
|
||||
<>
|
||||
<div>This proposal was rejected for the following reason:</div>
|
||||
<q>{rejectReason}</q>
|
||||
<div>You may edit this proposal and re-submit it for approval.</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
[STATUS.PENDING]: {
|
||||
color: 'orange',
|
||||
tag: 'Pending',
|
||||
blurb: (
|
||||
<div>
|
||||
You will receive an email when this proposal has completed the review process.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
} as { [key in STATUS]: { color: string; tag: string; blurb: ReactNode } };
|
||||
|
||||
return (
|
||||
<div className="ProfilePending">
|
||||
<div className="ProfilePending-block">
|
||||
<Link to={`/proposals/${proposalId}`} className="ProfilePending-title">
|
||||
{title} <Tag color={st[status].color}>{st[status].tag}</Tag>
|
||||
</Link>
|
||||
<div className={`ProfilePending-status is-${status.toLowerCase()}`}>
|
||||
{st[status].blurb}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProfilePending-block is-actions">
|
||||
{STATUS.APPROVED === status && (
|
||||
<Button
|
||||
loading={isPublishing}
|
||||
disabled={isDisableActions}
|
||||
type="primary"
|
||||
onClick={this.handlePublish}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
{STATUS.REJECTED === status && (
|
||||
<Link to={`/proposals/${proposalId}/edit`}>
|
||||
<Button disabled={isDisableActions} type="primary">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.handleDelete()}
|
||||
>
|
||||
<Button type="default" disabled={isDisableActions} loading={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handlePublish = async () => {
|
||||
const {
|
||||
user,
|
||||
proposal: { proposalId },
|
||||
onPublish,
|
||||
} = this.props;
|
||||
if (!user) return;
|
||||
this.setState({ isPublishing: true });
|
||||
try {
|
||||
await this.props.publishPendingProposal(user.userid, proposalId);
|
||||
onPublish(proposalId);
|
||||
} catch (e) {
|
||||
message.error(e.message || e.toString());
|
||||
this.setState({ isPublishing: false });
|
||||
}
|
||||
};
|
||||
|
||||
private handleDelete = async () => {
|
||||
const {
|
||||
user,
|
||||
proposal: { proposalId },
|
||||
} = this.props;
|
||||
if (!user) return;
|
||||
this.setState({ isDeleting: true });
|
||||
try {
|
||||
await this.props.deletePendingProposal(user.userid, proposalId);
|
||||
message.success('Proposal deleted.');
|
||||
} catch (e) {
|
||||
message.error(e.message || e.toString());
|
||||
this.setState({ isDeleting: false });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
state => ({
|
||||
user: state.auth.user,
|
||||
}),
|
||||
{
|
||||
deletePendingProposal,
|
||||
publishPendingProposal,
|
||||
},
|
||||
)(ProfilePending);
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { UserProposal } from 'types';
|
||||
import ProfilePending from './ProfilePending';
|
||||
|
||||
interface OwnProps {
|
||||
proposals: UserProposal[];
|
||||
}
|
||||
|
||||
type Props = OwnProps;
|
||||
|
||||
const STATE = {
|
||||
publishedId: null as null | UserProposal['proposalId'],
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
class ProfilePendingList extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
render() {
|
||||
const { proposals } = this.props;
|
||||
const { publishedId } = this.state;
|
||||
return (
|
||||
<>
|
||||
{proposals.map(p => (
|
||||
<ProfilePending
|
||||
key={p.proposalId}
|
||||
proposal={p}
|
||||
onPublish={this.handlePublish}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Modal
|
||||
title="Proposal Published"
|
||||
visible={!!publishedId}
|
||||
footer={null}
|
||||
onCancel={() => this.setState({ publishedId: null })}
|
||||
>
|
||||
<div>
|
||||
Your proposal is live!{' '}
|
||||
<Link to={`/proposals/${publishedId}`}>Click here</Link> to check it out.
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private handlePublish = (publishedId: UserProposal['proposalId']) => {
|
||||
this.setState({ publishedId });
|
||||
};
|
||||
}
|
||||
|
||||
export default ProfilePendingList;
|
|
@ -11,8 +11,7 @@ interface OwnProps {
|
|||
|
||||
export default class Profile extends React.Component<OwnProps> {
|
||||
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">
|
||||
|
|
|
@ -9,12 +9,12 @@ import {
|
|||
import { connect } from 'react-redux';
|
||||
import { compose } from 'recompose';
|
||||
import { Spin, Tabs, Badge } from 'antd';
|
||||
import { UsersState } from 'modules/users/reducers';
|
||||
import { usersActions } from 'modules/users';
|
||||
import { AppState } from 'store/reducers';
|
||||
import HeaderDetails from 'components/HeaderDetails';
|
||||
import ProfileUser from './ProfileUser';
|
||||
import ProfileEdit from './ProfileEdit';
|
||||
import ProfilePendingList from './ProfilePendingList';
|
||||
import ProfileProposal from './ProfileProposal';
|
||||
import ProfileComment from './ProfileComment';
|
||||
import ProfileInvite from './ProfileInvite';
|
||||
|
@ -23,7 +23,7 @@ import Exception from 'pages/exception';
|
|||
import './style.less';
|
||||
|
||||
interface StateProps {
|
||||
usersMap: UsersState['map'];
|
||||
usersMap: AppState['users']['map'];
|
||||
authUser: AppState['auth']['user'];
|
||||
}
|
||||
|
||||
|
@ -69,7 +69,14 @@ class Profile extends React.Component<Props> {
|
|||
return <Exception code="404" />;
|
||||
}
|
||||
|
||||
const { createdProposals, fundedProposals, comments, invites } = user;
|
||||
const {
|
||||
pendingProposals,
|
||||
createdProposals,
|
||||
fundedProposals,
|
||||
comments,
|
||||
invites,
|
||||
} = user;
|
||||
const nonePending = pendingProposals.length === 0;
|
||||
const noneCreated = createdProposals.length === 0;
|
||||
const noneFunded = fundedProposals.length === 0;
|
||||
const noneCommented = comments.length === 0;
|
||||
|
@ -96,6 +103,22 @@ class Profile extends React.Component<Props> {
|
|||
/>
|
||||
</Switch>
|
||||
<Tabs>
|
||||
{isAuthedUser && (
|
||||
<Tabs.TabPane
|
||||
tab={TabTitle('Pending', pendingProposals.length)}
|
||||
key="pending"
|
||||
>
|
||||
<div>
|
||||
{nonePending && (
|
||||
<Placeholder
|
||||
title="No pending proposals"
|
||||
subtitle="You do not have any proposals awaiting approval."
|
||||
/>
|
||||
)}
|
||||
<ProfilePendingList proposals={pendingProposals} />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
<Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created">
|
||||
<div>
|
||||
{noneCreated && (
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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%;
|
|
@ -1,13 +1,15 @@
|
|||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { compose } from 'recompose';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Markdown from 'components/Markdown';
|
||||
import { proposalActions } from 'modules/proposals';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Proposal } from 'types';
|
||||
import { Proposal, STATUS } from 'types';
|
||||
import { getProposal } from 'modules/proposals/selectors';
|
||||
import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd';
|
||||
import { Spin, Tabs, Icon, Dropdown, Menu, Button, Alert } from 'antd';
|
||||
import { AlertProps } from 'antd/lib/alert';
|
||||
import CampaignBlock from './CampaignBlock';
|
||||
import TeamBlock from './TeamBlock';
|
||||
import Milestones from './Milestones';
|
||||
|
@ -20,7 +22,7 @@ import CancelModal from './CancelModal';
|
|||
import classnames from 'classnames';
|
||||
import { withRouter } from 'react-router';
|
||||
import SocialShare from 'components/SocialShare';
|
||||
import './style.less';
|
||||
import './index.less';
|
||||
|
||||
interface OwnProps {
|
||||
proposalId: number;
|
||||
|
@ -56,9 +58,10 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.proposal) {
|
||||
this.props.fetchProposal(this.props.proposalId);
|
||||
} else {
|
||||
// always refresh from server
|
||||
this.props.fetchProposal(this.props.proposalId);
|
||||
|
||||
if (this.props.proposal) {
|
||||
this.checkBodyOverflow();
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
|
@ -91,37 +94,85 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
|
||||
if (!proposal) {
|
||||
return <Spin />;
|
||||
} else {
|
||||
const deadline = 0; // TODO: Use actual date for deadline
|
||||
// TODO: isTrustee - determine rework to isAdmin?
|
||||
// for now: check if authed user in member of proposal team
|
||||
const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid));
|
||||
const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding
|
||||
const isProposalActive = !hasBeenFunded && deadline > Date.now();
|
||||
const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet
|
||||
}
|
||||
|
||||
const adminMenu = isTrustee && (
|
||||
<Menu>
|
||||
<Menu.Item onClick={this.openUpdateModal}>Post an Update</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => alert('Sorry, not yet implemented!')}
|
||||
disabled={!isProposalActive}
|
||||
>
|
||||
Edit proposal
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
style={{ color: canCancel ? '#e74c3c' : undefined }}
|
||||
onClick={this.openCancelModal}
|
||||
disabled={!canCancel}
|
||||
>
|
||||
Cancel proposal
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
const deadline = 0; // TODO: Use actual date for deadline
|
||||
// TODO: isTrustee - determine rework to isAdmin?
|
||||
// for now: check if authed user in member of proposal team
|
||||
const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid));
|
||||
const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding
|
||||
const isProposalActive = !hasBeenFunded && deadline > Date.now();
|
||||
const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet
|
||||
const isLive = proposal.status === STATUS.LIVE;
|
||||
|
||||
return (
|
||||
<div className="Proposal">
|
||||
<div className="Proposal-top">
|
||||
const adminMenu = (
|
||||
<Menu>
|
||||
<Menu.Item disabled={!isLive} onClick={this.openUpdateModal}>
|
||||
Post an Update
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => alert('Sorry, not yet implemented!')}
|
||||
disabled={!isProposalActive}
|
||||
>
|
||||
Edit proposal
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
style={{ color: canCancel ? '#e74c3c' : undefined }}
|
||||
onClick={this.openCancelModal}
|
||||
disabled={!canCancel}
|
||||
>
|
||||
Cancel proposal
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
// BANNER
|
||||
const statusBanner = {
|
||||
[STATUS.PENDING]: {
|
||||
blurb: (
|
||||
<>
|
||||
Your proposal is being reviewed and is only visible to the team. You will get
|
||||
an email when it is complete.
|
||||
</>
|
||||
),
|
||||
type: 'warning',
|
||||
},
|
||||
[STATUS.APPROVED]: {
|
||||
blurb: (
|
||||
<>
|
||||
Your proposal has been approved! It is currently only visible to the team.
|
||||
Visit your <Link to="/profile">profile - pending</Link> tab to publish.
|
||||
</>
|
||||
),
|
||||
type: 'success',
|
||||
},
|
||||
[STATUS.REJECTED]: {
|
||||
blurb: (
|
||||
<>
|
||||
Your proposal was rejected and is only visible to the team. Visit your{' '}
|
||||
<Link to="/profile">profile - pending</Link> tab for more information.
|
||||
</>
|
||||
),
|
||||
type: 'error',
|
||||
},
|
||||
} as { [key in STATUS]: { blurb: ReactNode; type: AlertProps['type'] } };
|
||||
let banner = statusBanner[proposal.status];
|
||||
if (isPreview) {
|
||||
banner = {
|
||||
blurb: 'This is a preview of your proposal. It has not yet been published.',
|
||||
type: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Proposal">
|
||||
{banner && (
|
||||
<div className="Proposal-banner">
|
||||
<Alert type={banner.type} message={banner.blurb} showIcon={false} banner />
|
||||
</div>
|
||||
)}
|
||||
<div className="Proposal-top">
|
||||
{isLive && (
|
||||
<div className="Proposal-top-social">
|
||||
<SocialShare
|
||||
url={(typeof window !== 'undefined' && window.location.href) || ''}
|
||||
|
@ -131,34 +182,36 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
} needs funding on Grant.io! Come help make this proposal a reality by funding it.`}
|
||||
/>
|
||||
</div>
|
||||
<div className="Proposal-top-main">
|
||||
<h1 className="Proposal-top-main-title">
|
||||
{proposal ? proposal.title : <span> </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> </span>}
|
||||
</h1>
|
||||
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
|
||||
<div
|
||||
id={bodyId}
|
||||
className={classnames({
|
||||
['Proposal-top-main-block-bodyText']: true,
|
||||
['is-expanded']: isBodyExpanded,
|
||||
})}
|
||||
>
|
||||
{proposal ? (
|
||||
<Markdown source={proposal.content} />
|
||||
) : (
|
||||
<Spin size="large" />
|
||||
)}
|
||||
</div>
|
||||
{isTrustee && (
|
||||
{showExpand && (
|
||||
<button
|
||||
className="Proposal-top-main-block-bodyExpand"
|
||||
onClick={this.expandBody}
|
||||
>
|
||||
Read more <Icon type="arrow-down" style={{ fontSize: '0.7rem' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isLive &&
|
||||
isTrustee && (
|
||||
<div className="Proposal-top-main-menu">
|
||||
<Dropdown
|
||||
overlay={adminMenu}
|
||||
|
@ -172,48 +225,46 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="Proposal-top-side">
|
||||
<CampaignBlock proposal={proposal} isPreview={isPreview} />
|
||||
<TeamBlock proposal={proposal} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{proposal && (
|
||||
<Tabs>
|
||||
<Tabs.TabPane tab="Milestones" key="milestones">
|
||||
<div style={{ marginTop: '1.5rem', padding: '0 2rem' }}>
|
||||
<Milestones proposal={proposal} />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Discussion" key="discussions" disabled={isPreview}>
|
||||
<CommentsTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Updates" key="updates" disabled={isPreview}>
|
||||
<UpdatesTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Contributors" key="contributors">
|
||||
<ContributorsTab />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
)}
|
||||
{isTrustee && (
|
||||
<>
|
||||
<UpdateModal
|
||||
proposalId={proposal.proposalId}
|
||||
isVisible={isUpdateOpen}
|
||||
handleClose={this.closeUpdateModal}
|
||||
/>
|
||||
<CancelModal
|
||||
proposal={proposal}
|
||||
isVisible={isCancelOpen}
|
||||
handleClose={this.closeCancelModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="Proposal-top-side">
|
||||
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
||||
<TeamBlock proposal={proposal} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Tabs>
|
||||
<Tabs.TabPane tab="Milestones" key="milestones">
|
||||
<div style={{ marginTop: '1.5rem', padding: '0 2rem' }}>
|
||||
<Milestones proposal={proposal} />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Discussion" key="discussions" disabled={!isLive}>
|
||||
<CommentsTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
||||
<UpdatesTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
||||
<ContributorsTab />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
{isTrustee && (
|
||||
<>
|
||||
<UpdateModal
|
||||
proposalId={proposal.proposalId}
|
||||
isVisible={isUpdateOpen}
|
||||
handleClose={this.closeUpdateModal}
|
||||
/>
|
||||
<CancelModal
|
||||
proposal={proposal}
|
||||
isVisible={isCancelOpen}
|
||||
handleClose={this.closeCancelModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private expandBody = () => {
|
||||
|
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,6 +5,8 @@ import {
|
|||
updateUser as apiUpdateUser,
|
||||
fetchUserInvites as apiFetchUserInvites,
|
||||
putInviteResponse,
|
||||
deleteProposalDraft,
|
||||
putProposalPublish,
|
||||
} from 'api/api';
|
||||
import { Dispatch } from 'redux';
|
||||
import { cleanClone } from 'utils/helpers';
|
||||
|
@ -89,3 +91,25 @@ export function respondToInvite(
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deletePendingProposal(userId: number, proposalId: number) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
await dispatch({
|
||||
type: types.USER_DELETE_PROPOSAL,
|
||||
payload: deleteProposalDraft(proposalId).then(_ => ({ userId, proposalId })),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function publishPendingProposal(userId: number, proposalId: number) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
await dispatch({
|
||||
type: types.USER_PUBLISH_PROPOSAL,
|
||||
payload: putProposalPublish(proposalId).then(res => ({
|
||||
userId,
|
||||
proposalId,
|
||||
proposal: res.data,
|
||||
})),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -5,21 +5,22 @@ import { User } from 'types';
|
|||
|
||||
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
|
||||
isResponding: boolean;
|
||||
respondError: number | null;
|
||||
respondError: string | null;
|
||||
}
|
||||
|
||||
export interface UserState extends User {
|
||||
isFetching: boolean;
|
||||
hasFetched: boolean;
|
||||
fetchError: number | null;
|
||||
fetchError: string | null;
|
||||
isUpdating: boolean;
|
||||
updateError: number | null;
|
||||
updateError: string | null;
|
||||
pendingProposals: UserProposal[];
|
||||
createdProposals: UserProposal[];
|
||||
fundedProposals: UserProposal[];
|
||||
comments: UserComment[];
|
||||
isFetchingInvites: boolean;
|
||||
hasFetchedInvites: boolean;
|
||||
fetchErrorInvites: number | null;
|
||||
fetchErrorInvites: string | null;
|
||||
invites: TeamInviteWithResponse[];
|
||||
}
|
||||
|
||||
|
@ -43,6 +44,7 @@ export const INITIAL_USER_STATE: UserState = {
|
|||
fetchError: null,
|
||||
isUpdating: false,
|
||||
updateError: null,
|
||||
pendingProposals: [],
|
||||
createdProposals: [],
|
||||
fundedProposals: [],
|
||||
comments: [],
|
||||
|
@ -60,12 +62,10 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
const { payload } = action;
|
||||
const userFetchId = payload && payload.userFetchId;
|
||||
const invites = payload && payload.invites;
|
||||
const errorStatus =
|
||||
(payload &&
|
||||
payload.error &&
|
||||
payload.error.response &&
|
||||
payload.error.response.status) ||
|
||||
999;
|
||||
const errorMessage =
|
||||
(payload && payload.error && (payload.error.message || payload.error.toString())) ||
|
||||
null;
|
||||
|
||||
switch (action.type) {
|
||||
// fetch
|
||||
case types.FETCH_USER_PENDING:
|
||||
|
@ -81,7 +81,7 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
return updateUserState(state, userFetchId, {
|
||||
isFetching: false,
|
||||
hasFetched: true,
|
||||
fetchError: errorStatus,
|
||||
fetchError: errorMessage,
|
||||
});
|
||||
// update
|
||||
case types.UPDATE_USER_PENDING:
|
||||
|
@ -99,7 +99,7 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
case types.UPDATE_USER_REJECTED:
|
||||
return updateUserState(state, payload.user.userid, {
|
||||
isUpdating: false,
|
||||
updateError: errorStatus,
|
||||
updateError: errorMessage,
|
||||
});
|
||||
// invites
|
||||
case types.FETCH_USER_INVITES_PENDING:
|
||||
|
@ -117,7 +117,7 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
return updateUserState(state, userFetchId, {
|
||||
isFetchingInvites: false,
|
||||
hasFetchedInvites: true,
|
||||
fetchErrorInvites: errorStatus,
|
||||
fetchErrorInvites: errorMessage,
|
||||
});
|
||||
// invites
|
||||
case types.FETCH_USER_INVITES_PENDING:
|
||||
|
@ -135,7 +135,7 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
return updateUserState(state, userFetchId, {
|
||||
isFetchingInvites: false,
|
||||
hasFetchedInvites: true,
|
||||
fetchErrorInvites: errorStatus,
|
||||
fetchErrorInvites: errorMessage,
|
||||
});
|
||||
// invite response
|
||||
case types.RESPOND_TO_INVITE_PENDING:
|
||||
|
@ -148,8 +148,14 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
case types.RESPOND_TO_INVITE_REJECTED:
|
||||
return updateTeamInvite(state, payload.userId, payload.inviteId, {
|
||||
isResponding: false,
|
||||
respondError: errorStatus,
|
||||
respondError: errorMessage,
|
||||
});
|
||||
// proposal delete
|
||||
case types.USER_DELETE_PROPOSAL_FULFILLED:
|
||||
return removePendingProposal(state, payload.userId, payload.proposalId);
|
||||
// proposal publish
|
||||
case types.USER_PUBLISH_PROPOSAL_FULFILLED:
|
||||
return updatePublishedProposal(state, payload.userId, payload.proposal);
|
||||
// default
|
||||
default:
|
||||
return state;
|
||||
|
@ -171,6 +177,32 @@ function updateUserState(
|
|||
};
|
||||
}
|
||||
|
||||
function removePendingProposal(
|
||||
state: UsersState,
|
||||
userId: string | number,
|
||||
proposalId: number,
|
||||
) {
|
||||
const pendingProposals = state.map[userId].pendingProposals.filter(
|
||||
p => p.proposalId !== proposalId,
|
||||
);
|
||||
const userUpdates = {
|
||||
pendingProposals,
|
||||
};
|
||||
return updateUserState(state, userId, userUpdates);
|
||||
}
|
||||
|
||||
function updatePublishedProposal(
|
||||
state: UsersState,
|
||||
userId: string | number,
|
||||
proposal: UserProposal,
|
||||
) {
|
||||
const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
|
||||
const userUpdates = {
|
||||
createdProposals: [proposal, ...state.map[userId].createdProposals],
|
||||
};
|
||||
return updateUserState(withoutPending, userId, userUpdates);
|
||||
}
|
||||
|
||||
function updateTeamInvite(
|
||||
state: UsersState,
|
||||
userid: string | number,
|
||||
|
|
|
@ -18,6 +18,12 @@ enum UsersActions {
|
|||
RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING',
|
||||
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
|
||||
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
|
||||
|
||||
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
|
||||
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',
|
||||
|
||||
USER_PUBLISH_PROPOSAL = 'USER_PUBLISH_PROPOSAL',
|
||||
USER_PUBLISH_PROPOSAL_FULFILLED = 'USER_PUBLISH_PROPOSAL_FULFILLED',
|
||||
}
|
||||
|
||||
export default UsersActions;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -12,11 +12,14 @@ export function formatUserForPost(user: User) {
|
|||
}
|
||||
|
||||
export function formatUserFromGet(user: UserState) {
|
||||
const bnUserProp = (p: UserProposal) => {
|
||||
p.funded = new BN(p.funded);
|
||||
p.target = new BN(p.target);
|
||||
const bnUserProp = (p: any) => {
|
||||
p.funded = toZat(p.funded);
|
||||
p.target = toZat(p.target);
|
||||
return p;
|
||||
};
|
||||
if (user.pendingProposals) {
|
||||
user.pendingProposals = user.pendingProposals.map(bnUserProp);
|
||||
}
|
||||
user.createdProposals = user.createdProposals.map(bnUserProp);
|
||||
user.fundedProposals = user.fundedProposals.map(bnUserProp);
|
||||
return user;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import BN from 'bn.js';
|
||||
import { Zat } from 'utils/units';
|
||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import {
|
||||
CreateMilestone,
|
||||
Update,
|
||||
User,
|
||||
Comment,
|
||||
} from 'types';
|
||||
import { CreateMilestone, Update, User, Comment } from 'types';
|
||||
import { ProposalMilestone } from './milestone';
|
||||
|
||||
export interface TeamInvite {
|
||||
|
@ -39,9 +34,9 @@ export interface ProposalDraft {
|
|||
milestones: CreateMilestone[];
|
||||
team: User[];
|
||||
invites: TeamInvite[];
|
||||
status: STATUS;
|
||||
}
|
||||
|
||||
|
||||
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||
proposalAddress: string;
|
||||
proposalUrlId: string;
|
||||
|
@ -49,6 +44,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
|||
funded: BN;
|
||||
percentFunded: number;
|
||||
milestones: ProposalMilestone[];
|
||||
datePublished: number;
|
||||
}
|
||||
|
||||
export interface TeamInviteWithProposal extends TeamInvite {
|
||||
|
@ -68,9 +64,24 @@ export interface ProposalUpdates {
|
|||
|
||||
export interface UserProposal {
|
||||
proposalId: number;
|
||||
status: STATUS;
|
||||
title: string;
|
||||
brief: string;
|
||||
team: User[];
|
||||
funded: BN;
|
||||
target: BN;
|
||||
dateCreated: number;
|
||||
dateApproved: number;
|
||||
datePublished: number;
|
||||
team: User[];
|
||||
rejectReason: string;
|
||||
}
|
||||
|
||||
// NOTE: sync with backend/grant/proposal/models.py STATUSES
|
||||
export enum STATUS {
|
||||
DRAFT = 'DRAFT',
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
LIVE = 'LIVE',
|
||||
DELETED = 'DELETED',
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue