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 Proposals from 'components/Proposals';
|
||||
import ProposalDetail from 'components/ProposalDetail';
|
||||
import CCRs from 'components/CCRs';
|
||||
import CCRDetail from 'components/CCRDetail';
|
||||
import RFPs from 'components/RFPs';
|
||||
import RFPForm from 'components/RFPForm';
|
||||
import RFPDetail from 'components/RFPDetail';
|
||||
|
@ -47,6 +49,8 @@ class Routes extends React.Component<Props> {
|
|||
<Route path="/users" component={Users} />
|
||||
<Route path="/proposals/:id" component={ProposalDetail} />
|
||||
<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/:id/edit" component={RFPForm} />
|
||||
<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 {
|
||||
userCount,
|
||||
proposalCount,
|
||||
ccrPendingCount,
|
||||
proposalPendingCount,
|
||||
proposalNoArbiterCount,
|
||||
proposalMilestonePayoutsCount,
|
||||
|
@ -21,6 +22,13 @@ class Home extends React.Component {
|
|||
} = store.stats;
|
||||
|
||||
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 && (
|
||||
<div>
|
||||
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { view } from 'react-easy-state';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
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 Back from 'components/Back';
|
||||
import Markdown from 'components/Markdown';
|
||||
|
@ -69,6 +69,20 @@ class RFPDetail extends React.Component<Props> {
|
|||
|
||||
{/* RIGHT SIDE */}
|
||||
<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 */}
|
||||
<Card className="RFPDetail-actions" size="small">
|
||||
<Link to={`/rfps/${rfp.id}/edit`}>
|
||||
|
|
|
@ -51,6 +51,12 @@ class Template extends React.Component<Props> {
|
|||
<span className="nav-text">Proposals</span>
|
||||
</Link>
|
||||
</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">
|
||||
<Link to="/rfps">
|
||||
<Icon type="notification" />
|
||||
|
|
|
@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios';
|
|||
import {
|
||||
User,
|
||||
Proposal,
|
||||
CCR,
|
||||
Contribution,
|
||||
ContributionArgs,
|
||||
RFP,
|
||||
|
@ -149,8 +150,8 @@ async function cancelProposal(id: number) {
|
|||
}
|
||||
|
||||
async function changeProposalToAcceptedWithFunding(id: number) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`)
|
||||
return data
|
||||
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchComments(params: Partial<PageQuery>) {
|
||||
|
@ -176,6 +177,28 @@ async function getEmailExample(type: string) {
|
|||
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() {
|
||||
const { data } = await api.get(`/admin/rfps`);
|
||||
return data;
|
||||
|
@ -229,6 +252,7 @@ const app = store({
|
|||
stats: {
|
||||
userCount: 0,
|
||||
proposalCount: 0,
|
||||
ccrPendingCount: 0,
|
||||
proposalPendingCount: 0,
|
||||
proposalNoArbiterCount: 0,
|
||||
proposalMilestonePayoutsCount: 0,
|
||||
|
@ -295,6 +319,24 @@ const app = store({
|
|||
proposalDetailUpdated: 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: {
|
||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||
},
|
||||
|
@ -494,6 +536,53 @@ const app = store({
|
|||
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
|
||||
|
||||
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) {
|
||||
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
|
@ -558,7 +651,12 @@ const app = store({
|
|||
app.proposalDetailApproving = true;
|
||||
try {
|
||||
const { proposalId } = app.proposalDetail;
|
||||
const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason);
|
||||
const res = await approveProposal(
|
||||
proposalId,
|
||||
isAccepted,
|
||||
withFunding,
|
||||
rejectReason,
|
||||
);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
|
@ -578,16 +676,16 @@ const app = store({
|
|||
},
|
||||
|
||||
async changeProposalToAcceptedWithFunding(id: number) {
|
||||
app.proposalDetailChangingToAcceptedWithFunding = true
|
||||
app.proposalDetailChangingToAcceptedWithFunding = true;
|
||||
|
||||
try {
|
||||
const res = await changeProposalToAcceptedWithFunding(id)
|
||||
app.updateProposalInStore(res)
|
||||
const res = await changeProposalToAcceptedWithFunding(id);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e)
|
||||
handleApiError(e);
|
||||
}
|
||||
|
||||
app.proposalDetailChangingToAcceptedWithFunding = false
|
||||
app.proposalDetailChangingToAcceptedWithFunding = false;
|
||||
},
|
||||
|
||||
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
||||
|
|
|
@ -48,6 +48,7 @@ export interface RFP {
|
|||
bounty: string | null;
|
||||
dateCloses: number | null;
|
||||
isVersionTwo: boolean;
|
||||
ccr?: CCR;
|
||||
}
|
||||
export interface RFPArgs {
|
||||
title: string;
|
||||
|
@ -200,6 +201,30 @@ export enum PROPOSAL_CATEGORY {
|
|||
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 {
|
||||
page: number;
|
||||
filters: string[];
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
PROPOSAL_ARBITER_STATUSES,
|
||||
MILESTONE_STAGES,
|
||||
PROPOSAL_STAGES,
|
||||
CCR_STATUSES,
|
||||
} from './statuses';
|
||||
|
||||
export interface Filter {
|
||||
|
@ -94,6 +95,20 @@ export const rfpFilters: 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
|
||||
|
||||
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
PROPOSAL_STATUS,
|
||||
CCR_STATUS,
|
||||
RFP_STATUS,
|
||||
CONTRIBUTION_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>> = [
|
||||
{
|
||||
id: PROPOSAL_STATUS.APPROVED,
|
||||
|
|
|
@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
|
|||
PROPOSAL_STAKING_AMOUNT=0.025
|
||||
|
||||
# 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
|
||||
|
||||
To run only select test, Flask allows you to match against the test filename with ``-t` like so:
|
||||
|
||||
flask test -t proposal
|
||||
|
||||
## Migrations
|
||||
|
||||
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.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.email.send import generate_email, send_email
|
||||
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.user.models import User, UserSettings, admin_users_schema, admin_user_schema
|
||||
from grant.utils import pagination
|
||||
from grant.utils.enums import Category
|
||||
from grant.utils.enums import (
|
||||
ProposalStatus,
|
||||
ProposalStage,
|
||||
|
@ -34,6 +34,7 @@ from grant.utils.enums import (
|
|||
ProposalArbiterStatus,
|
||||
MilestoneStage,
|
||||
RFPStatus,
|
||||
CCRStatus
|
||||
)
|
||||
from grant.utils.misc import make_url, make_explore_url
|
||||
from .example_emails import example_email_args
|
||||
|
@ -137,6 +138,9 @@ def logout():
|
|||
def stats():
|
||||
user_count = db.session.query(func.count(User.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)) \
|
||||
.filter(Proposal.status == ProposalStatus.PENDING) \
|
||||
.scalar()
|
||||
|
@ -160,15 +164,16 @@ def stats():
|
|||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user) \
|
||||
.join(UserSettings) \
|
||||
.filter(UserSettings.refund_address != None) \
|
||||
.scalar()
|
||||
return {
|
||||
"userCount": user_count,
|
||||
"ccrPendingCount": ccr_pending_count,
|
||||
"proposalCount": proposal_count,
|
||||
"proposalPendingCount": proposal_pending_count,
|
||||
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
||||
|
@ -314,9 +319,9 @@ def set_arbiter(proposal_id, user_id):
|
|||
db.session.commit()
|
||||
|
||||
return {
|
||||
'proposal': proposal_schema.dump(proposal),
|
||||
'user': admin_user_schema.dump(user)
|
||||
}, 200
|
||||
'proposal': proposal_schema.dump(proposal),
|
||||
'user': admin_user_schema.dump(user)
|
||||
}, 200
|
||||
|
||||
|
||||
# PROPOSALS
|
||||
|
@ -473,6 +478,64 @@ def get_email_example(type):
|
|||
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
|
||||
|
||||
|
||||
|
@ -602,7 +665,7 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
|||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
#TODO: should this stay?
|
||||
# TODO: should this stay?
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
|
@ -726,7 +789,6 @@ def edit_comment(comment_id, hidden, reported):
|
|||
@blueprint.route("/financials", methods=["GET"])
|
||||
@admin.admin_auth_required
|
||||
def financials():
|
||||
|
||||
nfmt = '999999.99999999' # smallest unit of ZEC
|
||||
|
||||
def sql_pc(where: str):
|
||||
|
@ -758,7 +820,8 @@ def financials():
|
|||
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
||||
'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'"))),
|
||||
'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
|
||||
'refunding': str(ex(sql_pc_p(
|
||||
'''
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""The app module, containing the app factory function."""
|
||||
import sentry_sdk
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import sentry_sdk
|
||||
from animal_case import animalify
|
||||
from flask import Flask, Response, jsonify, request, current_app, g
|
||||
from flask_cors import CORS
|
||||
|
@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore
|
|||
from flask_sslify import SSLify
|
||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||
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.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
||||
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
||||
|
@ -129,6 +144,7 @@ def register_extensions(app):
|
|||
|
||||
def register_blueprints(app):
|
||||
"""Register Flask blueprints."""
|
||||
app.register_blueprint(ccr.views.blueprint)
|
||||
app.register_blueprint(comment.views.blueprint)
|
||||
app.register_blueprint(proposal.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.retire_v1_proposals)
|
||||
app.cli.add_command(user.commands.set_admin)
|
||||
app.cli.add_command(user.commands.mangle_users)
|
||||
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 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 = {
|
||||
'home_url': make_url('/'),
|
||||
|
@ -68,13 +69,29 @@ def change_password_info(email_args):
|
|||
|
||||
def proposal_approved(email_args):
|
||||
return {
|
||||
'subject': 'Your proposal has been approved!',
|
||||
'title': 'Your proposal has been approved',
|
||||
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
|
||||
'subject': 'Your proposal has been reviewed',
|
||||
'title': 'Your proposal has been reviewed',
|
||||
'preview': '{} is now live on ZF Grants.'.format(email_args['proposal'].title),
|
||||
'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):
|
||||
return {
|
||||
'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):
|
||||
return {
|
||||
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
||||
|
@ -346,6 +372,8 @@ get_info_lookup = {
|
|||
'change_email': change_email_info,
|
||||
'change_email_old': change_email_old_info,
|
||||
'change_password': change_password_info,
|
||||
'ccr_rejected': ccr_rejected,
|
||||
'ccr_approved': ccr_approved,
|
||||
'proposal_approved': proposal_approved,
|
||||
'proposal_rejected': proposal_rejected,
|
||||
'proposal_contribution': proposal_contribution,
|
||||
|
@ -367,6 +395,7 @@ get_info_lookup = {
|
|||
'milestone_accept': milestone_accept,
|
||||
'milestone_paid': milestone_paid,
|
||||
'admin_approval': admin_approval,
|
||||
'admin_approval_ccr': admin_approval_ccr,
|
||||
'admin_arbiter': admin_arbiter,
|
||||
'admin_payout': admin_payout,
|
||||
'followed_proposal_milestone': followed_proposal_milestone,
|
||||
|
|
|
@ -69,6 +69,10 @@ class EmailSubscription(Enum):
|
|||
'bit': 15,
|
||||
'key': 'followed_proposal'
|
||||
}
|
||||
ADMIN_APPROVAL_CCR = {
|
||||
'bit': 16,
|
||||
'key': 'admin_approval_ccr'
|
||||
}
|
||||
|
||||
|
||||
def is_email_sub_key(k: str):
|
||||
|
|
|
@ -228,6 +228,28 @@ class ProposalArbiter(db.Model):
|
|||
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):
|
||||
__tablename__ = "proposal"
|
||||
|
||||
|
@ -241,7 +263,7 @@ class Proposal(db.Model):
|
|||
title = db.Column(db.String(255), nullable=False)
|
||||
brief = 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)
|
||||
date_approved = db.Column(db.DateTime)
|
||||
date_published = db.Column(db.DateTime)
|
||||
|
@ -290,7 +312,7 @@ class Proposal(db.Model):
|
|||
status: str = ProposalStatus.DRAFT,
|
||||
title: str = '',
|
||||
brief: str = '',
|
||||
content: str = '',
|
||||
content: str = default_proposal_content(),
|
||||
stage: str = ProposalStage.PREVIEW,
|
||||
target: str = '0',
|
||||
payout_address: str = '',
|
||||
|
@ -521,7 +543,7 @@ class Proposal(db.Model):
|
|||
'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):
|
||||
self.validate_publishable()
|
||||
self.validate_milestone_days()
|
||||
|
@ -529,11 +551,7 @@ class Proposal(db.Model):
|
|||
# specific validation
|
||||
if self.status not in allowed_statuses:
|
||||
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
||||
# set to PENDING if staked, else STAKING
|
||||
if self.is_staked:
|
||||
self.status = ProposalStatus.PENDING
|
||||
else:
|
||||
self.status = ProposalStatus.STAKING
|
||||
self.set_pending()
|
||||
|
||||
def set_pending_when_ready(self):
|
||||
if self.status == ProposalStatus.STAKING and self.is_staked:
|
||||
|
@ -541,10 +559,6 @@ class Proposal(db.Model):
|
|||
|
||||
# state: status STAKING -> PENDING
|
||||
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.status = ProposalStatus.PENDING
|
||||
db.session.add(self)
|
||||
|
@ -566,16 +580,23 @@ class Proposal(db.Model):
|
|||
self.date_published = datetime.datetime.now()
|
||||
self.stage = ProposalStage.WIP
|
||||
|
||||
with_or_out = 'without'
|
||||
if with_funding:
|
||||
self.fully_fund_contibution_bounty()
|
||||
with_or_out = 'with'
|
||||
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', {
|
||||
'user': t,
|
||||
'proposal': self,
|
||||
'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:
|
||||
if not reject_reason:
|
||||
|
|
|
@ -321,17 +321,6 @@ def submit_for_approval_proposal(proposal_id):
|
|||
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"])
|
||||
@requires_team_member_auth
|
||||
def publish_proposal(proposal_id):
|
||||
|
|
|
@ -34,6 +34,8 @@ class RFP(db.Model):
|
|||
date_closed = db.Column(db.DateTime, nullable=True)
|
||||
version = db.Column(db.String(255), nullable=True)
|
||||
|
||||
ccr = db.relationship("CCR", uselist=False, back_populates="rfp")
|
||||
|
||||
# Relationships
|
||||
proposals = db.relationship(
|
||||
"Proposal",
|
||||
|
@ -57,7 +59,6 @@ class RFP(db.Model):
|
|||
.correlate_except(rfp_liker)
|
||||
)
|
||||
|
||||
|
||||
@hybrid_property
|
||||
def bounty(self):
|
||||
return self._bounty
|
||||
|
@ -134,9 +135,11 @@ class RFPSchema(ma.Schema):
|
|||
"accepted_proposals",
|
||||
"authed_liked",
|
||||
"likes_count",
|
||||
"is_version_two"
|
||||
"is_version_two",
|
||||
"ccr"
|
||||
)
|
||||
|
||||
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||
status = ma.Method("get_status")
|
||||
date_closes = ma.Method("get_date_closes")
|
||||
date_opened = ma.Method("get_date_opened")
|
||||
|
@ -184,9 +187,11 @@ class AdminRFPSchema(ma.Schema):
|
|||
"date_opened",
|
||||
"date_closed",
|
||||
"proposals",
|
||||
"is_version_two"
|
||||
"is_version_two",
|
||||
"ccr"
|
||||
)
|
||||
|
||||
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||
status = ma.Method("get_status")
|
||||
date_created = ma.Method("get_date_created")
|
||||
date_closes = ma.Method("get_date_closes")
|
||||
|
|
|
@ -151,15 +151,19 @@ class PruneDraft:
|
|||
|
||||
@staticmethod
|
||||
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()
|
||||
|
||||
# If it was deleted or moved out of a draft, noop out
|
||||
if not proposal or proposal.status != ProposalStatus.DRAFT:
|
||||
return
|
||||
|
||||
# If any of the proposal fields are filled, noop out
|
||||
if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0":
|
||||
# If proposal content deviates from the default, noop out
|
||||
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
|
||||
|
||||
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;">
|
||||
Congratulations on your approval! We look forward to seeing the support your
|
||||
proposal receives. To get your campaign started, click below and follow the
|
||||
instructions to publish your proposal.
|
||||
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
|
@ -13,22 +11,3 @@
|
|||
</p>
|
||||
{% endif %}
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="{{ 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
|
||||
proposal receives. To start the fundraising (and the clock) go to the URL
|
||||
below and publish your proposal.
|
||||
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the admin team was attached to your approval:
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
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
|
||||
shortly.
|
||||
has been canceled.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0;">
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
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
|
||||
shortly.
|
||||
has been canceled.
|
||||
|
||||
If you have any further questions, please contact support for more information:
|
||||
{{ args.support_url }}
|
|
@ -37,6 +37,7 @@ def set_admin(identity):
|
|||
|
||||
if user:
|
||||
user.set_admin(True)
|
||||
user.email_verification.has_verified = True
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
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 sqlalchemy.ext.hybrid import hybrid_property
|
||||
from grant.comment.models import Comment
|
||||
from grant.ccr.models import CCR
|
||||
from grant.email.models import EmailVerification, EmailRecovery
|
||||
from grant.email.send import send_email
|
||||
from grant.email.subscription_settings import (
|
||||
|
@ -125,6 +126,7 @@ class User(db.Model, UserMixin):
|
|||
# relations
|
||||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
||||
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")
|
||||
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
||||
lazy=True, cascade="all, delete-orphan")
|
||||
|
@ -148,7 +150,6 @@ class User(db.Model, UserMixin):
|
|||
"RFP", secondary="rfp_liker", back_populates="likes"
|
||||
)
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
email_address,
|
||||
|
|
|
@ -8,17 +8,18 @@ from webargs import validate
|
|||
import grant.utils.auth as auth
|
||||
from grant.comment.models import Comment, user_comments_schema
|
||||
from grant.email.models import EmailRecovery
|
||||
from grant.ccr.models import CCR, ccrs_schema
|
||||
from grant.extensions import limiter
|
||||
from grant.parser import query, body
|
||||
from grant.proposal.models import (
|
||||
Proposal,
|
||||
ProposalTeamInvite,
|
||||
invites_with_proposal_schema,
|
||||
ProposalContribution,
|
||||
user_proposal_contributions_schema,
|
||||
user_proposals_schema,
|
||||
user_proposal_arbiters_schema
|
||||
)
|
||||
from grant.proposal.models import ProposalContribution
|
||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.requests import validate_blockchain_get
|
||||
|
@ -50,14 +51,20 @@ def get_me():
|
|||
"withComments": fields.Bool(required=False, missing=None),
|
||||
"withFunded": 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)
|
||||
if user:
|
||||
result = user_schema.dump(user)
|
||||
authed_user = auth.get_authed_user()
|
||||
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:
|
||||
proposals = Proposal.get_by_user(user)
|
||||
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)
|
||||
result["comments"] = comments_dump
|
||||
if with_pending and is_self:
|
||||
pending = Proposal.get_by_user(user, [
|
||||
pending_proposals = Proposal.get_by_user(user, [
|
||||
ProposalStatus.STAKING,
|
||||
ProposalStatus.PENDING,
|
||||
ProposalStatus.APPROVED,
|
||||
ProposalStatus.REJECTED,
|
||||
])
|
||||
pending_dump = user_proposals_schema.dump(pending)
|
||||
result["pendingProposals"] = pending_dump
|
||||
pending_proposals_dump = user_proposals_schema.dump(pending_proposals)
|
||||
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:
|
||||
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
||||
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import sentry_sdk
|
||||
from flask import request, g, jsonify, session, current_app
|
||||
from flask_security.core import current_user
|
||||
from flask_security.utils import logout_user
|
||||
|
||||
from grant.settings import BLOCKCHAIN_API_SECRET
|
||||
|
||||
|
||||
|
@ -26,7 +27,7 @@ def throw_on_banned(user):
|
|||
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:
|
||||
last = session['last_login_time']
|
||||
now = datetime.now()
|
||||
|
@ -135,6 +136,28 @@ def requires_team_member_auth(f):
|
|||
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):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
|
|
|
@ -11,10 +11,22 @@ class CustomEnum():
|
|||
not attr.startswith('__')]
|
||||
|
||||
|
||||
class ProposalStatusEnum(CustomEnum):
|
||||
class CCRStatusEnum(CustomEnum):
|
||||
DRAFT = 'DRAFT'
|
||||
PENDING = 'PENDING'
|
||||
APPROVED = 'APPROVED'
|
||||
REJECTED = 'REJECTED'
|
||||
LIVE = 'LIVE'
|
||||
DELETED = 'DELETED'
|
||||
|
||||
|
||||
CCRStatus = CCRStatusEnum()
|
||||
|
||||
|
||||
class ProposalStatusEnum(CustomEnum):
|
||||
DRAFT = 'DRAFT'
|
||||
STAKING = 'STAKING'
|
||||
PENDING = 'PENDING'
|
||||
APPROVED = 'APPROVED'
|
||||
REJECTED = 'REJECTED'
|
||||
LIVE = 'LIVE'
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
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.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 .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):
|
||||
|
@ -39,13 +41,13 @@ class Pagination(abc.ABC):
|
|||
# consider moving these args into __init__ and attaching to self
|
||||
@abc.abstractmethod
|
||||
def paginate(
|
||||
self,
|
||||
schema: ma.Schema,
|
||||
query: db.Query,
|
||||
page: int,
|
||||
filters: list,
|
||||
search: str,
|
||||
sort: str,
|
||||
self,
|
||||
schema: ma.Schema,
|
||||
query: db.Query,
|
||||
page: int,
|
||||
filters: list,
|
||||
search: str,
|
||||
sort: str,
|
||||
):
|
||||
pass
|
||||
|
||||
|
@ -68,13 +70,13 @@ class ProposalPagination(Pagination):
|
|||
}
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
schema: ma.Schema,
|
||||
query: db.Query=None,
|
||||
page: int=1,
|
||||
filters: list=None,
|
||||
search: str=None,
|
||||
sort: str='PUBLISHED:DESC',
|
||||
self,
|
||||
schema: ma.Schema,
|
||||
query: db.Query = None,
|
||||
page: int = 1,
|
||||
filters: list = None,
|
||||
search: str = None,
|
||||
sort: str = 'PUBLISHED:DESC',
|
||||
):
|
||||
query = query or Proposal.query
|
||||
sort = sort or 'PUBLISHED:DESC'
|
||||
|
@ -142,13 +144,13 @@ class ContributionPagination(Pagination):
|
|||
}
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
schema: ma.Schema=proposal_contributions_schema,
|
||||
query: db.Query=None,
|
||||
page: int=1,
|
||||
filters: list=None,
|
||||
search: str=None,
|
||||
sort: str='PUBLISHED:DESC',
|
||||
self,
|
||||
schema: ma.Schema = proposal_contributions_schema,
|
||||
query: db.Query = None,
|
||||
page: int = 1,
|
||||
filters: list = None,
|
||||
search: str = None,
|
||||
sort: str = 'PUBLISHED:DESC',
|
||||
):
|
||||
query = query or ProposalContribution.query
|
||||
sort = sort or 'CREATED:DESC'
|
||||
|
@ -167,9 +169,9 @@ class ContributionPagination(Pagination):
|
|||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user) \
|
||||
.join(UserSettings) \
|
||||
.filter(UserSettings.refund_address != None)
|
||||
|
@ -179,9 +181,9 @@ class ContributionPagination(Pagination):
|
|||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user, isouter=True) \
|
||||
.join(UserSettings, isouter=True) \
|
||||
.filter(UserSettings.refund_address == None)
|
||||
|
@ -222,13 +224,13 @@ class UserPagination(Pagination):
|
|||
}
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
schema: ma.Schema=users_schema,
|
||||
query: db.Query=None,
|
||||
page: int=1,
|
||||
filters: list=None,
|
||||
search: str=None,
|
||||
sort: str='EMAIL:DESC',
|
||||
self,
|
||||
schema: ma.Schema = users_schema,
|
||||
query: db.Query = None,
|
||||
page: int = 1,
|
||||
filters: list = None,
|
||||
search: str = None,
|
||||
sort: str = 'EMAIL:DESC',
|
||||
):
|
||||
query = query or Proposal.query
|
||||
sort = sort or 'EMAIL:DESC'
|
||||
|
@ -278,13 +280,13 @@ class CommentPagination(Pagination):
|
|||
}
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
schema: ma.Schema=comments_schema,
|
||||
query: db.Query=None,
|
||||
page: int=1,
|
||||
filters: list=None,
|
||||
search: str=None,
|
||||
sort: str='CREATED:DESC',
|
||||
self,
|
||||
schema: ma.Schema = comments_schema,
|
||||
query: db.Query = None,
|
||||
page: int = 1,
|
||||
filters: list = None,
|
||||
search: str = None,
|
||||
sort: str = 'CREATED:DESC',
|
||||
):
|
||||
query = query or Comment.query
|
||||
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
|
||||
ccr = CCRPagination().paginate
|
||||
proposal = ProposalPagination().paginate
|
||||
contribution = ContributionPagination().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 grant.app import create_app
|
||||
from grant.ccr.models import CCR
|
||||
from grant.extensions import limiter
|
||||
from grant.milestone.models import Milestone
|
||||
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.user.models import User, SocialMedia, db, Avatar
|
||||
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):
|
||||
|
@ -184,3 +185,23 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
|||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
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()
|
||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||
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)
|
||||
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))
|
||||
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)
|
||||
def test_invalid_status_proposal_publish_proposal(self, mock_get):
|
||||
self.login_default_user()
|
||||
|
@ -223,19 +169,18 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
|
||||
# /
|
||||
def test_get_proposals(self):
|
||||
self.test_publish_proposal_approved()
|
||||
self.proposal.status = ProposalStatus.LIVE
|
||||
resp = self.app.get("/api/v1/proposals/")
|
||||
self.assert200(resp)
|
||||
|
||||
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/")
|
||||
self.assert200(resp)
|
||||
for each_proposal in resp.json['items']:
|
||||
for team_member in each_proposal["team"]:
|
||||
self.assertIsNone(team_member.get('email_address'))
|
||||
|
||||
|
||||
def test_follow_proposal(self):
|
||||
# not logged in
|
||||
resp = self.app.put(
|
||||
|
|
|
@ -49,6 +49,14 @@ test_proposal = {
|
|||
"deadlineDuration": 100
|
||||
}
|
||||
|
||||
test_ccr = {
|
||||
"user_id": test_user,
|
||||
"content": "## My Proposal",
|
||||
"title": "Give Me Money",
|
||||
"brief": "$$$",
|
||||
"target": "123.456",
|
||||
}
|
||||
|
||||
test_comment = {
|
||||
"comment": "Test comment"
|
||||
}
|
||||
|
|
|
@ -26,4 +26,4 @@ DISABLE_SSL=true
|
|||
# TESTNET=true
|
||||
|
||||
# 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 Home = loadable(() => import('pages/index'), 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 Proposals = loadable(() => import('pages/proposals'), opts);
|
||||
const Proposal = loadable(() => import('pages/proposal'), opts);
|
||||
const Ccr = loadable(() => import('pages/ccr'), opts);
|
||||
const Auth = loadable(() => import('pages/auth'));
|
||||
const SignOut = loadable(() => import('pages/sign-out'), opts);
|
||||
const Profile = loadable(() => import('pages/profile'), opts);
|
||||
|
@ -63,6 +66,43 @@ const routeConfigs: RouteConfig[] = [
|
|||
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
|
||||
route: {
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
ProposalPageParams,
|
||||
PageParams,
|
||||
UserSettings,
|
||||
CCR,
|
||||
} from 'types';
|
||||
import {
|
||||
formatUserForPost,
|
||||
|
@ -23,6 +24,7 @@ import {
|
|||
formatProposalPageParamsForGet,
|
||||
formatProposalPageFromGet,
|
||||
} from 'utils/api';
|
||||
import { CCRDraft } from 'types/ccr';
|
||||
|
||||
export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> {
|
||||
let serverParams;
|
||||
|
@ -88,6 +90,7 @@ export function getUser(address: string): Promise<{ data: User }> {
|
|||
return axios
|
||||
.get(`/api/v1/users/${address}`, {
|
||||
params: {
|
||||
withRequests: true,
|
||||
withProposals: true,
|
||||
withComments: 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 });
|
||||
}
|
||||
|
||||
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 {
|
||||
address?: string;
|
||||
viewKey?: string;
|
||||
|
@ -225,7 +218,6 @@ export function updateProposalTipJarSettings(
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
export function postProposalUpdate(
|
||||
proposalId: number,
|
||||
title: string,
|
||||
|
@ -379,12 +371,6 @@ export function getProposalContribution(
|
|||
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[] }> {
|
||||
return axios.get('/api/v1/rfps/').then(res => {
|
||||
res.data = res.data.map(formatRFPFromGet);
|
||||
|
@ -417,3 +403,34 @@ export function getHomeLatest(): Promise<{
|
|||
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 { Link } from 'react-router-dom';
|
||||
import { Proposal } from 'types';
|
||||
import Like from 'components/Like'
|
||||
import Like from 'components/Like';
|
||||
|
||||
interface CardInfoProps {
|
||||
proposal: Proposal;
|
||||
|
@ -13,14 +13,10 @@ interface CardInfoProps {
|
|||
|
||||
export const CardInfo: React.SFC<CardInfoProps> = ({ proposal, time }) => (
|
||||
<div className="Card-info">
|
||||
<div
|
||||
className="ProposalCard-info-category"
|
||||
>
|
||||
<Like proposal={proposal} proposal_card/>
|
||||
</div>
|
||||
<div className="ProposalCard-info-created">
|
||||
{moment(time).fromNow()}
|
||||
<div className="ProposalCard-info-category">
|
||||
<Like proposal={proposal} proposal_card />
|
||||
</div>
|
||||
<div className="ProposalCard-info-created">{moment(time).fromNow()}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -43,7 +39,7 @@ export class Card extends React.Component<CardProps> {
|
|||
{children}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Button, Form, Input, message } from 'antd';
|
|||
import classnames from 'classnames';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
||||
import './ContributionModal/PaymentInfo.less'
|
||||
import './ContributionModal/PaymentInfo.less';
|
||||
|
||||
interface CopyInputProps {
|
||||
label: string;
|
||||
|
|
|
@ -30,7 +30,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
<MarkdownEditor
|
||||
onChange={this.handleChange}
|
||||
initialMarkdown={this.state.content}
|
||||
minHeight={200}
|
||||
minHeight={400}
|
||||
/>
|
||||
{errors.content && <Alert type="error" message={errors.content} showIcon />}
|
||||
</Form>
|
||||
|
|
|
@ -31,11 +31,12 @@
|
|||
display: block;
|
||||
width: 280px;
|
||||
margin-top: 0.5rem;
|
||||
height: 3.2rem;
|
||||
font-size: 1.5rem;
|
||||
height: 4.2rem;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
margin: 4rem auto;
|
||||
margin: 6rem auto;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -32,9 +32,10 @@ const Explainer: React.SFC<Props> = ({ t, startSteps }) => {
|
|||
return (
|
||||
<div className="Explainer">
|
||||
<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">
|
||||
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 className="Explainer-items">
|
||||
|
|
|
@ -2,14 +2,10 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import { Icon } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Result from 'ant-design-pro/lib/Result';
|
||||
import Loader from 'components/Loader';
|
||||
import { createActions } from 'modules/create';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { getProposalStakingContribution } from 'api/api';
|
||||
import './Final.less';
|
||||
import PaymentInfo from 'components/ContributionModal/PaymentInfo';
|
||||
import { ContributionWithAddresses } from 'types';
|
||||
|
||||
interface OwnProps {
|
||||
goBack(): void;
|
||||
|
@ -27,34 +23,15 @@ interface DispatchProps {
|
|||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
||||
const STATE = {
|
||||
contribution: null as null | ContributionWithAddresses,
|
||||
contributionError: null as null | Error,
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
class CreateFinal extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
class CreateFinal extends React.Component<Props, {}> {
|
||||
componentDidMount() {
|
||||
this.submit();
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
const { submittedProposal } = this.props;
|
||||
if (!prev.submittedProposal && submittedProposal) {
|
||||
if (!submittedProposal.isStaked) {
|
||||
this.getStakingContribution();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { submittedProposal, submitError, goBack } = this.props;
|
||||
const { contribution, contributionError } = this.state;
|
||||
|
||||
const ready = submittedProposal && (submittedProposal.isStaked || contribution);
|
||||
const staked = submittedProposal && submittedProposal.isStaked;
|
||||
const ready = submittedProposal;
|
||||
|
||||
let content;
|
||||
if (submitError) {
|
||||
|
@ -75,67 +52,14 @@ class CreateFinal extends React.Component<Props, State> {
|
|||
<>
|
||||
<div className="CreateFinal-message is-success">
|
||||
<Icon type="check-circle" />
|
||||
{staked && (
|
||||
<div className="CreateFinal-message-text">
|
||||
Your proposal has been staked and submitted! Check your{' '}
|
||||
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link>{' '}
|
||||
to check its status.
|
||||
</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 className="CreateFinal-message-text">
|
||||
Your proposal has been submitted! Check your{' '}
|
||||
<Link to={`/profile?tab=pending`}>profile's pending tab</Link> to check its
|
||||
status.
|
||||
</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 {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>(
|
||||
|
|
|
@ -144,6 +144,12 @@ const MilestoneFields = ({
|
|||
maxLength={255}
|
||||
/>
|
||||
</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' }}>
|
||||
<Input
|
||||
|
@ -187,7 +193,7 @@ const MilestoneFields = ({
|
|||
<span style={{ opacity: 0.7 }}>Payout Immediately</span>
|
||||
</Checkbox>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -124,8 +124,8 @@ class CreateReview extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<div className="CreateReview">
|
||||
{sections.map(s => (
|
||||
<div className="CreateReview-section" key={s.step}>
|
||||
{sections.map((s, i) => (
|
||||
<div className="CreateReview-section" key={`${s.step}${i}`}>
|
||||
{s.fields.map(
|
||||
f =>
|
||||
!f.isHide && (
|
||||
|
|
|
@ -16,13 +16,11 @@ export default class SubmitWarningModal extends React.Component<Props> {
|
|||
const { proposal, isVisible, handleClose, handleSubmit } = this.props;
|
||||
const warnings = proposal ? getCreateWarnings(proposal) : [];
|
||||
|
||||
const staked = proposal && proposal.isStaked;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<>Confirm submission</>}
|
||||
visible={isVisible}
|
||||
okText={staked ? 'Submit' : `I'm ready to stake`}
|
||||
okText={'Submit'}
|
||||
cancelText="Never mind"
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleClose}
|
||||
|
@ -45,20 +43,10 @@ export default class SubmitWarningModal extends React.Component<Props> {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{staked && (
|
||||
<p>
|
||||
Are you sure you're ready to submit your proposal for approval? Once you’ve
|
||||
done so, you won't be able to edit it.
|
||||
</p>
|
||||
)}
|
||||
{!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>
|
||||
)}
|
||||
<p>
|
||||
Are you sure you're ready to submit your proposal for approval? Once you’ve
|
||||
done so, you won't be able to edit it.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
padding: 2.5rem 2rem 8rem;
|
||||
|
||||
&-header {
|
||||
max-width: 860px;
|
||||
max-width: 1200px;
|
||||
padding: 0 1rem;
|
||||
margin: 1rem auto 3rem;
|
||||
|
||||
|
|
|
@ -248,7 +248,8 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
key="next"
|
||||
onClick={this.nextStep}
|
||||
>
|
||||
{isSecondToLastStep ? 'Review' : 'Continue' } <Icon type="right-circle-o" />
|
||||
{isSecondToLastStep ? 'Review' : 'Continue'}{' '}
|
||||
<Icon type="right-circle-o" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -135,7 +135,7 @@ class DraftList extends React.Component<Props, State> {
|
|||
<List.Item.Meta
|
||||
title={
|
||||
<>
|
||||
{d.title || <em>Untitled proposal</em>}
|
||||
{d.title || <em>Untitled Proposal</em>}
|
||||
{d.status === STATUS.REJECTED && <em> (changes requested)</em>}
|
||||
</>
|
||||
}
|
||||
|
@ -158,7 +158,7 @@ class DraftList extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<div className="DraftList">
|
||||
<h2 className="DraftList-title">Your drafts</h2>
|
||||
<h2 className="DraftList-title">Your Proposal Drafts</h2>
|
||||
{draftsEl}
|
||||
<Divider>or</Divider>
|
||||
<Button
|
||||
|
|
|
@ -58,7 +58,6 @@ class Follow extends React.Component<Props, State> {
|
|||
try {
|
||||
await followProposal(proposalId, !authedFollows);
|
||||
await this.props.fetchProposal(proposalId);
|
||||
message.success(<>Proposal {authedFollows ? 'unfollowed' : 'followed'}</>);
|
||||
} catch (error) {
|
||||
// tslint:disable:no-console
|
||||
console.error('Follow.handleFollow - unable to change follow state', error);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
.AuthButton {
|
||||
transition: opacity 200ms ease;
|
||||
padding-left: 0.7rem;
|
||||
|
||||
&.is-loading {
|
||||
opacity: 0;
|
||||
|
|
|
@ -11,6 +11,7 @@ interface StateProps {
|
|||
user: AppState['auth']['user'];
|
||||
isAuthingUser: AppState['auth']['isAuthingUser'];
|
||||
isCheckingUser: AppState['auth']['isCheckingUser'];
|
||||
hasCheckedUser: AppState['auth']['hasCheckedUser'];
|
||||
}
|
||||
|
||||
type Props = StateProps;
|
||||
|
@ -25,7 +26,7 @@ class HeaderAuth extends React.Component<Props> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { user, isAuthingUser, isCheckingUser } = this.props;
|
||||
const { user, isAuthingUser, isCheckingUser, hasCheckedUser } = this.props;
|
||||
const { isMenuOpen } = this.state;
|
||||
const isAuthed = !!user;
|
||||
|
||||
|
@ -33,7 +34,7 @@ class HeaderAuth extends React.Component<Props> {
|
|||
let isLoading;
|
||||
if (user) {
|
||||
avatar = <UserAvatar user={user} />;
|
||||
} else if (isAuthingUser || isCheckingUser) {
|
||||
} else if (isAuthingUser || isCheckingUser || !hasCheckedUser) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
|
@ -83,16 +84,13 @@ class HeaderAuth extends React.Component<Props> {
|
|||
>
|
||||
{link}
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
else {
|
||||
);
|
||||
} else {
|
||||
content = link;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classnames('AuthButton', isLoading && 'is-loading')}>
|
||||
{content}
|
||||
</div>
|
||||
<div className={classnames('AuthButton', isLoading && 'is-loading')}>{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -120,4 +118,5 @@ export default connect<StateProps, {}, {}, AppState>(state => ({
|
|||
user: state.auth.user,
|
||||
isAuthingUser: state.auth.isAuthingUser,
|
||||
isCheckingUser: state.auth.isCheckingUser,
|
||||
hasCheckedUser: state.auth.hasCheckedUser,
|
||||
}))(HeaderAuth);
|
||||
|
|
|
@ -88,6 +88,9 @@ class HeaderDrawer extends React.Component<Props> {
|
|||
<Menu.Item key="/requests">
|
||||
<Link to="/requests">Browse requests</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/create-request">
|
||||
<Link to="/create-request">Create a Request</Link>
|
||||
</Menu.Item>
|
||||
</Menu.ItemGroup>
|
||||
</Menu>
|
||||
</Drawer>
|
||||
|
|
|
@ -6,8 +6,24 @@ import HeaderDrawer from './Drawer';
|
|||
import MenuIcon from 'static/images/menu.svg';
|
||||
import Logo from 'static/images/logo-name.svg';
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -15,13 +31,25 @@ interface State {
|
|||
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 = {
|
||||
isDrawerOpen: false,
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
this.props.fetchCCRDrafts();
|
||||
this.props.fetchDrafts();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isTransparent } = this.props;
|
||||
const { isTransparent, ccrDrafts, proposalDrafts, hasCheckedUser } = this.props;
|
||||
const { isDrawerOpen } = this.state;
|
||||
|
||||
return (
|
||||
|
@ -39,9 +67,6 @@ export default class Header extends React.Component<Props, State> {
|
|||
<Link to="/requests" className="Header-links-link">
|
||||
Requests
|
||||
</Link>
|
||||
<Link to="/create" className="Header-links-link">
|
||||
Start a Proposal
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</Link>
|
||||
|
||||
<div className="Header-links is-right">
|
||||
<HeaderAuth />
|
||||
</div>
|
||||
{!hasCheckedUser && (ccrDrafts === null || proposalDrafts === null) ? null : (
|
||||
<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 />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 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;
|
||||
@small-query: ~'(max-width: 820px)';
|
||||
@big-query: ~'(min-width: 821px)';
|
||||
@big: ~'(max-width: 1040px)';
|
||||
|
||||
.is-desktop {
|
||||
@media @big {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
top: 0;
|
||||
|
@ -67,6 +74,8 @@
|
|||
|
||||
&-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform @header-transition ease;
|
||||
|
||||
.is-transparent & {
|
||||
|
@ -95,6 +104,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-button {
|
||||
padding: 0 @link-padding / 2;
|
||||
}
|
||||
|
||||
|
||||
&-link {
|
||||
display: block;
|
||||
background: none;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import '~styles/variables.less';
|
||||
@min-tablet-query: ~'(min-width: 920px)';
|
||||
|
||||
.HomeIntro {
|
||||
position: relative;
|
||||
|
@ -7,7 +8,7 @@
|
|||
align-items: center;
|
||||
max-width: 1440px;
|
||||
padding: 0 4rem;
|
||||
margin: 0 auto 4rem;
|
||||
margin: 4rem auto;
|
||||
overflow: hidden;
|
||||
|
||||
@media @thin-query {
|
||||
|
@ -20,7 +21,6 @@
|
|||
|
||||
&-content {
|
||||
|
||||
|
||||
&-title {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2.6rem;
|
||||
|
@ -35,27 +35,78 @@
|
|||
&-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
&-main {
|
||||
@media @tablet-query {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media @mobile-query {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3.6rem;
|
||||
padding: 0 3rem;
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
background: @primary-color;
|
||||
color: #FFF;
|
||||
height: 4.2rem;
|
||||
width: 16rem;
|
||||
padding: 0;
|
||||
margin: 0 10px;
|
||||
border: 2px solid rgba(@text-color, 0.7);
|
||||
color: rgba(@text-color, 0.7);
|
||||
text-align: center;
|
||||
font-size: 1.4rem;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
|
||||
&:hover {
|
||||
color: #FFF;
|
||||
opacity: 0.9;
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(@text-color, 0.9);
|
||||
color: rgba(@text-color, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&-learn {
|
||||
font-size: 1rem;
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-primary {
|
||||
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>
|
||||
<div className="HomeIntro-content-buttons">
|
||||
{authUser ? (
|
||||
<Link className="HomeIntro-content-buttons-main" to="/proposals">
|
||||
<Link className="HomeIntro-content-buttons-button is-primary" to="/proposals">
|
||||
{t('home.intro.browse')}
|
||||
</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')}
|
||||
</Link>
|
||||
)}
|
||||
<a className="HomeIntro-content-buttons-learn" href="#home-guide">
|
||||
{t('home.intro.learn')}
|
||||
</a>
|
||||
<Link className="HomeIntro-content-buttons-button" to="/create-request">
|
||||
{t('home.intro.ccr')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
@ -127,7 +127,6 @@ class Like extends React.Component<Props, State> {
|
|||
try {
|
||||
await likeProposal(proposalId, !authedLiked);
|
||||
await fetchProposal(proposalId);
|
||||
message.success(<>Proposal {authedLiked ? 'unliked' : 'liked'}</>);
|
||||
} catch (error) {
|
||||
// tslint:disable:no-console
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { Button, Popconfirm, message, Tag } from 'antd';
|
||||
import { UserProposal, STATUS, ContributionWithAddressesAndUser } from 'types';
|
||||
import ContributionModal from 'components/ContributionModal';
|
||||
import { getProposalStakingContribution } from 'api/api';
|
||||
import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
|
||||
import { UserProposal, STATUS } from 'types';
|
||||
import { deletePendingProposal } from 'modules/users/actions';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './ProfilePending.less';
|
||||
|
||||
interface OwnProps {
|
||||
proposal: UserProposal;
|
||||
onPublish(id: UserProposal['proposalId']): void;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
|
@ -20,7 +17,6 @@ interface StateProps {
|
|||
|
||||
interface DispatchProps {
|
||||
deletePendingProposal: typeof deletePendingProposal;
|
||||
publishPendingProposal: typeof publishPendingProposal;
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
@ -28,21 +24,17 @@ type Props = OwnProps & StateProps & DispatchProps;
|
|||
interface State {
|
||||
isDeleting: boolean;
|
||||
isPublishing: boolean;
|
||||
isLoadingStake: boolean;
|
||||
stakeContribution: ContributionWithAddressesAndUser | null;
|
||||
}
|
||||
|
||||
class ProfilePending extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
isDeleting: false,
|
||||
isPublishing: false,
|
||||
isLoadingStake: false,
|
||||
stakeContribution: null,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { status, title, proposalId, rejectReason } = this.props.proposal;
|
||||
const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state;
|
||||
const { isDeleting, isPublishing } = this.state;
|
||||
|
||||
const isDisableActions = isDeleting || isPublishing;
|
||||
|
||||
|
@ -68,7 +60,7 @@ class ProfilePending extends React.Component<Props, State> {
|
|||
tag: 'Staking',
|
||||
blurb: (
|
||||
<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
|
||||
"funded" tab.
|
||||
</div>
|
||||
|
@ -89,23 +81,13 @@ class ProfilePending extends React.Component<Props, State> {
|
|||
<div className="ProfilePending">
|
||||
<div className="ProfilePending-block">
|
||||
<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>
|
||||
<div className={`ProfilePending-status is-${status.toLowerCase()}`}>
|
||||
{st[status].blurb}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProfilePending-block is-actions">
|
||||
{STATUS.APPROVED === status && (
|
||||
<Button
|
||||
loading={isPublishing}
|
||||
disabled={isDisableActions}
|
||||
type="primary"
|
||||
onClick={this.handlePublish}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
{STATUS.REJECTED === status && (
|
||||
<Link to={`/proposals/${proposalId}/edit`}>
|
||||
<Button disabled={isDisableActions} type="primary">
|
||||
|
@ -113,15 +95,6 @@ class ProfilePending extends React.Component<Props, State> {
|
|||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{STATUS.STAKING === status && (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoadingStake}
|
||||
onClick={this.openStakingModal}
|
||||
>
|
||||
Stake
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
|
@ -133,43 +106,10 @@ class ProfilePending extends React.Component<Props, State> {
|
|||
</Button>
|
||||
</Popconfirm>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
private handlePublish = async () => {
|
||||
const {
|
||||
user,
|
||||
proposal: { proposalId },
|
||||
onPublish,
|
||||
} = this.props;
|
||||
if (!user) return;
|
||||
this.setState({ isPublishing: true });
|
||||
try {
|
||||
await this.props.publishPendingProposal(user.userid, proposalId);
|
||||
onPublish(proposalId);
|
||||
} catch (e) {
|
||||
message.error(e.message || e.toString());
|
||||
this.setState({ isPublishing: false });
|
||||
}
|
||||
};
|
||||
|
||||
private handleDelete = async () => {
|
||||
const {
|
||||
user,
|
||||
|
@ -185,26 +125,6 @@ class ProfilePending extends React.Component<Props, State> {
|
|||
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>(
|
||||
|
@ -213,6 +133,5 @@ export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|||
}),
|
||||
{
|
||||
deletePendingProposal,
|
||||
publishPendingProposal,
|
||||
},
|
||||
)(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 { Link } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { UserProposal } from 'types';
|
||||
import { UserProposal, UserCCR } from 'types';
|
||||
import ProfilePending from './ProfilePending';
|
||||
import ProfilePendingCCR from './ProfilePendingCCR';
|
||||
|
||||
interface OwnProps {
|
||||
proposals: UserProposal[];
|
||||
requests: UserCCR[];
|
||||
}
|
||||
|
||||
type Props = OwnProps;
|
||||
|
||||
const STATE = {
|
||||
publishedId: null as null | UserProposal['proposalId'],
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
||||
class ProfilePendingList extends React.Component<Props, State> {
|
||||
state = STATE;
|
||||
class ProfilePendingList extends React.Component<Props> {
|
||||
render() {
|
||||
const { proposals } = this.props;
|
||||
const { publishedId } = this.state;
|
||||
const { proposals, requests } = this.props;
|
||||
return (
|
||||
<>
|
||||
{proposals.map(p => (
|
||||
<ProfilePending
|
||||
key={p.proposalId}
|
||||
proposal={p}
|
||||
onPublish={this.handlePublish}
|
||||
/>
|
||||
<ProfilePending key={p.proposalId} proposal={p} />
|
||||
))}
|
||||
{requests.map(r => (
|
||||
<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;
|
||||
|
|
|
@ -19,6 +19,7 @@ import ProfileProposal from './ProfileProposal';
|
|||
import ProfileContribution from './ProfileContribution';
|
||||
import ProfileComment from './ProfileComment';
|
||||
import ProfileInvite from './ProfileInvite';
|
||||
import ProfileCCR from './ProfileCCR';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import Loader from 'components/Loader';
|
||||
import ExceptionPage from 'components/ExceptionPage';
|
||||
|
@ -91,6 +92,8 @@ class Profile extends React.Component<Props, State> {
|
|||
const {
|
||||
proposals,
|
||||
pendingProposals,
|
||||
pendingRequests,
|
||||
requests,
|
||||
contributions,
|
||||
comments,
|
||||
invites,
|
||||
|
@ -98,8 +101,10 @@ class Profile extends React.Component<Props, State> {
|
|||
} = user;
|
||||
|
||||
const isLoading = user.isFetching;
|
||||
const nonePending = pendingProposals.length === 0;
|
||||
const noneCreated = proposals.length === 0;
|
||||
const noProposalsPending = pendingProposals.length === 0;
|
||||
const noProposalsCreated = proposals.length === 0;
|
||||
const noRequestsPending = pendingRequests.length === 0;
|
||||
const noRequestsCreated = requests.length === 0;
|
||||
const noneFunded = contributions.length === 0;
|
||||
const noneCommented = comments.length === 0;
|
||||
const noneArbitrated = arbitrated.length === 0;
|
||||
|
@ -108,8 +113,8 @@ class Profile extends React.Component<Props, State> {
|
|||
return (
|
||||
<div className="Profile">
|
||||
<HeaderDetails
|
||||
title={`${user.displayName} is funding projects on ZF Grants`}
|
||||
description={`Join ${user.displayName} in funding the future!`}
|
||||
title={`${user.displayName} on ZF Grants`}
|
||||
description={`Join ${user.displayName} in improving the Zcash ecosystem!`}
|
||||
image={user.avatar ? user.avatar.imageUrl : undefined}
|
||||
/>
|
||||
<Switch>
|
||||
|
@ -128,33 +133,47 @@ class Profile extends React.Component<Props, State> {
|
|||
<LinkableTabs defaultActiveKey={(isAuthedUser && 'pending') || 'created'}>
|
||||
{isAuthedUser && (
|
||||
<Tabs.TabPane
|
||||
tab={TabTitle('Pending', pendingProposals.length)}
|
||||
tab={TabTitle(
|
||||
'Pending',
|
||||
pendingProposals.length + pendingRequests.length,
|
||||
)}
|
||||
key="pending"
|
||||
>
|
||||
<div>
|
||||
{nonePending && (
|
||||
<Placeholder
|
||||
loading={isLoading}
|
||||
title="No pending proposals"
|
||||
subtitle="You do not have any proposals awaiting approval."
|
||||
/>
|
||||
)}
|
||||
<ProfilePendingList proposals={pendingProposals} />
|
||||
{noProposalsPending &&
|
||||
noRequestsPending && (
|
||||
<Placeholder
|
||||
loading={isLoading}
|
||||
title="No pending items"
|
||||
subtitle="You do not have any proposals or requests awaiting approval."
|
||||
/>
|
||||
)}
|
||||
<ProfilePendingList
|
||||
proposals={pendingProposals}
|
||||
requests={pendingRequests}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
<Tabs.TabPane tab={TabTitle('Created', proposals.length)} key="created">
|
||||
<Tabs.TabPane
|
||||
tab={TabTitle('Created', proposals.length + requests.length)}
|
||||
key="created"
|
||||
>
|
||||
<div>
|
||||
{noneCreated && (
|
||||
<Placeholder
|
||||
loading={isLoading}
|
||||
title="No created proposals"
|
||||
subtitle="There have not been any created proposals."
|
||||
/>
|
||||
)}
|
||||
{noProposalsCreated &&
|
||||
noRequestsCreated && (
|
||||
<Placeholder
|
||||
loading={isLoading}
|
||||
title="No created items"
|
||||
subtitle="There have not been any created proposals or requests."
|
||||
/>
|
||||
)}
|
||||
{proposals.map(p => (
|
||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||
))}
|
||||
{requests.map(c => (
|
||||
<ProfileCCR key={c.ccrId} ccr={c} />
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
|
||||
|
|
|
@ -24,7 +24,7 @@ const TippingBlock: React.SFC<Props> = ({ proposal }) => {
|
|||
???
|
||||
<Tooltip
|
||||
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" />
|
||||
</Tooltip>
|
||||
|
|
|
@ -14,7 +14,7 @@ import { AlertProps } from 'antd/lib/alert';
|
|||
import ExceptionPage from 'components/ExceptionPage';
|
||||
import HeaderDetails from 'components/HeaderDetails';
|
||||
import CampaignBlock from './CampaignBlock';
|
||||
import TippingBlock from './TippingBlock'
|
||||
import TippingBlock from './TippingBlock';
|
||||
import TeamBlock from './TeamBlock';
|
||||
import RFPBlock from './RFPBlock';
|
||||
import Milestones from './Milestones';
|
||||
|
@ -28,7 +28,7 @@ import { withRouter } from 'react-router';
|
|||
import SocialShare from 'components/SocialShare';
|
||||
import Follow from 'components/Follow';
|
||||
import Like from 'components/Like';
|
||||
import { TipJarProposalSettingsModal } from 'components/TipJar'
|
||||
import { TipJarProposalSettingsModal } from 'components/TipJar';
|
||||
import './index.less';
|
||||
|
||||
interface OwnProps {
|
||||
|
@ -63,7 +63,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
isBodyOverflowing: false,
|
||||
isUpdateOpen: false,
|
||||
isCancelOpen: false,
|
||||
isTipJarOpen: false
|
||||
isTipJarOpen: false,
|
||||
};
|
||||
|
||||
bodyEl: HTMLElement | null = null;
|
||||
|
@ -94,7 +94,13 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
|
||||
render() {
|
||||
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 wrongProposal = proposal && proposal.proposalId !== this.props.proposalId;
|
||||
|
||||
|
@ -246,8 +252,8 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
</div>
|
||||
</div>
|
||||
<div className="Proposal-top-side">
|
||||
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
||||
<TippingBlock proposal={proposal} />
|
||||
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
||||
<TeamBlock proposal={proposal} />
|
||||
{proposal.rfp && <RFPBlock rfp={proposal.rfp} />}
|
||||
</div>
|
||||
|
@ -266,9 +272,11 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
|
||||
<UpdatesTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
||||
<ContributorsTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
{!proposal.isVersionTwo && (
|
||||
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}>
|
||||
<ContributorsTab proposalId={proposal.proposalId} />
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
</LinkableTabs>
|
||||
</div>
|
||||
|
||||
|
@ -284,7 +292,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
isVisible={isCancelOpen}
|
||||
handleClose={this.closeCancelModal}
|
||||
/>
|
||||
<TipJarProposalSettingsModal
|
||||
<TipJarProposalSettingsModal
|
||||
proposal={proposal}
|
||||
isVisible={isTipJarOpen}
|
||||
handleClose={this.closeTipJarModal}
|
||||
|
@ -315,7 +323,6 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
private openTipJarModal = () => this.setState({ isTipJarOpen: true });
|
||||
private closeTipJarModal = () => this.setState({ isTipJarOpen: false });
|
||||
|
||||
|
|
|
@ -2,12 +2,7 @@ import React from 'react';
|
|||
import { Select, Radio, Card } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||
import { SelectValue } from 'antd/lib/select';
|
||||
import {
|
||||
PROPOSAL_SORT,
|
||||
SORT_LABELS,
|
||||
PROPOSAL_STAGE,
|
||||
STAGE_UI,
|
||||
} from 'api/constants';
|
||||
import { PROPOSAL_SORT, SORT_LABELS, PROPOSAL_STAGE, STAGE_UI } from 'api/constants';
|
||||
import { typedKeys } from 'utils/ts';
|
||||
import { ProposalPage } from 'types';
|
||||
|
||||
|
@ -55,7 +50,7 @@ export default class ProposalFilters extends React.Component<Props> {
|
|||
PROPOSAL_STAGE.PREVIEW,
|
||||
PROPOSAL_STAGE.FAILED,
|
||||
PROPOSAL_STAGE.CANCELED,
|
||||
PROPOSAL_STAGE.FUNDING_REQUIRED
|
||||
PROPOSAL_STAGE.FUNDING_REQUIRED,
|
||||
].includes(s as PROPOSAL_STAGE),
|
||||
) // skip a few
|
||||
.map(s => (
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import { Progress } from 'antd'
|
||||
import { Progress } from 'antd';
|
||||
import { Proposal } from 'types';
|
||||
import Card from 'components/Card';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
import { formatUsd } from 'utils/formatters'
|
||||
import { formatUsd } from 'utils/formatters';
|
||||
import './style.less';
|
||||
|
||||
export class ProposalCard extends React.Component<Proposal> {
|
||||
|
@ -26,7 +26,7 @@ export class ProposalCard extends React.Component<Proposal> {
|
|||
contributionMatching,
|
||||
isVersionTwo,
|
||||
funded,
|
||||
percentFunded
|
||||
percentFunded,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
|
|
|
@ -86,8 +86,6 @@
|
|||
}
|
||||
|
||||
&-funding {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
line-height: 2.5rem;
|
||||
|
||||
&-raised {
|
||||
|
|
|
@ -61,20 +61,6 @@ class Proposals extends React.Component<Props, State> {
|
|||
);
|
||||
return (
|
||||
<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">
|
||||
{isFiltersDrawered ? (
|
||||
<Drawer
|
||||
|
@ -95,26 +81,43 @@ class Proposals extends React.Component<Props, State> {
|
|||
</Button>
|
||||
</Drawer>
|
||||
) : (
|
||||
<div className="Proposals-filters">{filtersComponent}</div>
|
||||
<div className="Proposals-filters">
|
||||
<div className="Proposals-search">
|
||||
<Input.Search
|
||||
placeholder="Search for a proposal"
|
||||
onChange={this.handleChangeSearch}
|
||||
value={this.state.searchQuery}
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
className="Proposals-search-filterButton"
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={this.openFilterDrawer}
|
||||
>
|
||||
<Icon type="filter" /> Filters
|
||||
</Button>
|
||||
</div>
|
||||
{filtersComponent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="Proposals-results">
|
||||
<div className="Proposals-search">
|
||||
<Input.Search
|
||||
placeholder="Search for a proposal"
|
||||
onChange={this.handleChangeSearch}
|
||||
value={this.state.searchQuery}
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
className="Proposals-search-filterButton"
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={this.openFilterDrawer}
|
||||
>
|
||||
<Icon type="filter" /> Filters
|
||||
</Button>
|
||||
<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 />
|
||||
<ProposalResults
|
||||
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