CCRs (#86)
* CCRs API / Models boilerplate * start on frontend * backendy things * Create CCR redux module, integrate API endpoints, create types * Fix/Cleanup API * Wire up CreateRequestDraftList * bounty->target * Add 'Create Request Flow' MVP * cleanup * Tweak filenames * Simplify migrations * fix migrations * CCR Staking MVP * tslint * Get Pending Requests into Profile * Remove staking requirement * more staking related removals * MVP Admin integration * Make RFP when CCR is accepted * Add pagination to CCRs in Admin Improve styles for Proposals * Hookup notifications Adjust copy * Simplify ccr->rfp relationship Add admin approval email Fixup copy * Show Message on RFP Detail Make Header CTAs change based on draft status Adjust proposal card style * Bugfix: Show header for non signed in users * Add 'create a request' to intro * Profile Created CCRs RFP CCR attribution * ignore * CCR Price in USD (#85) * init profile tipjar backend * init profile tipjar frontend * fix lint * implement tip jar block * fix wrapping, hide tip block on self * init backend proposal tipjar * init frontend proposal tipjar * add hide title, fix bug * uncomment rate limit * rename vars, use null check * allow address and view key to be unset * add api tests * fix tsc errors * fix lint * fix CopyInput styling * fix migrations * hide tipping in proposal if address not set * add tip address to create flow * redesign campaign block * fix typo * init backend changes * init admin changes * init frontend changes * fix backend tests * update campaign block * be - init rfp usd changes * admin - init rfp usd changes * fe - fully adapt api util functions to usd * fe - init rfp usd changes * adapt profile created to usd * misc usd changes * add tip jar to dedicated card * fix tipjar bug * use zf light logo * switch to zf grants logo * hide profile tip jar if address not set * add comment, run prettier * conditionally add info icon and tooltip to funding line * admin - disallow decimals in RFPs * fe - cover usd string edge case * add Usd as rfp bounty type * fix migration order * fix email bug * adapt CCRs to USD * implement CCR preview * fix tsc * Copy Updates and UX Tweaks (#87) * Add default structure to proposal content * Landing page copy * Hide contributors tab for v2 proposals * Minor UX tweaks for Liking/Following/Tipping * Copy for Tipping Tooltip, proposal explainer for review, and milestone day estimate notice. * Fix header styles bug and remove commented out styles. * Revert "like" / "unfollow" hyphenication * Comment out unused tests related to staking Increase PROPOSAL_TARGET_MAX in .env.example * Comment out ccr approval email send until ready * Adjust styles, copy. * fix proposal prune test (#88) * fix USD display in preview, fix non-unique key (#90) * Pre-stepper explainer for CCRs. * Tweak styles * Default content for CCRs * fix tsc * CCR approval and rejection emails * add back admin_approval_ccr email templates * Link ccr author name to profile in RFPs * copy tweaks * copy tweak * hookup mangle user command * Fix/add endif in jinja * fix tests * review * fix review
This commit is contained in:
parent
95102842a7
commit
3311be8e98
|
@ -13,6 +13,8 @@ import UserDetail from 'components/UserDetail';
|
||||||
import Emails from 'components/Emails';
|
import Emails from 'components/Emails';
|
||||||
import Proposals from 'components/Proposals';
|
import Proposals from 'components/Proposals';
|
||||||
import ProposalDetail from 'components/ProposalDetail';
|
import ProposalDetail from 'components/ProposalDetail';
|
||||||
|
import CCRs from 'components/CCRs';
|
||||||
|
import CCRDetail from 'components/CCRDetail';
|
||||||
import RFPs from 'components/RFPs';
|
import RFPs from 'components/RFPs';
|
||||||
import RFPForm from 'components/RFPForm';
|
import RFPForm from 'components/RFPForm';
|
||||||
import RFPDetail from 'components/RFPDetail';
|
import RFPDetail from 'components/RFPDetail';
|
||||||
|
@ -47,6 +49,8 @@ class Routes extends React.Component<Props> {
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
<Route path="/proposals/:id" component={ProposalDetail} />
|
<Route path="/proposals/:id" component={ProposalDetail} />
|
||||||
<Route path="/proposals" component={Proposals} />
|
<Route path="/proposals" component={Proposals} />
|
||||||
|
<Route path="/ccrs/:id" component={CCRDetail} />
|
||||||
|
<Route path="/ccrs" component={CCRs} />
|
||||||
<Route path="/rfps/new" component={RFPForm} />
|
<Route path="/rfps/new" component={RFPForm} />
|
||||||
<Route path="/rfps/:id/edit" component={RFPForm} />
|
<Route path="/rfps/:id/edit" component={RFPForm} />
|
||||||
<Route path="/rfps/:id" component={RFPDetail} />
|
<Route path="/rfps/:id" component={RFPDetail} />
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
.CCRDetail {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
&-control + &-control {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-deet {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.8;
|
||||||
|
bottom: -0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ant-card,
|
||||||
|
.ant-alert,
|
||||||
|
.ant-collapse {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-popover {
|
||||||
|
&-overlay {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-alert {
|
||||||
|
& pre {
|
||||||
|
margin: 1rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-review {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,221 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Alert, Button, Card, Col, Collapse, message, Row } from 'antd';
|
||||||
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import { CCR_STATUS } from 'src/types';
|
||||||
|
import Back from 'components/Back';
|
||||||
|
import Markdown from 'components/Markdown';
|
||||||
|
import FeedbackModal from '../FeedbackModal';
|
||||||
|
import './index.less';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
paidTxId: '',
|
||||||
|
showCancelAndRefundPopover: false,
|
||||||
|
showChangeToAcceptedWithFundingPopover: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = typeof STATE;
|
||||||
|
|
||||||
|
class CCRDetailNaked extends React.Component<Props, State> {
|
||||||
|
state = STATE;
|
||||||
|
rejectInput: null | TextArea = null;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const id = this.getIdFromQuery();
|
||||||
|
const { ccrDetail: c, ccrDetailFetching } = store;
|
||||||
|
|
||||||
|
if (!c || (c && c.ccrId !== id) || ccrDetailFetching) {
|
||||||
|
return 'loading ccr...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderApproved = () =>
|
||||||
|
c.status === CCR_STATUS.APPROVED && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="success"
|
||||||
|
message={`Approved on ${formatDateSeconds(c.dateApproved)}`}
|
||||||
|
description={`
|
||||||
|
This ccr has been approved.
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderReview = () =>
|
||||||
|
c.status === CCR_STATUS.PENDING && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="Review Pending"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Please review this Community Created Request and render your judgment.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="CCRDetail-review"
|
||||||
|
loading={store.ccrDetailApproving}
|
||||||
|
icon="check"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => this.handleApprove()}
|
||||||
|
>
|
||||||
|
Generate RFP from CCR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="CCRDetail-review"
|
||||||
|
loading={store.ccrDetailApproving}
|
||||||
|
icon="close"
|
||||||
|
type="danger"
|
||||||
|
onClick={() => {
|
||||||
|
FeedbackModal.open({
|
||||||
|
title: 'Request changes for this Request?',
|
||||||
|
label: 'Please provide a reason:',
|
||||||
|
okText: 'Request changes',
|
||||||
|
onOk: this.handleReject,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRejected = () =>
|
||||||
|
c.status === CCR_STATUS.REJECTED && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="error"
|
||||||
|
message="Changes requested"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This CCR has changes requested. The team will be able to re-submit it for
|
||||||
|
approval should they desire to do so.
|
||||||
|
</p>
|
||||||
|
<b>Reason:</b>
|
||||||
|
<br />
|
||||||
|
<i>{c.rejectReason}</i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
|
<div className="CCRDetail-deet">
|
||||||
|
<span>{name}</span>
|
||||||
|
{val}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CCRDetail">
|
||||||
|
<Back to="/ccrs" text="CCRs" />
|
||||||
|
<h1>{c.title}</h1>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* MAIN */}
|
||||||
|
<Col span={18}>
|
||||||
|
{renderApproved()}
|
||||||
|
{renderReview()}
|
||||||
|
{renderRejected()}
|
||||||
|
|
||||||
|
<Collapse defaultActiveKey={['brief', 'content', 'target']}>
|
||||||
|
<Collapse.Panel key="brief" header="brief">
|
||||||
|
{c.brief}
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="content" header="content">
|
||||||
|
<Markdown source={c.content} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="target" header="target">
|
||||||
|
<Markdown source={c.target} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="json" header="json">
|
||||||
|
<pre>{JSON.stringify(c, null, 4)}</pre>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* RIGHT SIDE */}
|
||||||
|
<Col span={6}>
|
||||||
|
{c.rfp && (
|
||||||
|
<Alert
|
||||||
|
message="Linked to RFP"
|
||||||
|
description={
|
||||||
|
<React.Fragment>
|
||||||
|
This CCR has been accepted and is instantiated as an RFP{' '}
|
||||||
|
<Link to={`/rfps/${c.rfp.id}`}>here</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DETAILS */}
|
||||||
|
<Card title="Details" size="small">
|
||||||
|
{renderDeetItem('id', c.ccrId)}
|
||||||
|
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
|
||||||
|
{renderDeetItem(
|
||||||
|
'published',
|
||||||
|
c.datePublished ? formatDateSeconds(c.datePublished) : 'n/a',
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderDeetItem(
|
||||||
|
'status',
|
||||||
|
c.status === CCR_STATUS.LIVE ? 'Accepted/Generated RFP' : c.status,
|
||||||
|
)}
|
||||||
|
{renderDeetItem('target', c.target)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Author" size="small">
|
||||||
|
<div key={c.author.userid}>
|
||||||
|
<Link to={`/users/${c.author.userid}`}>{c.author.displayName}</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIdFromQuery = () => {
|
||||||
|
return Number(this.props.match.params.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadDetail = () => {
|
||||||
|
store.fetchCCRDetail(this.getIdFromQuery());
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleApprove = async () => {
|
||||||
|
await store.approveCCR(true);
|
||||||
|
if (store.ccrCreatedRFPId) {
|
||||||
|
message.success('Successfully created RFP from CCR!', 1);
|
||||||
|
setTimeout(
|
||||||
|
() => this.props.history.replace(`/rfps/${store.ccrCreatedRFPId}/edit`),
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleReject = async (reason: string) => {
|
||||||
|
await store.approveCCR(false, reason);
|
||||||
|
message.info('CCR changes requested');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CCRDetail = withRouter(view(CCRDetailNaked));
|
||||||
|
export default CCRDetail;
|
|
@ -0,0 +1,16 @@
|
||||||
|
.CCRItem {
|
||||||
|
& h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
& .ant-tag {
|
||||||
|
vertical-align: text-top;
|
||||||
|
margin: 0.2rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
color: rgba(#000, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Tag, Tooltip, List } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CCR } from 'src/types';
|
||||||
|
import { CCR_STATUSES, getStatusById } from 'util/statuses';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import './CCRItem.less';
|
||||||
|
|
||||||
|
class CCRItemNaked extends React.Component<CCR> {
|
||||||
|
render() {
|
||||||
|
const props = this.props;
|
||||||
|
const status = getStatusById(CCR_STATUSES, props.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item key={props.ccrId} className="CCRItem">
|
||||||
|
<Link to={`/ccrs/${props.ccrId}`}>
|
||||||
|
<h2>
|
||||||
|
{props.title || '(no title)'}
|
||||||
|
<Tooltip title={status.hint}>
|
||||||
|
<Tag color={status.tagColor}>
|
||||||
|
{status.tagDisplay === 'Live'
|
||||||
|
? 'Accepted/Generated RFP'
|
||||||
|
: status.tagDisplay}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</h2>
|
||||||
|
<p>Created: {formatDateSeconds(props.dateCreated)}</p>
|
||||||
|
<p>{props.brief}</p>
|
||||||
|
</Link>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CCRItem = view(CCRItemNaked);
|
||||||
|
export default CCRItem;
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import store from 'src/store';
|
||||||
|
import CCRItem from './CCRItem';
|
||||||
|
import Pageable from 'components/Pageable';
|
||||||
|
import { CCR } from 'src/types';
|
||||||
|
import { ccrFilters } from 'util/filters';
|
||||||
|
|
||||||
|
class CCRs extends React.Component<{}> {
|
||||||
|
render() {
|
||||||
|
const { page } = store.ccrs;
|
||||||
|
// NOTE: sync with /backend ... pagination.py CCRPagination.SORT_MAP
|
||||||
|
const sorts = ['CREATED:DESC', 'CREATED:ASC'];
|
||||||
|
return (
|
||||||
|
<Pageable
|
||||||
|
page={page}
|
||||||
|
filters={ccrFilters}
|
||||||
|
sorts={sorts}
|
||||||
|
searchPlaceholder="Search CCR titles"
|
||||||
|
renderItem={(c: CCR) => <CCRItem key={c.ccrId} {...c} />}
|
||||||
|
handleSearch={store.fetchCCRs}
|
||||||
|
handleChangeQuery={store.setCCRPageQuery}
|
||||||
|
handleResetQuery={store.resetCCRPageQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(CCRs);
|
|
@ -14,6 +14,7 @@ class Home extends React.Component {
|
||||||
const {
|
const {
|
||||||
userCount,
|
userCount,
|
||||||
proposalCount,
|
proposalCount,
|
||||||
|
ccrPendingCount,
|
||||||
proposalPendingCount,
|
proposalPendingCount,
|
||||||
proposalNoArbiterCount,
|
proposalNoArbiterCount,
|
||||||
proposalMilestonePayoutsCount,
|
proposalMilestonePayoutsCount,
|
||||||
|
@ -21,6 +22,13 @@ class Home extends React.Component {
|
||||||
} = store.stats;
|
} = store.stats;
|
||||||
|
|
||||||
const actionItems = [
|
const actionItems = [
|
||||||
|
!!ccrPendingCount && (
|
||||||
|
<div>
|
||||||
|
<Icon type="exclamation-circle" /> There are <b>{ccrPendingCount}</b> community
|
||||||
|
created requests <b>waiting for review</b>.{' '}
|
||||||
|
<Link to="/ccrs?filters[]=STATUS_PENDING">Click here</Link> to view them.
|
||||||
|
</div>
|
||||||
|
),
|
||||||
!!proposalPendingCount && (
|
!!proposalPendingCount && (
|
||||||
<div>
|
<div>
|
||||||
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin } from 'antd';
|
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin, Alert } from 'antd';
|
||||||
import Exception from 'ant-design-pro/lib/Exception';
|
import Exception from 'ant-design-pro/lib/Exception';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
|
@ -69,6 +69,20 @@ class RFPDetail extends React.Component<Props> {
|
||||||
|
|
||||||
{/* RIGHT SIDE */}
|
{/* RIGHT SIDE */}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
|
{rfp.ccr && (
|
||||||
|
<Alert
|
||||||
|
message="Linked CCR"
|
||||||
|
description={
|
||||||
|
<React.Fragment>
|
||||||
|
This RFP has been generated from a CCR{' '}
|
||||||
|
<Link to={`/ccrs/${rfp.ccr.ccrId}`}>here</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<Card className="RFPDetail-actions" size="small">
|
<Card className="RFPDetail-actions" size="small">
|
||||||
<Link to={`/rfps/${rfp.id}/edit`}>
|
<Link to={`/rfps/${rfp.id}/edit`}>
|
||||||
|
|
|
@ -51,6 +51,12 @@ class Template extends React.Component<Props> {
|
||||||
<span className="nav-text">Proposals</span>
|
<span className="nav-text">Proposals</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="ccrs">
|
||||||
|
<Link to="/ccrs">
|
||||||
|
<Icon type="solution" />
|
||||||
|
<span className="nav-text">CCRs</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item key="rfps">
|
<Menu.Item key="rfps">
|
||||||
<Link to="/rfps">
|
<Link to="/rfps">
|
||||||
<Icon type="notification" />
|
<Icon type="notification" />
|
||||||
|
|
|
@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Proposal,
|
Proposal,
|
||||||
|
CCR,
|
||||||
Contribution,
|
Contribution,
|
||||||
ContributionArgs,
|
ContributionArgs,
|
||||||
RFP,
|
RFP,
|
||||||
|
@ -149,8 +150,8 @@ async function cancelProposal(id: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeProposalToAcceptedWithFunding(id: number) {
|
async function changeProposalToAcceptedWithFunding(id: number) {
|
||||||
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`)
|
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`);
|
||||||
return data
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchComments(params: Partial<PageQuery>) {
|
async function fetchComments(params: Partial<PageQuery>) {
|
||||||
|
@ -176,6 +177,28 @@ async function getEmailExample(type: string) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCCRDetail(id: number) {
|
||||||
|
const { data } = await api.get(`/admin/ccrs/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string) {
|
||||||
|
const { data } = await api.put(`/admin/ccrs/${id}/accept`, {
|
||||||
|
isAccepted,
|
||||||
|
rejectReason,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCCRs(params: Partial<PageQuery>) {
|
||||||
|
const { data } = await api.get(`/admin/ccrs`, { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCCR(id: number) {
|
||||||
|
await api.delete(`/admin/ccrs/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function getRFPs() {
|
async function getRFPs() {
|
||||||
const { data } = await api.get(`/admin/rfps`);
|
const { data } = await api.get(`/admin/rfps`);
|
||||||
return data;
|
return data;
|
||||||
|
@ -229,6 +252,7 @@ const app = store({
|
||||||
stats: {
|
stats: {
|
||||||
userCount: 0,
|
userCount: 0,
|
||||||
proposalCount: 0,
|
proposalCount: 0,
|
||||||
|
ccrPendingCount: 0,
|
||||||
proposalPendingCount: 0,
|
proposalPendingCount: 0,
|
||||||
proposalNoArbiterCount: 0,
|
proposalNoArbiterCount: 0,
|
||||||
proposalMilestonePayoutsCount: 0,
|
proposalMilestonePayoutsCount: 0,
|
||||||
|
@ -295,6 +319,24 @@ const app = store({
|
||||||
proposalDetailUpdated: false,
|
proposalDetailUpdated: false,
|
||||||
proposalDetailChangingToAcceptedWithFunding: false,
|
proposalDetailChangingToAcceptedWithFunding: false,
|
||||||
|
|
||||||
|
ccrs: {
|
||||||
|
page: createDefaultPageData<CCR>('CREATED:DESC'),
|
||||||
|
},
|
||||||
|
ccrSaving: false,
|
||||||
|
ccrSaved: false,
|
||||||
|
ccrDeleting: false,
|
||||||
|
ccrDeleted: false,
|
||||||
|
|
||||||
|
ccrDetail: null as null | CCR,
|
||||||
|
ccrDetailFetching: false,
|
||||||
|
ccrDetailApproving: false,
|
||||||
|
ccrDetailMarkingMilestonePaid: false,
|
||||||
|
ccrDetailCanceling: false,
|
||||||
|
ccrDetailUpdating: false,
|
||||||
|
ccrDetailUpdated: false,
|
||||||
|
ccrDetailChangingToAcceptedWithFunding: false,
|
||||||
|
ccrCreatedRFPId: null,
|
||||||
|
|
||||||
comments: {
|
comments: {
|
||||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||||
},
|
},
|
||||||
|
@ -494,6 +536,53 @@ const app = store({
|
||||||
app.arbiterSaving = false;
|
app.arbiterSaving = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// CCRS
|
||||||
|
|
||||||
|
async fetchCCRs() {
|
||||||
|
return await pageFetch(app.ccrs, fetchCCRs);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCCRPageQuery(params: Partial<PageQuery>) {
|
||||||
|
setPageParams(app.ccrs, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetCCRPageQuery() {
|
||||||
|
resetPageParams(app.ccrs);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCCRDetail(id: number) {
|
||||||
|
app.ccrDetailFetching = true;
|
||||||
|
try {
|
||||||
|
app.ccrDetail = await fetchCCRDetail(id);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.ccrDetailFetching = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveCCR(isAccepted: boolean, rejectReason?: string) {
|
||||||
|
if (!app.ccrDetail) {
|
||||||
|
const m = 'store.approveCCR(): Expected ccrDetail to be populated!';
|
||||||
|
app.generalError.push(m);
|
||||||
|
console.error(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.ccrCreatedRFPId = null;
|
||||||
|
app.ccrDetailApproving = true;
|
||||||
|
try {
|
||||||
|
const { ccrId } = app.ccrDetail;
|
||||||
|
const res = await approveCCR(ccrId, isAccepted, rejectReason);
|
||||||
|
await app.fetchCCRs();
|
||||||
|
await app.fetchRFPs();
|
||||||
|
if (isAccepted) {
|
||||||
|
app.ccrCreatedRFPId = res.rfpId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.ccrDetailApproving = false;
|
||||||
|
},
|
||||||
|
|
||||||
// Proposals
|
// Proposals
|
||||||
|
|
||||||
async fetchProposals() {
|
async fetchProposals() {
|
||||||
|
@ -548,7 +637,11 @@ const app = store({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async approveProposal(isAccepted: boolean, withFunding: boolean, rejectReason?: string) {
|
async approveProposal(
|
||||||
|
isAccepted: boolean,
|
||||||
|
withFunding: boolean,
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
if (!app.proposalDetail) {
|
if (!app.proposalDetail) {
|
||||||
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||||
app.generalError.push(m);
|
app.generalError.push(m);
|
||||||
|
@ -558,7 +651,12 @@ const app = store({
|
||||||
app.proposalDetailApproving = true;
|
app.proposalDetailApproving = true;
|
||||||
try {
|
try {
|
||||||
const { proposalId } = app.proposalDetail;
|
const { proposalId } = app.proposalDetail;
|
||||||
const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason);
|
const res = await approveProposal(
|
||||||
|
proposalId,
|
||||||
|
isAccepted,
|
||||||
|
withFunding,
|
||||||
|
rejectReason,
|
||||||
|
);
|
||||||
app.updateProposalInStore(res);
|
app.updateProposalInStore(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e);
|
handleApiError(e);
|
||||||
|
@ -578,16 +676,16 @@ const app = store({
|
||||||
},
|
},
|
||||||
|
|
||||||
async changeProposalToAcceptedWithFunding(id: number) {
|
async changeProposalToAcceptedWithFunding(id: number) {
|
||||||
app.proposalDetailChangingToAcceptedWithFunding = true
|
app.proposalDetailChangingToAcceptedWithFunding = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await changeProposalToAcceptedWithFunding(id)
|
const res = await changeProposalToAcceptedWithFunding(id);
|
||||||
app.updateProposalInStore(res)
|
app.updateProposalInStore(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e)
|
handleApiError(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.proposalDetailChangingToAcceptedWithFunding = false
|
app.proposalDetailChangingToAcceptedWithFunding = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
||||||
|
|
|
@ -48,6 +48,7 @@ export interface RFP {
|
||||||
bounty: string | null;
|
bounty: string | null;
|
||||||
dateCloses: number | null;
|
dateCloses: number | null;
|
||||||
isVersionTwo: boolean;
|
isVersionTwo: boolean;
|
||||||
|
ccr?: CCR;
|
||||||
}
|
}
|
||||||
export interface RFPArgs {
|
export interface RFPArgs {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -200,6 +201,30 @@ export enum PROPOSAL_CATEGORY {
|
||||||
ACCESSIBILITY = 'ACCESSIBILITY',
|
ACCESSIBILITY = 'ACCESSIBILITY',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CCR_STATUS {
|
||||||
|
DRAFT = 'DRAFT',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
APPROVED = 'APPROVED',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
LIVE = 'LIVE',
|
||||||
|
DELETED = 'DELETED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCR {
|
||||||
|
ccrId: number;
|
||||||
|
brief: string;
|
||||||
|
status: CCR_STATUS;
|
||||||
|
dateCreated: number;
|
||||||
|
dateApproved: number;
|
||||||
|
datePublished: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
target: string;
|
||||||
|
rejectReason: string;
|
||||||
|
rfp?: RFP;
|
||||||
|
author: User;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageQuery {
|
export interface PageQuery {
|
||||||
page: number;
|
page: number;
|
||||||
filters: string[];
|
filters: string[];
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
PROPOSAL_ARBITER_STATUSES,
|
PROPOSAL_ARBITER_STATUSES,
|
||||||
MILESTONE_STAGES,
|
MILESTONE_STAGES,
|
||||||
PROPOSAL_STAGES,
|
PROPOSAL_STAGES,
|
||||||
|
CCR_STATUSES,
|
||||||
} from './statuses';
|
} from './statuses';
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
@ -94,6 +95,20 @@ export const rfpFilters: Filters = {
|
||||||
getById: getFilterById(RFP_FILTERS),
|
getById: getFilterById(RFP_FILTERS),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CCR
|
||||||
|
|
||||||
|
const CCR_FILTERS = CCR_STATUSES.map(c => ({
|
||||||
|
id: `STATUS_${c.id}`,
|
||||||
|
display: `Status: ${c.tagDisplay}`,
|
||||||
|
color: c.tagColor,
|
||||||
|
group: 'Status',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ccrFilters: Filters = {
|
||||||
|
list: CCR_FILTERS,
|
||||||
|
getById: getFilterById(CCR_FILTERS),
|
||||||
|
};
|
||||||
|
|
||||||
// Contribution
|
// Contribution
|
||||||
|
|
||||||
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
PROPOSAL_STATUS,
|
PROPOSAL_STATUS,
|
||||||
|
CCR_STATUS,
|
||||||
RFP_STATUS,
|
RFP_STATUS,
|
||||||
CONTRIBUTION_STATUS,
|
CONTRIBUTION_STATUS,
|
||||||
PROPOSAL_ARBITER_STATUS,
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
@ -48,6 +49,46 @@ export const MILESTONE_STAGES: Array<StatusSoT<MILESTONE_STAGE>> = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const CCR_STATUSES: Array<StatusSoT<CCR_STATUS>> = [
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.APPROVED,
|
||||||
|
tagDisplay: 'Approved',
|
||||||
|
tagColor: '#afd500',
|
||||||
|
hint: 'Request has been approved and is awaiting being published by user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.DELETED,
|
||||||
|
tagDisplay: 'Deleted',
|
||||||
|
tagColor: '#bebebe',
|
||||||
|
hint: 'Request has been deleted and is not visible on the platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.DRAFT,
|
||||||
|
tagDisplay: 'Draft',
|
||||||
|
tagColor: '#8d8d8d',
|
||||||
|
hint: 'Request is being created by the user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.LIVE,
|
||||||
|
tagDisplay: 'Live',
|
||||||
|
tagColor: '#108ee9',
|
||||||
|
hint: 'Request is live on the platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.PENDING,
|
||||||
|
tagDisplay: 'Awaiting Approval',
|
||||||
|
tagColor: '#ffaa00',
|
||||||
|
hint: 'User is waiting for admin to approve or request changes to this Request.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.REJECTED,
|
||||||
|
tagDisplay: 'Changes Requested',
|
||||||
|
tagColor: '#eb4118',
|
||||||
|
hint:
|
||||||
|
'Admin has requested changes for this Request. User may adjust it and resubmit for approval.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.APPROVED,
|
id: PROPOSAL_STATUS.APPROVED,
|
||||||
|
|
|
@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
|
||||||
PROPOSAL_STAKING_AMOUNT=0.025
|
PROPOSAL_STAKING_AMOUNT=0.025
|
||||||
|
|
||||||
# Maximum amount for a proposal target, keep in sync with frontend .env
|
# Maximum amount for a proposal target, keep in sync with frontend .env
|
||||||
PROPOSAL_TARGET_MAX=500000
|
PROPOSAL_TARGET_MAX=999999
|
||||||
|
|
|
@ -69,6 +69,10 @@ To run all tests, run
|
||||||
|
|
||||||
flask test
|
flask test
|
||||||
|
|
||||||
|
To run only select test, Flask allows you to match against the test filename with ``-t` like so:
|
||||||
|
|
||||||
|
flask test -t proposal
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Whenever a database migration needs to be made. Run the following commands
|
Whenever a database migration needs to be made. Run the following commands
|
||||||
|
|
|
@ -8,6 +8,7 @@ from sqlalchemy import func, or_, text
|
||||||
|
|
||||||
import grant.utils.admin as admin
|
import grant.utils.admin as admin
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
|
from grant.ccr.models import CCR, ccrs_schema, ccr_schema
|
||||||
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
|
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
|
||||||
from grant.email.send import generate_email, send_email
|
from grant.email.send import generate_email, send_email
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
|
@ -26,7 +27,6 @@ from grant.proposal.models import (
|
||||||
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
||||||
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
|
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from grant.utils.enums import Category
|
|
||||||
from grant.utils.enums import (
|
from grant.utils.enums import (
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
ProposalStage,
|
ProposalStage,
|
||||||
|
@ -34,6 +34,7 @@ from grant.utils.enums import (
|
||||||
ProposalArbiterStatus,
|
ProposalArbiterStatus,
|
||||||
MilestoneStage,
|
MilestoneStage,
|
||||||
RFPStatus,
|
RFPStatus,
|
||||||
|
CCRStatus
|
||||||
)
|
)
|
||||||
from grant.utils.misc import make_url, make_explore_url
|
from grant.utils.misc import make_url, make_explore_url
|
||||||
from .example_emails import example_email_args
|
from .example_emails import example_email_args
|
||||||
|
@ -137,6 +138,9 @@ def logout():
|
||||||
def stats():
|
def stats():
|
||||||
user_count = db.session.query(func.count(User.id)).scalar()
|
user_count = db.session.query(func.count(User.id)).scalar()
|
||||||
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
||||||
|
ccr_pending_count = db.session.query(func.count(CCR.id)) \
|
||||||
|
.filter(CCR.status == CCRStatus.PENDING) \
|
||||||
|
.scalar()
|
||||||
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
|
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
|
||||||
.filter(Proposal.status == ProposalStatus.PENDING) \
|
.filter(Proposal.status == ProposalStatus.PENDING) \
|
||||||
.scalar()
|
.scalar()
|
||||||
|
@ -169,6 +173,7 @@ def stats():
|
||||||
.scalar()
|
.scalar()
|
||||||
return {
|
return {
|
||||||
"userCount": user_count,
|
"userCount": user_count,
|
||||||
|
"ccrPendingCount": ccr_pending_count,
|
||||||
"proposalCount": proposal_count,
|
"proposalCount": proposal_count,
|
||||||
"proposalPendingCount": proposal_pending_count,
|
"proposalPendingCount": proposal_pending_count,
|
||||||
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
||||||
|
@ -473,6 +478,64 @@ def get_email_example(type):
|
||||||
return email
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
# CCRs
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/ccrs", methods=["GET"])
|
||||||
|
@query(paginated_fields)
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def get_ccrs(page, filters, search, sort):
|
||||||
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
|
page = pagination.ccr(
|
||||||
|
schema=ccrs_schema,
|
||||||
|
query=CCR.query,
|
||||||
|
page=page,
|
||||||
|
filters=filters_workaround,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<ccr_id>', methods=['DELETE'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def delete_ccr(ccr_id):
|
||||||
|
ccr = CCR.query.filter(CCR.id == ccr_id).first()
|
||||||
|
if not ccr:
|
||||||
|
return {"message": "No CCR matching that id"}, 404
|
||||||
|
|
||||||
|
db.session.delete(ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<id>', methods=['GET'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def get_ccr(id):
|
||||||
|
ccr = CCR.query.filter(CCR.id == id).first()
|
||||||
|
if ccr:
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
return {"message": f"Could not find ccr with id {id}"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<ccr_id>/accept', methods=['PUT'])
|
||||||
|
@body({
|
||||||
|
"isAccepted": fields.Bool(required=True),
|
||||||
|
"rejectReason": fields.Str(required=False, missing=None)
|
||||||
|
})
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def approve_ccr(ccr_id, is_accepted, reject_reason=None):
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if ccr:
|
||||||
|
rfp_id = ccr.approve_pending(is_accepted, reject_reason)
|
||||||
|
if is_accepted:
|
||||||
|
return {"rfpId": rfp_id}, 201
|
||||||
|
else:
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
|
||||||
|
return {"message": "No CCR found."}, 404
|
||||||
|
|
||||||
|
|
||||||
# Requests for Proposal
|
# Requests for Proposal
|
||||||
|
|
||||||
|
|
||||||
|
@ -602,7 +665,7 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
#TODO: should this stay?
|
# TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -726,7 +789,6 @@ def edit_comment(comment_id, hidden, reported):
|
||||||
@blueprint.route("/financials", methods=["GET"])
|
@blueprint.route("/financials", methods=["GET"])
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def financials():
|
def financials():
|
||||||
|
|
||||||
nfmt = '999999.99999999' # smallest unit of ZEC
|
nfmt = '999999.99999999' # smallest unit of ZEC
|
||||||
|
|
||||||
def sql_pc(where: str):
|
def sql_pc(where: str):
|
||||||
|
@ -758,7 +820,8 @@ def financials():
|
||||||
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
||||||
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
|
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
|
||||||
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
|
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
|
||||||
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
|
'funded': str(
|
||||||
|
ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
|
||||||
# should have a refund_address
|
# should have a refund_address
|
||||||
'refunding': str(ex(sql_pc_p(
|
'refunding': str(ex(sql_pc_p(
|
||||||
'''
|
'''
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""The app module, containing the app factory function."""
|
"""The app module, containing the app factory function."""
|
||||||
import sentry_sdk
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
from animal_case import animalify
|
from animal_case import animalify
|
||||||
from flask import Flask, Response, jsonify, request, current_app, g
|
from flask import Flask, Response, jsonify, request, current_app, g
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore
|
||||||
from flask_sslify import SSLify
|
from flask_sslify import SSLify
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e, home
|
from grant import (
|
||||||
|
commands,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
ccr,
|
||||||
|
comment,
|
||||||
|
milestone,
|
||||||
|
admin,
|
||||||
|
email,
|
||||||
|
blockchain,
|
||||||
|
task,
|
||||||
|
rfp,
|
||||||
|
e2e,
|
||||||
|
home
|
||||||
|
)
|
||||||
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
||||||
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
||||||
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
||||||
|
@ -129,6 +144,7 @@ def register_extensions(app):
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register Flask blueprints."""
|
"""Register Flask blueprints."""
|
||||||
|
app.register_blueprint(ccr.views.blueprint)
|
||||||
app.register_blueprint(comment.views.blueprint)
|
app.register_blueprint(comment.views.blueprint)
|
||||||
app.register_blueprint(proposal.views.blueprint)
|
app.register_blueprint(proposal.views.blueprint)
|
||||||
app.register_blueprint(user.views.blueprint)
|
app.register_blueprint(user.views.blueprint)
|
||||||
|
@ -165,4 +181,5 @@ def register_commands(app):
|
||||||
app.cli.add_command(proposal.commands.create_proposals)
|
app.cli.add_command(proposal.commands.create_proposals)
|
||||||
app.cli.add_command(proposal.commands.retire_v1_proposals)
|
app.cli.add_command(proposal.commands.retire_v1_proposals)
|
||||||
app.cli.add_command(user.commands.set_admin)
|
app.cli.add_command(user.commands.set_admin)
|
||||||
|
app.cli.add_command(user.commands.mangle_users)
|
||||||
app.cli.add_command(task.commands.create_task)
|
app.cli.add_command(task.commands.create_task)
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import models
|
||||||
|
from . import views
|
|
@ -0,0 +1,230 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
|
||||||
|
from grant.email.send import send_email
|
||||||
|
from grant.extensions import ma, db
|
||||||
|
from grant.utils.enums import CCRStatus
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from grant.utils.misc import make_admin_url, gen_random_id, dt_to_unix
|
||||||
|
|
||||||
|
|
||||||
|
def default_content():
|
||||||
|
return """# Overview
|
||||||
|
|
||||||
|
What you think should be accomplished
|
||||||
|
|
||||||
|
|
||||||
|
# Approach
|
||||||
|
|
||||||
|
How you expect a proposing team to accomplish your request
|
||||||
|
|
||||||
|
|
||||||
|
# Deliverable
|
||||||
|
|
||||||
|
The end result of a proposal the fulfills this request
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CCR(db.Model):
|
||||||
|
__tablename__ = "ccr"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
date_created = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
title = db.Column(db.String(255), nullable=True)
|
||||||
|
brief = db.Column(db.String(255), nullable=True)
|
||||||
|
content = db.Column(db.Text, nullable=True)
|
||||||
|
status = db.Column(db.String(255), nullable=False)
|
||||||
|
_target = db.Column("target", db.String(255), nullable=True)
|
||||||
|
reject_reason = db.Column(db.String())
|
||||||
|
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||||
|
author = db.relationship("User", back_populates="ccrs")
|
||||||
|
|
||||||
|
rfp_id = db.Column(db.Integer, db.ForeignKey("rfp.id"), nullable=True)
|
||||||
|
rfp = db.relationship("RFP", back_populates="ccr")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_user(user, statuses=[CCRStatus.LIVE]):
|
||||||
|
status_filter = or_(CCR.status == v for v in statuses)
|
||||||
|
return CCR.query \
|
||||||
|
.filter(CCR.user_id == user.id) \
|
||||||
|
.filter(status_filter) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(**kwargs):
|
||||||
|
ccr = CCR(
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
db.session.add(ccr)
|
||||||
|
db.session.flush()
|
||||||
|
return ccr
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def target(self):
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
@target.setter
|
||||||
|
def target(self, target: str):
|
||||||
|
if target and Decimal(target) > 0:
|
||||||
|
self._target = target
|
||||||
|
else:
|
||||||
|
self._target = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
content: str = default_content(),
|
||||||
|
target: str = '0',
|
||||||
|
status: str = CCRStatus.DRAFT,
|
||||||
|
):
|
||||||
|
assert CCRStatus.includes(status)
|
||||||
|
self.id = gen_random_id(CCR)
|
||||||
|
self.date_created = datetime.now()
|
||||||
|
self.title = title[:255]
|
||||||
|
self.brief = brief[:255]
|
||||||
|
self.content = content
|
||||||
|
self.target = target
|
||||||
|
self.status = status
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
content: str = '',
|
||||||
|
target: str = '0',
|
||||||
|
):
|
||||||
|
self.title = title[:255]
|
||||||
|
self.brief = brief[:255]
|
||||||
|
self.content = content[:300000]
|
||||||
|
self._target = target[:255] if target != '' and target else '0'
|
||||||
|
|
||||||
|
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
||||||
|
def submit_for_approval(self):
|
||||||
|
self.validate_publishable()
|
||||||
|
allowed_statuses = [CCRStatus.DRAFT, CCRStatus.REJECTED]
|
||||||
|
# specific validation
|
||||||
|
if self.status not in allowed_statuses:
|
||||||
|
raise ValidationException(f"CCR status must be draft or rejected to submit for approval")
|
||||||
|
self.set_pending()
|
||||||
|
|
||||||
|
def send_admin_email(self, type: str):
|
||||||
|
from grant.user.models import User
|
||||||
|
admins = User.get_admins()
|
||||||
|
for a in admins:
|
||||||
|
send_email(a.email_address, type, {
|
||||||
|
'user': a,
|
||||||
|
'ccr': self,
|
||||||
|
'ccr_url': make_admin_url(f'/ccrs/{self.id}'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# state: status DRAFT -> PENDING
|
||||||
|
def set_pending(self):
|
||||||
|
self.send_admin_email('admin_approval_ccr')
|
||||||
|
self.status = CCRStatus.PENDING
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def validate_publishable(self):
|
||||||
|
# Require certain fields
|
||||||
|
required_fields = ['title', 'content', 'brief', 'target']
|
||||||
|
for field in required_fields:
|
||||||
|
if not hasattr(self, field):
|
||||||
|
raise ValidationException("Proposal must have a {}".format(field))
|
||||||
|
|
||||||
|
# Stricter limits on certain fields
|
||||||
|
if len(self.title) > 60:
|
||||||
|
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
||||||
|
if len(self.brief) > 140:
|
||||||
|
raise ValidationException("Brief cannot be longer than 140 characters")
|
||||||
|
if len(self.content) > 250000:
|
||||||
|
raise ValidationException("Content cannot be longer than 250,000 characters")
|
||||||
|
|
||||||
|
# state: status PENDING -> (LIVE || REJECTED)
|
||||||
|
def approve_pending(self, is_approve, reject_reason=None):
|
||||||
|
from grant.rfp.models import RFP
|
||||||
|
self.validate_publishable()
|
||||||
|
# specific validation
|
||||||
|
if not self.status == CCRStatus.PENDING:
|
||||||
|
raise ValidationException(f"CCR must be pending to approve or reject")
|
||||||
|
|
||||||
|
if is_approve:
|
||||||
|
self.status = CCRStatus.LIVE
|
||||||
|
rfp = RFP(
|
||||||
|
title=self.title,
|
||||||
|
brief=self.brief,
|
||||||
|
content=self.content,
|
||||||
|
bounty=self._target,
|
||||||
|
date_closes=datetime.now() + timedelta(days=90),
|
||||||
|
)
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.flush()
|
||||||
|
self.rfp_id = rfp.id
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# for emails
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
send_email(self.author.email_address, 'ccr_approved', {
|
||||||
|
'user': self.author,
|
||||||
|
'ccr': self,
|
||||||
|
'admin_note': f'Congratulations! Your Request has been accepted. There may be a delay between acceptance and final posting as required by the Zcash Foundation.'
|
||||||
|
})
|
||||||
|
return rfp.id
|
||||||
|
else:
|
||||||
|
if not reject_reason:
|
||||||
|
raise ValidationException("Please provide a reason for rejecting the ccr")
|
||||||
|
self.status = CCRStatus.REJECTED
|
||||||
|
self.reject_reason = reject_reason
|
||||||
|
# for emails
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
send_email(self.author.email_address, 'ccr_rejected', {
|
||||||
|
'user': self.author,
|
||||||
|
'ccr': self,
|
||||||
|
'admin_note': reject_reason
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CCRSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = CCR
|
||||||
|
# Fields to expose
|
||||||
|
fields = (
|
||||||
|
"author",
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"brief",
|
||||||
|
"ccr_id",
|
||||||
|
"content",
|
||||||
|
"status",
|
||||||
|
"target",
|
||||||
|
"date_created",
|
||||||
|
"reject_reason",
|
||||||
|
"rfp"
|
||||||
|
)
|
||||||
|
|
||||||
|
rfp = ma.Nested("RFPSchema")
|
||||||
|
date_created = ma.Method("get_date_created")
|
||||||
|
author = ma.Nested("UserSchema")
|
||||||
|
ccr_id = ma.Method("get_ccr_id")
|
||||||
|
|
||||||
|
def get_date_created(self, obj):
|
||||||
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
def get_ccr_id(self, obj):
|
||||||
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
|
ccr_schema = CCRSchema()
|
||||||
|
ccrs_schema = CCRSchema(many=True)
|
|
@ -0,0 +1,112 @@
|
||||||
|
from flask import Blueprint, g
|
||||||
|
from marshmallow import fields
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.extensions import limiter
|
||||||
|
from grant.parser import body
|
||||||
|
from grant.utils.auth import (
|
||||||
|
requires_auth,
|
||||||
|
requires_email_verified_auth,
|
||||||
|
get_authed_user
|
||||||
|
)
|
||||||
|
from grant.utils.auth import requires_ccr_owner_auth
|
||||||
|
from grant.utils.enums import CCRStatus
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from .models import CCR, ccr_schema, ccrs_schema, db
|
||||||
|
|
||||||
|
blueprint = Blueprint("ccr", __name__, url_prefix="/api/v1/ccrs")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["GET"])
|
||||||
|
def get_ccr(ccr_id):
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if ccr:
|
||||||
|
if ccr.status != CCRStatus.LIVE:
|
||||||
|
if CCR.status == CCRStatus.DELETED:
|
||||||
|
return {"message": "CCR was deleted"}, 404
|
||||||
|
authed_user = get_authed_user()
|
||||||
|
|
||||||
|
if authed_user.id != ccr.author.id:
|
||||||
|
return {"message": "User cannot view this CCR"}, 404
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
else:
|
||||||
|
return {"message": "No CCR matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/drafts", methods=["POST"])
|
||||||
|
@limiter.limit("10/hour;3/minute")
|
||||||
|
@requires_email_verified_auth
|
||||||
|
def make_ccr_draft():
|
||||||
|
user = g.current_user
|
||||||
|
ccr = CCR.create(status=CCRStatus.DRAFT, user_id=user.id)
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(ccr), 201
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/drafts", methods=["GET"])
|
||||||
|
@requires_auth
|
||||||
|
def get_ccr_drafts():
|
||||||
|
ccrs = (
|
||||||
|
CCR.query
|
||||||
|
.filter(or_(
|
||||||
|
CCR.status == CCRStatus.DRAFT,
|
||||||
|
CCR.status == CCRStatus.REJECTED,
|
||||||
|
))
|
||||||
|
.order_by(CCR.date_created.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return ccrs_schema.dump(ccrs), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["DELETE"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
def delete_ccr(ccr_id):
|
||||||
|
deleteable_statuses = [
|
||||||
|
CCRStatus.DRAFT,
|
||||||
|
CCRStatus.PENDING,
|
||||||
|
CCRStatus.APPROVED,
|
||||||
|
CCRStatus.REJECTED,
|
||||||
|
]
|
||||||
|
status = g.current_ccr.status
|
||||||
|
if status not in deleteable_statuses:
|
||||||
|
return {"message": "Cannot delete CCRs with %s status" % status}, 400
|
||||||
|
db.session.delete(g.current_ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 202
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["PUT"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
@body({
|
||||||
|
"title": fields.Str(required=True),
|
||||||
|
"brief": fields.Str(required=True),
|
||||||
|
"content": fields.Str(required=True),
|
||||||
|
"target": fields.Str(required=True, allow_none=True),
|
||||||
|
})
|
||||||
|
def update_ccr(ccr_id, **kwargs):
|
||||||
|
try:
|
||||||
|
if g.current_ccr.status not in [CCRStatus.DRAFT,
|
||||||
|
CCRStatus.REJECTED]:
|
||||||
|
raise ValidationException(
|
||||||
|
f"CCR with status: {g.current_ccr.status} are not authorized for updates"
|
||||||
|
)
|
||||||
|
g.current_ccr.update(**kwargs)
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "{}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_ccr)
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(g.current_ccr), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>/submit_for_approval", methods=["PUT"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
def submit_for_approval_ccr(ccr_id):
|
||||||
|
try:
|
||||||
|
g.current_ccr.submit_for_approval()
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "{}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(g.current_ccr), 200
|
|
@ -1,14 +1,15 @@
|
||||||
from .subscription_settings import EmailSubscription, is_subscribed
|
|
||||||
from sendgrid.helpers.mail import Email, Mail, Content
|
|
||||||
from python_http_client import HTTPError
|
|
||||||
from grant.utils.misc import make_url
|
|
||||||
from sentry_sdk import capture_exception
|
|
||||||
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI
|
|
||||||
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
|
|
||||||
import sendgrid
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from flask import render_template, Markup, current_app, g
|
|
||||||
|
|
||||||
|
import sendgrid
|
||||||
|
from flask import render_template, Markup, current_app, g
|
||||||
|
from python_http_client import HTTPError
|
||||||
|
from sendgrid.helpers.mail import Email, Mail, Content
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
|
||||||
|
from grant.settings import SENDGRID_DEFAULT_FROMNAME
|
||||||
|
from grant.utils.misc import make_url
|
||||||
|
from .subscription_settings import EmailSubscription, is_subscribed
|
||||||
|
|
||||||
default_template_args = {
|
default_template_args = {
|
||||||
'home_url': make_url('/'),
|
'home_url': make_url('/'),
|
||||||
|
@ -68,13 +69,29 @@ def change_password_info(email_args):
|
||||||
|
|
||||||
def proposal_approved(email_args):
|
def proposal_approved(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has been approved!',
|
'subject': 'Your proposal has been reviewed',
|
||||||
'title': 'Your proposal has been approved',
|
'title': 'Your proposal has been reviewed',
|
||||||
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
|
'preview': '{} is now live on ZF Grants.'.format(email_args['proposal'].title),
|
||||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ccr_approved(email_args):
|
||||||
|
return {
|
||||||
|
'subject': 'Your request has been approved!',
|
||||||
|
'title': 'Your request has been approved',
|
||||||
|
'preview': '{} will soon be live on ZF Grants!'.format(email_args['ccr'].title),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ccr_rejected(email_args):
|
||||||
|
return {
|
||||||
|
'subject': 'Your request has changes requested',
|
||||||
|
'title': 'Your request has changes requested',
|
||||||
|
'preview': '{} has changes requested'.format(email_args['ccr'].title),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def proposal_rejected(email_args):
|
def proposal_rejected(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has changes requested',
|
'subject': 'Your proposal has changes requested',
|
||||||
|
@ -300,6 +317,15 @@ def admin_approval(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_approval_ccr(email_args):
|
||||||
|
return {
|
||||||
|
'subject': f'Review needed for {email_args["ccr"].title}',
|
||||||
|
'title': f'CCR Review',
|
||||||
|
'preview': f'{email_args["ccr"].title} needs review, as an admin you can help.',
|
||||||
|
'subscription': EmailSubscription.ADMIN_APPROVAL_CCR,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def admin_arbiter(email_args):
|
def admin_arbiter(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
||||||
|
@ -346,6 +372,8 @@ get_info_lookup = {
|
||||||
'change_email': change_email_info,
|
'change_email': change_email_info,
|
||||||
'change_email_old': change_email_old_info,
|
'change_email_old': change_email_old_info,
|
||||||
'change_password': change_password_info,
|
'change_password': change_password_info,
|
||||||
|
'ccr_rejected': ccr_rejected,
|
||||||
|
'ccr_approved': ccr_approved,
|
||||||
'proposal_approved': proposal_approved,
|
'proposal_approved': proposal_approved,
|
||||||
'proposal_rejected': proposal_rejected,
|
'proposal_rejected': proposal_rejected,
|
||||||
'proposal_contribution': proposal_contribution,
|
'proposal_contribution': proposal_contribution,
|
||||||
|
@ -367,6 +395,7 @@ get_info_lookup = {
|
||||||
'milestone_accept': milestone_accept,
|
'milestone_accept': milestone_accept,
|
||||||
'milestone_paid': milestone_paid,
|
'milestone_paid': milestone_paid,
|
||||||
'admin_approval': admin_approval,
|
'admin_approval': admin_approval,
|
||||||
|
'admin_approval_ccr': admin_approval_ccr,
|
||||||
'admin_arbiter': admin_arbiter,
|
'admin_arbiter': admin_arbiter,
|
||||||
'admin_payout': admin_payout,
|
'admin_payout': admin_payout,
|
||||||
'followed_proposal_milestone': followed_proposal_milestone,
|
'followed_proposal_milestone': followed_proposal_milestone,
|
||||||
|
|
|
@ -69,6 +69,10 @@ class EmailSubscription(Enum):
|
||||||
'bit': 15,
|
'bit': 15,
|
||||||
'key': 'followed_proposal'
|
'key': 'followed_proposal'
|
||||||
}
|
}
|
||||||
|
ADMIN_APPROVAL_CCR = {
|
||||||
|
'bit': 16,
|
||||||
|
'key': 'admin_approval_ccr'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_email_sub_key(k: str):
|
def is_email_sub_key(k: str):
|
||||||
|
|
|
@ -228,6 +228,28 @@ class ProposalArbiter(db.Model):
|
||||||
raise ValidationException('User is not arbiter')
|
raise ValidationException('User is not arbiter')
|
||||||
|
|
||||||
|
|
||||||
|
def default_proposal_content():
|
||||||
|
return """# Overview
|
||||||
|
|
||||||
|
Help us understand the goal(s) of the proposal at a high level.
|
||||||
|
|
||||||
|
|
||||||
|
# Approach
|
||||||
|
|
||||||
|
The plan for accomplishing the goal(s) laid out in the overview.
|
||||||
|
|
||||||
|
|
||||||
|
# Team
|
||||||
|
|
||||||
|
Who you are, and why you're credible to execute on the goals of the proposal.
|
||||||
|
|
||||||
|
|
||||||
|
# Deliverable
|
||||||
|
|
||||||
|
The end result of your efforts as related to this proposal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Proposal(db.Model):
|
class Proposal(db.Model):
|
||||||
__tablename__ = "proposal"
|
__tablename__ = "proposal"
|
||||||
|
|
||||||
|
@ -241,7 +263,7 @@ class Proposal(db.Model):
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
brief = db.Column(db.String(255), nullable=False)
|
brief = db.Column(db.String(255), nullable=False)
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False, default=default_proposal_content())
|
||||||
category = db.Column(db.String(255), nullable=True)
|
category = db.Column(db.String(255), nullable=True)
|
||||||
date_approved = db.Column(db.DateTime)
|
date_approved = db.Column(db.DateTime)
|
||||||
date_published = db.Column(db.DateTime)
|
date_published = db.Column(db.DateTime)
|
||||||
|
@ -290,7 +312,7 @@ class Proposal(db.Model):
|
||||||
status: str = ProposalStatus.DRAFT,
|
status: str = ProposalStatus.DRAFT,
|
||||||
title: str = '',
|
title: str = '',
|
||||||
brief: str = '',
|
brief: str = '',
|
||||||
content: str = '',
|
content: str = default_proposal_content(),
|
||||||
stage: str = ProposalStage.PREVIEW,
|
stage: str = ProposalStage.PREVIEW,
|
||||||
target: str = '0',
|
target: str = '0',
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
|
@ -521,7 +543,7 @@ class Proposal(db.Model):
|
||||||
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
|
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
|
||||||
})
|
})
|
||||||
|
|
||||||
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
# state: status (DRAFT || REJECTED) -> (PENDING)
|
||||||
def submit_for_approval(self):
|
def submit_for_approval(self):
|
||||||
self.validate_publishable()
|
self.validate_publishable()
|
||||||
self.validate_milestone_days()
|
self.validate_milestone_days()
|
||||||
|
@ -529,11 +551,7 @@ class Proposal(db.Model):
|
||||||
# specific validation
|
# specific validation
|
||||||
if self.status not in allowed_statuses:
|
if self.status not in allowed_statuses:
|
||||||
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
||||||
# set to PENDING if staked, else STAKING
|
self.set_pending()
|
||||||
if self.is_staked:
|
|
||||||
self.status = ProposalStatus.PENDING
|
|
||||||
else:
|
|
||||||
self.status = ProposalStatus.STAKING
|
|
||||||
|
|
||||||
def set_pending_when_ready(self):
|
def set_pending_when_ready(self):
|
||||||
if self.status == ProposalStatus.STAKING and self.is_staked:
|
if self.status == ProposalStatus.STAKING and self.is_staked:
|
||||||
|
@ -541,10 +559,6 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
# state: status STAKING -> PENDING
|
# state: status STAKING -> PENDING
|
||||||
def set_pending(self):
|
def set_pending(self):
|
||||||
if self.status != ProposalStatus.STAKING:
|
|
||||||
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
|
|
||||||
if not self.is_staked:
|
|
||||||
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
|
|
||||||
self.send_admin_email('admin_approval')
|
self.send_admin_email('admin_approval')
|
||||||
self.status = ProposalStatus.PENDING
|
self.status = ProposalStatus.PENDING
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
|
@ -566,16 +580,23 @@ class Proposal(db.Model):
|
||||||
self.date_published = datetime.datetime.now()
|
self.date_published = datetime.datetime.now()
|
||||||
self.stage = ProposalStage.WIP
|
self.stage = ProposalStage.WIP
|
||||||
|
|
||||||
with_or_out = 'without'
|
|
||||||
if with_funding:
|
if with_funding:
|
||||||
self.fully_fund_contibution_bounty()
|
self.fully_fund_contibution_bounty()
|
||||||
with_or_out = 'with'
|
|
||||||
for t in self.team:
|
for t in self.team:
|
||||||
|
admin_note = ''
|
||||||
|
if with_funding:
|
||||||
|
admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.'
|
||||||
|
else:
|
||||||
|
admin_note = '''
|
||||||
|
We've chosen to list your proposal on ZF Grants, but we won't be funding your proposal at this time.
|
||||||
|
Your proposal can still receive funding from the community in the form of tips if you have set a tip address for your proposal.
|
||||||
|
If you have not yet done so, you can do this from the actions dropdown at your proposal.
|
||||||
|
'''
|
||||||
send_email(t.email_address, 'proposal_approved', {
|
send_email(t.email_address, 'proposal_approved', {
|
||||||
'user': t,
|
'user': t,
|
||||||
'proposal': self,
|
'proposal': self,
|
||||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||||
'admin_note': f'Congratulations! Your proposal has been accepted {with_or_out} funding.'
|
'admin_note': admin_note
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
if not reject_reason:
|
if not reject_reason:
|
||||||
|
|
|
@ -321,17 +321,6 @@ def submit_for_approval_proposal(proposal_id):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
|
|
||||||
@requires_team_member_auth
|
|
||||||
def get_proposal_stake(proposal_id):
|
|
||||||
if g.current_proposal.status != ProposalStatus.STAKING:
|
|
||||||
return {"message": "ok"}, 400
|
|
||||||
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
|
|
||||||
if contribution:
|
|
||||||
return proposal_contribution_schema.dump(contribution)
|
|
||||||
return {"message": "ok"}, 404
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
def publish_proposal(proposal_id):
|
def publish_proposal(proposal_id):
|
||||||
|
|
|
@ -34,6 +34,8 @@ class RFP(db.Model):
|
||||||
date_closed = db.Column(db.DateTime, nullable=True)
|
date_closed = db.Column(db.DateTime, nullable=True)
|
||||||
version = db.Column(db.String(255), nullable=True)
|
version = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
ccr = db.relationship("CCR", uselist=False, back_populates="rfp")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
proposals = db.relationship(
|
proposals = db.relationship(
|
||||||
"Proposal",
|
"Proposal",
|
||||||
|
@ -57,7 +59,6 @@ class RFP(db.Model):
|
||||||
.correlate_except(rfp_liker)
|
.correlate_except(rfp_liker)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def bounty(self):
|
def bounty(self):
|
||||||
return self._bounty
|
return self._bounty
|
||||||
|
@ -134,9 +135,11 @@ class RFPSchema(ma.Schema):
|
||||||
"accepted_proposals",
|
"accepted_proposals",
|
||||||
"authed_liked",
|
"authed_liked",
|
||||||
"likes_count",
|
"likes_count",
|
||||||
"is_version_two"
|
"is_version_two",
|
||||||
|
"ccr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||||
status = ma.Method("get_status")
|
status = ma.Method("get_status")
|
||||||
date_closes = ma.Method("get_date_closes")
|
date_closes = ma.Method("get_date_closes")
|
||||||
date_opened = ma.Method("get_date_opened")
|
date_opened = ma.Method("get_date_opened")
|
||||||
|
@ -184,9 +187,11 @@ class AdminRFPSchema(ma.Schema):
|
||||||
"date_opened",
|
"date_opened",
|
||||||
"date_closed",
|
"date_closed",
|
||||||
"proposals",
|
"proposals",
|
||||||
"is_version_two"
|
"is_version_two",
|
||||||
|
"ccr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||||
status = ma.Method("get_status")
|
status = ma.Method("get_status")
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
date_closes = ma.Method("get_date_closes")
|
date_closes = ma.Method("get_date_closes")
|
||||||
|
|
|
@ -151,15 +151,19 @@ class PruneDraft:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def process_task(task):
|
def process_task(task):
|
||||||
from grant.proposal.models import Proposal
|
from grant.proposal.models import Proposal, default_proposal_content
|
||||||
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
|
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
|
||||||
|
|
||||||
# If it was deleted or moved out of a draft, noop out
|
# If it was deleted or moved out of a draft, noop out
|
||||||
if not proposal or proposal.status != ProposalStatus.DRAFT:
|
if not proposal or proposal.status != ProposalStatus.DRAFT:
|
||||||
return
|
return
|
||||||
|
|
||||||
# If any of the proposal fields are filled, noop out
|
# If proposal content deviates from the default, noop out
|
||||||
if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0":
|
if proposal.content != default_proposal_content():
|
||||||
|
return
|
||||||
|
|
||||||
|
# If any of the remaining proposal fields are filled, noop out
|
||||||
|
if proposal.title or proposal.brief or proposal.category or proposal.target != "0":
|
||||||
return
|
return
|
||||||
|
|
||||||
if proposal.payout_address or proposal.milestones:
|
if proposal.payout_address or proposal.milestones:
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
<a href="{{ args.ccr_url }}" target="_blank">
|
||||||
|
{{ args.ccr.title }}</a
|
||||||
|
>
|
||||||
|
is awaiting approval. As an admin you can help out by reviewing it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.ccr_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 {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
Review Request
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{ args.ccr.title }} is awaiting approval. As an admin you can help out by reviewing it.
|
||||||
|
|
||||||
|
Visit the request and review: {{ args.ccr_url }}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
|
||||||
|
</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 %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
|
||||||
|
|
||||||
|
{% 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 request has changes requested. 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 request has changes requested. 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.
|
|
@ -1,7 +1,5 @@
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
Congratulations on your approval! We look forward to seeing the support your
|
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||||
proposal receives. To get your campaign started, click below and follow the
|
|
||||||
instructions to publish your proposal.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
|
@ -13,22 +11,3 @@
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% 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="{{ UI.PRIMARY }}">
|
|
||||||
<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 {{ UI.PRIMARY }}; display: inline-block;"
|
|
||||||
>
|
|
||||||
Publish your proposal
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
|
@ -1,6 +1,5 @@
|
||||||
Congratulations on your approval! We look forward to seeing the support your
|
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||||
proposal receives. To start the fundraising (and the clock) go to the URL
|
|
||||||
below and publish your proposal.
|
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
A note from the admin team was attached to your approval:
|
A note from the admin team was attached to your approval:
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<p style="margin: 0 0 20px;">
|
<p style="margin: 0 0 20px;">
|
||||||
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
||||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
has been canceled.
|
||||||
shortly.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
||||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
has been canceled.
|
||||||
shortly.
|
|
||||||
|
|
||||||
If you have any further questions, please contact support for more information:
|
If you have any further questions, please contact support for more information:
|
||||||
{{ args.support_url }}
|
{{ args.support_url }}
|
|
@ -37,6 +37,7 @@ def set_admin(identity):
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
user.set_admin(True)
|
user.set_admin(True)
|
||||||
|
user.email_verification.has_verified = True
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
|
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
|
||||||
|
|
|
@ -3,6 +3,7 @@ from flask_security.core import current_user
|
||||||
from flask_security.utils import hash_password, verify_and_update_password, login_user
|
from flask_security.utils import hash_password, verify_and_update_password, login_user
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.email.models import EmailVerification, EmailRecovery
|
from grant.email.models import EmailVerification, EmailRecovery
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.email.subscription_settings import (
|
from grant.email.subscription_settings import (
|
||||||
|
@ -125,6 +126,7 @@ class User(db.Model, UserMixin):
|
||||||
# relations
|
# relations
|
||||||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
||||||
comments = db.relationship(Comment, backref="user", lazy=True)
|
comments = db.relationship(Comment, backref="user", lazy=True)
|
||||||
|
ccrs = db.relationship(CCR, back_populates="author", lazy=True, cascade="all, delete-orphan")
|
||||||
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
||||||
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
||||||
lazy=True, cascade="all, delete-orphan")
|
lazy=True, cascade="all, delete-orphan")
|
||||||
|
@ -148,7 +150,6 @@ class User(db.Model, UserMixin):
|
||||||
"RFP", secondary="rfp_liker", back_populates="likes"
|
"RFP", secondary="rfp_liker", back_populates="likes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
email_address,
|
email_address,
|
||||||
|
|
|
@ -8,17 +8,18 @@ from webargs import validate
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
from grant.comment.models import Comment, user_comments_schema
|
from grant.comment.models import Comment, user_comments_schema
|
||||||
from grant.email.models import EmailRecovery
|
from grant.email.models import EmailRecovery
|
||||||
|
from grant.ccr.models import CCR, ccrs_schema
|
||||||
from grant.extensions import limiter
|
from grant.extensions import limiter
|
||||||
from grant.parser import query, body
|
from grant.parser import query, body
|
||||||
from grant.proposal.models import (
|
from grant.proposal.models import (
|
||||||
Proposal,
|
Proposal,
|
||||||
ProposalTeamInvite,
|
ProposalTeamInvite,
|
||||||
invites_with_proposal_schema,
|
invites_with_proposal_schema,
|
||||||
ProposalContribution,
|
|
||||||
user_proposal_contributions_schema,
|
user_proposal_contributions_schema,
|
||||||
user_proposals_schema,
|
user_proposals_schema,
|
||||||
user_proposal_arbiters_schema
|
user_proposal_arbiters_schema
|
||||||
)
|
)
|
||||||
|
from grant.proposal.models import ProposalContribution
|
||||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ContributionStatus
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.requests import validate_blockchain_get
|
from grant.utils.requests import validate_blockchain_get
|
||||||
|
@ -50,14 +51,20 @@ def get_me():
|
||||||
"withComments": fields.Bool(required=False, missing=None),
|
"withComments": fields.Bool(required=False, missing=None),
|
||||||
"withFunded": fields.Bool(required=False, missing=None),
|
"withFunded": fields.Bool(required=False, missing=None),
|
||||||
"withPending": fields.Bool(required=False, missing=None),
|
"withPending": fields.Bool(required=False, missing=None),
|
||||||
"withArbitrated": fields.Bool(required=False, missing=None)
|
"withArbitrated": fields.Bool(required=False, missing=None),
|
||||||
|
"withRequests": fields.Bool(required=False, missing=None)
|
||||||
|
|
||||||
})
|
})
|
||||||
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated):
|
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests):
|
||||||
user = User.get_by_id(user_id)
|
user = User.get_by_id(user_id)
|
||||||
if user:
|
if user:
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
authed_user = auth.get_authed_user()
|
authed_user = auth.get_authed_user()
|
||||||
is_self = authed_user and authed_user.id == user.id
|
is_self = authed_user and authed_user.id == user.id
|
||||||
|
if with_requests:
|
||||||
|
requests = CCR.get_by_user(user)
|
||||||
|
requests_dump = ccrs_schema.dump(requests)
|
||||||
|
result["requests"] = requests_dump
|
||||||
if with_proposals:
|
if with_proposals:
|
||||||
proposals = Proposal.get_by_user(user)
|
proposals = Proposal.get_by_user(user)
|
||||||
proposals_dump = user_proposals_schema.dump(proposals)
|
proposals_dump = user_proposals_schema.dump(proposals)
|
||||||
|
@ -75,14 +82,22 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
||||||
comments_dump = user_comments_schema.dump(comments)
|
comments_dump = user_comments_schema.dump(comments)
|
||||||
result["comments"] = comments_dump
|
result["comments"] = comments_dump
|
||||||
if with_pending and is_self:
|
if with_pending and is_self:
|
||||||
pending = Proposal.get_by_user(user, [
|
pending_proposals = Proposal.get_by_user(user, [
|
||||||
ProposalStatus.STAKING,
|
ProposalStatus.STAKING,
|
||||||
ProposalStatus.PENDING,
|
ProposalStatus.PENDING,
|
||||||
ProposalStatus.APPROVED,
|
ProposalStatus.APPROVED,
|
||||||
ProposalStatus.REJECTED,
|
ProposalStatus.REJECTED,
|
||||||
])
|
])
|
||||||
pending_dump = user_proposals_schema.dump(pending)
|
pending_proposals_dump = user_proposals_schema.dump(pending_proposals)
|
||||||
result["pendingProposals"] = pending_dump
|
result["pendingProposals"] = pending_proposals_dump
|
||||||
|
pending_ccrs = CCR.get_by_user(user, [
|
||||||
|
ProposalStatus.STAKING,
|
||||||
|
ProposalStatus.PENDING,
|
||||||
|
ProposalStatus.APPROVED,
|
||||||
|
ProposalStatus.REJECTED,
|
||||||
|
])
|
||||||
|
pending_ccrs_dump = ccrs_schema.dump(pending_ccrs)
|
||||||
|
result["pendingRequests"] = pending_ccrs_dump
|
||||||
if with_arbitrated and is_self:
|
if with_arbitrated and is_self:
|
||||||
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
from functools import wraps
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from flask import request, g, jsonify, session, current_app
|
from flask import request, g, jsonify, session, current_app
|
||||||
from flask_security.core import current_user
|
from flask_security.core import current_user
|
||||||
from flask_security.utils import logout_user
|
from flask_security.utils import logout_user
|
||||||
|
|
||||||
from grant.settings import BLOCKCHAIN_API_SECRET
|
from grant.settings import BLOCKCHAIN_API_SECRET
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,7 +27,7 @@ def throw_on_banned(user):
|
||||||
raise AuthException("You are banned")
|
raise AuthException("You are banned")
|
||||||
|
|
||||||
|
|
||||||
def is_auth_fresh(minutes: int=20):
|
def is_auth_fresh(minutes: int = 20):
|
||||||
if 'last_login_time' in session:
|
if 'last_login_time' in session:
|
||||||
last = session['last_login_time']
|
last = session['last_login_time']
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
@ -135,6 +136,28 @@ def requires_team_member_auth(f):
|
||||||
return requires_email_verified_auth(decorated)
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
|
def requires_ccr_owner_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.ccr.models import CCR
|
||||||
|
|
||||||
|
ccr_id = kwargs["ccr_id"]
|
||||||
|
if not ccr_id:
|
||||||
|
return jsonify(message="Decorator requires_ccr_owner_auth requires path variable <ccr_id>"), 500
|
||||||
|
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if not ccr:
|
||||||
|
return jsonify(message="No CCR exists with id {}".format(ccr_id)), 404
|
||||||
|
|
||||||
|
if g.current_user.id != ccr.author.id:
|
||||||
|
return jsonify(message="You are not authorized to modify this CCR"), 403
|
||||||
|
|
||||||
|
g.current_ccr = ccr
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
def requires_arbiter_auth(f):
|
def requires_arbiter_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
|
|
@ -11,10 +11,22 @@ class CustomEnum():
|
||||||
not attr.startswith('__')]
|
not attr.startswith('__')]
|
||||||
|
|
||||||
|
|
||||||
class ProposalStatusEnum(CustomEnum):
|
class CCRStatusEnum(CustomEnum):
|
||||||
DRAFT = 'DRAFT'
|
DRAFT = 'DRAFT'
|
||||||
PENDING = 'PENDING'
|
PENDING = 'PENDING'
|
||||||
|
APPROVED = 'APPROVED'
|
||||||
|
REJECTED = 'REJECTED'
|
||||||
|
LIVE = 'LIVE'
|
||||||
|
DELETED = 'DELETED'
|
||||||
|
|
||||||
|
|
||||||
|
CCRStatus = CCRStatusEnum()
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalStatusEnum(CustomEnum):
|
||||||
|
DRAFT = 'DRAFT'
|
||||||
STAKING = 'STAKING'
|
STAKING = 'STAKING'
|
||||||
|
PENDING = 'PENDING'
|
||||||
APPROVED = 'APPROVED'
|
APPROVED = 'APPROVED'
|
||||||
REJECTED = 'REJECTED'
|
REJECTED = 'REJECTED'
|
||||||
LIVE = 'LIVE'
|
LIVE = 'LIVE'
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import abc
|
import abc
|
||||||
from sqlalchemy import or_, and_
|
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.comment.models import Comment, comments_schema
|
from grant.comment.models import Comment, comments_schema
|
||||||
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
|
||||||
from grant.comment.models import Comment, comments_schema
|
|
||||||
from grant.user.models import User, UserSettings, users_schema
|
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
|
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
||||||
|
from grant.user.models import User, UserSettings, users_schema
|
||||||
|
from .enums import CCRStatus, ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, \
|
||||||
|
MilestoneStage
|
||||||
|
|
||||||
|
|
||||||
def extract_filters(sw, strings):
|
def extract_filters(sw, strings):
|
||||||
|
@ -70,11 +72,11 @@ class ProposalPagination(Pagination):
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema,
|
schema: ma.Schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='PUBLISHED:DESC',
|
sort: str = 'PUBLISHED:DESC',
|
||||||
):
|
):
|
||||||
query = query or Proposal.query
|
query = query or Proposal.query
|
||||||
sort = sort or 'PUBLISHED:DESC'
|
sort = sort or 'PUBLISHED:DESC'
|
||||||
|
@ -143,12 +145,12 @@ class ContributionPagination(Pagination):
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=proposal_contributions_schema,
|
schema: ma.Schema = proposal_contributions_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='PUBLISHED:DESC',
|
sort: str = 'PUBLISHED:DESC',
|
||||||
):
|
):
|
||||||
query = query or ProposalContribution.query
|
query = query or ProposalContribution.query
|
||||||
sort = sort or 'CREATED:DESC'
|
sort = sort or 'CREATED:DESC'
|
||||||
|
@ -223,12 +225,12 @@ class UserPagination(Pagination):
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=users_schema,
|
schema: ma.Schema = users_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='EMAIL:DESC',
|
sort: str = 'EMAIL:DESC',
|
||||||
):
|
):
|
||||||
query = query or Proposal.query
|
query = query or Proposal.query
|
||||||
sort = sort or 'EMAIL:DESC'
|
sort = sort or 'EMAIL:DESC'
|
||||||
|
@ -279,12 +281,12 @@ class CommentPagination(Pagination):
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=comments_schema,
|
schema: ma.Schema = comments_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='CREATED:DESC',
|
sort: str = 'CREATED:DESC',
|
||||||
):
|
):
|
||||||
query = query or Comment.query
|
query = query or Comment.query
|
||||||
sort = sort or 'CREATED:DESC'
|
sort = sort or 'CREATED:DESC'
|
||||||
|
@ -320,7 +322,58 @@ class CommentPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CCRPagination(Pagination):
|
||||||
|
def __init__(self):
|
||||||
|
self.FILTERS = [f'STATUS_{s}' for s in CCRStatus.list()]
|
||||||
|
self.PAGE_SIZE = 9
|
||||||
|
self.SORT_MAP = {
|
||||||
|
'CREATED:DESC': CCR.date_created.desc(),
|
||||||
|
'CREATED:ASC': CCR.date_created
|
||||||
|
}
|
||||||
|
|
||||||
|
def paginate(
|
||||||
|
self,
|
||||||
|
schema: ma.Schema,
|
||||||
|
query: db.Query = None,
|
||||||
|
page: int = 1,
|
||||||
|
filters: list = None,
|
||||||
|
search: str = None,
|
||||||
|
sort: str = 'PUBLISHED:DESC',
|
||||||
|
):
|
||||||
|
query = query or CCR.query
|
||||||
|
sort = sort or 'PUBLISHED:DESC'
|
||||||
|
|
||||||
|
# FILTER
|
||||||
|
if filters:
|
||||||
|
self.validate_filters(filters)
|
||||||
|
status_filters = extract_filters('STATUS_', filters)
|
||||||
|
|
||||||
|
if status_filters:
|
||||||
|
query = query.filter(CCR.status.in_(status_filters))
|
||||||
|
|
||||||
|
# SORT (see self.SORT_MAP)
|
||||||
|
if sort:
|
||||||
|
self.validate_sort(sort)
|
||||||
|
query = query.order_by(self.SORT_MAP[sort])
|
||||||
|
|
||||||
|
# SEARCH
|
||||||
|
if search:
|
||||||
|
query = query.filter(CCR.title.ilike(f'%{search}%'))
|
||||||
|
|
||||||
|
res = query.paginate(page, self.PAGE_SIZE, False)
|
||||||
|
return {
|
||||||
|
'page': res.page,
|
||||||
|
'total': res.total,
|
||||||
|
'page_size': self.PAGE_SIZE,
|
||||||
|
'items': schema.dump(res.items),
|
||||||
|
'filters': filters,
|
||||||
|
'search': search,
|
||||||
|
'sort': sort
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# expose pagination methods here
|
# expose pagination methods here
|
||||||
|
ccr = CCRPagination().paginate
|
||||||
proposal = ProposalPagination().paginate
|
proposal = ProposalPagination().paginate
|
||||||
contribution = ContributionPagination().paginate
|
contribution = ContributionPagination().paginate
|
||||||
comment = CommentPagination().paginate
|
comment = CommentPagination().paginate
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 2721189b0c8f
|
||||||
|
Revises: 1e1460456ce4
|
||||||
|
Create Date: 2019-11-27 19:59:20.246227
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2721189b0c8f'
|
||||||
|
down_revision = '1e1460456ce4'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('ccr',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date_created', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('brief', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('content', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('target', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('reject_reason', sa.String(), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('rfp_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('ccr')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,40 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from grant.ccr.models import CCR
|
||||||
|
from ..config import BaseCCRCreatorConfig
|
||||||
|
from ..test_data import test_ccr
|
||||||
|
|
||||||
|
|
||||||
|
class TestCCRApi(BaseCCRCreatorConfig):
|
||||||
|
|
||||||
|
def test_create_new_draft(self):
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/ccrs/drafts",
|
||||||
|
)
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
|
||||||
|
ccr_db = CCR.query.filter_by(id=resp.json['ccrId'])
|
||||||
|
self.assertIsNotNone(ccr_db)
|
||||||
|
|
||||||
|
def test_no_auth_create_new_draft(self):
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/ccrs/drafts"
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
def test_update_CCR_draft(self):
|
||||||
|
new_title = "Updated!"
|
||||||
|
new_ccr = test_ccr.copy()
|
||||||
|
new_ccr["title"] = new_title
|
||||||
|
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/ccrs/{}".format(self.ccr.id),
|
||||||
|
data=json.dumps(new_ccr),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
print(resp)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["title"], new_title)
|
||||||
|
self.assertEqual(self.ccr.title, new_title)
|
|
@ -6,6 +6,7 @@ from flask_testing import TestCase
|
||||||
from mock import patch
|
from mock import patch
|
||||||
|
|
||||||
from grant.app import create_app
|
from grant.app import create_app
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.extensions import limiter
|
from grant.extensions import limiter
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.proposal.models import Proposal
|
from grant.proposal.models import Proposal
|
||||||
|
@ -13,7 +14,7 @@ from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||||
from grant.task.jobs import ProposalReminder
|
from grant.task.jobs import ProposalReminder
|
||||||
from grant.user.models import User, SocialMedia, db, Avatar
|
from grant.user.models import User, SocialMedia, db, Avatar
|
||||||
from grant.utils.enums import ProposalStatus
|
from grant.utils.enums import ProposalStatus
|
||||||
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests
|
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests, test_ccr
|
||||||
|
|
||||||
|
|
||||||
class BaseTestConfig(TestCase):
|
class BaseTestConfig(TestCase):
|
||||||
|
@ -184,3 +185,23 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
self.proposal.set_pending_when_ready()
|
self.proposal.set_pending_when_ready()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCCRCreatorConfig(BaseUserConfig):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._ccr = CCR.create(
|
||||||
|
status=ProposalStatus.DRAFT,
|
||||||
|
title=test_ccr["title"],
|
||||||
|
content=test_ccr["content"],
|
||||||
|
brief=test_ccr["brief"],
|
||||||
|
target=test_ccr["target"],
|
||||||
|
user_id=self.user.id
|
||||||
|
)
|
||||||
|
self._ccr_id = self._ccr.id
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# always return fresh (avoid detached instance issues)
|
||||||
|
@property
|
||||||
|
def ccr(self):
|
||||||
|
return CCR.query.filter_by(id=self._ccr_id).first()
|
||||||
|
|
|
@ -126,7 +126,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
self.login_default_user()
|
self.login_default_user()
|
||||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json['status'], ProposalStatus.STAKING)
|
self.assertEqual(resp.json['status'], ProposalStatus.PENDING)
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_no_auth_proposal_draft_submit_for_approval(self, mock_get):
|
def test_no_auth_proposal_draft_submit_for_approval(self, mock_get):
|
||||||
|
@ -152,60 +152,6 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||||
self.assert400(resp)
|
self.assert400(resp)
|
||||||
|
|
||||||
# /stake
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_proposal_stake(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
self.proposal.status = ProposalStatus.STAKING
|
|
||||||
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
|
|
||||||
print(resp)
|
|
||||||
self.assert200(resp)
|
|
||||||
self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT.normalize()))
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_proposal_stake_no_auth(self, mock_get):
|
|
||||||
self.proposal.status = ProposalStatus.STAKING
|
|
||||||
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
|
|
||||||
print(resp)
|
|
||||||
self.assert401(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_proposal_stake_bad_status(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
self.proposal.status = ProposalStatus.PENDING # should be staking
|
|
||||||
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
|
|
||||||
print(resp)
|
|
||||||
self.assert400(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_proposal_stake_funded(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
# fake stake contribution with confirmation
|
|
||||||
self.stake_proposal()
|
|
||||||
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
|
|
||||||
print(resp)
|
|
||||||
self.assert400(resp)
|
|
||||||
|
|
||||||
# /publish
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_publish_proposal_approved(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
# proposal needs to be APPROVED
|
|
||||||
self.proposal.status = ProposalStatus.APPROVED
|
|
||||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
|
||||||
self.assert200(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_no_auth_publish_proposal(self, mock_get):
|
|
||||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
|
||||||
self.assert401(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_invalid_proposal_publish_proposal(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
resp = self.app.put("/api/v1/proposals/12345/publish")
|
|
||||||
self.assert404(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_invalid_status_proposal_publish_proposal(self, mock_get):
|
def test_invalid_status_proposal_publish_proposal(self, mock_get):
|
||||||
self.login_default_user()
|
self.login_default_user()
|
||||||
|
@ -223,19 +169,18 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
# /
|
# /
|
||||||
def test_get_proposals(self):
|
def test_get_proposals(self):
|
||||||
self.test_publish_proposal_approved()
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
resp = self.app.get("/api/v1/proposals/")
|
resp = self.app.get("/api/v1/proposals/")
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
|
|
||||||
def test_get_proposals_does_not_include_team_member_email_addresses(self):
|
def test_get_proposals_does_not_include_team_member_email_addresses(self):
|
||||||
self.test_publish_proposal_approved()
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
resp = self.app.get("/api/v1/proposals/")
|
resp = self.app.get("/api/v1/proposals/")
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
for each_proposal in resp.json['items']:
|
for each_proposal in resp.json['items']:
|
||||||
for team_member in each_proposal["team"]:
|
for team_member in each_proposal["team"]:
|
||||||
self.assertIsNone(team_member.get('email_address'))
|
self.assertIsNone(team_member.get('email_address'))
|
||||||
|
|
||||||
|
|
||||||
def test_follow_proposal(self):
|
def test_follow_proposal(self):
|
||||||
# not logged in
|
# not logged in
|
||||||
resp = self.app.put(
|
resp = self.app.put(
|
||||||
|
|
|
@ -49,6 +49,14 @@ test_proposal = {
|
||||||
"deadlineDuration": 100
|
"deadlineDuration": 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test_ccr = {
|
||||||
|
"user_id": test_user,
|
||||||
|
"content": "## My Proposal",
|
||||||
|
"title": "Give Me Money",
|
||||||
|
"brief": "$$$",
|
||||||
|
"target": "123.456",
|
||||||
|
}
|
||||||
|
|
||||||
test_comment = {
|
test_comment = {
|
||||||
"comment": "Test comment"
|
"comment": "Test comment"
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,4 +26,4 @@ DISABLE_SSL=true
|
||||||
# TESTNET=true
|
# TESTNET=true
|
||||||
|
|
||||||
# Maximum amount for a proposal target, keep in sync with backend .env
|
# Maximum amount for a proposal target, keep in sync with backend .env
|
||||||
PROPOSAL_TARGET_MAX=500000
|
PROPOSAL_TARGET_MAX=999999
|
||||||
|
|
|
@ -20,9 +20,12 @@ import 'styles/style.less';
|
||||||
const opts = { fallback: <Loader size="large" /> };
|
const opts = { fallback: <Loader size="large" /> };
|
||||||
const Home = loadable(() => import('pages/index'), opts);
|
const Home = loadable(() => import('pages/index'), opts);
|
||||||
const Create = loadable(() => import('pages/create'), opts);
|
const Create = loadable(() => import('pages/create'), opts);
|
||||||
|
const CreateRequest = loadable(() => import('pages/create-request'), opts);
|
||||||
|
const RequestEdit = loadable(() => import('pages/request-edit'), opts);
|
||||||
const ProposalEdit = loadable(() => import('pages/proposal-edit'), opts);
|
const ProposalEdit = loadable(() => import('pages/proposal-edit'), opts);
|
||||||
const Proposals = loadable(() => import('pages/proposals'), opts);
|
const Proposals = loadable(() => import('pages/proposals'), opts);
|
||||||
const Proposal = loadable(() => import('pages/proposal'), opts);
|
const Proposal = loadable(() => import('pages/proposal'), opts);
|
||||||
|
const Ccr = loadable(() => import('pages/ccr'), opts);
|
||||||
const Auth = loadable(() => import('pages/auth'));
|
const Auth = loadable(() => import('pages/auth'));
|
||||||
const SignOut = loadable(() => import('pages/sign-out'), opts);
|
const SignOut = loadable(() => import('pages/sign-out'), opts);
|
||||||
const Profile = loadable(() => import('pages/profile'), opts);
|
const Profile = loadable(() => import('pages/profile'), opts);
|
||||||
|
@ -63,6 +66,43 @@ const routeConfigs: RouteConfig[] = [
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Create request
|
||||||
|
route: {
|
||||||
|
path: '/create-request',
|
||||||
|
component: CreateRequest,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Create a Request',
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Request edit page
|
||||||
|
route: {
|
||||||
|
path: '/ccrs/:id/edit',
|
||||||
|
component: RequestEdit,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Edit Request',
|
||||||
|
isFullScreen: true,
|
||||||
|
hideFooter: true,
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Request view page
|
||||||
|
route: {
|
||||||
|
path: '/ccrs/:id',
|
||||||
|
component: Ccr,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'View Request',
|
||||||
|
isFullScreen: true,
|
||||||
|
hideFooter: true,
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Create proposal
|
// Create proposal
|
||||||
route: {
|
route: {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
ProposalPageParams,
|
ProposalPageParams,
|
||||||
PageParams,
|
PageParams,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
|
CCR,
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import {
|
import {
|
||||||
formatUserForPost,
|
formatUserForPost,
|
||||||
|
@ -23,6 +24,7 @@ import {
|
||||||
formatProposalPageParamsForGet,
|
formatProposalPageParamsForGet,
|
||||||
formatProposalPageFromGet,
|
formatProposalPageFromGet,
|
||||||
} from 'utils/api';
|
} from 'utils/api';
|
||||||
|
import { CCRDraft } from 'types/ccr';
|
||||||
|
|
||||||
export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> {
|
export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> {
|
||||||
let serverParams;
|
let serverParams;
|
||||||
|
@ -88,6 +90,7 @@ export function getUser(address: string): Promise<{ data: User }> {
|
||||||
return axios
|
return axios
|
||||||
.get(`/api/v1/users/${address}`, {
|
.get(`/api/v1/users/${address}`, {
|
||||||
params: {
|
params: {
|
||||||
|
withRequests: true,
|
||||||
withProposals: true,
|
withProposals: true,
|
||||||
withComments: true,
|
withComments: true,
|
||||||
withFunded: true,
|
withFunded: true,
|
||||||
|
@ -201,16 +204,6 @@ export function verifySocial(service: SOCIAL_SERVICE, code: string): Promise<any
|
||||||
return axios.post(`/api/v1/users/social/${service}/verify`, { code });
|
return axios.post(`/api/v1/users/social/${service}/verify`, { code });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCrowdFundFactoryJSON(): Promise<any> {
|
|
||||||
const res = await axios.get(process.env.CROWD_FUND_FACTORY_URL as string);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchCrowdFundJSON(): Promise<any> {
|
|
||||||
const res = await axios.get(process.env.CROWD_FUND_URL as string);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProposalTipJarArgs {
|
interface ProposalTipJarArgs {
|
||||||
address?: string;
|
address?: string;
|
||||||
viewKey?: string;
|
viewKey?: string;
|
||||||
|
@ -225,7 +218,6 @@ export function updateProposalTipJarSettings(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function postProposalUpdate(
|
export function postProposalUpdate(
|
||||||
proposalId: number,
|
proposalId: number,
|
||||||
title: string,
|
title: string,
|
||||||
|
@ -379,12 +371,6 @@ export function getProposalContribution(
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
|
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProposalStakingContribution(
|
|
||||||
proposalId: number,
|
|
||||||
): Promise<{ data: ContributionWithAddressesAndUser }> {
|
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/stake`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRFPs(): Promise<{ data: RFP[] }> {
|
export function getRFPs(): Promise<{ data: RFP[] }> {
|
||||||
return axios.get('/api/v1/rfps/').then(res => {
|
return axios.get('/api/v1/rfps/').then(res => {
|
||||||
res.data = res.data.map(formatRFPFromGet);
|
res.data = res.data.map(formatRFPFromGet);
|
||||||
|
@ -417,3 +403,34 @@ export function getHomeLatest(): Promise<{
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CCRs
|
||||||
|
export function getCCRDrafts(): Promise<{ data: CCRDraft[] }> {
|
||||||
|
return axios.get('/api/v1/ccrs/drafts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postCCRDraft(): Promise<{ data: CCRDraft }> {
|
||||||
|
return axios.post('/api/v1/ccrs/drafts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCCR(ccrId: number): Promise<any> {
|
||||||
|
return axios.delete(`/api/v1/ccrs/${ccrId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putCCR(ccr: CCRDraft): Promise<{ data: CCRDraft }> {
|
||||||
|
// Exclude some keys
|
||||||
|
const { ccrId, author, dateCreated, status, ...rest } = ccr;
|
||||||
|
return axios.put(`/api/v1/ccrs/${ccrId}`, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCCR(ccrId: number | string): Promise<{ data: CCR }> {
|
||||||
|
return axios.get(`/api/v1/ccrs/${ccrId}`).then(res => {
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCCRSubmitForApproval(ccr: CCRDraft): Promise<{ data: CCR }> {
|
||||||
|
return axios.put(`/api/v1/ccrs/${ccr.ccrId}/submit_for_approval`).then(res => {
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,177 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Divider, List, message, Popconfirm, Spin } from 'antd';
|
||||||
|
import Placeholder from 'components/Placeholder';
|
||||||
|
import { getIsVerified } from 'modules/auth/selectors';
|
||||||
|
import Loader from 'components/Loader';
|
||||||
|
import { CCRDraft, CCRSTATUS } from 'types';
|
||||||
|
import {
|
||||||
|
createCCRDraft,
|
||||||
|
deleteCCRDraft,
|
||||||
|
fetchAndCreateCCRDrafts,
|
||||||
|
} from 'modules/ccr/actions';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import './style.less';
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
drafts: AppState['ccr']['drafts'];
|
||||||
|
isFetchingDrafts: AppState['ccr']['isFetchingDrafts'];
|
||||||
|
fetchDraftsError: AppState['ccr']['fetchDraftsError'];
|
||||||
|
isCreatingDraft: AppState['ccr']['isCreatingDraft'];
|
||||||
|
createDraftError: AppState['ccr']['createDraftError'];
|
||||||
|
isDeletingDraft: AppState['ccr']['isDeletingDraft'];
|
||||||
|
deleteDraftError: AppState['ccr']['deleteDraftError'];
|
||||||
|
isVerified: ReturnType<typeof getIsVerified>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
createCCRDraft: typeof createCCRDraft;
|
||||||
|
deleteCCRDraft: typeof deleteCCRDraft;
|
||||||
|
fetchAndCreateCCRDrafts: typeof fetchAndCreateCCRDrafts;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
createIfNone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & DispatchProps & OwnProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
deletingId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRDraftList extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
deletingId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchAndCreateCCRDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
const { isDeletingDraft, deleteDraftError, createDraftError } = this.props;
|
||||||
|
if (prevProps.isDeletingDraft && !isDeletingDraft) {
|
||||||
|
this.setState({ deletingId: null });
|
||||||
|
}
|
||||||
|
if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) {
|
||||||
|
message.error(deleteDraftError, 3);
|
||||||
|
}
|
||||||
|
if (createDraftError && prevProps.createDraftError !== createDraftError) {
|
||||||
|
message.error(createDraftError, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { drafts, isCreatingDraft, isFetchingDrafts, isVerified } = this.props;
|
||||||
|
const { deletingId } = this.state;
|
||||||
|
|
||||||
|
if (!isVerified) {
|
||||||
|
return (
|
||||||
|
<div className="CreateRequestDraftList">
|
||||||
|
<Placeholder
|
||||||
|
title="Your email is not verified"
|
||||||
|
subtitle="Please confirm your email before creating a request."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drafts || isCreatingDraft) {
|
||||||
|
return <Loader size="large" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftsEl;
|
||||||
|
if (drafts.length) {
|
||||||
|
draftsEl = (
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={drafts}
|
||||||
|
loading={isFetchingDrafts}
|
||||||
|
renderItem={(d: CCRDraft) => {
|
||||||
|
const actions = [
|
||||||
|
<Link key="edit" to={`/ccrs/${d.ccrId}/edit`}>
|
||||||
|
Edit
|
||||||
|
</Link>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="Are you sure?"
|
||||||
|
onConfirm={() => this.deleteDraft(d.ccrId)}
|
||||||
|
>
|
||||||
|
<a>Delete</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Spin tip="deleting..." spinning={deletingId === d.ccrId}>
|
||||||
|
<List.Item actions={actions}>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{d.title || <em>Untitled Request</em>}
|
||||||
|
{d.status === CCRSTATUS.REJECTED && <em> (rejected)</em>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={d.brief || <em>No description</em>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
draftsEl = (
|
||||||
|
<Placeholder
|
||||||
|
title="You have no drafts"
|
||||||
|
subtitle="Why not make one now? Click below to start."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CreateRequestDraftList">
|
||||||
|
<h2 className="CreateRequestDraftList-title">Your Request Drafts</h2>
|
||||||
|
{draftsEl}
|
||||||
|
<Divider>or</Divider>
|
||||||
|
<Button
|
||||||
|
className="CreateRequestDraftList-create"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={() => this.createDraft()}
|
||||||
|
loading={isCreatingDraft}
|
||||||
|
>
|
||||||
|
Create a new Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDraft = () => {
|
||||||
|
this.props.createCCRDraft();
|
||||||
|
};
|
||||||
|
|
||||||
|
private deleteDraft = (ccrId: number) => {
|
||||||
|
this.props.deleteCCRDraft(ccrId);
|
||||||
|
this.setState({ deletingId: ccrId });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
state => ({
|
||||||
|
drafts: state.ccr.drafts,
|
||||||
|
isFetchingDrafts: state.ccr.isFetchingDrafts,
|
||||||
|
fetchDraftsError: state.ccr.fetchDraftsError,
|
||||||
|
isCreatingDraft: state.ccr.isCreatingDraft,
|
||||||
|
createDraftError: state.ccr.createDraftError,
|
||||||
|
isDeletingDraft: state.ccr.isDeletingDraft,
|
||||||
|
deleteDraftError: state.ccr.deleteDraftError,
|
||||||
|
isVerified: getIsVerified(state),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
createCCRDraft,
|
||||||
|
deleteCCRDraft,
|
||||||
|
fetchAndCreateCCRDrafts,
|
||||||
|
},
|
||||||
|
)(CCRDraftList);
|
|
@ -0,0 +1,26 @@
|
||||||
|
.CreateRequestDraftList {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-create {
|
||||||
|
display: block;
|
||||||
|
max-width: 280px;
|
||||||
|
height: 3.2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-divider {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Form, Input } from 'antd';
|
||||||
|
import { CCRDraft } from 'types';
|
||||||
|
import { getCCRErrors } from 'modules/ccr/utils';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
ccrId: number;
|
||||||
|
initialState?: Partial<State>;
|
||||||
|
|
||||||
|
updateForm(form: Partial<CCRDraft>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps;
|
||||||
|
|
||||||
|
interface State extends Partial<CCRDraft> {
|
||||||
|
title: string;
|
||||||
|
brief: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRFlowBasics extends React.Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
title: '',
|
||||||
|
brief: '',
|
||||||
|
target: '',
|
||||||
|
...(props.initialState || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { title, brief, target } = this.state;
|
||||||
|
const errors = getCCRErrors(this.state, true);
|
||||||
|
|
||||||
|
// Don't show target error at zero since it defaults to that
|
||||||
|
// Error just shows up at the end to prevent submission
|
||||||
|
if (target === '0') {
|
||||||
|
errors.target = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||||
|
<Form.Item
|
||||||
|
label="Title"
|
||||||
|
validateStatus={errors.title ? 'error' : undefined}
|
||||||
|
help={errors.title}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
name="title"
|
||||||
|
placeholder="Short and sweet"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Brief"
|
||||||
|
validateStatus={errors.brief ? 'error' : undefined}
|
||||||
|
help={errors.brief}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
name="brief"
|
||||||
|
placeholder="An elevator-pitch version of your request, max 140 chars"
|
||||||
|
value={brief}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Target amount"
|
||||||
|
validateStatus={errors.target ? 'error' : undefined}
|
||||||
|
help={
|
||||||
|
errors.target ||
|
||||||
|
'Accepted proposals will be paid out in ZEC at market price at payout time. Zcash Foundation administrators may opt to adjust this value before approval.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
name="target"
|
||||||
|
placeholder="500"
|
||||||
|
type="number"
|
||||||
|
value={target}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
addonBefore="$"
|
||||||
|
maxLength={16}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInputChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
const { value, name } = event.currentTarget;
|
||||||
|
this.setState({ [name]: value } as any, () => {
|
||||||
|
this.props.updateForm(this.state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CCRFlowBasics;
|
|
@ -0,0 +1,86 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
@small-query: ~'(max-width: 640px)';
|
||||||
|
|
||||||
|
.CCRExplainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&-header {
|
||||||
|
margin: 3rem auto 5rem;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&-subtitle {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-create {
|
||||||
|
display: block;
|
||||||
|
width: 280px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
height: 4.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-actions {
|
||||||
|
margin: 6rem auto;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-items {
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 2rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
margin-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 8rem;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { withNamespaces, WithNamespaces } from 'react-i18next';
|
||||||
|
import SubmitIcon from 'static/images/guide-submit.svg';
|
||||||
|
import ReviewIcon from 'static/images/guide-review.svg';
|
||||||
|
import './CCRExplainer.less';
|
||||||
|
import * as ls from 'local-storage';
|
||||||
|
import { Button, Checkbox, Icon } from 'antd';
|
||||||
|
|
||||||
|
interface CreateProps {
|
||||||
|
startSteps: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = WithNamespaces & CreateProps;
|
||||||
|
|
||||||
|
const CCRExplainer: React.SFC<Props> = ({ startSteps }) => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
'Anyone can create a request for improvements to the Zcash ecosystem. Approved requests are posted publicly to garner interest for proposals.',
|
||||||
|
icon: <SubmitIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"The request is reviewed by the Zcash Foundation. \nYou'll be notified should the Zcash Foundation choose to make your request public.",
|
||||||
|
icon: <ReviewIcon />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CCRExplainer">
|
||||||
|
<div className="CCRExplainer-header">
|
||||||
|
<h2 className="CCRExplainer-header-title">Creating a Request</h2>
|
||||||
|
<div className="CCRExplainer-header-subtitle">
|
||||||
|
We can't wait to get your request! Before starting, here's what you should
|
||||||
|
know...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="CCRExplainer-items">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div className="CCRExplainer-items-item" key={idx}>
|
||||||
|
<div className="CCRExplainer-items-item-icon">{item.icon}</div>
|
||||||
|
<div className="CCRExplainer-items-item-text">{item.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="CCRExplainer-actions">
|
||||||
|
<Checkbox onChange={ev => ls.set<boolean>('noExplainCCR', ev.target.checked)}>
|
||||||
|
Don't show this again
|
||||||
|
</Checkbox>
|
||||||
|
<Button
|
||||||
|
className="CCRExplainer-create"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={() => startSteps()}
|
||||||
|
>
|
||||||
|
Let's do this <Icon type="right-circle-o" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNamespaces()(CCRExplainer);
|
|
@ -0,0 +1,41 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
.CCRFinal {
|
||||||
|
max-width: 550px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 3rem auto;
|
||||||
|
|
||||||
|
&-message {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-size: 3.2rem;
|
||||||
|
}
|
||||||
|
&.is-error .anticon {
|
||||||
|
color: @error-color;
|
||||||
|
}
|
||||||
|
&.is-success .anticon {
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-contribute {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-staked {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Icon } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Loader from 'components/Loader';
|
||||||
|
import { ccrActions } from 'modules/ccr';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import './CCRFinal.less';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
goBack(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
form: AppState['ccr']['form'];
|
||||||
|
submittedCCR: AppState['ccr']['submittedCCR'];
|
||||||
|
submitError: AppState['ccr']['submitError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
submitCCR: typeof ccrActions['submitCCR'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
|
||||||
|
class CCRFinal extends React.Component<Props, {}> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { submittedCCR, submitError, goBack } = this.props;
|
||||||
|
const ready = submittedCCR;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
if (submitError) {
|
||||||
|
content = (
|
||||||
|
<div className="CCRFinal-message is-error">
|
||||||
|
<Icon type="close-circle" />
|
||||||
|
<div className="CCRFinal-message-text">
|
||||||
|
<h3>
|
||||||
|
<b>Something went wrong during creation</b>
|
||||||
|
</h3>
|
||||||
|
<h5>{submitError}</h5>
|
||||||
|
<a onClick={goBack}>Click here</a> to go back to the form and try again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (ready) {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<div className="CCRFinal-message is-success">
|
||||||
|
<Icon type="check-circle" />
|
||||||
|
|
||||||
|
<div className="CCRFinal-message-text">
|
||||||
|
Your request has been submitted! Check your{' '}
|
||||||
|
<Link to={`/profile?tab=pending`}>profile's pending tab</Link> to check its
|
||||||
|
status.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = <Loader size="large" tip="Submitting your request..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="CCRFinal">{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private submit = () => {
|
||||||
|
if (this.props.form) {
|
||||||
|
this.props.submitCCR(this.props.form);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
(state: AppState) => ({
|
||||||
|
form: state.ccr.form,
|
||||||
|
submittedCCR: state.ccr.submittedCCR,
|
||||||
|
submitError: state.ccr.submitError,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
submitCCR: ccrActions.submitCCR,
|
||||||
|
},
|
||||||
|
)(CCRFinal);
|
|
@ -0,0 +1,28 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
.CCRPreview {
|
||||||
|
&-preview {
|
||||||
|
// simulate non-fullscreen template margins
|
||||||
|
margin: @template-space-top @template-space-sides;
|
||||||
|
padding-bottom: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-banner {
|
||||||
|
width: 100vw;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
right: 50%;
|
||||||
|
margin: 0 -50vw 1rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.ant-alert {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-loader {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 14rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert } from 'antd';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Loader from 'components/Loader';
|
||||||
|
import { RFPDetail } from 'components/RFP';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { makeRfpPreviewFromCcrDraft } from 'modules/create/utils';
|
||||||
|
import { CCRDraft, CCR, CCRSTATUS } from 'types';
|
||||||
|
import { getCCR } from 'api/api';
|
||||||
|
import './CCRPreview.less';
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
form: CCRDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & OwnProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
loading: boolean;
|
||||||
|
ccr?: CCR;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRFlowPreview extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
async componentWillMount() {
|
||||||
|
const { id } = this.props;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
this.setState({ loading: true });
|
||||||
|
try {
|
||||||
|
const { data } = await getCCR(id);
|
||||||
|
this.setState({ ccr: data });
|
||||||
|
} catch (e) {
|
||||||
|
this.setState({ error: e.message || e.toString()})
|
||||||
|
}
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { ccr, loading, error } = this.state;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="CCRPreview-loader">
|
||||||
|
<Loader size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="CCRPreview-banner">
|
||||||
|
<Alert type={'error'} message={`An error occurred while fetching request: ${error}`} showIcon={false} banner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { form } = this.props;
|
||||||
|
const previewData = ccr ? ccr : form;
|
||||||
|
const rfp = makeRfpPreviewFromCcrDraft(previewData);
|
||||||
|
|
||||||
|
// BANNER
|
||||||
|
const statusBanner = {
|
||||||
|
[CCRSTATUS.DRAFT]: {
|
||||||
|
blurb: <>This is a preview of your request. It has not yet been published.</>,
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
[CCRSTATUS.PENDING]: {
|
||||||
|
blurb: (
|
||||||
|
<>Your request is being reviewed. You will get an email when it is complete.</>
|
||||||
|
),
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
[CCRSTATUS.APPROVED]: {
|
||||||
|
blurb: (
|
||||||
|
<>
|
||||||
|
Your request has been approved! It will be made live to the community sometime
|
||||||
|
soon.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
type: 'success',
|
||||||
|
},
|
||||||
|
[CCRSTATUS.REJECTED]: {
|
||||||
|
blurb: (
|
||||||
|
<>
|
||||||
|
Your request has changes requested. Visit your profile's pending tab for more
|
||||||
|
information.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
type: 'error',
|
||||||
|
},
|
||||||
|
[CCRSTATUS.LIVE]: {
|
||||||
|
blurb: (
|
||||||
|
<>
|
||||||
|
Your request has been approved and is live! You can find it on the{' '}
|
||||||
|
<Link to="/requests">requests page</Link>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
type: 'success',
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const banner = statusBanner[previewData.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{banner && (
|
||||||
|
<div className="CCRPreview-banner">
|
||||||
|
<Alert type={banner.type} message={banner.blurb} showIcon={false} banner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="CCRPreview-preview">
|
||||||
|
<RFPDetail
|
||||||
|
rfp={rfp}
|
||||||
|
rfpId={0}
|
||||||
|
isFetchingRfps={false}
|
||||||
|
fetchRfpsError={null}
|
||||||
|
fetchRfp={(() => null) as any}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<StateProps, {}, {}, AppState>(state => ({
|
||||||
|
form: state.ccr.form as CCRDraft,
|
||||||
|
}))(CCRFlowPreview);
|
|
@ -0,0 +1,60 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
.CCRReview {
|
||||||
|
&-section {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CCRReviewField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
width: 220px;
|
||||||
|
padding: 0 1.5rem 1rem 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
&-error {
|
||||||
|
color: @error-color;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
padding: 0 0 1rem 1.5rem;
|
||||||
|
border-left: 1px solid #ddd;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-empty {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
letter-spacing: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-edit {
|
||||||
|
margin-bottom: 5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
opacity: 0.8;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { FIELD_NAME_MAP, getCCRErrors, KeyOfForm } from 'modules/ccr/utils';
|
||||||
|
import Markdown from 'components/Markdown';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { CCR_STEP } from './index';
|
||||||
|
import { CCRDraft } from 'types';
|
||||||
|
import { formatUsd } from 'utils/formatters'
|
||||||
|
import './CCRReview.less';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
setStep(step: CCR_STEP): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
form: CCRDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & StateProps;
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
key: KeyOfForm;
|
||||||
|
content: React.ReactNode;
|
||||||
|
error: string | Falsy;
|
||||||
|
isHide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
step: CCR_STEP;
|
||||||
|
name: string;
|
||||||
|
fields: Field[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRReview extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { form } = this.props;
|
||||||
|
const errors = getCCRErrors(this.props.form);
|
||||||
|
const sections: Section[] = [
|
||||||
|
{
|
||||||
|
step: CCR_STEP.BASICS,
|
||||||
|
name: 'Basics',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
content: <h2 style={{ fontSize: '1.6rem', margin: 0 }}>{form.title}</h2>,
|
||||||
|
error: errors.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'brief',
|
||||||
|
content: form.brief,
|
||||||
|
error: errors.brief,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'target',
|
||||||
|
content: <div style={{ fontSize: '1.2rem' }}>{formatUsd(form.target)}</div>,
|
||||||
|
error: errors.target,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
step: CCR_STEP.DETAILS,
|
||||||
|
name: 'Details',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'content',
|
||||||
|
content: <Markdown source={form.content} />,
|
||||||
|
error: errors.content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CCRReview">
|
||||||
|
{sections.map(s => (
|
||||||
|
<div className="CCRReview-section" key={s.step}>
|
||||||
|
{s.fields.map(
|
||||||
|
f =>
|
||||||
|
!f.isHide && (
|
||||||
|
<div className="CCRReviewField" key={f.key}>
|
||||||
|
<div className="CCRReviewField-label">
|
||||||
|
{FIELD_NAME_MAP[f.key]}
|
||||||
|
{f.error && (
|
||||||
|
<div className="CCRReviewField-label-error">{f.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="CCRReviewField-content">
|
||||||
|
{this.isEmpty(form[f.key]) ? (
|
||||||
|
<div className="CCRReviewField-content-empty">N/A</div>
|
||||||
|
) : (
|
||||||
|
f.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<div className="CCRReviewField">
|
||||||
|
<div className="CCRReviewField-label" />
|
||||||
|
<div className="CCRReviewField-content">
|
||||||
|
<button
|
||||||
|
className="CCRReviewField-content-edit"
|
||||||
|
onClick={() => this.setStep(s.step)}
|
||||||
|
>
|
||||||
|
Edit {s.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStep = (step: CCR_STEP) => {
|
||||||
|
this.props.setStep(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
private isEmpty(value: any) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return false; // defined booleans are never empty
|
||||||
|
}
|
||||||
|
return !value || value.length === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
|
||||||
|
form: state.ccr.form as CCRDraft,
|
||||||
|
}))(CCRReview);
|
|
@ -0,0 +1,13 @@
|
||||||
|
.CCRSubmitWarningModal {
|
||||||
|
.ant-alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
import './CCRSubmitWarningModal.less';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isVisible: boolean;
|
||||||
|
handleClose(): void;
|
||||||
|
handleSubmit(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CCRSubmitWarningModal extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { isVisible, handleClose, handleSubmit } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<>Confirm submission</>}
|
||||||
|
visible={isVisible}
|
||||||
|
okText={'Submit'}
|
||||||
|
cancelText="Never mind"
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={handleClose}
|
||||||
|
>
|
||||||
|
<div className="CCRSubmitWarningModal">
|
||||||
|
<p>
|
||||||
|
Are you sure you're ready to submit your request for approval? Once you’ve
|
||||||
|
done so, you won't be able to edit it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Form, Alert } from 'antd';
|
||||||
|
import MarkdownEditor from 'components/MarkdownEditor';
|
||||||
|
import { CCRDraft } from 'types';
|
||||||
|
import { getCCRErrors } from 'modules/ccr/utils';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialState?: Partial<State>;
|
||||||
|
updateForm(form: Partial<CCRDraft>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
content: '',
|
||||||
|
...(props.initialState || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const errors = getCCRErrors(this.state, true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
|
||||||
|
<MarkdownEditor
|
||||||
|
onChange={this.handleChange}
|
||||||
|
initialMarkdown={this.state.content}
|
||||||
|
minHeight={400}
|
||||||
|
/>
|
||||||
|
{errors.content && <Alert type="error" message={errors.content} showIcon />}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleChange = (markdown: string) => {
|
||||||
|
if (markdown !== this.state.content) {
|
||||||
|
this.setState({ content: markdown }, () => {
|
||||||
|
this.props.updateForm(this.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
@keyframes draft-notification-popup {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CCRFlow {
|
||||||
|
padding: 2.5rem 2rem 8rem;
|
||||||
|
|
||||||
|
&-header {
|
||||||
|
max-width: 860px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin: 1rem auto 3rem;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 3rem auto 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-subtitle {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 7rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
&-help {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-button {
|
||||||
|
display: block;
|
||||||
|
height: 4rem;
|
||||||
|
line-height: 4rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 12rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
border: 1px solid #999;
|
||||||
|
color: #777;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition-property: background, color, border-color, opacity;
|
||||||
|
transition-duration: 100ms;
|
||||||
|
transition-timing-function: ease;
|
||||||
|
|
||||||
|
&.is-primary {
|
||||||
|
background: @primary-color;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-example {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
opacity: 0.08;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-draftNotification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 8rem;
|
||||||
|
right: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
animation: draft-notification-popup 120ms ease 1;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: @error-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,320 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { compose } from 'recompose';
|
||||||
|
import { Steps, Icon } from 'antd';
|
||||||
|
import qs from 'query-string';
|
||||||
|
import { withRouter, RouteComponentProps } from 'react-router';
|
||||||
|
import { History } from 'history';
|
||||||
|
import { debounce } from 'underscore';
|
||||||
|
import Basics from './Basics';
|
||||||
|
import Details from './Details';
|
||||||
|
import Review from './CCRReview';
|
||||||
|
import Preview from './CCRPreview';
|
||||||
|
import Final from './CCRFinal';
|
||||||
|
import CCRSubmitWarningModal from './CCRSubmitWarningModal';
|
||||||
|
import { ccrActions } from 'modules/ccr';
|
||||||
|
import { CCRDraft } from 'types';
|
||||||
|
import { getCCRErrors } from 'modules/ccr/utils';
|
||||||
|
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
|
||||||
|
import './index.less';
|
||||||
|
import ls from 'local-storage';
|
||||||
|
import Explainer from './CCRExplainer';
|
||||||
|
|
||||||
|
export enum CCR_STEP {
|
||||||
|
BASICS = 'BASICS',
|
||||||
|
DETAILS = 'DETAILS',
|
||||||
|
REVIEW = 'REVIEW',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_ORDER = [CCR_STEP.BASICS, CCR_STEP.DETAILS, CCR_STEP.REVIEW];
|
||||||
|
|
||||||
|
interface StepInfo {
|
||||||
|
short: string;
|
||||||
|
title: React.ReactNode;
|
||||||
|
subtitle: React.ReactNode;
|
||||||
|
help: React.ReactNode;
|
||||||
|
component: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LSExplainer {
|
||||||
|
noExplainCCR: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_INFO: { [key in CCR_STEP]: StepInfo } = {
|
||||||
|
[CCR_STEP.BASICS]: {
|
||||||
|
short: 'Basics',
|
||||||
|
title: 'Let’s start with the basics',
|
||||||
|
subtitle: 'Don’t worry, you can come back and change things before publishing',
|
||||||
|
help:
|
||||||
|
'You don’t have to fill out everything at once right now, you can come back later.',
|
||||||
|
component: Basics,
|
||||||
|
},
|
||||||
|
[CCR_STEP.DETAILS]: {
|
||||||
|
short: 'Details',
|
||||||
|
title: 'Dive into the details',
|
||||||
|
subtitle: 'Here’s your chance to lay out the full request, in all its glory',
|
||||||
|
help: `Make sure people know what you’re requesting, why it's needed, how they can accomplish it`,
|
||||||
|
component: Details,
|
||||||
|
},
|
||||||
|
[CCR_STEP.REVIEW]: {
|
||||||
|
short: 'Review',
|
||||||
|
title: 'Review your request',
|
||||||
|
subtitle: 'Feel free to edit any field that doesn’t look right',
|
||||||
|
help: 'You’ll get a chance to preview your request next before you publish it',
|
||||||
|
component: Review,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
form: AppState['ccr']['form'];
|
||||||
|
isSavingDraft: AppState['ccr']['isSavingDraft'];
|
||||||
|
hasSavedDraft: AppState['ccr']['hasSavedDraft'];
|
||||||
|
saveDraftError: AppState['ccr']['saveDraftError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
updateCCRForm: typeof ccrActions['updateCCRForm'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & DispatchProps & RouteComponentProps<any>;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
step: CCR_STEP;
|
||||||
|
isPreviewing: boolean;
|
||||||
|
isShowingSubmitWarning: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isExample: boolean;
|
||||||
|
isExplaining: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRFlow extends React.Component<Props, State> {
|
||||||
|
private historyUnlisten: () => void;
|
||||||
|
private debouncedUpdateForm: (form: Partial<CCRDraft>) => void;
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
const searchValues = qs.parse(props.location.search);
|
||||||
|
const queryStep = searchValues.step ? searchValues.step.toUpperCase() : null;
|
||||||
|
const step =
|
||||||
|
queryStep && CCR_STEP[queryStep]
|
||||||
|
? (CCR_STEP[queryStep] as CCR_STEP)
|
||||||
|
: CCR_STEP.BASICS;
|
||||||
|
const noExplain = !!ls<LSExplainer>('noExplainCCR');
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
step,
|
||||||
|
isPreviewing: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
isExample: false,
|
||||||
|
isShowingSubmitWarning: false,
|
||||||
|
isExplaining: !noExplain,
|
||||||
|
};
|
||||||
|
this.debouncedUpdateForm = debounce(this.updateForm, 800);
|
||||||
|
this.historyUnlisten = this.props.history.listen(this.handlePop);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.historyUnlisten) {
|
||||||
|
this.historyUnlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { isSavingDraft, saveDraftError } = this.props;
|
||||||
|
const {
|
||||||
|
step,
|
||||||
|
isPreviewing,
|
||||||
|
isSubmitting,
|
||||||
|
isShowingSubmitWarning,
|
||||||
|
isExplaining,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const info = STEP_INFO[step];
|
||||||
|
const currentIndex = STEP_ORDER.indexOf(step);
|
||||||
|
const isLastStep = STEP_ORDER.indexOf(step) === STEP_ORDER.length - 1;
|
||||||
|
const StepComponent = info.component;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
let showFooter = true;
|
||||||
|
if (isSubmitting) {
|
||||||
|
content = <Final goBack={this.cancelSubmit} />;
|
||||||
|
showFooter = false;
|
||||||
|
} else if (isPreviewing) {
|
||||||
|
content = <Preview />;
|
||||||
|
} else if (isExplaining) {
|
||||||
|
content = <Explainer startSteps={this.startSteps} />;
|
||||||
|
showFooter = false;
|
||||||
|
} else {
|
||||||
|
// Antd definitions are missing `onClick` for step, even though it works.
|
||||||
|
const Step = Steps.Step as any;
|
||||||
|
content = (
|
||||||
|
<div className="CCRFlow">
|
||||||
|
<div className="CCRFlow-header">
|
||||||
|
<Steps current={currentIndex}>
|
||||||
|
{STEP_ORDER.slice(0, 3).map(s => (
|
||||||
|
<Step
|
||||||
|
key={s}
|
||||||
|
title={STEP_INFO[s].short}
|
||||||
|
onClick={() => this.setStep(s)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
<h1 className="CCRFlow-header-title">{info.title}</h1>
|
||||||
|
<div className="CCRFlow-header-subtitle">{info.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<div className="CCRFlow-content">
|
||||||
|
<StepComponent
|
||||||
|
ccrId={this.props.form && this.props.form.ccrId}
|
||||||
|
initialState={this.props.form}
|
||||||
|
updateForm={this.debouncedUpdateForm}
|
||||||
|
setStep={this.setStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{content}
|
||||||
|
{showFooter && (
|
||||||
|
<div className="CCRFlow-footer">
|
||||||
|
{isLastStep ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="CCRFlow-footer-button"
|
||||||
|
key="preview"
|
||||||
|
onClick={this.togglePreview}
|
||||||
|
>
|
||||||
|
{isPreviewing ? 'Back to Edit' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="CCRFlow-footer-button is-primary"
|
||||||
|
key="submit"
|
||||||
|
onClick={this.openPublishWarning}
|
||||||
|
disabled={this.checkFormErrors()}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="CCRFlow-footer-help">{info.help}</div>
|
||||||
|
<button
|
||||||
|
className="CCRFlow-footer-button"
|
||||||
|
key="next"
|
||||||
|
onClick={this.nextStep}
|
||||||
|
>
|
||||||
|
Continue <Icon type="right-circle-o" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSavingDraft ? (
|
||||||
|
<div className="CCRFlow-draftNotification">Saving draft...</div>
|
||||||
|
) : (
|
||||||
|
saveDraftError && (
|
||||||
|
<div className="CCRFlow-draftNotification is-error">
|
||||||
|
Failed to save draft!
|
||||||
|
<br />
|
||||||
|
{saveDraftError}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<CCRSubmitWarningModal
|
||||||
|
isVisible={isShowingSubmitWarning}
|
||||||
|
handleClose={this.closePublishWarning}
|
||||||
|
handleSubmit={this.startSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateForm = (form: Partial<CCRDraft>) => {
|
||||||
|
this.props.updateCCRForm(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
private setStep = (step: CCR_STEP, skipHistory?: boolean) => {
|
||||||
|
this.setState({ step });
|
||||||
|
if (!skipHistory) {
|
||||||
|
const { history, location } = this.props;
|
||||||
|
history.push(`${location.pathname}?step=${step.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private nextStep = () => {
|
||||||
|
const idx = STEP_ORDER.indexOf(this.state.step);
|
||||||
|
if (idx !== STEP_ORDER.length - 1) {
|
||||||
|
this.setStep(STEP_ORDER[idx + 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private togglePreview = () => {
|
||||||
|
this.setState({ isPreviewing: !this.state.isPreviewing });
|
||||||
|
};
|
||||||
|
|
||||||
|
private startSubmit = () => {
|
||||||
|
this.setState({
|
||||||
|
isSubmitting: true,
|
||||||
|
isShowingSubmitWarning: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private checkFormErrors = () => {
|
||||||
|
if (!this.props.form) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const errors = getCCRErrors(this.props.form);
|
||||||
|
return !!Object.keys(errors).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePop: History.LocationListener = (location, action) => {
|
||||||
|
if (action === 'POP') {
|
||||||
|
this.setState({ isPreviewing: false });
|
||||||
|
const searchValues = qs.parse(location.search);
|
||||||
|
const urlStep = searchValues.step && searchValues.step.toUpperCase();
|
||||||
|
if (urlStep && CCR_STEP[urlStep]) {
|
||||||
|
this.setStep(urlStep as CCR_STEP, true);
|
||||||
|
} else {
|
||||||
|
this.setStep(CCR_STEP.BASICS, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private openPublishWarning = () => {
|
||||||
|
this.setState({ isShowingSubmitWarning: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private closePublishWarning = () => {
|
||||||
|
this.setState({ isShowingSubmitWarning: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private cancelSubmit = () => {
|
||||||
|
this.setState({ isSubmitting: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private startSteps = () => {
|
||||||
|
this.setState({ step: CCR_STEP.BASICS, isExplaining: false });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
|
(state: AppState) => ({
|
||||||
|
form: state.ccr.form,
|
||||||
|
isSavingDraft: state.ccr.isSavingDraft,
|
||||||
|
hasSavedDraft: state.ccr.hasSavedDraft,
|
||||||
|
saveDraftError: state.ccr.saveDraftError,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
updateCCRForm: ccrActions.updateCCRForm,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default compose<Props, {}>(
|
||||||
|
withRouter,
|
||||||
|
withConnect,
|
||||||
|
)(CCRFlow);
|
|
@ -4,7 +4,7 @@ import classnames from 'classnames';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Proposal } from 'types';
|
import { Proposal } from 'types';
|
||||||
import Like from 'components/Like'
|
import Like from 'components/Like';
|
||||||
|
|
||||||
interface CardInfoProps {
|
interface CardInfoProps {
|
||||||
proposal: Proposal;
|
proposal: Proposal;
|
||||||
|
@ -13,14 +13,10 @@ interface CardInfoProps {
|
||||||
|
|
||||||
export const CardInfo: React.SFC<CardInfoProps> = ({ proposal, time }) => (
|
export const CardInfo: React.SFC<CardInfoProps> = ({ proposal, time }) => (
|
||||||
<div className="Card-info">
|
<div className="Card-info">
|
||||||
<div
|
<div className="ProposalCard-info-category">
|
||||||
className="ProposalCard-info-category"
|
<Like proposal={proposal} proposal_card />
|
||||||
>
|
|
||||||
<Like proposal={proposal} proposal_card/>
|
|
||||||
</div>
|
|
||||||
<div className="ProposalCard-info-created">
|
|
||||||
{moment(time).fromNow()}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ProposalCard-info-created">{moment(time).fromNow()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -43,7 +39,7 @@ export class Card extends React.Component<CardProps> {
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Button, Form, Input, message } from 'antd';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||||
|
|
||||||
import './ContributionModal/PaymentInfo.less'
|
import './ContributionModal/PaymentInfo.less';
|
||||||
|
|
||||||
interface CopyInputProps {
|
interface CopyInputProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
@ -30,7 +30,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
initialMarkdown={this.state.content}
|
initialMarkdown={this.state.content}
|
||||||
minHeight={200}
|
minHeight={400}
|
||||||
/>
|
/>
|
||||||
{errors.content && <Alert type="error" message={errors.content} showIcon />}
|
{errors.content && <Alert type="error" message={errors.content} showIcon />}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -31,11 +31,12 @@
|
||||||
display: block;
|
display: block;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
height: 3.2rem;
|
font-size: 1.5rem;
|
||||||
|
height: 4.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-actions {
|
&-actions {
|
||||||
margin: 4rem auto;
|
margin: 6rem auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
@ -32,9 +32,10 @@ const Explainer: React.SFC<Props> = ({ t, startSteps }) => {
|
||||||
return (
|
return (
|
||||||
<div className="Explainer">
|
<div className="Explainer">
|
||||||
<div className="Explainer-header">
|
<div className="Explainer-header">
|
||||||
<h2 className="Explainer-header-title">{t('home.guide.title')}</h2>
|
<h2 className="Explainer-header-title">Creating a Proposal</h2>
|
||||||
<div className="Explainer-header-subtitle">
|
<div className="Explainer-header-subtitle">
|
||||||
You're almost ready to create a proposal.
|
We can't wait to get your request! Before starting, here's what you should
|
||||||
|
know...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="Explainer-items">
|
<div className="Explainer-items">
|
||||||
|
|
|
@ -2,14 +2,10 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Icon } from 'antd';
|
import { Icon } from 'antd';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Result from 'ant-design-pro/lib/Result';
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import { createActions } from 'modules/create';
|
import { createActions } from 'modules/create';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { getProposalStakingContribution } from 'api/api';
|
|
||||||
import './Final.less';
|
import './Final.less';
|
||||||
import PaymentInfo from 'components/ContributionModal/PaymentInfo';
|
|
||||||
import { ContributionWithAddresses } from 'types';
|
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
goBack(): void;
|
goBack(): void;
|
||||||
|
@ -27,34 +23,15 @@ interface DispatchProps {
|
||||||
|
|
||||||
type Props = OwnProps & StateProps & DispatchProps;
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
|
||||||
const STATE = {
|
class CreateFinal extends React.Component<Props, {}> {
|
||||||
contribution: null as null | ContributionWithAddresses,
|
|
||||||
contributionError: null as null | Error,
|
|
||||||
};
|
|
||||||
|
|
||||||
type State = typeof STATE;
|
|
||||||
|
|
||||||
class CreateFinal extends React.Component<Props, State> {
|
|
||||||
state = STATE;
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.submit();
|
this.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prev: Props) {
|
|
||||||
const { submittedProposal } = this.props;
|
|
||||||
if (!prev.submittedProposal && submittedProposal) {
|
|
||||||
if (!submittedProposal.isStaked) {
|
|
||||||
this.getStakingContribution();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { submittedProposal, submitError, goBack } = this.props;
|
const { submittedProposal, submitError, goBack } = this.props;
|
||||||
const { contribution, contributionError } = this.state;
|
|
||||||
|
|
||||||
const ready = submittedProposal && (submittedProposal.isStaked || contribution);
|
const ready = submittedProposal;
|
||||||
const staked = submittedProposal && submittedProposal.isStaked;
|
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (submitError) {
|
if (submitError) {
|
||||||
|
@ -75,66 +52,13 @@ class CreateFinal extends React.Component<Props, State> {
|
||||||
<>
|
<>
|
||||||
<div className="CreateFinal-message is-success">
|
<div className="CreateFinal-message is-success">
|
||||||
<Icon type="check-circle" />
|
<Icon type="check-circle" />
|
||||||
{staked && (
|
|
||||||
<div className="CreateFinal-message-text">
|
<div className="CreateFinal-message-text">
|
||||||
Your proposal has been staked and submitted! Check your{' '}
|
Your proposal has been submitted! Check your{' '}
|
||||||
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link>{' '}
|
<Link to={`/profile?tab=pending`}>profile's pending tab</Link> to check its
|
||||||
to check its status.
|
status.
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!staked && (
|
|
||||||
<div className="CreateFinal-message-text">
|
|
||||||
Your proposal has been submitted! Please send the staking contribution of{' '}
|
|
||||||
<b>{contribution && contribution.amount} ZEC</b> using the instructions
|
|
||||||
below.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!staked && (
|
|
||||||
<>
|
|
||||||
<div className="CreateFinal-contribute">
|
|
||||||
<PaymentInfo
|
|
||||||
text={
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
If you cannot send the payment now, you may bring up these
|
|
||||||
instructions again by visiting your{' '}
|
|
||||||
<Link to={`/profile?tab=funded`}>profile's funded tab</Link>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Once your payment has been sent and processed with 6
|
|
||||||
confirmations, you will receive an email. Visit your{' '}
|
|
||||||
<Link to={`/profile?tab=pending`}>
|
|
||||||
profile's pending proposals tab
|
|
||||||
</Link>{' '}
|
|
||||||
at any time to check its status.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
}
|
|
||||||
contribution={contribution}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="CreateFinal-staked">
|
|
||||||
I'm finished, take me to{' '}
|
|
||||||
<Link to="/profile?tab=pending">my pending proposals</Link>!
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (contributionError) {
|
|
||||||
content = (
|
|
||||||
<Result
|
|
||||||
type="error"
|
|
||||||
title="Something went wrong"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
We were unable to get your staking contribution started. You can finish
|
|
||||||
staking from <Link to="/profile?tab=pending">your profile</Link>, please try
|
|
||||||
again from there soon.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = <Loader size="large" tip="Submitting your proposal..." />;
|
content = <Loader size="large" tip="Submitting your proposal..." />;
|
||||||
|
@ -148,18 +72,6 @@ class CreateFinal extends React.Component<Props, State> {
|
||||||
this.props.submitProposal(this.props.form);
|
this.props.submitProposal(this.props.form);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private getStakingContribution = async () => {
|
|
||||||
const { submittedProposal } = this.props;
|
|
||||||
if (submittedProposal) {
|
|
||||||
try {
|
|
||||||
const res = await getProposalStakingContribution(submittedProposal.proposalId);
|
|
||||||
this.setState({ contribution: res.data });
|
|
||||||
} catch (err) {
|
|
||||||
this.setState({ contributionError: err });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
|
|
@ -144,6 +144,12 @@ const MilestoneFields = ({
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{index > 0 && (
|
||||||
|
<div style={{ marginBottom: '8px', opacity: 0.7, fontSize: '13px' }}>
|
||||||
|
(Note: This number represents the number of days past the previous milestone day
|
||||||
|
estimate)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex' }}>
|
||||||
<Input
|
<Input
|
||||||
|
@ -187,7 +193,7 @@ const MilestoneFields = ({
|
||||||
<span style={{ opacity: 0.7 }}>Payout Immediately</span>
|
<span style={{ opacity: 0.7 }}>Payout Immediately</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Tooltip title="Allows the milestone to be paid out immediatly if the proposal is accepted with funding.">
|
<Tooltip title="Allows the milestone to be paid out immediatly if the proposal is accepted with funding.">
|
||||||
<Icon type="info-circle" style={{fontSize: '16px'}} />
|
<Icon type="info-circle" style={{ fontSize: '16px' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -124,8 +124,8 @@ class CreateReview extends React.Component<Props> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="CreateReview">
|
<div className="CreateReview">
|
||||||
{sections.map(s => (
|
{sections.map((s, i) => (
|
||||||
<div className="CreateReview-section" key={s.step}>
|
<div className="CreateReview-section" key={`${s.step}${i}`}>
|
||||||
{s.fields.map(
|
{s.fields.map(
|
||||||
f =>
|
f =>
|
||||||
!f.isHide && (
|
!f.isHide && (
|
||||||
|
|
|
@ -16,13 +16,11 @@ export default class SubmitWarningModal extends React.Component<Props> {
|
||||||
const { proposal, isVisible, handleClose, handleSubmit } = this.props;
|
const { proposal, isVisible, handleClose, handleSubmit } = this.props;
|
||||||
const warnings = proposal ? getCreateWarnings(proposal) : [];
|
const warnings = proposal ? getCreateWarnings(proposal) : [];
|
||||||
|
|
||||||
const staked = proposal && proposal.isStaked;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={<>Confirm submission</>}
|
title={<>Confirm submission</>}
|
||||||
visible={isVisible}
|
visible={isVisible}
|
||||||
okText={staked ? 'Submit' : `I'm ready to stake`}
|
okText={'Submit'}
|
||||||
cancelText="Never mind"
|
cancelText="Never mind"
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
|
@ -45,20 +43,10 @@ export default class SubmitWarningModal extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{staked && (
|
|
||||||
<p>
|
<p>
|
||||||
Are you sure you're ready to submit your proposal for approval? Once you’ve
|
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.
|
done so, you won't be able to edit it.
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
{!staked && (
|
|
||||||
<p>
|
|
||||||
Are you sure you're ready to submit your proposal? You will be asked to send
|
|
||||||
a staking contribution of <b>{process.env.PROPOSAL_STAKING_AMOUNT} ZEC</b>.
|
|
||||||
Once confirmed, the proposal will be submitted for approval by site
|
|
||||||
administrators.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
padding: 2.5rem 2rem 8rem;
|
padding: 2.5rem 2rem 8rem;
|
||||||
|
|
||||||
&-header {
|
&-header {
|
||||||
max-width: 860px;
|
max-width: 1200px;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
margin: 1rem auto 3rem;
|
margin: 1rem auto 3rem;
|
||||||
|
|
||||||
|
|
|
@ -248,7 +248,8 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
key="next"
|
key="next"
|
||||||
onClick={this.nextStep}
|
onClick={this.nextStep}
|
||||||
>
|
>
|
||||||
{isSecondToLastStep ? 'Review' : 'Continue' } <Icon type="right-circle-o" />
|
{isSecondToLastStep ? 'Review' : 'Continue'}{' '}
|
||||||
|
<Icon type="right-circle-o" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -135,7 +135,7 @@ class DraftList extends React.Component<Props, State> {
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
{d.title || <em>Untitled proposal</em>}
|
{d.title || <em>Untitled Proposal</em>}
|
||||||
{d.status === STATUS.REJECTED && <em> (changes requested)</em>}
|
{d.status === STATUS.REJECTED && <em> (changes requested)</em>}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -158,7 +158,7 @@ class DraftList extends React.Component<Props, State> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="DraftList">
|
<div className="DraftList">
|
||||||
<h2 className="DraftList-title">Your drafts</h2>
|
<h2 className="DraftList-title">Your Proposal Drafts</h2>
|
||||||
{draftsEl}
|
{draftsEl}
|
||||||
<Divider>or</Divider>
|
<Divider>or</Divider>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -58,7 +58,6 @@ class Follow extends React.Component<Props, State> {
|
||||||
try {
|
try {
|
||||||
await followProposal(proposalId, !authedFollows);
|
await followProposal(proposalId, !authedFollows);
|
||||||
await this.props.fetchProposal(proposalId);
|
await this.props.fetchProposal(proposalId);
|
||||||
message.success(<>Proposal {authedFollows ? 'unfollowed' : 'followed'}</>);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// tslint:disable:no-console
|
// tslint:disable:no-console
|
||||||
console.error('Follow.handleFollow - unable to change follow state', error);
|
console.error('Follow.handleFollow - unable to change follow state', error);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
.AuthButton {
|
.AuthButton {
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
|
||||||
&.is-loading {
|
&.is-loading {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
@ -11,6 +11,7 @@ interface StateProps {
|
||||||
user: AppState['auth']['user'];
|
user: AppState['auth']['user'];
|
||||||
isAuthingUser: AppState['auth']['isAuthingUser'];
|
isAuthingUser: AppState['auth']['isAuthingUser'];
|
||||||
isCheckingUser: AppState['auth']['isCheckingUser'];
|
isCheckingUser: AppState['auth']['isCheckingUser'];
|
||||||
|
hasCheckedUser: AppState['auth']['hasCheckedUser'];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = StateProps;
|
type Props = StateProps;
|
||||||
|
@ -25,7 +26,7 @@ class HeaderAuth extends React.Component<Props> {
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { user, isAuthingUser, isCheckingUser } = this.props;
|
const { user, isAuthingUser, isCheckingUser, hasCheckedUser } = this.props;
|
||||||
const { isMenuOpen } = this.state;
|
const { isMenuOpen } = this.state;
|
||||||
const isAuthed = !!user;
|
const isAuthed = !!user;
|
||||||
|
|
||||||
|
@ -33,7 +34,7 @@ class HeaderAuth extends React.Component<Props> {
|
||||||
let isLoading;
|
let isLoading;
|
||||||
if (user) {
|
if (user) {
|
||||||
avatar = <UserAvatar user={user} />;
|
avatar = <UserAvatar user={user} />;
|
||||||
} else if (isAuthingUser || isCheckingUser) {
|
} else if (isAuthingUser || isCheckingUser || !hasCheckedUser) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,16 +84,13 @@ class HeaderAuth extends React.Component<Props> {
|
||||||
>
|
>
|
||||||
{link}
|
{link}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)
|
);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
content = link;
|
content = link;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames('AuthButton', isLoading && 'is-loading')}>
|
<div className={classnames('AuthButton', isLoading && 'is-loading')}>{content}</div>
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,4 +118,5 @@ export default connect<StateProps, {}, {}, AppState>(state => ({
|
||||||
user: state.auth.user,
|
user: state.auth.user,
|
||||||
isAuthingUser: state.auth.isAuthingUser,
|
isAuthingUser: state.auth.isAuthingUser,
|
||||||
isCheckingUser: state.auth.isCheckingUser,
|
isCheckingUser: state.auth.isCheckingUser,
|
||||||
|
hasCheckedUser: state.auth.hasCheckedUser,
|
||||||
}))(HeaderAuth);
|
}))(HeaderAuth);
|
||||||
|
|
|
@ -88,6 +88,9 @@ class HeaderDrawer extends React.Component<Props> {
|
||||||
<Menu.Item key="/requests">
|
<Menu.Item key="/requests">
|
||||||
<Link to="/requests">Browse requests</Link>
|
<Link to="/requests">Browse requests</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="/create-request">
|
||||||
|
<Link to="/create-request">Create a Request</Link>
|
||||||
|
</Menu.Item>
|
||||||
</Menu.ItemGroup>
|
</Menu.ItemGroup>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
|
@ -6,8 +6,24 @@ import HeaderDrawer from './Drawer';
|
||||||
import MenuIcon from 'static/images/menu.svg';
|
import MenuIcon from 'static/images/menu.svg';
|
||||||
import Logo from 'static/images/logo-name.svg';
|
import Logo from 'static/images/logo-name.svg';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { ccrActions } from 'modules/ccr';
|
||||||
|
import { createActions } from 'modules/create';
|
||||||
|
|
||||||
interface Props {
|
import { compose } from 'recompose';
|
||||||
|
import { withRouter } from 'react-router';
|
||||||
|
import { fetchCCRDrafts } from 'modules/ccr/actions';
|
||||||
|
import { fetchDrafts } from 'modules/create/actions';
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
hasCheckedUser: AppState['auth']['hasCheckedUser'];
|
||||||
|
ccrDrafts: AppState['ccr']['drafts'];
|
||||||
|
proposalDrafts: AppState['create']['drafts'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
isTransparent?: boolean;
|
isTransparent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,13 +31,25 @@ interface State {
|
||||||
isDrawerOpen: boolean;
|
isDrawerOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Header extends React.Component<Props, State> {
|
interface DispatchProps {
|
||||||
|
fetchCCRDrafts: typeof fetchCCRDrafts;
|
||||||
|
fetchDrafts: typeof fetchDrafts;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & OwnProps & DispatchProps;
|
||||||
|
|
||||||
|
class Header extends React.Component<Props, State> {
|
||||||
state: State = {
|
state: State = {
|
||||||
isDrawerOpen: false,
|
isDrawerOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount = () => {
|
||||||
|
this.props.fetchCCRDrafts();
|
||||||
|
this.props.fetchDrafts();
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isTransparent } = this.props;
|
const { isTransparent, ccrDrafts, proposalDrafts, hasCheckedUser } = this.props;
|
||||||
const { isDrawerOpen } = this.state;
|
const { isDrawerOpen } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -39,9 +67,6 @@ export default class Header extends React.Component<Props, State> {
|
||||||
<Link to="/requests" className="Header-links-link">
|
<Link to="/requests" className="Header-links-link">
|
||||||
Requests
|
Requests
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/create" className="Header-links-link">
|
|
||||||
Start a Proposal
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="Header-links is-left is-mobile">
|
<div className="Header-links is-left is-mobile">
|
||||||
|
@ -54,9 +79,30 @@ export default class Header extends React.Component<Props, State> {
|
||||||
<Logo className="Header-title-logo" />
|
<Logo className="Header-title-logo" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{!hasCheckedUser && (ccrDrafts === null || proposalDrafts === null) ? null : (
|
||||||
<div className="Header-links is-right">
|
<div className="Header-links is-right">
|
||||||
|
<div className="Header-links-button is-desktop">
|
||||||
|
<Link to="/create">
|
||||||
|
{Array.isArray(proposalDrafts) && proposalDrafts.length > 0 ? (
|
||||||
|
<Button>My Proposals</Button>
|
||||||
|
) : (
|
||||||
|
<Button>Start a Proposal</Button>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="Header-links-button is-desktop">
|
||||||
|
<Link to="/create-request">
|
||||||
|
{Array.isArray(ccrDrafts) && ccrDrafts.length > 0 ? (
|
||||||
|
<Button type={'primary'}>My Requests</Button>
|
||||||
|
) : (
|
||||||
|
<Button type={'primary'}>Create a Request</Button>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<HeaderAuth />
|
<HeaderAuth />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<HeaderDrawer isOpen={isDrawerOpen} onClose={this.closeDrawer} />
|
<HeaderDrawer isOpen={isDrawerOpen} onClose={this.closeDrawer} />
|
||||||
|
|
||||||
|
@ -73,3 +119,20 @@ export default class Header extends React.Component<Props, State> {
|
||||||
private openDrawer = () => this.setState({ isDrawerOpen: true });
|
private openDrawer = () => this.setState({ isDrawerOpen: true });
|
||||||
private closeDrawer = () => this.setState({ isDrawerOpen: false });
|
private closeDrawer = () => this.setState({ isDrawerOpen: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withConnect = connect<StateProps, {}, {}, AppState>(
|
||||||
|
(state: AppState) => ({
|
||||||
|
hasCheckedUser: state.auth.hasCheckedUser,
|
||||||
|
ccrDrafts: state.ccr.drafts,
|
||||||
|
proposalDrafts: state.create.drafts,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
fetchCCRDrafts: ccrActions.fetchCCRDrafts,
|
||||||
|
fetchDrafts: createActions.fetchDrafts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default compose<Props, {}>(
|
||||||
|
withRouter,
|
||||||
|
withConnect,
|
||||||
|
)(Header);
|
||||||
|
|
|
@ -4,6 +4,13 @@
|
||||||
@link-padding: 0.7rem;
|
@link-padding: 0.7rem;
|
||||||
@small-query: ~'(max-width: 820px)';
|
@small-query: ~'(max-width: 820px)';
|
||||||
@big-query: ~'(min-width: 821px)';
|
@big-query: ~'(min-width: 821px)';
|
||||||
|
@big: ~'(max-width: 1040px)';
|
||||||
|
|
||||||
|
.is-desktop {
|
||||||
|
@media @big {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.Header {
|
.Header {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -67,6 +74,8 @@
|
||||||
|
|
||||||
&-links {
|
&-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
transition: transform @header-transition ease;
|
transition: transform @header-transition ease;
|
||||||
|
|
||||||
.is-transparent & {
|
.is-transparent & {
|
||||||
|
@ -95,6 +104,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-button {
|
||||||
|
padding: 0 @link-padding / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
&-link {
|
&-link {
|
||||||
display: block;
|
display: block;
|
||||||
background: none;
|
background: none;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@import '~styles/variables.less';
|
@import '~styles/variables.less';
|
||||||
|
@min-tablet-query: ~'(min-width: 920px)';
|
||||||
|
|
||||||
.HomeIntro {
|
.HomeIntro {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -7,7 +8,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
padding: 0 4rem;
|
padding: 0 4rem;
|
||||||
margin: 0 auto 4rem;
|
margin: 4rem auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@media @thin-query {
|
@media @thin-query {
|
||||||
|
@ -20,7 +21,6 @@
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
|
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
font-size: 2.6rem;
|
font-size: 2.6rem;
|
||||||
|
@ -35,27 +35,78 @@
|
||||||
&-buttons {
|
&-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
&-main {
|
@media @tablet-query {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media @mobile-query {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 3.6rem;
|
height: 4.2rem;
|
||||||
padding: 0 3rem;
|
width: 16rem;
|
||||||
margin-right: 0.75rem;
|
padding: 0;
|
||||||
font-size: 1.2rem;
|
margin: 0 10px;
|
||||||
background: @primary-color;
|
border: 2px solid rgba(@text-color, 0.7);
|
||||||
color: #FFF;
|
color: rgba(@text-color, 0.7);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
color: #FFF;
|
&:focus {
|
||||||
opacity: 0.9;
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(@text-color, 0.9);
|
||||||
|
color: rgba(@text-color, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
border-color: rgba(@text-color, 1);
|
||||||
|
color: rgba(@text-color, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media @tablet-query {
|
||||||
|
width: 100%;
|
||||||
|
height: 5rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
max-width: 320px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-learn {
|
&.is-primary {
|
||||||
font-size: 1rem;
|
border-color: rgba(@primary-color, 0.7);
|
||||||
|
color: rgba(@primary-color, 0.7);
|
||||||
|
|
||||||
|
@media @min-tablet-query {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&hover,
|
||||||
|
&:focus {
|
||||||
|
color: @primary-color;
|
||||||
|
border-color: rgba(@primary-color, 0.9);
|
||||||
|
color: rgba(@primary-color, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-color: rgba(@primary-color, 1);
|
||||||
|
color: rgba(@primary-color, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,17 +19,20 @@ const HomeIntro: React.SFC<Props> = ({ t, authUser }) => (
|
||||||
<p className="HomeIntro-content-subtitle">{t('home.intro.subtitle')}</p>
|
<p className="HomeIntro-content-subtitle">{t('home.intro.subtitle')}</p>
|
||||||
<div className="HomeIntro-content-buttons">
|
<div className="HomeIntro-content-buttons">
|
||||||
{authUser ? (
|
{authUser ? (
|
||||||
<Link className="HomeIntro-content-buttons-main" to="/proposals">
|
<Link className="HomeIntro-content-buttons-button is-primary" to="/proposals">
|
||||||
{t('home.intro.browse')}
|
{t('home.intro.browse')}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link className="HomeIntro-content-buttons-main" to="/auth/sign-up">
|
<Link
|
||||||
|
className="HomeIntro-content-buttons-button is-primary"
|
||||||
|
to="/auth/sign-up"
|
||||||
|
>
|
||||||
{t('home.intro.signup')}
|
{t('home.intro.signup')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<a className="HomeIntro-content-buttons-learn" href="#home-guide">
|
<Link className="HomeIntro-content-buttons-button" to="/create-request">
|
||||||
{t('home.intro.learn')}
|
{t('home.intro.ccr')}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -127,7 +127,6 @@ class Like extends React.Component<Props, State> {
|
||||||
try {
|
try {
|
||||||
await likeProposal(proposalId, !authedLiked);
|
await likeProposal(proposalId, !authedLiked);
|
||||||
await fetchProposal(proposalId);
|
await fetchProposal(proposalId);
|
||||||
message.success(<>Proposal {authedLiked ? 'unliked' : 'liked'}</>);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// tslint:disable:no-console
|
// tslint:disable:no-console
|
||||||
console.error('Like.handleProposalLike - unable to change like state', error);
|
console.error('Like.handleProposalLike - unable to change like state', error);
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
@small-query: ~'(max-width: 640px)';
|
||||||
|
|
||||||
|
.ProfileCCR {
|
||||||
|
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.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-team {
|
||||||
|
@media @small-query {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .UserRow {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-raised {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
|
||||||
|
& small {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { UserCCR } from 'types';
|
||||||
|
import UserRow from 'components/UserRow';
|
||||||
|
import './ProfileCCR.less';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
ccr: UserCCR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ProfileCCR extends React.Component<OwnProps> {
|
||||||
|
render() {
|
||||||
|
const { title, brief, ccrId, author } = this.props.ccr;
|
||||||
|
return (
|
||||||
|
<div className="ProfileCCR">
|
||||||
|
<div className="ProfileCCR-block">
|
||||||
|
<Link to={`/ccrs/${ccrId}`} className="ProfileCCR-title">
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
<div className="ProfileCCR-brief">{brief}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ProfileCCR-block">
|
||||||
|
<h3>Author</h3>
|
||||||
|
<div className="ProfileCCR-block-team">
|
||||||
|
<UserRow key={author.userid} user={author} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,14 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button, Popconfirm, message, Tag } from 'antd';
|
import { Button, Popconfirm, message, Tag } from 'antd';
|
||||||
import { UserProposal, STATUS, ContributionWithAddressesAndUser } from 'types';
|
import { UserProposal, STATUS } from 'types';
|
||||||
import ContributionModal from 'components/ContributionModal';
|
import { deletePendingProposal } from 'modules/users/actions';
|
||||||
import { getProposalStakingContribution } from 'api/api';
|
|
||||||
import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import './ProfilePending.less';
|
import './ProfilePending.less';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
proposal: UserProposal;
|
proposal: UserProposal;
|
||||||
onPublish(id: UserProposal['proposalId']): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
|
@ -20,7 +17,6 @@ interface StateProps {
|
||||||
|
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
deletePendingProposal: typeof deletePendingProposal;
|
deletePendingProposal: typeof deletePendingProposal;
|
||||||
publishPendingProposal: typeof publishPendingProposal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps & StateProps & DispatchProps;
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
@ -28,21 +24,17 @@ type Props = OwnProps & StateProps & DispatchProps;
|
||||||
interface State {
|
interface State {
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
isLoadingStake: boolean;
|
|
||||||
stakeContribution: ContributionWithAddressesAndUser | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProfilePending extends React.Component<Props, State> {
|
class ProfilePending extends React.Component<Props, State> {
|
||||||
state: State = {
|
state: State = {
|
||||||
isDeleting: false,
|
isDeleting: false,
|
||||||
isPublishing: false,
|
isPublishing: false,
|
||||||
isLoadingStake: false,
|
|
||||||
stakeContribution: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { status, title, proposalId, rejectReason } = this.props.proposal;
|
const { status, title, proposalId, rejectReason } = this.props.proposal;
|
||||||
const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state;
|
const { isDeleting, isPublishing } = this.state;
|
||||||
|
|
||||||
const isDisableActions = isDeleting || isPublishing;
|
const isDisableActions = isDeleting || isPublishing;
|
||||||
|
|
||||||
|
@ -68,7 +60,7 @@ class ProfilePending extends React.Component<Props, State> {
|
||||||
tag: 'Staking',
|
tag: 'Staking',
|
||||||
blurb: (
|
blurb: (
|
||||||
<div>
|
<div>
|
||||||
Awaiting staking contribution, you will recieve an email when staking has been
|
Awaiting staking contribution, you will receive an email when staking has been
|
||||||
confirmed. If you staked this proposal you may check its status under the
|
confirmed. If you staked this proposal you may check its status under the
|
||||||
"funded" tab.
|
"funded" tab.
|
||||||
</div>
|
</div>
|
||||||
|
@ -89,23 +81,13 @@ class ProfilePending extends React.Component<Props, State> {
|
||||||
<div className="ProfilePending">
|
<div className="ProfilePending">
|
||||||
<div className="ProfilePending-block">
|
<div className="ProfilePending-block">
|
||||||
<Link to={`/proposals/${proposalId}`} className="ProfilePending-title">
|
<Link to={`/proposals/${proposalId}`} className="ProfilePending-title">
|
||||||
{title} <Tag color={st[status].color}>{st[status].tag}</Tag>
|
{title} <Tag color={st[status].color}>{st[status].tag} Proposal</Tag>
|
||||||
</Link>
|
</Link>
|
||||||
<div className={`ProfilePending-status is-${status.toLowerCase()}`}>
|
<div className={`ProfilePending-status is-${status.toLowerCase()}`}>
|
||||||
{st[status].blurb}
|
{st[status].blurb}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ProfilePending-block is-actions">
|
<div className="ProfilePending-block is-actions">
|
||||||
{STATUS.APPROVED === status && (
|
|
||||||
<Button
|
|
||||||
loading={isPublishing}
|
|
||||||
disabled={isDisableActions}
|
|
||||||
type="primary"
|
|
||||||
onClick={this.handlePublish}
|
|
||||||
>
|
|
||||||
Publish
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{STATUS.REJECTED === status && (
|
{STATUS.REJECTED === status && (
|
||||||
<Link to={`/proposals/${proposalId}/edit`}>
|
<Link to={`/proposals/${proposalId}/edit`}>
|
||||||
<Button disabled={isDisableActions} type="primary">
|
<Button disabled={isDisableActions} type="primary">
|
||||||
|
@ -113,15 +95,6 @@ class ProfilePending extends React.Component<Props, State> {
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{STATUS.STAKING === status && (
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
loading={isLoadingStake}
|
|
||||||
onClick={this.openStakingModal}
|
|
||||||
>
|
|
||||||
Stake
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
key="delete"
|
key="delete"
|
||||||
|
@ -133,43 +106,10 @@ class ProfilePending extends React.Component<Props, State> {
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{STATUS.STAKING && (
|
|
||||||
<ContributionModal
|
|
||||||
isVisible={!!stakeContribution}
|
|
||||||
contribution={stakeContribution}
|
|
||||||
handleClose={this.closeStakingModal}
|
|
||||||
text={
|
|
||||||
<p>
|
|
||||||
For your proposal to be considered, please send a staking contribution of{' '}
|
|
||||||
<b>{stakeContribution && stakeContribution.amount} ZEC</b> using the
|
|
||||||
instructions below. Once your payment has been sent and received 6
|
|
||||||
confirmations, you will receive an email.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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 () => {
|
private handleDelete = async () => {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
|
@ -185,26 +125,6 @@ class ProfilePending extends React.Component<Props, State> {
|
||||||
this.setState({ isDeleting: false });
|
this.setState({ isDeleting: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private openStakingModal = async () => {
|
|
||||||
try {
|
|
||||||
this.setState({ isLoadingStake: true });
|
|
||||||
const res = await getProposalStakingContribution(this.props.proposal.proposalId);
|
|
||||||
this.setState({ stakeContribution: res.data }, () => {
|
|
||||||
this.setState({ isLoadingStake: false });
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
message.error('Failed to get staking contribution, try again later', 3);
|
|
||||||
this.setState({ isLoadingStake: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private closeStakingModal = () =>
|
|
||||||
this.setState({
|
|
||||||
isLoadingStake: false,
|
|
||||||
stakeContribution: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
@ -213,6 +133,5 @@ export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
deletePendingProposal,
|
deletePendingProposal,
|
||||||
publishPendingProposal,
|
|
||||||
},
|
},
|
||||||
)(ProfilePending);
|
)(ProfilePending);
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Popconfirm, message, Tag } from 'antd';
|
||||||
|
import { CCRSTATUS, STATUS, UserCCR } from 'types';
|
||||||
|
import { deletePendingRequest } from 'modules/users/actions';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import './ProfilePending.less';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
ccr: UserCCR;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
user: AppState['auth']['user'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
deletePendingRequest: typeof deletePendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfilePendingCCR extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
isDeleting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { status, title, ccrId, rejectReason } = this.props.ccr;
|
||||||
|
const { isDeleting } = this.state;
|
||||||
|
|
||||||
|
const isDisableActions = isDeleting;
|
||||||
|
|
||||||
|
const st = {
|
||||||
|
[STATUS.REJECTED]: {
|
||||||
|
color: 'red',
|
||||||
|
tag: 'Rejected',
|
||||||
|
blurb: (
|
||||||
|
<>
|
||||||
|
<div>This request was rejected for the following reason:</div>
|
||||||
|
<q>{rejectReason}</q>
|
||||||
|
<div>You may edit this request and re-submit it for approval.</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
[STATUS.PENDING]: {
|
||||||
|
color: 'purple',
|
||||||
|
tag: 'Pending',
|
||||||
|
blurb: (
|
||||||
|
<div>
|
||||||
|
You will receive an email when this request 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={`/ccrs/${ccrId}`} className="ProfilePending-title">
|
||||||
|
{title} <Tag color={st[status].color}>{st[status].tag} Request</Tag>
|
||||||
|
</Link>
|
||||||
|
<div className={`ProfilePending-status is-${status.toLowerCase()}`}>
|
||||||
|
{st[status].blurb}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ProfilePending-block is-actions">
|
||||||
|
{CCRSTATUS.REJECTED === status && (
|
||||||
|
<Link to={`/ccrs/${ccrId}/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 handleDelete = async () => {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
ccr: { ccrId },
|
||||||
|
} = this.props;
|
||||||
|
if (!user) return;
|
||||||
|
this.setState({ isDeleting: true });
|
||||||
|
try {
|
||||||
|
await this.props.deletePendingRequest(user.userid, ccrId);
|
||||||
|
message.success('Request 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,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
deletePendingRequest,
|
||||||
|
},
|
||||||
|
)(ProfilePendingCCR);
|
|
@ -1,54 +1,29 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { UserProposal, UserCCR } from 'types';
|
||||||
import { Modal } from 'antd';
|
|
||||||
import { UserProposal } from 'types';
|
|
||||||
import ProfilePending from './ProfilePending';
|
import ProfilePending from './ProfilePending';
|
||||||
|
import ProfilePendingCCR from './ProfilePendingCCR';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
proposals: UserProposal[];
|
proposals: UserProposal[];
|
||||||
|
requests: UserCCR[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps;
|
type Props = OwnProps;
|
||||||
|
|
||||||
const STATE = {
|
class ProfilePendingList extends React.Component<Props> {
|
||||||
publishedId: null as null | UserProposal['proposalId'],
|
|
||||||
};
|
|
||||||
|
|
||||||
type State = typeof STATE;
|
|
||||||
|
|
||||||
class ProfilePendingList extends React.Component<Props, State> {
|
|
||||||
state = STATE;
|
|
||||||
render() {
|
render() {
|
||||||
const { proposals } = this.props;
|
const { proposals, requests } = this.props;
|
||||||
const { publishedId } = this.state;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{proposals.map(p => (
|
{proposals.map(p => (
|
||||||
<ProfilePending
|
<ProfilePending key={p.proposalId} proposal={p} />
|
||||||
key={p.proposalId}
|
))}
|
||||||
proposal={p}
|
{requests.map(r => (
|
||||||
onPublish={this.handlePublish}
|
<ProfilePendingCCR key={r.ccrId} ccr={r} />
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<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;
|
export default ProfilePendingList;
|
||||||
|
|
|
@ -19,6 +19,7 @@ import ProfileProposal from './ProfileProposal';
|
||||||
import ProfileContribution from './ProfileContribution';
|
import ProfileContribution from './ProfileContribution';
|
||||||
import ProfileComment from './ProfileComment';
|
import ProfileComment from './ProfileComment';
|
||||||
import ProfileInvite from './ProfileInvite';
|
import ProfileInvite from './ProfileInvite';
|
||||||
|
import ProfileCCR from './ProfileCCR';
|
||||||
import Placeholder from 'components/Placeholder';
|
import Placeholder from 'components/Placeholder';
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import ExceptionPage from 'components/ExceptionPage';
|
import ExceptionPage from 'components/ExceptionPage';
|
||||||
|
@ -91,6 +92,8 @@ class Profile extends React.Component<Props, State> {
|
||||||
const {
|
const {
|
||||||
proposals,
|
proposals,
|
||||||
pendingProposals,
|
pendingProposals,
|
||||||
|
pendingRequests,
|
||||||
|
requests,
|
||||||
contributions,
|
contributions,
|
||||||
comments,
|
comments,
|
||||||
invites,
|
invites,
|
||||||
|
@ -98,8 +101,10 @@ class Profile extends React.Component<Props, State> {
|
||||||
} = user;
|
} = user;
|
||||||
|
|
||||||
const isLoading = user.isFetching;
|
const isLoading = user.isFetching;
|
||||||
const nonePending = pendingProposals.length === 0;
|
const noProposalsPending = pendingProposals.length === 0;
|
||||||
const noneCreated = proposals.length === 0;
|
const noProposalsCreated = proposals.length === 0;
|
||||||
|
const noRequestsPending = pendingRequests.length === 0;
|
||||||
|
const noRequestsCreated = requests.length === 0;
|
||||||
const noneFunded = contributions.length === 0;
|
const noneFunded = contributions.length === 0;
|
||||||
const noneCommented = comments.length === 0;
|
const noneCommented = comments.length === 0;
|
||||||
const noneArbitrated = arbitrated.length === 0;
|
const noneArbitrated = arbitrated.length === 0;
|
||||||
|
@ -108,8 +113,8 @@ class Profile extends React.Component<Props, State> {
|
||||||
return (
|
return (
|
||||||
<div className="Profile">
|
<div className="Profile">
|
||||||
<HeaderDetails
|
<HeaderDetails
|
||||||
title={`${user.displayName} is funding projects on ZF Grants`}
|
title={`${user.displayName} on ZF Grants`}
|
||||||
description={`Join ${user.displayName} in funding the future!`}
|
description={`Join ${user.displayName} in improving the Zcash ecosystem!`}
|
||||||
image={user.avatar ? user.avatar.imageUrl : undefined}
|
image={user.avatar ? user.avatar.imageUrl : undefined}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
@ -128,33 +133,47 @@ class Profile extends React.Component<Props, State> {
|
||||||
<LinkableTabs defaultActiveKey={(isAuthedUser && 'pending') || 'created'}>
|
<LinkableTabs defaultActiveKey={(isAuthedUser && 'pending') || 'created'}>
|
||||||
{isAuthedUser && (
|
{isAuthedUser && (
|
||||||
<Tabs.TabPane
|
<Tabs.TabPane
|
||||||
tab={TabTitle('Pending', pendingProposals.length)}
|
tab={TabTitle(
|
||||||
|
'Pending',
|
||||||
|
pendingProposals.length + pendingRequests.length,
|
||||||
|
)}
|
||||||
key="pending"
|
key="pending"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{nonePending && (
|
{noProposalsPending &&
|
||||||
|
noRequestsPending && (
|
||||||
<Placeholder
|
<Placeholder
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
title="No pending proposals"
|
title="No pending items"
|
||||||
subtitle="You do not have any proposals awaiting approval."
|
subtitle="You do not have any proposals or requests awaiting approval."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ProfilePendingList proposals={pendingProposals} />
|
<ProfilePendingList
|
||||||
|
proposals={pendingProposals}
|
||||||
|
requests={pendingRequests}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
)}
|
)}
|
||||||
<Tabs.TabPane tab={TabTitle('Created', proposals.length)} key="created">
|
<Tabs.TabPane
|
||||||
|
tab={TabTitle('Created', proposals.length + requests.length)}
|
||||||
|
key="created"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{noneCreated && (
|
{noProposalsCreated &&
|
||||||
|
noRequestsCreated && (
|
||||||
<Placeholder
|
<Placeholder
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
title="No created proposals"
|
title="No created items"
|
||||||
subtitle="There have not been any created proposals."
|
subtitle="There have not been any created proposals or requests."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{proposals.map(p => (
|
{proposals.map(p => (
|
||||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||||
))}
|
))}
|
||||||
|
{requests.map(c => (
|
||||||
|
<ProfileCCR key={c.ccrId} ccr={c} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
|
<Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
|
||||||
|
|
|
@ -24,7 +24,7 @@ const TippingBlock: React.SFC<Props> = ({ proposal }) => {
|
||||||
???
|
???
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="left"
|
placement="left"
|
||||||
title="Made possible if a proposal owner supplies a view key with their tip address."
|
title="Tip amount unavailable until view key support is added. A future update to ZF Grants will enable this."
|
||||||
>
|
>
|
||||||
<Icon type="info-circle" />
|
<Icon type="info-circle" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { AlertProps } from 'antd/lib/alert';
|
||||||
import ExceptionPage from 'components/ExceptionPage';
|
import ExceptionPage from 'components/ExceptionPage';
|
||||||
import HeaderDetails from 'components/HeaderDetails';
|
import HeaderDetails from 'components/HeaderDetails';
|
||||||
import CampaignBlock from './CampaignBlock';
|
import CampaignBlock from './CampaignBlock';
|
||||||
import TippingBlock from './TippingBlock'
|
import TippingBlock from './TippingBlock';
|
||||||
import TeamBlock from './TeamBlock';
|
import TeamBlock from './TeamBlock';
|
||||||
import RFPBlock from './RFPBlock';
|
import RFPBlock from './RFPBlock';
|
||||||
import Milestones from './Milestones';
|
import Milestones from './Milestones';
|
||||||
|
@ -28,7 +28,7 @@ import { withRouter } from 'react-router';
|
||||||
import SocialShare from 'components/SocialShare';
|
import SocialShare from 'components/SocialShare';
|
||||||
import Follow from 'components/Follow';
|
import Follow from 'components/Follow';
|
||||||
import Like from 'components/Like';
|
import Like from 'components/Like';
|
||||||
import { TipJarProposalSettingsModal } from 'components/TipJar'
|
import { TipJarProposalSettingsModal } from 'components/TipJar';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
|
@ -63,7 +63,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
isBodyOverflowing: false,
|
isBodyOverflowing: false,
|
||||||
isUpdateOpen: false,
|
isUpdateOpen: false,
|
||||||
isCancelOpen: false,
|
isCancelOpen: false,
|
||||||
isTipJarOpen: false
|
isTipJarOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
bodyEl: HTMLElement | null = null;
|
bodyEl: HTMLElement | null = null;
|
||||||
|
@ -94,7 +94,13 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { user, detail: proposal, isPreview, detailError } = this.props;
|
const { user, detail: proposal, isPreview, detailError } = this.props;
|
||||||
const { isBodyExpanded, isBodyOverflowing, isCancelOpen, isUpdateOpen, isTipJarOpen } = this.state;
|
const {
|
||||||
|
isBodyExpanded,
|
||||||
|
isBodyOverflowing,
|
||||||
|
isCancelOpen,
|
||||||
|
isUpdateOpen,
|
||||||
|
isTipJarOpen,
|
||||||
|
} = this.state;
|
||||||
const showExpand = !isBodyExpanded && isBodyOverflowing;
|
const showExpand = !isBodyExpanded && isBodyOverflowing;
|
||||||
const wrongProposal = proposal && proposal.proposalId !== this.props.proposalId;
|
const wrongProposal = proposal && proposal.proposalId !== this.props.proposalId;
|
||||||
|
|
||||||
|
@ -246,8 +252,8 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="Proposal-top-side">
|
<div className="Proposal-top-side">
|
||||||
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
|
||||||
<TippingBlock proposal={proposal} />
|
<TippingBlock proposal={proposal} />
|
||||||
|
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
||||||
<TeamBlock proposal={proposal} />
|
<TeamBlock proposal={proposal} />
|
||||||
{proposal.rfp && <RFPBlock rfp={proposal.rfp} />}
|
{proposal.rfp && <RFPBlock rfp={proposal.rfp} />}
|
||||||
</div>
|
</div>
|
||||||
|
@ -266,9 +272,11 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
||||||
<UpdatesTab proposalId={proposal.proposalId} />
|
<UpdatesTab proposalId={proposal.proposalId} />
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
{!proposal.isVersionTwo && (
|
||||||
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
||||||
<ContributorsTab proposalId={proposal.proposalId} />
|
<ContributorsTab proposalId={proposal.proposalId} />
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
)}
|
||||||
</LinkableTabs>
|
</LinkableTabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -315,7 +323,6 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
private openTipJarModal = () => this.setState({ isTipJarOpen: true });
|
private openTipJarModal = () => this.setState({ isTipJarOpen: true });
|
||||||
private closeTipJarModal = () => this.setState({ isTipJarOpen: false });
|
private closeTipJarModal = () => this.setState({ isTipJarOpen: false });
|
||||||
|
|
||||||
|
|
|
@ -2,12 +2,7 @@ import React from 'react';
|
||||||
import { Select, Radio, Card } from 'antd';
|
import { Select, Radio, Card } from 'antd';
|
||||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||||
import { SelectValue } from 'antd/lib/select';
|
import { SelectValue } from 'antd/lib/select';
|
||||||
import {
|
import { PROPOSAL_SORT, SORT_LABELS, PROPOSAL_STAGE, STAGE_UI } from 'api/constants';
|
||||||
PROPOSAL_SORT,
|
|
||||||
SORT_LABELS,
|
|
||||||
PROPOSAL_STAGE,
|
|
||||||
STAGE_UI,
|
|
||||||
} from 'api/constants';
|
|
||||||
import { typedKeys } from 'utils/ts';
|
import { typedKeys } from 'utils/ts';
|
||||||
import { ProposalPage } from 'types';
|
import { ProposalPage } from 'types';
|
||||||
|
|
||||||
|
@ -55,7 +50,7 @@ export default class ProposalFilters extends React.Component<Props> {
|
||||||
PROPOSAL_STAGE.PREVIEW,
|
PROPOSAL_STAGE.PREVIEW,
|
||||||
PROPOSAL_STAGE.FAILED,
|
PROPOSAL_STAGE.FAILED,
|
||||||
PROPOSAL_STAGE.CANCELED,
|
PROPOSAL_STAGE.CANCELED,
|
||||||
PROPOSAL_STAGE.FUNDING_REQUIRED
|
PROPOSAL_STAGE.FUNDING_REQUIRED,
|
||||||
].includes(s as PROPOSAL_STAGE),
|
].includes(s as PROPOSAL_STAGE),
|
||||||
) // skip a few
|
) // skip a few
|
||||||
.map(s => (
|
.map(s => (
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Progress } from 'antd'
|
import { Progress } from 'antd';
|
||||||
import { Proposal } from 'types';
|
import { Proposal } from 'types';
|
||||||
import Card from 'components/Card';
|
import Card from 'components/Card';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import UnitDisplay from 'components/UnitDisplay';
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
import { formatUsd } from 'utils/formatters'
|
import { formatUsd } from 'utils/formatters';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
|
||||||
export class ProposalCard extends React.Component<Proposal> {
|
export class ProposalCard extends React.Component<Proposal> {
|
||||||
|
@ -26,7 +26,7 @@ export class ProposalCard extends React.Component<Proposal> {
|
||||||
contributionMatching,
|
contributionMatching,
|
||||||
isVersionTwo,
|
isVersionTwo,
|
||||||
funded,
|
funded,
|
||||||
percentFunded
|
percentFunded,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -86,8 +86,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&-funding {
|
&-funding {
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 2.5rem;
|
line-height: 2.5rem;
|
||||||
|
|
||||||
&-raised {
|
&-raised {
|
||||||
|
|
|
@ -61,20 +61,6 @@ class Proposals extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="Proposals">
|
<div className="Proposals">
|
||||||
<div className="Proposals-about">
|
|
||||||
<div className="Proposals-about-logo">
|
|
||||||
<ZCFLogo />
|
|
||||||
</div>
|
|
||||||
<div className="Proposals-about-text">
|
|
||||||
<h2 className="Proposals-about-text-title">Zcash Foundation Proposals</h2>
|
|
||||||
<p className="Proposals-about-text-desc">
|
|
||||||
The Zcash Foundation accepts proposals from community members to improve the
|
|
||||||
Zcash ecosystem. Proposals are either accepted with or without funding,
|
|
||||||
should they be approved by the Zcash Foundation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<div className="Proposals-content">
|
<div className="Proposals-content">
|
||||||
{isFiltersDrawered ? (
|
{isFiltersDrawered ? (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
@ -95,10 +81,7 @@ class Proposals extends React.Component<Props, State> {
|
||||||
</Button>
|
</Button>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
) : (
|
) : (
|
||||||
<div className="Proposals-filters">{filtersComponent}</div>
|
<div className="Proposals-filters">
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="Proposals-results">
|
|
||||||
<div className="Proposals-search">
|
<div className="Proposals-search">
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="Search for a proposal"
|
placeholder="Search for a proposal"
|
||||||
|
@ -115,6 +98,26 @@ class Proposals extends React.Component<Props, State> {
|
||||||
<Icon type="filter" /> Filters
|
<Icon type="filter" /> Filters
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{filtersComponent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="Proposals-results">
|
||||||
|
<div className="Proposals-about">
|
||||||
|
<div className="Proposals-about-logo">
|
||||||
|
<ZCFLogo />
|
||||||
|
</div>
|
||||||
|
<div className="Proposals-about-text">
|
||||||
|
<h2 className="Proposals-about-text-title">Zcash Foundation Proposals</h2>
|
||||||
|
<p className="Proposals-about-text-desc">
|
||||||
|
The Zcash Foundation accepts proposals from community members to improve
|
||||||
|
the Zcash ecosystem. Proposals are either funded by the Zcash Foundation
|
||||||
|
directly, or are opened for community donations should they be approved
|
||||||
|
by the Zcash Foundation."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<ProposalResults
|
<ProposalResults
|
||||||
page={this.props.page}
|
page={this.props.page}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue