Merge pull request #459 from ZcashFoundation/develop

Release 1.4.0
This commit is contained in:
Daniel Ternyak 2019-06-11 21:51:33 -05:00 committed by GitHub
commit dd966ae853
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 257 additions and 132 deletions

View File

@ -89,7 +89,7 @@
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"react-dev-utils": "^5.0.2", "react-dev-utils": "^5.0.2",
"react-dom": "16.5.2", "react-dom": "16.5.2",
"react-easy-state": "^6.0.4", "react-easy-state": "^6.1.3",
"react-hot-loader": "^4.3.8", "react-hot-loader": "^4.3.8",
"react-router": "^4.3.1", "react-router": "^4.3.1",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",

View File

@ -36,23 +36,27 @@ class ContributionDetail extends React.Component<Props, State> {
const renderDeetItem = (label: string, val: React.ReactNode) => ( const renderDeetItem = (label: string, val: React.ReactNode) => (
<div className="ContributionDetail-deet"> <div className="ContributionDetail-deet">
<div className="ContributionDetail-deet-value"> <div className="ContributionDetail-deet-value">{val}</div>
{val} <div className="ContributionDetail-deet-label">{label}</div>
</div>
<div className="ContributionDetail-deet-label">
{label}
</div>
</div> </div>
); );
const renderSendRefund = () => { const renderSendRefund = () => {
if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed || !c.user) { if (
c.staking ||
!c.refundAddress ||
c.refundTxId ||
!c.proposal.isFailed ||
!c.user
) {
return; return;
} }
const percent = c.proposal.milestones.reduce((prev, m) => { const percent = c.proposal.milestones.reduce((prev, m) => {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev; return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100); }, 100);
const amount = toZat(c.amount).muln(percent).divn(100); const amount = toZat(c.amount)
.muln(percent)
.divn(100);
return ( return (
<Alert <Alert
className="ContributionDetail-alert" className="ContributionDetail-alert"
@ -62,15 +66,15 @@ class ContributionDetail extends React.Component<Props, State> {
description={ description={
<div> <div>
<p> <p>
The proposal this contribution was made towards has failed, and The proposal this contribution was made towards has failed, and is ready
is ready to be refunded to <strong>{c.user.displayName}</strong> to be refunded to <strong>{c.user.displayName}</strong> for{' '}
{' '}for <strong>{percent}%</strong> of the contribution amount. <strong>{percent}%</strong> of the contribution amount. Please Make a
Please Make a payment of <strong>{fromZat(amount)} ZEC</strong> to: payment of <strong>{fromZat(amount)} ZEC</strong> to:
</p> </p>
<pre>{c.refundAddress}</pre> <pre>{c.refundAddress}</pre>
<p> <p>
They will be sent an email notifying them of the refund when you They will be sent an email notifying them of the refund when you enter the
enter the txid below. txid below.
</p> </p>
<Input.Search <Input.Search
placeholder="Enter payment txid" placeholder="Enter payment txid"
@ -98,7 +102,6 @@ class ContributionDetail extends React.Component<Props, State> {
<pre>{JSON.stringify(c.addresses, null, 4)}</pre> <pre>{JSON.stringify(c.addresses, null, 4)}</pre>
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel key="user" header="user"> <Collapse.Panel key="user" header="user">
{c.user ? <UserItem {...c.user} /> : <em>Anonymous contribution</em>} {c.user ? <UserItem {...c.user} /> : <em>Anonymous contribution</em>}
</Collapse.Panel> </Collapse.Panel>
@ -118,7 +121,9 @@ class ContributionDetail extends React.Component<Props, State> {
{/* ACTIONS */} {/* ACTIONS */}
<Card size="small" className="ContributionDetail-controls"> <Card size="small" className="ContributionDetail-controls">
<Link to={`/contributions/${id}/edit`}> <Link to={`/contributions/${id}/edit`}>
<Button type="primary" block>Edit</Button> <Button type="primary" block>
Edit
</Button>
</Link> </Link>
</Card> </Card>
@ -128,16 +133,20 @@ class ContributionDetail extends React.Component<Props, State> {
{renderDeetItem('created', formatDateSeconds(c.dateCreated))} {renderDeetItem('created', formatDateSeconds(c.dateCreated))}
{renderDeetItem('status', c.status)} {renderDeetItem('status', c.status)}
{renderDeetItem('amount', c.amount)} {renderDeetItem('amount', c.amount)}
{renderDeetItem('txid', c.txId {renderDeetItem(
? <Input size="small" value={c.txId} readOnly /> 'txid',
: <em>N/A</em> c.txId ? <Input size="small" value={c.txId} readOnly /> : <em>N/A</em>,
)} )}
{renderDeetItem('refund txid', c.refundTxId {renderDeetItem(
? <Input size="small" value={c.refundTxId} readOnly /> 'refund txid',
: <em>N/A</em> c.refundTxId ? (
<Input size="small" value={c.refundTxId} readOnly />
) : (
<em>N/A</em>
),
)} )}
{renderDeetItem('staking tx', JSON.stringify(c.staking))} {renderDeetItem('staking tx', JSON.stringify(c.staking))}
{renderDeetItem('no refund', JSON.stringify(c.noRefund))} {renderDeetItem('private', JSON.stringify(c.private))}
</Card> </Card>
</Col> </Col>
</Row> </Row>

View File

@ -152,7 +152,7 @@ export interface Contribution {
memo: string; memo: string;
}; };
staking: boolean; staking: boolean;
noRefund: boolean; private: boolean;
refundAddress?: string; refundAddress?: string;
refundTxId?: string; refundTxId?: string;
} }

View File

@ -792,9 +792,10 @@
reflect-metadata "^0.1.12" reflect-metadata "^0.1.12"
tslib "^1.8.1" tslib "^1.8.1"
"@nx-js/observer-util@^4.1.1": "@nx-js/observer-util@^4.2.2":
version "4.2.0" version "4.2.2"
resolved "https://registry.yarnpkg.com/@nx-js/observer-util/-/observer-util-4.2.0.tgz#ec9e2f903dda94cc3d8ac077617bc369f2ad2d6e" resolved "https://registry.yarnpkg.com/@nx-js/observer-util/-/observer-util-4.2.2.tgz#faea1bc61936653486d1b5dec7485220991e628d"
integrity sha512-9OayX1xkdGjdnsDiO2YdaYJ6aMyCF7/NY4QWVgIgjSAZJ4OX2fD766Ts79hEzBscenQy2DCaSoY8VkguIMB1ZA==
"@samverschueren/stream-to-observable@^0.3.0": "@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0" version "0.3.0"
@ -6616,11 +6617,12 @@ react-dom@16.5.2:
prop-types "^15.6.2" prop-types "^15.6.2"
schedule "^0.5.0" schedule "^0.5.0"
react-easy-state@^6.0.4: react-easy-state@^6.1.3:
version "6.0.4" version "6.1.3"
resolved "https://registry.yarnpkg.com/react-easy-state/-/react-easy-state-6.0.4.tgz#94a124fe69723abcb922c15059dd444b2e3499f3" resolved "https://registry.yarnpkg.com/react-easy-state/-/react-easy-state-6.1.3.tgz#f9db4e8d842b5acfb73b6899aaf49a26900f7d26"
integrity sha512-uWQ7ittvJylwn/Xgz7Ub1jjsbpthQ9Ad1KDLxXfbXCb2OPnov4porVdnOJU2PKeRezcam3ZgfPUtf9L9rjtyWg==
dependencies: dependencies:
"@nx-js/observer-util" "^4.1.1" "@nx-js/observer-util" "^4.2.2"
react-error-overlay@^4.0.1: react-error-overlay@^4.0.1:
version "4.0.1" version "4.0.1"

View File

@ -156,7 +156,6 @@ def stats():
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \ contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
.filter(ProposalContribution.refund_tx_id == None) \ .filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \ .filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \ .join(Proposal) \
.filter(or_( .filter(or_(
@ -609,7 +608,7 @@ def get_contribution(contribution_id):
@body({ @body({
"proposalId": fields.Int(required=False, missing=None), "proposalId": fields.Int(required=False, missing=None),
"userId": fields.Int(required=False, missing=None), "userId": fields.Int(required=False, missing=None),
"status": fields.Str(required=True, validate=validate.OneOf(choices=ContributionStatus.list())), "status": fields.Str(required=False, missing=None, validate=validate.OneOf(choices=ContributionStatus.list())),
"amount": fields.Str(required=False, missing=None), "amount": fields.Str(required=False, missing=None),
"txId": fields.Str(required=False, missing=None), "txId": fields.Str(required=False, missing=None),
"refundTxId": fields.Str(required=False, allow_none=True, missing=None), "refundTxId": fields.Str(required=False, allow_none=True, missing=None),
@ -723,6 +722,8 @@ def financials():
SELECT SUM(TO_NUMBER(amount, '{nfmt}')) SELECT SUM(TO_NUMBER(amount, '{nfmt}'))
FROM proposal_contribution as pc FROM proposal_contribution as pc
INNER JOIN proposal as p ON pc.proposal_id = p.id INNER JOIN proposal as p ON pc.proposal_id = p.id
LEFT OUTER JOIN "user" as u ON pc.user_id = u.id
LEFT OUTER JOIN user_settings as us ON u.id = us.user_id
WHERE {where} WHERE {where}
''' '''
@ -743,14 +744,34 @@ def financials():
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))), 'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))), 'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))), 'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
# should have a refund_address
'refunding': str(ex(sql_pc_p( 'refunding': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NULL AND p.stage IN ('CANCELED', 'FAILED')" '''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NULL AND
p.stage IN ('CANCELED', 'FAILED') AND
us.refund_address IS NOT NULL
'''
))), ))),
# here we don't care about current refund_address of user, just that there has been a refund_tx_id
'refunded': str(ex(sql_pc_p( 'refunded': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NOT NULL AND p.stage IN ('CANCELED', 'FAILED')" '''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NOT NULL AND
p.stage IN ('CANCELED', 'FAILED')
'''
))), ))),
# if there is no user, or the user hasn't any refund_address
'donations': str(ex(sql_pc_p( 'donations': str(ex(sql_pc_p(
"(pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.refund_tx_id IS NULL) AND (pc.no_refund = TRUE OR pc.user_id IS NULL) AND p.stage IN ('CANCELED', 'FAILED')" '''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NULL AND
(pc.user_id IS NULL OR us.refund_address IS NULL) AND
p.stage IN ('CANCELED', 'FAILED')
'''
))), ))),
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))), 'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
} }

View File

@ -30,7 +30,7 @@ class Comment(db.Model):
self.proposal_id = proposal_id self.proposal_id = proposal_id
self.user_id = user_id self.user_id = user_id
self.parent_comment_id = parent_comment_id self.parent_comment_id = parent_comment_id
self.content = content[:1000] self.content = content[:5000]
self.date_created = datetime.datetime.now() self.date_created = datetime.datetime.now()
@staticmethod @staticmethod

View File

@ -88,7 +88,7 @@ class ProposalContribution(db.Model):
tx_id = db.Column(db.String(255), nullable=True) tx_id = db.Column(db.String(255), nullable=True)
refund_tx_id = db.Column(db.String(255), nullable=True) refund_tx_id = db.Column(db.String(255), nullable=True)
staking = db.Column(db.Boolean, nullable=False) staking = db.Column(db.Boolean, nullable=False)
no_refund = db.Column(db.Boolean, nullable=False) private = db.Column(db.Boolean, nullable=False, default=False, server_default='true')
user = db.relationship("User") user = db.relationship("User")
@ -98,23 +98,23 @@ class ProposalContribution(db.Model):
amount: str, amount: str,
user_id: int = None, user_id: int = None,
staking: bool = False, staking: bool = False,
no_refund: bool = False, private: bool = True,
): ):
self.proposal_id = proposal_id self.proposal_id = proposal_id
self.amount = amount self.amount = amount
self.user_id = user_id self.user_id = user_id
self.staking = staking self.staking = staking
self.no_refund = no_refund self.private = private
self.date_created = datetime.datetime.now() self.date_created = datetime.datetime.now()
self.status = ContributionStatus.PENDING self.status = ContributionStatus.PENDING
@staticmethod @staticmethod
def get_existing_contribution(user_id: int, proposal_id: int, amount: str, no_refund: bool = False): def get_existing_contribution(user_id: int, proposal_id: int, amount: str, private: bool = False):
return ProposalContribution.query.filter_by( return ProposalContribution.query.filter_by(
user_id=user_id, user_id=user_id,
proposal_id=proposal_id, proposal_id=proposal_id,
amount=amount, amount=amount,
no_refund=no_refund, private=private,
status=ContributionStatus.PENDING, status=ContributionStatus.PENDING,
).first() ).first()
@ -425,14 +425,14 @@ class Proposal(db.Model):
amount, amount,
user_id: int = None, user_id: int = None,
staking: bool = False, staking: bool = False,
no_refund: bool = False, private: bool = True,
): ):
contribution = ProposalContribution( contribution = ProposalContribution(
proposal_id=self.id, proposal_id=self.id,
amount=amount, amount=amount,
user_id=user_id, user_id=user_id,
staking=staking, staking=staking,
no_refund=no_refund, private=private
) )
db.session.add(contribution) db.session.add(contribution)
db.session.flush() db.session.flush()
@ -841,6 +841,7 @@ class ProposalContributionSchema(ma.Schema):
"date_created", "date_created",
"addresses", "addresses",
"is_anonymous", "is_anonymous",
"private"
) )
proposal = ma.Nested("ProposalSchema") proposal = ma.Nested("ProposalSchema")
@ -861,11 +862,11 @@ class ProposalContributionSchema(ma.Schema):
} }
def get_is_anonymous(self, obj): def get_is_anonymous(self, obj):
return not obj.user_id return not obj.user_id or obj.private
@post_dump @post_dump
def stub_anonymous_user(self, data): def stub_anonymous_user(self, data):
if 'user' in data and data['user'] is None: if 'user' in data and data['user'] is None or data['private']:
data['user'] = anonymous_user data['user'] = anonymous_user
return data return data
@ -894,7 +895,7 @@ class AdminProposalContributionSchema(ma.Schema):
"refund_address", "refund_address",
"refund_tx_id", "refund_tx_id",
"staking", "staking",
"no_refund", "private",
) )
proposal = ma.Nested("ProposalSchema") proposal = ma.Nested("ProposalSchema")

View File

@ -99,7 +99,7 @@ def report_proposal_comment(proposal_id, comment_id):
@limiter.limit("30/hour;2/minute") @limiter.limit("30/hour;2/minute")
@requires_email_verified_auth @requires_email_verified_auth
@body({ @body({
"comment": fields.Str(required=True, validate=validate.Length(max=1000)), "comment": fields.Str(required=True, validate=validate.Length(max=5000)),
"parentCommentId": fields.Int(required=False, missing=None), "parentCommentId": fields.Int(required=False, missing=None),
}) })
def post_proposal_comments(proposal_id, comment, parent_comment_id): def post_proposal_comments(proposal_id, comment, parent_comment_id):
@ -478,30 +478,26 @@ def get_proposal_contribution(proposal_id, contribution_id):
@limiter.limit("30/day;10/hour;2/minute") @limiter.limit("30/day;10/hour;2/minute")
@body({ @body({
"amount": fields.Str(required=True, validate=lambda p: 0.0001 <= float(p) <= 1000000), "amount": fields.Str(required=True, validate=lambda p: 0.0001 <= float(p) <= 1000000),
"anonymous": fields.Bool(required=False, missing=None), "private": fields.Bool(required=False, missing=True)
"noRefund": fields.Bool(required=False, missing=False),
}) })
def post_proposal_contribution(proposal_id, amount, anonymous, no_refund): def post_proposal_contribution(proposal_id, amount, private):
proposal = Proposal.query.filter_by(id=proposal_id).first() proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal: if not proposal:
return {"message": "No proposal matching id"}, 404 return {"message": "No proposal matching id"}, 404
code = 200 code = 200
user = None
contribution = None
if not anonymous:
user = get_authed_user() user = get_authed_user()
contribution = None
if user: if user:
contribution = ProposalContribution \ contribution = ProposalContribution \
.get_existing_contribution(user.id, proposal_id, amount, no_refund) .get_existing_contribution(user.id, proposal_id, amount, private)
if not contribution: if not contribution:
code = 201 code = 201
contribution = proposal.create_contribution( contribution = proposal.create_contribution(
amount=amount, amount=amount,
no_refund=no_refund, private=private,
user_id=user.id if user else None, user_id=user.id if user else None,
) )

View File

@ -66,6 +66,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
contributions = ProposalContribution.get_by_userid(user_id) contributions = ProposalContribution.get_by_userid(user_id)
if not authed_user or user.id != authed_user.id: if not authed_user or user.id != authed_user.id:
contributions = [c for c in contributions if c.status == ContributionStatus.CONFIRMED] contributions = [c for c in contributions if c.status == ContributionStatus.CONFIRMED]
contributions = [c for c in contributions if not c.private]
contributions = [c for c in contributions if c.proposal.status == ProposalStatus.LIVE] contributions = [c for c in contributions if c.proposal.status == ProposalStatus.LIVE]
contributions_dump = user_proposal_contributions_schema.dump(contributions) contributions_dump = user_proposal_contributions_schema.dump(contributions)
result["contributions"] = contributions_dump result["contributions"] = contributions_dump

View File

@ -159,7 +159,6 @@ class ContributionPagination(Pagination):
if 'REFUNDABLE' in filters: if 'REFUNDABLE' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \ query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \ .filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \ .join(Proposal) \
.filter(or_( .filter(or_(
@ -173,15 +172,14 @@ class ContributionPagination(Pagination):
if 'DONATION' in filters: if 'DONATION' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \ query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.filter(or_(
ProposalContribution.no_refund == True,
ProposalContribution.user_id == None,
)) \
.join(Proposal) \ .join(Proposal) \
.filter(or_( .filter(or_(
Proposal.stage == ProposalStage.FAILED, Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED, Proposal.stage == ProposalStage.CANCELED,
)) )) \
.join(ProposalContribution.user, isouter=True) \
.join(UserSettings, isouter=True) \
.filter(UserSettings.refund_address == None)
# SORT (see self.SORT_MAP) # SORT (see self.SORT_MAP)
if sort: if sort:

View File

@ -0,0 +1,33 @@
"""proposal_contribution: add private, remove no_refund
Revision ID: 4505f00c4ebd
Revises: 0f08974b4118
Create Date: 2019-06-07 10:31:47.120185
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4505f00c4ebd'
down_revision = '0f08974b4118'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('private', sa.Boolean(), server_default='true', nullable=False))
op.drop_column('proposal_contribution', 'no_refund')
# ### end Alembic commands ###
# existing contributions with user ids are public
op.execute("UPDATE proposal_contribution SET private = FALSE WHERE user_id IS NOT NULL")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('no_refund', sa.BOOLEAN(), autoincrement=False, nullable=False))
op.drop_column('proposal_contribution', 'private')
# ### end Alembic commands ###

View File

@ -1,4 +1,5 @@
from grant.utils.enums import Category from grant.utils.enums import Category
from .mocks import mock_request from .mocks import mock_request
test_user = { test_user = {
@ -54,21 +55,70 @@ test_comment = {
test_comment_large = { test_comment_large = {
"comment": """ "comment": """
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa massa vitae tortor condimentum lacinia quis vel eros donec ac odio tempor orci dapibus ultrices
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa in iaculis nunc sed augue lacus viverra vitae congue eu consequat ac felis donec et odio
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa pellentesque diam volutpat commodo sed egestas egestas fringilla phasellus
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa faucibus scelerisque eleifend donec pretium vulputate sapien nec
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa sagittis aliquam malesuada bibendum arcu vitae elementum curabitur
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa vitae nunc sed velit dignissim sodales ut eu sem integer vitae justo
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eget magna fermentum iaculis eu non diam phasellus vestibulum lorem sed
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa risus ultricies tristique nulla aliquet enim tortor at auctor urna nunc id
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cursus metus aliquam eleifend mi in nulla posuere sollicitudin aliquam ultrices
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa sagittis orci a scelerisque purus semper eget duis at tellus at urna condimentum
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa mattis pellentesque id nibh tortor id aliquet lectus proin nibh nisl condimentum id
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa venenatis a condimentum vitae sapien pellentesque habitant morbi tristique senectus
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa et netus et malesuada fames ac turpis egestas sed tempus urna et pharetra pharetra
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa amet consectetur adipiscing elit pellentesque habitant morbi tristique senectus et
netus et malesuada fames ac turpis egestas integer eget aliquet nibh praesent
tristique magna sit amet purus gravida quis blandit turpis cursus in hac habitasse
platea dictumst quisque sagittis purus sit amet volutpat consequat mauris nunc
congue nisi vitae suscipit tellus mauris a diam maecenas sed enim ut sem viverra
aliquet eget sit amet tellus cras adipiscing enim eu turpis egestas pretium aenean
pharetra magna ac placerat vestibulum lectus mauris ultrices eros in cursus
turpis massa tincidunt dui ut ornare lectus sit amet est placerat in egestas
erat imperdiet sed euismod nisi porta lorem mollis aliquam ut
porttitor leo a diam sollicitudin tempor id eu nisl nunc mi
ipsum faucibus vitae aliquet nec ullamcorper sit amet risus
nullam eget felis eget nunc lobortis mattis aliquam faucibus
purus in massa tempor nec feugiat nisl pretium fusce id velit ut tortor
pretium viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare suspendisse
sed nisi lacus sed viverra tellus in hac habitasse platea
dictumst vestibulum rhoncus est pellentesque
elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu dictum varius duis at
consectetur lorem donec massa sapien faucibus et molestie ac feugiat sed lectus
vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt ornare massa
eget egestas purus viverra accumsan in nisl nisi scelerisque eu ultrices vitae auctor
eu augue ut lectus arcu bibendum at varius vel pharetra vel turpis nunc eget lorem dolor sed viverra ipsum nunc
aliquet bibendum enim facilisis gravida neque convallis a cras semper auctor neque vitae tempus quam pellentesque
nec nam aliquam sem et tortor consequat id porta nibh venenatis cras sed felis eget velit aliquet sagittis id
consectetur purus ut faucibus pulvinar elementum integer enim neque volutpat ac tincidunt vitae semper quis lectus
nulla at volutpat diam ut venenatis tellus in metus vulputate eu scelerisque felis imperdiet proin fermentum leo
vel orci porta non pulvinar neque laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget
nullam non nisi est sit amet facilisis magna etiam tempor orci eu lobortis elementum nibh tellus molestie nunc non
blandit massa enim nec dui nunc mattis enim ut tellus elementum sagittis vitae et leo duis ut diam quam nulla
porttitor massa id neque aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus imperdiet nulla
malesuada pellentesque elit eget gravida cum sociis natoque penatibus et magnis dis parturient montes nascetur
ridiculus mus mauris vitae ultricies leo integer malesuada nunc vel risus commodo viverra maecenas accumsan lacus
vel facilisis volutpat est velit egestas dui id ornare arcu odio ut sem nulla pharetra diam sit amet nisl suscipit
adipiscing bibendum est ultricies integer quis auctor elit sed vulputate mi sit amet mauris commodo quis imperdiet
massa tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin libero nunc consequat interdum varius
sit amet mattis vulputate enim nulla aliquet porttitor lacus luctus accumsan tortor posuere ac ut consequat semper
viverra nam libero justo laoreet sit amet cursus sit amet dictum sit amet justo donec enim diam vulputate ut
pharetra sit amet aliquam id diam maecenas ultricies mi eget mauris pharetra et ultrices neque ornare aenean
euismod elementum nisi quis eleifend quam adipiscing vitae proin sagittis nisl rhoncus mattis rhoncus urna neque
viverra justo nec ultrices dui sapien eget mi proin sed libero enim sed faucibus turpis in eu mi bibendum neque
egestas congue quisque egestas diam in arcu cursus euismod quis viverra nibh cras pulvinar mattis nunc sed blandit
libero volutpat sed cras ornare arcu dui vivamus arcu felis bibendum ut tristique et egestas quis ipsum
suspendisse ultrices gravida dictum fusce ut placerat orci nulla pellentesque dignissim enim sit amet venenatis
urna cursus eget nunc scelerisque viverra mauris in aliquam sem fringilla ut morbi tincidunt augue interdum velit
euismod in pellentesque massa placerat duis ultricies lacus sed turpis tincidunt id aliquet risus feugiat in ante
metus dictum at tempor commodo ullamcorper a lacus vestibulum sed arcu non odio euismod lacinia at quis risus sed
vulputate odio ut enim blandit volutpat maecenas volutpat blandit aliquam etiam erat velit scelerisque in dictum
non consectetur a erat nam at lectus urna duis convallis convallis tellus id interdum velit laoreet id donec
ultrices tincidunt arcu non sodales neque
sodales ut etiam sit amet nisl purus in mollis nunc sed id semper risus in hendrerit gravida rutrum quisque non
tellus orci ac auctor augue mauris augue neque gravida in fermentum et sollicitudin
""" """
} }

View File

@ -316,13 +316,11 @@ export function putInviteResponse(
export function postProposalContribution( export function postProposalContribution(
proposalId: number, proposalId: number,
amount: string, amount: string,
anonymous: boolean = false, isPrivate: boolean = true,
noRefund: boolean = false,
): Promise<{ data: ContributionWithAddressesAndUser }> { ): Promise<{ data: ContributionWithAddressesAndUser }> {
return axios.post(`/api/v1/proposals/${proposalId}/contributions`, { return axios.post(`/api/v1/proposals/${proposalId}/contributions`, {
amount, amount,
anonymous, private: isPrivate,
noRefund,
}); });
} }

View File

@ -20,6 +20,7 @@ interface OwnProps {
contributionId?: number; contributionId?: number;
amount?: string; amount?: string;
isAnonymous?: boolean; isAnonymous?: boolean;
isPublic?: boolean;
hasNoButtons?: boolean; hasNoButtons?: boolean;
text?: React.ReactNode; text?: React.ReactNode;
handleClose(): void; handleClose(): void;
@ -127,17 +128,13 @@ class ContributionModal extends React.Component<Props, State> {
message="This contribution will not be attributed" message="This contribution will not be attributed"
description={ description={
<> <>
Your contribution will show up without attribution. Even if you're logged ZF Grants is unable to offer refunds for contributions made without
in, the contribution will not appear anywhere on your account after you accounts with a set refund address. If refunds for this campaign are issued, your contribution will be
close this modal. treated as a donation to the Zcash Foundation.
<br /> <br /> <br /> <br />
ZF Grants is unable to offer refunds for non-attributed contributions. If If you would like your contribution to be eligible for refund, you can close
refunds for this campaign are issued, your contribution will be treated as a this modal, make sure you're logged in, set a refund address, and attempt to contribute again. You
donation to the Zcash Foundation. can still choose to contribute without public attribution while logged in.
<br /> <br />
If you would like to have your contribution attached to an account and
remain eligible for refunds, you can close this modal, make sure you're
logged in, and don't check the "Contribute without attribution" checkbox.
<br /> <br /> <br /> <br />
NOTE: The Zcash Foundation is unable to accept donations of more than $5,000 NOTE: The Zcash Foundation is unable to accept donations of more than $5,000
USD worth of ZEC from anonymous users. USD worth of ZEC from anonymous users.
@ -221,7 +218,7 @@ class ContributionModal extends React.Component<Props, State> {
) { ) {
this.setState({ isFetchingContribution: true }); this.setState({ isFetchingContribution: true });
try { try {
const { amount, isAnonymous, authUser } = this.props; const { amount, isAnonymous, authUser, isPublic } = this.props;
// Ensure auth'd users have a refund address unless they've confirmed // Ensure auth'd users have a refund address unless they've confirmed
if (!isAnonymous && !noRefund) { if (!isAnonymous && !noRefund) {
@ -240,12 +237,7 @@ class ContributionModal extends React.Component<Props, State> {
if (contributionId) { if (contributionId) {
res = await getProposalContribution(proposalId, contributionId); res = await getProposalContribution(proposalId, contributionId);
} else { } else {
res = await postProposalContribution( res = await postProposalContribution(proposalId, amount || '0', !isPublic);
proposalId,
amount || '0',
isAnonymous,
noRefund,
);
} }
this.setState({ contribution: res.data }); this.setState({ contribution: res.data });
} catch (err) { } catch (err) {

View File

@ -39,10 +39,7 @@ const commands: { [key in MARKDOWN_TYPE]: ReactMdeProps['commands'] } = {
], ],
}, },
{ {
commands: [ commands: [ReactMdeCommands.linkCommand, ReactMdeCommands.quoteCommand],
ReactMdeCommands.linkCommand,
ReactMdeCommands.quoteCommand,
],
}, },
{ {
commands: [ commands: [
@ -104,7 +101,7 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
const { randomKey, value, tab } = this.state; const { randomKey, value, tab } = this.state;
return ( return (
<div <div
ref={(el) => this.el = el} ref={el => (this.el = el)}
className={classnames({ className={classnames({
MarkdownEditor: true, MarkdownEditor: true,
['is-reduced']: type === MARKDOWN_TYPE.REDUCED, ['is-reduced']: type === MARKDOWN_TYPE.REDUCED,
@ -119,6 +116,7 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
generateMarkdownPreview={this.generatePreview} generateMarkdownPreview={this.generatePreview}
commands={commands[type]} commands={commands[type]}
readOnly={!!readOnly} readOnly={!!readOnly}
textAreaProps={{ maxLength: 5000 }}
minEditorHeight={minHeight} minEditorHeight={minHeight}
minPreviewHeight={minHeight - 10} minPreviewHeight={minHeight - 10}
maxEditorHeight={99999} maxEditorHeight={99999}

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Tag, Popconfirm } from 'antd'; import { Tag, Popconfirm, Tooltip } from 'antd';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import { ONE_DAY } from 'utils/time'; import { ONE_DAY } from 'utils/time';
import { formatTxExplorerUrl } from 'utils/formatters'; import { formatTxExplorerUrl } from 'utils/formatters';
@ -25,7 +25,7 @@ type Props = OwnProps & DispatchProps;
class ProfileContribution extends React.Component<Props> { class ProfileContribution extends React.Component<Props> {
render() { render() {
const { contribution } = this.props; const { contribution } = this.props;
const { proposal } = contribution; const { proposal, private: isPrivate } = contribution;
const isConfirmed = contribution.status === 'CONFIRMED'; const isConfirmed = contribution.status === 'CONFIRMED';
const isExpired = const isExpired =
(!isConfirmed && contribution.dateCreated < Date.now() / 1000 - ONE_DAY) || (!isConfirmed && contribution.dateCreated < Date.now() / 1000 - ONE_DAY) ||
@ -63,6 +63,18 @@ class ProfileContribution extends React.Component<Props> {
); );
} }
const privateTag = isPrivate ? (
<Tooltip
title={
<>
Other users will <b>not</b> be able to see that you made this contribution.
</>
}
>
<Tag>Private</Tag>
</Tooltip>
) : null;
return ( return (
<div className="ProfileContribution"> <div className="ProfileContribution">
<div className="ProfileContribution-info"> <div className="ProfileContribution-info">
@ -70,7 +82,8 @@ class ProfileContribution extends React.Component<Props> {
className="ProfileContribution-info-title" className="ProfileContribution-info-title"
to={`/proposals/${proposal.proposalId}`} to={`/proposals/${proposal.proposalId}`}
> >
{proposal.title} {tag} {proposal.title} {privateTag}
{tag}
</Link> </Link>
<div className="ProfileContribution-info-brief">{proposal.brief}</div> <div className="ProfileContribution-info-brief">{proposal.brief}</div>
</div> </div>

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Form, Input, Checkbox, Button, Icon, Popover, Tooltip } from 'antd'; import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd';
import { CheckboxChangeEvent } from 'antd/lib/checkbox'; import { RadioChangeEvent } from 'antd/lib/radio';
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';
@ -30,7 +30,7 @@ type Props = OwnProps & StateProps;
interface State { interface State {
amountToRaise: string; amountToRaise: string;
amountError: string | null; amountError: string | null;
isAnonymous: boolean; isPrivate: boolean;
isContributing: boolean; isContributing: boolean;
} }
@ -40,14 +40,14 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
this.state = { this.state = {
amountToRaise: '', amountToRaise: '',
amountError: null, amountError: null,
isAnonymous: false, isPrivate: true,
isContributing: false, isContributing: false,
}; };
} }
render() { render() {
const { proposal, isPreview, authUser } = this.props; const { proposal, isPreview, authUser } = this.props;
const { amountToRaise, amountError, isAnonymous, isContributing } = this.state; const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0; const amountFloat = parseFloat(amountToRaise) || 0;
let content; let content;
if (proposal) { if (proposal) {
@ -190,12 +190,23 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</Form.Item> </Form.Item>
{amountToRaise && {amountToRaise &&
!!authUser && ( !!authUser && (
<Checkbox checked={isAnonymous} onChange={this.handleChangeAnonymity}> <Radio.Group
onChange={this.handleChangePrivate}
value={isPrivate ? 'isPrivate' : 'isNotPrivate'}
>
<Radio value={'isPrivate'}>
Contribute without attribution Contribute without attribution
<Tooltip title="Your contribution will not be linked to your account. ZF Grants cannot refund non-attributed contributions."> <Tooltip title="Other users will not see who made this contribution.">
<Icon type="question-circle" /> <Icon type="question-circle" />
</Tooltip> </Tooltip>
</Checkbox> </Radio>
<Radio value={'isNotPrivate'}>
Attribute contribution publicly
<Tooltip title="Other users will be able to see that you made this contribution.">
<Icon type="question-circle" />
</Tooltip>
</Radio>
</Radio.Group>
)} )}
<Button <Button
onClick={this.openContributionModal} onClick={this.openContributionModal}
@ -214,7 +225,8 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
isVisible={isContributing} isVisible={isContributing}
proposalId={proposal.proposalId} proposalId={proposal.proposalId}
amount={amountToRaise} amount={amountToRaise}
isAnonymous={isAnonymous || !authUser} isAnonymous={!authUser}
isPublic={!isPrivate}
handleClose={this.closeContributionModal} handleClose={this.closeContributionModal}
/> />
</React.Fragment> </React.Fragment>
@ -255,8 +267,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
this.setState({ amountToRaise: value, amountError }); this.setState({ amountToRaise: value, amountError });
}; };
private handleChangeAnonymity = (ev: CheckboxChangeEvent) => { private handleChangePrivate = (ev: RadioChangeEvent) => {
this.setState({ isAnonymous: ev.target.checked }); const isPrivate = ev.target.value === 'isPrivate';
this.setState({ isPrivate });
}; };
private openContributionModal = () => this.setState({ isContributing: true }); private openContributionModal = () => this.setState({ isContributing: true });

View File

@ -37,7 +37,7 @@
margin: 0.5rem -1.5rem; margin: 0.5rem -1.5rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: center; text-align: center;
color: #FFF; color: #fff;
font-size: 1rem; font-size: 1rem;
.anticon { .anticon {
@ -57,7 +57,6 @@
margin-top: 0; margin-top: 0;
} }
&-popover { &-popover {
&-overlay { &-overlay {
max-width: 400px; max-width: 400px;
@ -91,7 +90,7 @@
width: 100%; width: 100%;
} }
.ant-checkbox-wrapper { .ant-radio-wrapper {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
opacity: 0.7; opacity: 0.7;
font-size: 0.8rem; font-size: 0.8rem;

View File

@ -9,7 +9,7 @@ import {
LoadableProposalPage, LoadableProposalPage,
Moreable, Moreable,
} from 'types'; } from 'types';
import { PROPOSAL_SORT, PROPOSAL_STAGE } from 'api/constants'; import { PROPOSAL_SORT } from 'api/constants';
export interface ProposalDetail extends Proposal { export interface ProposalDetail extends Proposal {
isRequestingPayout: boolean; isRequestingPayout: boolean;
@ -62,7 +62,7 @@ export const INITIAL_STATE: ProposalState = {
sort: PROPOSAL_SORT.NEWEST, sort: PROPOSAL_SORT.NEWEST,
filters: { filters: {
category: [], category: [],
stage: [PROPOSAL_STAGE.FUNDING_REQUIRED], stage: [],
}, },
items: [], items: [],
hasFetched: false, hasFetched: false,

View File

@ -2,4 +2,4 @@
* You may reach out to the Zcash Foundation by emailing us at contact@zfnd.org * You may reach out to the Zcash Foundation by emailing us at contact@zfnd.org
* You can find us on twitter at https://twitter.com/zcashfoundation * You can find us on twitter at https://twitter.com/zcashfoundation
* You can contribute or report issues at https://github.com/zcashfoundation * You can contribute or report issues at https://github.com/ZcashFoundation/zcash-grant-system/issues

View File

@ -30,4 +30,5 @@ export interface UserContribution extends Omit<Contribution, 'amount' | 'txId'>
amount: Zat; amount: Zat;
txId?: string; txId?: string;
proposal: Proposal; proposal: Proposal;
private: boolean;
} }