* 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:
Daniel Ternyak 2019-12-05 19:01:02 -06:00 committed by GitHub
parent 95102842a7
commit 3311be8e98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
133 changed files with 4365 additions and 669 deletions

View File

@ -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} />

View File

@ -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;
}
}

View File

@ -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} &nbsp;
</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;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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>{' '}

View File

@ -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`}>

View File

@ -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" />

View File

@ -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) {

View File

@ -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[];

View File

@ -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 => ({

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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(
'''

View File

@ -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)

View File

@ -0,0 +1,2 @@
from . import models
from . import views

230
backend/grant/ccr/models.py Normal file
View File

@ -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)

112
backend/grant/ccr/views.py Normal file
View File

@ -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

View File

@ -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,

View File

@ -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):

View File

@ -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:

View File

@ -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):

View File

@ -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")

View File

@ -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:

View File

@ -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>

View File

@ -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 }}

View File

@ -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 %}

View File

@ -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 }}

View File

@ -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>

View File

@ -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.

View File

@ -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>

View File

@ -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:

View File

@ -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;">

View File

@ -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 }}

View File

@ -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')

View File

@ -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,

View File

@ -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)

View File

@ -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):

View File

@ -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'

View File

@ -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

View File

@ -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 ###

View File

View File

@ -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)

View File

@ -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()

View File

@ -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(

View File

@ -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"
}

View File

@ -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

View File

@ -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: {

View File

@ -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;
});
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}
}
}
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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;
}
}
}
}

View File

@ -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);

View File

@ -0,0 +1,13 @@
.CCRSubmitWarningModal {
.ant-alert {
margin-bottom: 1rem;
ul {
padding-top: 0.25rem;
}
p:last-child {
margin-bottom: 0;
}
}
}

View File

@ -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 youve
done so, you won't be able to edit it.
</p>
</div>
</Modal>
);
}
}

View File

@ -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);
});
}
};
}

View File

@ -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%);
}
}

View File

@ -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: 'Lets start with the basics',
subtitle: 'Dont worry, you can come back and change things before publishing',
help:
'You dont 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: 'Heres your chance to lay out the full request, in all its glory',
help: `Make sure people know what youre 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 doesnt look right',
help: 'Youll 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);

View File

@ -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>
)
);
}
}

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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">

View File

@ -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>(

View File

@ -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>
)}

View File

@ -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 && (

View File

@ -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 youve
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 youve
done so, you won't be able to edit it.
</p>
</div>
</Modal>
);

View File

@ -15,7 +15,7 @@
padding: 2.5rem 2rem 8rem;
&-header {
max-width: 860px;
max-width: 1200px;
padding: 0 1rem;
margin: 1rem auto 3rem;

View File

@ -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>
</>
)}

View File

@ -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

View File

@ -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);

View File

@ -1,5 +1,6 @@
.AuthButton {
transition: opacity 200ms ease;
padding-left: 0.7rem;
&.is-loading {
opacity: 0;

View File

@ -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);

View File

@ -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>

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}
}
}

View File

@ -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

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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">

View File

@ -24,7 +24,7 @@ const TippingBlock: React.SFC<Props> = ({ proposal }) => {
??? &nbsp;
<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>

View File

@ -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 });

View File

@ -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 => (

View File

@ -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 (

View File

@ -86,8 +86,6 @@
}
&-funding {
display: flex;
justify-content: center;
line-height: 2.5rem;
&-raised {

View File

@ -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