Proposal contribution matching (#117)
* BE: contribution_matching + admin proposal update end-point + tests * admin: set proposal matching status * frontend: contributionMatching * improve CampaignBlock matching callout - thx Will * adjust ProposalDetail matching confirmation popover child scope * contributed & funded Proposal fields (hybrid props) + remove funded derivation from FE * include "contributed" sum for ProposalDetail view * fix branched migration revision
This commit is contained in:
parent
73bbd191d9
commit
b0d16ace7d
|
@ -0,0 +1,10 @@
|
||||||
|
.Info {
|
||||||
|
position: relative;
|
||||||
|
&-overlay {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .anticon {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Popover, Icon } from 'antd';
|
||||||
|
import './index.less';
|
||||||
|
import { PopoverProps } from 'antd/lib/popover';
|
||||||
|
|
||||||
|
const Info: React.SFC<PopoverProps> = p => (
|
||||||
|
<span className="Info">
|
||||||
|
<Popover overlayClassName="Info-overlay" {...p}>
|
||||||
|
{p.children} <Icon type="question-circle" />
|
||||||
|
</Popover>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Info;
|
|
@ -3,6 +3,12 @@
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
&-control + &-control {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&-deet {
|
&-deet {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
|
@ -23,4 +29,10 @@
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-popover {
|
||||||
|
&-overlay {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { Row, Col, Card, Alert, Button, Collapse, Popconfirm, Modal, Input } from 'antd';
|
import {
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Card,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
Popconfirm,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
Switch,
|
||||||
|
} from 'antd';
|
||||||
import TextArea from 'antd/lib/input/TextArea';
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { formatDateSeconds } from 'util/time';
|
import { formatDateSeconds } from 'util/time';
|
||||||
import { PROPOSAL_STATUS } from 'src/types';
|
import { PROPOSAL_STATUS } from 'src/types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
|
import Info from 'components/Info';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
|
@ -42,12 +54,50 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
okText="delete"
|
okText="delete"
|
||||||
cancelText="cancel"
|
cancelText="cancel"
|
||||||
>
|
>
|
||||||
<Button icon="delete" block>
|
<Button icon="delete" className="ProposalDetail-controls-control" block>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderMatching = () => (
|
||||||
|
<div className="ProposalDetail-controls-control">
|
||||||
|
<Popconfirm
|
||||||
|
overlayClassName="ProposalDetail-popover-overlay"
|
||||||
|
onConfirm={this.handleToggleMatching}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
|
||||||
|
</div>
|
||||||
|
{p.status === PROPOSAL_STATUS.LIVE && (
|
||||||
|
<div>
|
||||||
|
This is a LIVE proposal, this will alter the funding state of the
|
||||||
|
proposal!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
okText="ok"
|
||||||
|
cancelText="cancel"
|
||||||
|
>
|
||||||
|
<Switch checked={p.contributionMatching === 1} loading={false} />{' '}
|
||||||
|
</Popconfirm>
|
||||||
|
<span>
|
||||||
|
matching{' '}
|
||||||
|
<Info
|
||||||
|
placement="right"
|
||||||
|
content={
|
||||||
|
<span>
|
||||||
|
<b>Contribution matching</b>
|
||||||
|
<br /> Funded amount will be multiplied by 2.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderApproved = () =>
|
const renderApproved = () =>
|
||||||
p.status === PROPOSAL_STATUS.APPROVED && (
|
p.status === PROPOSAL_STATUS.APPROVED && (
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -150,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
const renderDeetItem = (name: string, val: any) => (
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
<div className="ProposalDetail-deet">
|
<div className="ProposalDetail-deet">
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
{val}
|
{val}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -183,8 +233,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{/* RIGHT SIDE */}
|
{/* RIGHT SIDE */}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<Card size="small">
|
<Card size="small" className="ProposalDetail-controls">
|
||||||
{renderDelete()}
|
{renderDelete()}
|
||||||
|
{renderMatching()}
|
||||||
{/* TODO - other actions */}
|
{/* TODO - other actions */}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
@ -195,10 +246,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderDeetItem('status', p.status)}
|
{renderDeetItem('status', p.status)}
|
||||||
{renderDeetItem('category', p.category)}
|
{renderDeetItem('category', p.category)}
|
||||||
{renderDeetItem('target', p.target)}
|
{renderDeetItem('target', p.target)}
|
||||||
|
{renderDeetItem('contributed', p.contributed)}
|
||||||
|
{renderDeetItem('funded (inc. matching)', p.funded)}
|
||||||
|
{renderDeetItem('matching', p.contributionMatching)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* TEAM */}
|
{/* TEAM */}
|
||||||
<Card title="Team" size="small">
|
<Card title="team" size="small">
|
||||||
{p.team.map(t => (
|
{p.team.map(t => (
|
||||||
<div key={t.userid}>
|
<div key={t.userid}>
|
||||||
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
|
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
|
||||||
|
@ -233,6 +287,15 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
await store.approveProposal(false, this.state.rejectReason);
|
await store.approveProposal(false, this.state.rejectReason);
|
||||||
this.setState({ showRejectModal: false });
|
this.setState({ showRejectModal: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleToggleMatching = async () => {
|
||||||
|
if (store.proposalDetail) {
|
||||||
|
// we lock this to be 1 or 0 for now, we may support more values later on
|
||||||
|
const contributionMatching =
|
||||||
|
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
|
||||||
|
store.updateProposalDetail({ contributionMatching });
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
||||||
|
|
|
@ -58,6 +58,11 @@ async function fetchProposalDetail(id: number) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateProposal(p: Partial<Proposal>) {
|
||||||
|
const { data } = await api.put('/admin/proposals/' + p.proposalId, p);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteProposal(id: number) {
|
async function deleteProposal(id: number) {
|
||||||
const { data } = await api.delete('/admin/proposals/' + id);
|
const { data } = await api.delete('/admin/proposals/' + id);
|
||||||
return data;
|
return data;
|
||||||
|
@ -202,6 +207,21 @@ const app = store({
|
||||||
app.proposalDetailFetching = false;
|
app.proposalDetailFetching = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateProposalDetail(updates: Partial<Proposal>) {
|
||||||
|
if (!app.proposalDetail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await updateProposal({
|
||||||
|
...updates,
|
||||||
|
proposalId: app.proposalDetail.proposalId,
|
||||||
|
});
|
||||||
|
app.updateProposalInStore(res);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async deleteProposal(id: number) {
|
async deleteProposal(id: number) {
|
||||||
try {
|
try {
|
||||||
await deleteProposal(id);
|
await deleteProposal(id);
|
||||||
|
@ -213,10 +233,9 @@ const app = store({
|
||||||
|
|
||||||
async approveProposal(isApprove: boolean, rejectReason?: string) {
|
async approveProposal(isApprove: boolean, rejectReason?: string) {
|
||||||
if (!app.proposalDetail) {
|
if (!app.proposalDetail) {
|
||||||
(x => {
|
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||||
app.generalError.push(x);
|
app.generalError.push(m);
|
||||||
console.error(x);
|
console.error(m);
|
||||||
})('store.approveProposal(): Expected proposalDetail to be populated!');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.proposalDetailApproving = true;
|
app.proposalDetailApproving = true;
|
||||||
|
|
|
@ -39,7 +39,10 @@ export interface Proposal {
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
contractStatus: string;
|
contractStatus: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
contributed: string;
|
||||||
|
funded: string;
|
||||||
rejectReason: string;
|
rejectReason: string;
|
||||||
|
contributionMatching: number;
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
|
|
|
@ -124,7 +124,7 @@ def get_proposal(id):
|
||||||
proposal = Proposal.query.filter(Proposal.id == id).first()
|
proposal = Proposal.query.filter(Proposal.id == id).first()
|
||||||
if proposal:
|
if proposal:
|
||||||
return proposal_schema.dump(proposal)
|
return proposal_schema.dump(proposal)
|
||||||
return {"message": "Could not find proposal with id %s" % id}, 404
|
return {"message": f"Could not find proposal with id {id}"}, 404
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>', methods=['DELETE'])
|
@blueprint.route('/proposals/<id>', methods=['DELETE'])
|
||||||
|
@ -134,6 +134,29 @@ def delete_proposal(id):
|
||||||
return {"message": "Not implemented."}, 400
|
return {"message": "Not implemented."}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/proposals/<id>', methods=['PUT'])
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('contributionMatching', type=float, required=False, default=None)
|
||||||
|
)
|
||||||
|
@admin_auth_required
|
||||||
|
def update_proposal(id, contribution_matching):
|
||||||
|
proposal = Proposal.query.filter(Proposal.id == id).first()
|
||||||
|
if proposal:
|
||||||
|
if contribution_matching is not None:
|
||||||
|
# enforce 1 or 0 for now
|
||||||
|
if contribution_matching == 0.0 or contribution_matching == 1.0:
|
||||||
|
proposal.contribution_matching = contribution_matching
|
||||||
|
# TODO: trigger check if funding target reached OR make sure
|
||||||
|
# job schedule checks for funding completion include matching funds
|
||||||
|
else:
|
||||||
|
return {"message": f"Bad value for contributionMatching: {contribution_matching}"}, 400
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
|
return {"message": f"Could not find proposal with id {id}"}, 404
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
|
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('isApprove', type=bool, required=True),
|
parameter('isApprove', type=bool, required=True),
|
||||||
|
|
|
@ -8,6 +8,7 @@ from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import dt_to_unix, make_url
|
from grant.utils.misc import dt_to_unix, make_url
|
||||||
from grant.utils.requests import blockchain_get
|
from grant.utils.requests import blockchain_get
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
|
||||||
# Proposal states
|
# Proposal states
|
||||||
DRAFT = 'DRAFT'
|
DRAFT = 'DRAFT'
|
||||||
|
@ -154,6 +155,8 @@ class Proposal(db.Model):
|
||||||
target = db.Column(db.String(255), nullable=False)
|
target = db.Column(db.String(255), nullable=False)
|
||||||
payout_address = db.Column(db.String(255), nullable=False)
|
payout_address = db.Column(db.String(255), nullable=False)
|
||||||
deadline_duration = db.Column(db.Integer(), nullable=False)
|
deadline_duration = db.Column(db.Integer(), nullable=False)
|
||||||
|
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
||||||
|
contributed = db.column_property()
|
||||||
|
|
||||||
# Relations
|
# Relations
|
||||||
team = db.relationship("User", secondary=proposal_team)
|
team = db.relationship("User", secondary=proposal_team)
|
||||||
|
@ -298,13 +301,25 @@ class Proposal(db.Model):
|
||||||
self.date_published = datetime.datetime.now()
|
self.date_published = datetime.datetime.now()
|
||||||
self.status = LIVE
|
self.status = LIVE
|
||||||
|
|
||||||
def get_amount_funded(self):
|
@hybrid_property
|
||||||
|
def contributed(self):
|
||||||
contributions = ProposalContribution.query \
|
contributions = ProposalContribution.query \
|
||||||
.filter_by(proposal_id=self.id, status=CONFIRMED) \
|
.filter_by(proposal_id=self.id, status=CONFIRMED) \
|
||||||
.all()
|
.all()
|
||||||
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
|
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
|
||||||
return str(funded)
|
return str(funded)
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def funded(self):
|
||||||
|
target = float(self.target)
|
||||||
|
# apply matching multiplier
|
||||||
|
funded = float(self.contributed) * (1 + self.contribution_matching)
|
||||||
|
# if funded > target, just set as target
|
||||||
|
if funded > target:
|
||||||
|
return str(target)
|
||||||
|
|
||||||
|
return str(funded)
|
||||||
|
|
||||||
|
|
||||||
class ProposalSchema(ma.Schema):
|
class ProposalSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -321,6 +336,7 @@ class ProposalSchema(ma.Schema):
|
||||||
"brief",
|
"brief",
|
||||||
"proposal_id",
|
"proposal_id",
|
||||||
"target",
|
"target",
|
||||||
|
"contributed",
|
||||||
"funded",
|
"funded",
|
||||||
"content",
|
"content",
|
||||||
"comments",
|
"comments",
|
||||||
|
@ -330,6 +346,7 @@ class ProposalSchema(ma.Schema):
|
||||||
"team",
|
"team",
|
||||||
"payout_address",
|
"payout_address",
|
||||||
"deadline_duration",
|
"deadline_duration",
|
||||||
|
"contribution_matching",
|
||||||
"invites"
|
"invites"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -337,7 +354,6 @@ class ProposalSchema(ma.Schema):
|
||||||
date_approved = ma.Method("get_date_approved")
|
date_approved = ma.Method("get_date_approved")
|
||||||
date_published = ma.Method("get_date_published")
|
date_published = ma.Method("get_date_published")
|
||||||
proposal_id = ma.Method("get_proposal_id")
|
proposal_id = ma.Method("get_proposal_id")
|
||||||
funded = ma.Method("get_funded")
|
|
||||||
|
|
||||||
comments = ma.Nested("CommentSchema", many=True)
|
comments = ma.Nested("CommentSchema", many=True)
|
||||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
|
@ -357,9 +373,6 @@ class ProposalSchema(ma.Schema):
|
||||||
def get_date_published(self, obj):
|
def get_date_published(self, obj):
|
||||||
return dt_to_unix(obj.date_published) if obj.date_published else None
|
return dt_to_unix(obj.date_published) if obj.date_published else None
|
||||||
|
|
||||||
def get_funded(self, obj):
|
|
||||||
return obj.get_amount_funded()
|
|
||||||
|
|
||||||
|
|
||||||
proposal_schema = ProposalSchema()
|
proposal_schema = ProposalSchema()
|
||||||
proposals_schema = ProposalSchema(many=True)
|
proposals_schema = ProposalSchema(many=True)
|
||||||
|
@ -370,6 +383,7 @@ user_fields = [
|
||||||
"brief",
|
"brief",
|
||||||
"target",
|
"target",
|
||||||
"funded",
|
"funded",
|
||||||
|
"contribution_matching",
|
||||||
"date_created",
|
"date_created",
|
||||||
"date_approved",
|
"date_approved",
|
||||||
"date_published",
|
"date_published",
|
||||||
|
|
|
@ -465,7 +465,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
||||||
'proposal': contribution.proposal,
|
'proposal': contribution.proposal,
|
||||||
'contribution': contribution,
|
'contribution': contribution,
|
||||||
'contributor': contribution.user,
|
'contributor': contribution.user,
|
||||||
'funded': contribution.proposal.get_amount_funded(),
|
'funded': contribution.proposal.funded,
|
||||||
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
|
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
|
||||||
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
|
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: eddbe541cff1
|
||||||
|
Revises: 722b4e7f7a58
|
||||||
|
Create Date: 2019-01-24 11:20:32.989266
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'eddbe541cff1'
|
||||||
|
down_revision = '722b4e7f7a58'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('proposal', sa.Column('contribution_matching',
|
||||||
|
sa.Float(), server_default=sa.text('0'), nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('proposal', 'contribution_matching')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -75,6 +75,25 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
# 2 proposals created by BaseProposalCreatorConfig
|
# 2 proposals created by BaseProposalCreatorConfig
|
||||||
self.assertEqual(len(resp.json), 2)
|
self.assertEqual(len(resp.json), 2)
|
||||||
|
|
||||||
|
def test_update_proposal(self):
|
||||||
|
self.login_admin()
|
||||||
|
# set to 1 (on)
|
||||||
|
resp_on = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 1})
|
||||||
|
self.assert200(resp_on)
|
||||||
|
self.assertEqual(resp_on.json['contributionMatching'], 1)
|
||||||
|
resp_off = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 0})
|
||||||
|
self.assert200(resp_off)
|
||||||
|
self.assertEqual(resp_off.json['contributionMatching'], 0)
|
||||||
|
|
||||||
|
def test_update_proposal_no_auth(self):
|
||||||
|
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 1})
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
def test_update_proposal_bad_matching(self):
|
||||||
|
self.login_admin()
|
||||||
|
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 2})
|
||||||
|
self.assert400(resp)
|
||||||
|
|
||||||
def test_approve_proposal(self):
|
def test_approve_proposal(self):
|
||||||
self.login_admin()
|
self.login_admin()
|
||||||
# submit for approval (performed by end-user)
|
# submit for approval (performed by end-user)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { Form, Input, Button, Icon } from 'antd';
|
import { Form, Input, Button, Icon, Popover } from 'antd';
|
||||||
import { Proposal, STATUS } from 'types';
|
import { Proposal, STATUS } from 'types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { fromZat } from 'utils/units';
|
import { fromZat } from 'utils/units';
|
||||||
|
@ -95,6 +95,26 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{proposal.contributionMatching > 0 && (
|
||||||
|
<div className="ProposalCampaignBlock-matching">
|
||||||
|
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
|
||||||
|
<Popover
|
||||||
|
overlayClassName="ProposalCampaignBlock-popover-overlay"
|
||||||
|
placement="left"
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
<b>Matching</b>
|
||||||
|
<br />
|
||||||
|
Increase your impact! Contributions to this proposal are being
|
||||||
|
matched by the Zcash Foundation, up to the target amount.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon type="question-circle" theme="filled" />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFundingOver ? (
|
{isFundingOver ? (
|
||||||
<div
|
<div
|
||||||
className={classnames({
|
className={classnames({
|
||||||
|
|
|
@ -23,6 +23,31 @@
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
||||||
|
&-matching {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: @info-color;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-matching {
|
||||||
|
margin: 0.5rem -1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
background: @info-color;
|
||||||
|
color: #FFF;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-popover {
|
||||||
|
&-overlay {
|
||||||
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ export class ProposalCard extends React.Component<Proposal> {
|
||||||
team,
|
team,
|
||||||
target,
|
target,
|
||||||
funded,
|
funded,
|
||||||
|
contributionMatching,
|
||||||
percentFunded,
|
percentFunded,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
@ -33,6 +34,14 @@ export class ProposalCard extends React.Component<Proposal> {
|
||||||
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
|
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
|
||||||
>
|
>
|
||||||
<h3 className="ProposalCard-title">{title}</h3>
|
<h3 className="ProposalCard-title">{title}</h3>
|
||||||
|
{contributionMatching > 0 && (
|
||||||
|
<div className="ProposalCard-ribbon">
|
||||||
|
<span>
|
||||||
|
x2
|
||||||
|
<small>matching</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="ProposalCard-funding">
|
<div className="ProposalCard-funding">
|
||||||
<div className="ProposalCard-funding-raised">
|
<div className="ProposalCard-funding-raised">
|
||||||
<UnitDisplay value={funded} symbol="ZEC" /> <small>raised</small> of{' '}
|
<UnitDisplay value={funded} symbol="ZEC" /> <small>raised</small> of{' '}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
@import '~styles/variables.less';
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
.ProposalCard {
|
.ProposalCard {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
padding: 1rem 1rem 0;
|
padding: 1rem 1rem 0;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
@ -17,6 +19,34 @@
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 66px;
|
||||||
|
height: 66px;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: -80px;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
line-height: 0.8rem;
|
||||||
|
display: block;
|
||||||
|
background: @info-color;
|
||||||
|
color: white;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
width: 200px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
@ -58,7 +88,7 @@
|
||||||
height: 1.8rem;
|
height: 1.8rem;
|
||||||
margin-left: -0.75rem;
|
margin-left: -0.75rem;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
border: 2px solid #FFF;
|
border: 2px solid #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -187,6 +187,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
|
||||||
deadlineDuration: 86400 * 60,
|
deadlineDuration: 86400 * 60,
|
||||||
target: toZat(draft.target),
|
target: toZat(draft.target),
|
||||||
funded: Zat('0'),
|
funded: Zat('0'),
|
||||||
|
contributionMatching: 0,
|
||||||
percentFunded: 0,
|
percentFunded: 0,
|
||||||
stage: 'preview',
|
stage: 'preview',
|
||||||
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
||||||
|
|
|
@ -22,7 +22,7 @@ export function formatUserFromGet(user: UserState) {
|
||||||
}
|
}
|
||||||
user.proposals = user.proposals.map(bnUserProp);
|
user.proposals = user.proposals.map(bnUserProp);
|
||||||
user.contributions = user.contributions.map(c => {
|
user.contributions = user.contributions.map(c => {
|
||||||
c.amount = toZat(c.amount as any as string);
|
c.amount = toZat((c.amount as any) as string);
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
|
|
|
@ -153,6 +153,7 @@ export function generateProposal({
|
||||||
target: amountBn,
|
target: amountBn,
|
||||||
funded: fundedBn,
|
funded: fundedBn,
|
||||||
percentFunded,
|
percentFunded,
|
||||||
|
contributionMatching: 0,
|
||||||
title: 'Crowdfund Title',
|
title: 'Crowdfund Title',
|
||||||
brief: 'A cool test crowdfund',
|
brief: 'A cool test crowdfund',
|
||||||
content: 'body',
|
content: 'body',
|
||||||
|
|
|
@ -42,6 +42,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||||
target: Zat;
|
target: Zat;
|
||||||
funded: Zat;
|
funded: Zat;
|
||||||
percentFunded: number;
|
percentFunded: number;
|
||||||
|
contributionMatching: number;
|
||||||
milestones: ProposalMilestone[];
|
milestones: ProposalMilestone[];
|
||||||
datePublished: number;
|
datePublished: number;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue