diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 24a2cbcd..01f84bd2 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -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 { + + diff --git a/admin/src/components/ArbiterControl/index.tsx b/admin/src/components/ArbiterControl/index.tsx index 4c85d617..fd71ae5f 100644 --- a/admin/src/components/ArbiterControl/index.tsx +++ b/admin/src/components/ArbiterControl/index.tsx @@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component { }, 1000); render() { - const { arbiter } = this.props; + const { arbiter, isVersionTwo, acceptedWithFunding } = this.props; const { showSearch, searching } = this.state; const { results, search, error } = store.arbitersSearch; const showEmpty = !results.length && !searching; + const buttonDisabled = isVersionTwo && acceptedWithFunding === false const disp = { [PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter', @@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component { type="primary" onClick={this.handleShowSearch} {...this.props.buttonProps} + disabled={buttonDisabled} > {disp[arbiter.status]} diff --git a/admin/src/components/CCRDetail/index.less b/admin/src/components/CCRDetail/index.less new file mode 100644 index 00000000..5789dcfc --- /dev/null +++ b/admin/src/components/CCRDetail/index.less @@ -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; + } +} diff --git a/admin/src/components/CCRDetail/index.tsx b/admin/src/components/CCRDetail/index.tsx new file mode 100644 index 00000000..860d4d87 --- /dev/null +++ b/admin/src/components/CCRDetail/index.tsx @@ -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; + +const STATE = { + paidTxId: '', + showCancelAndRefundPopover: false, + showChangeToAcceptedWithFundingPopover: false, +}; + +type State = typeof STATE; + +class CCRDetailNaked extends React.Component { + 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 && ( + + ); + + const renderReview = () => + c.status === CCR_STATUS.PENDING && ( + +

+ Please review this Community Created Request and render your judgment. +

+ + + + } + /> + ); + + const renderRejected = () => + c.status === CCR_STATUS.REJECTED && ( + +

+ This CCR has changes requested. The team will be able to re-submit it for + approval should they desire to do so. +

+ Reason: +
+ {c.rejectReason} + + } + /> + ); + + const renderDeetItem = (name: string, val: any) => ( +
+ {name} + {val}   +
+ ); + + return ( +
+ +

{c.title}

+ + {/* MAIN */} + + {renderApproved()} + {renderReview()} + {renderRejected()} + + + + {c.brief} + + + + + + + + + + + +
{JSON.stringify(c, null, 4)}
+
+
+ + + {/* RIGHT SIDE */} + + {c.rfp && ( + + This CCR has been accepted and is instantiated as an RFP{' '} + here. + + } + type="info" + showIcon + /> + )} + + {/* DETAILS */} + + {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)} + + + +
+ {c.author.displayName} +
+
+ + +
+
+ ); + } + + 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; diff --git a/admin/src/components/CCRs/CCRItem.less b/admin/src/components/CCRs/CCRItem.less new file mode 100644 index 00000000..c9e18451 --- /dev/null +++ b/admin/src/components/CCRs/CCRItem.less @@ -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; + } +} diff --git a/admin/src/components/CCRs/CCRItem.tsx b/admin/src/components/CCRs/CCRItem.tsx new file mode 100644 index 00000000..67775e59 --- /dev/null +++ b/admin/src/components/CCRs/CCRItem.tsx @@ -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 { + render() { + const props = this.props; + const status = getStatusById(CCR_STATUSES, props.status); + + return ( + + +

+ {props.title || '(no title)'} + + + {status.tagDisplay === 'Live' + ? 'Accepted/Generated RFP' + : status.tagDisplay} + + +

+

Created: {formatDateSeconds(props.dateCreated)}

+

{props.brief}

+ +
+ ); + } +} + +const CCRItem = view(CCRItemNaked); +export default CCRItem; diff --git a/admin/src/components/CCRs/index.tsx b/admin/src/components/CCRs/index.tsx new file mode 100644 index 00000000..9eb5c059 --- /dev/null +++ b/admin/src/components/CCRs/index.tsx @@ -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 ( + } + handleSearch={store.fetchCCRs} + handleChangeQuery={store.setCCRPageQuery} + handleResetQuery={store.resetCCRPageQuery} + /> + ); + } +} + +export default view(CCRs); diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index 7312117e..1b7e888f 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -43,8 +43,8 @@ export default [ }, { id: 'proposal_rejected', - title: 'Proposal rejected', - description: 'Sent when an admin rejects your submitted proposal', + title: 'Proposal changes requested', + description: 'Sent when an admin requests changes for your submitted proposal', }, { id: 'proposal_contribution', @@ -130,6 +130,11 @@ export default [ title: 'Milestone paid', description: 'Sent when milestone is paid', }, + { + id: 'milestone_deadline', + title: 'Milestone deadline', + description: 'Sent when the estimated deadline for milestone has been reached', + }, { id: 'admin_approval', title: 'Admin Approval', @@ -145,4 +150,15 @@ export default [ title: 'Admin Payout', description: 'Sent when milestone payout has been approved', }, + { + id: 'followed_proposal_milestone', + title: 'Followed Proposal Milestone', + description: + 'Sent to followers of a proposal when one of its milestones has been approved', + }, + { + id: 'followed_proposal_update', + title: 'Followed Proposal Update', + description: 'Sent to followers of a proposal when it has a new update', + }, ] as Email[]; diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx index 66ebb62a..fd6eeffa 100644 --- a/admin/src/components/Home/index.tsx +++ b/admin/src/components/Home/index.tsx @@ -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 && ( +
+ There are {ccrPendingCount} community + created requests waiting for review.{' '} + Click here to view them. +
+ ), !!proposalPendingCount && (
There are {proposalPendingCount}{' '} @@ -32,7 +40,7 @@ class Home extends React.Component {
There are {proposalNoArbiterCount}{' '} live proposals without an arbiter.{' '} - + Click here {' '} to view them. diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index b36ac488..63ec023e 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -26,10 +26,6 @@ .ant-alert, .ant-collapse { margin-bottom: 16px; - - button + button { - margin-left: 0.5rem; - } } &-popover { @@ -46,4 +42,9 @@ white-space: inherit; } } + + &-review { + margin-right: 0.5rem; + margin-bottom: 0.25rem; + } } diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 977ad24b..3d921d34 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -11,7 +11,6 @@ import { Collapse, Popconfirm, Input, - Switch, Tag, message, } from 'antd'; @@ -26,11 +25,11 @@ import { } from 'src/types'; import { Link } from 'react-router-dom'; import Back from 'components/Back'; -import Info from 'components/Info'; import Markdown from 'components/Markdown'; import ArbiterControl from 'components/ArbiterControl'; import { toZat, fromZat } from 'src/util/units'; import FeedbackModal from '../FeedbackModal'; +import { formatUsd } from 'util/formatters'; import './index.less'; type Props = RouteComponentProps; @@ -38,6 +37,7 @@ type Props = RouteComponentProps; const STATE = { paidTxId: '', showCancelAndRefundPopover: false, + showChangeToAcceptedWithFundingPopover: false, }; type State = typeof STATE; @@ -65,17 +65,32 @@ class ProposalDetailNaked extends React.Component { return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev; }, 100); + const { isVersionTwo } = p; + const shouldShowArbiter = + !isVersionTwo || (isVersionTwo && p.acceptedWithFunding === true); + const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund'; + const shouldShowChangeToAcceptedWithFunding = + isVersionTwo && p.acceptedWithFunding === false; + const renderCancelControl = () => { const disabled = this.getCancelAndRefundDisabled(); return ( - Are you sure you want to cancel proposal and begin -
- the refund process? This cannot be undone. -

+ isVersionTwo ? ( +

+ Are you sure you want to cancel proposal? +
+ This cannot be undone. +

+ ) : ( +

+ Are you sure you want to cancel proposal and begin +
+ the refund process? This cannot be undone. +

+ ) } placement="left" cancelText="cancel" @@ -95,7 +110,40 @@ class ProposalDetailNaked extends React.Component { disabled={disabled} block > - Cancel & refund + {cancelButtonText} + +
+ ); + }; + + const renderChangeToAcceptedWithFundingControl = () => { + return ( + + Are you sure you want to accept the proposal +
+ with funding? This cannot be undone. +

+ } + placement="left" + cancelText="cancel" + okText="confirm" + visible={this.state.showChangeToAcceptedWithFundingPopover} + okButtonProps={{ + loading: store.proposalDetailCanceling, + }} + onCancel={this.handleChangeToAcceptWithFundingCancel} + onConfirm={this.handleChangeToAcceptWithFundingConfirm} + > +
); @@ -116,69 +164,6 @@ class ProposalDetailNaked extends React.Component { /> ); - const renderMatchingControl = () => ( -
- -
- Turn {p.contributionMatching ? 'off' : 'on'} contribution matching? -
- {p.status === PROPOSAL_STATUS.LIVE && ( -
- This is a LIVE proposal, this will alter the funding state of the - proposal! -
- )} - - } - okText="ok" - cancelText="cancel" - > - {' '} -
- - matching{' '} - - Contribution matching -
Funded amount will be multiplied by 2. -
Disabled after proposal is fully-funded. -
- } - /> - -
- ); - - const renderBountyControl = () => ( -
- -
- ); - const renderApproved = () => p.status === PROPOSAL_STATUS.APPROVED && ( { const renderReview = () => p.status === PROPOSAL_STATUS.PENDING && ( - -

Please review this proposal and render your judgment.

- - -
- } - /> + <> + + + +

Please review this proposal and render your judgment.

+ + + +
+ } + /> + + {p.isVersionTwo && ( + + + {p.rfpOptIn ? ( +

KYC has been accepted by the proposer.

+ ) : ( +

+ KYC has been rejected. Recommend against approving with funding. +

+ )} + + } + /> + + )} + + ); const renderRejected = () => @@ -234,12 +256,12 @@ class ProposalDetailNaked extends React.Component {

- This proposal has been rejected. The team will be able to re-submit it for - approval should they desire to do so. + This proposal has changes requested. The team will be able to re-submit it + for approval should they desire to do so.

Reason:
@@ -250,7 +272,8 @@ class ProposalDetailNaked extends React.Component { ); const renderNominateArbiter = () => - needsArbiter && ( + needsArbiter && + shouldShowArbiter && ( { return; } const ms = p.currentMilestone; - const amount = fromZat( - toZat(p.target) - .mul(new BN(ms.payoutPercent)) - .divn(100), - ); + + let paymentMsg; + if (p.isVersionTwo) { + const target = parseFloat(p.target.toString()); + const payoutPercent = parseFloat(ms.payoutPercent); + const amountNum = (target * payoutPercent) / 100; + const amount = formatUsd(amountNum, true, 2); + paymentMsg = `${amount} in ZEC`; + } else { + const amount = fromZat( + toZat(p.target) + .mul(new BN(ms.payoutPercent)) + .divn(100), + ); + paymentMsg = `${amount} ZEC`; + } + return ( {

{' '} - Please make a payment of {amount.toString()} ZEC to: + Please make a payment of {paymentMsg} to:

{' '}
{p.payoutAddress}
{ {renderMilestoneAccepted()} {renderFailed()} - {p.brief} @@ -391,24 +425,35 @@ class ProposalDetailNaked extends React.Component { - { - p.milestones.map((milestone, i) => + {p.milestones.map((milestone, i) => ( + + {milestone.title + ' '} + {milestone.immediatePayout && ( + Immediate Payout + )} + + } + extra={`${milestone.payoutPercent}% Payout`} + key={i} + > + {p.isVersionTwo && ( +

+ Estimated Days to Complete:{' '} + {milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '} +

+ )} +

+ Estimated Date:{' '} + {milestone.dateEstimated + ? formatDateSeconds(milestone.dateEstimated) + : 'N/A'}{' '} +

- - {milestone.title + ' '} - {milestone.immediatePayout && Immediate Payout} - - } - extra={`${milestone.payoutPercent}% Payout`} - key={i} - > -

Estimated Date: {formatDateSeconds(milestone.dateEstimated )}

-

{milestone.content}

-
- - ) - } +

{milestone.content}

+
+ ))}
@@ -419,12 +464,23 @@ class ProposalDetailNaked extends React.Component { {/* RIGHT SIDE */} + {p.isVersionTwo && + !p.acceptedWithFunding && + p.stage === PROPOSAL_STAGE.WIP && ( + + )} + {/* ACTIONS */} {renderCancelControl()} {renderArbiterControl()} - {renderBountyControl()} - {renderMatchingControl()} + {shouldShowChangeToAcceptedWithFunding && + renderChangeToAcceptedWithFundingControl()} {/* DETAILS */} @@ -447,13 +503,19 @@ class ProposalDetailNaked extends React.Component { {renderDeetItem('isFailed', JSON.stringify(p.isFailed))} {renderDeetItem('status', p.status)} {renderDeetItem('stage', p.stage)} - {renderDeetItem('category', p.category)} - {renderDeetItem('target', p.target)} + {renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)} {renderDeetItem('contributed', p.contributed)} - {renderDeetItem('funded (inc. matching)', p.funded)} + {renderDeetItem( + 'funded (inc. matching)', + p.isVersionTwo ? formatUsd(p.funded) : p.funded, + )} {renderDeetItem('matching', p.contributionMatching)} {renderDeetItem('bounty', p.contributionBounty)} {renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))} + {renderDeetItem( + 'acceptedWithFunding', + JSON.stringify(p.acceptedWithFunding), + )} {renderDeetItem( 'arbiter', <> @@ -508,6 +570,20 @@ class ProposalDetailNaked extends React.Component { } }; + private handleChangeToAcceptedWithFunding = () => { + this.setState({ showChangeToAcceptedWithFundingPopover: true }); + }; + + private handleChangeToAcceptWithFundingCancel = () => { + this.setState({ showChangeToAcceptedWithFundingPopover: false }); + }; + + private handleChangeToAcceptWithFundingConfirm = () => { + if (!store.proposalDetail) return; + store.changeProposalToAcceptedWithFunding(store.proposalDetail.proposalId); + this.setState({ showChangeToAcceptedWithFundingPopover: false }); + }; + private getIdFromQuery = () => { return Number(this.props.match.params.id); }; @@ -526,44 +602,13 @@ class ProposalDetailNaked extends React.Component { this.setState({ showCancelAndRefundPopover: false }); }; - private handleApprove = () => { - store.approveProposal(true); + private handleApprove = (withFunding: boolean) => { + store.approveProposal(true, withFunding); }; private handleReject = async (reason: string) => { - await store.approveProposal(false, reason); - message.info('Proposal rejected'); - }; - - 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; - await store.updateProposalDetail({ contributionMatching }); - message.success('Updated matching'); - } - }; - - private handleSetBounty = async () => { - if (store.proposalDetail) { - FeedbackModal.open({ - title: 'Set bounty?', - content: - 'Set the bounty for this proposal. The bounty will count towards the funding goal.', - type: 'input', - inputProps: { - addonBefore: 'Amount', - addonAfter: 'ZEC', - placeholder: '1.5', - }, - okText: 'Set bounty', - onOk: async contributionBounty => { - await store.updateProposalDetail({ contributionBounty }); - message.success('Updated bounty'); - }, - }); - } + await store.approveProposal(false, false, reason); + message.info('Proposal changes requested'); }; private handlePaidMilestone = async () => { diff --git a/admin/src/components/RFPDetail/index.tsx b/admin/src/components/RFPDetail/index.tsx index d6c5c8df..9f1e7e16 100644 --- a/admin/src/components/RFPDetail/index.tsx +++ b/admin/src/components/RFPDetail/index.tsx @@ -2,13 +2,14 @@ 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'; import { formatDateSeconds } from 'util/time'; import store from 'src/store'; import { PROPOSAL_STATUS } from 'src/types'; +import { formatUsd } from 'src/util/formatters'; import './index.less'; type Props = RouteComponentProps<{ id?: string }>; @@ -37,9 +38,11 @@ class RFPDetail extends React.Component { ); - const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING); - const acceptedProposals = rfp.proposals.filter(p => - p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED + const pendingProposals = rfp.proposals.filter( + p => p.status === PROPOSAL_STATUS.PENDING, + ); + const acceptedProposals = rfp.proposals.filter( + p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED, ); return ( @@ -66,6 +69,20 @@ class RFPDetail extends React.Component { {/* RIGHT SIDE */} + {rfp.ccr && ( + + This RFP has been generated from a CCR{' '} + here. + + } + type="info" + showIcon + /> + )} + {/* ACTIONS */} @@ -90,10 +107,15 @@ class RFPDetail extends React.Component { {renderDeetItem('id', rfp.id)} {renderDeetItem('created', formatDateSeconds(rfp.dateCreated))} {renderDeetItem('status', rfp.status)} - {renderDeetItem('category', rfp.category)} {renderDeetItem('matching', String(rfp.matching))} - {renderDeetItem('bounty', `${rfp.bounty} ZEC`)} - {renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))} + {renderDeetItem( + 'bounty', + rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`, + )} + {renderDeetItem( + 'dateCloses', + rfp.dateCloses && formatDateSeconds(rfp.dateCloses), + )} {/* PROPOSALS */} diff --git a/admin/src/components/RFPForm/index.tsx b/admin/src/components/RFPForm/index.tsx index 0726a4a4..a82789f8 100644 --- a/admin/src/components/RFPForm/index.tsx +++ b/admin/src/components/RFPForm/index.tsx @@ -3,23 +3,10 @@ import moment from 'moment'; import { view } from 'react-easy-state'; import { RouteComponentProps, withRouter } from 'react-router'; import { Link } from 'react-router-dom'; -import { - Form, - Input, - Select, - Icon, - Button, - message, - Spin, - Checkbox, - Row, - Col, - DatePicker, -} from 'antd'; +import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd'; import Exception from 'ant-design-pro/lib/Exception'; import { FormComponentProps } from 'antd/lib/form'; -import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types'; -import { CATEGORY_UI } from 'util/ui'; +import { RFP_STATUS, RFPArgs } from 'src/types'; import { typedKeys } from 'util/ts'; import { RFP_STATUSES, getStatusById } from 'util/statuses'; import Markdown from 'components/Markdown'; @@ -54,13 +41,14 @@ class RFPForm extends React.Component { title: '', brief: '', content: '', - category: '', status: '', matching: false, bounty: undefined, dateCloses: undefined, }; const rfpId = this.getRFPId(); + let isVersionTwo = true; + if (rfpId) { if (!store.rfpsFetched) { return ; @@ -72,12 +60,12 @@ class RFPForm extends React.Component { title: rfp.title, brief: rfp.brief, content: rfp.content, - category: rfp.category, status: rfp.status, matching: rfp.matching, bounty: rfp.bounty, dateCloses: rfp.dateCloses || undefined, }; + isVersionTwo = rfp.isVersionTwo; } else { return ; } @@ -88,6 +76,10 @@ class RFPForm extends React.Component { : defaults.dateCloses && moment(defaults.dateCloses * 1000); const forceClosed = dateCloses && dateCloses.isBefore(moment.now()); + const bountyMatchRule = isVersionTwo + ? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' } + : {}; + return (
@@ -131,28 +123,6 @@ class RFPForm extends React.Component { )} - - {getFieldDecorator('category', { - initialValue: defaults.category, - rules: [ - { required: true, message: 'Category is required' }, - { max: 60, message: 'Max 60 chars' }, - ], - })( - , - )} - - {getFieldDecorator('brief', { initialValue: defaults.brief, @@ -199,26 +169,20 @@ class RFPForm extends React.Component { {getFieldDecorator('bounty', { initialValue: defaults.bounty, + rules: [ + { required: true, message: 'Bounty is required' }, + bountyMatchRule, + ], })( , )} - {getFieldDecorator('matching', { - initialValue: defaults.matching, - })( - - Match community contributions for approved proposals - , - )} diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index 4954f3a2..5d5aad58 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -51,6 +51,12 @@ class Template extends React.Component { Proposals + + + + CCRs + + diff --git a/admin/src/store.ts b/admin/src/store.ts index 02b82236..6c1dc42b 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios'; import { User, Proposal, + CCR, Contribution, ContributionArgs, RFP, @@ -129,9 +130,15 @@ async function deleteProposal(id: number) { return data; } -async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) { - const { data } = await api.put(`/admin/proposals/${id}/approve`, { - isApprove, +async function approveProposal( + id: number, + isAccepted: boolean, + withFunding: boolean, + rejectReason?: string, +) { + const { data } = await api.put(`/admin/proposals/${id}/accept`, { + isAccepted, + withFunding, rejectReason, }); return data; @@ -142,6 +149,11 @@ async function cancelProposal(id: number) { return data; } +async function changeProposalToAcceptedWithFunding(id: number) { + const { data } = await api.put(`/admin/proposals/${id}/accept/fund`); + return data; +} + async function fetchComments(params: Partial) { const { data } = await api.get('/admin/comments', { params }); return data; @@ -165,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) { + const { data } = await api.get(`/admin/ccrs`, { params }); + return data; +} + +export async function deleteCCR(id: number) { + await api.delete(`/admin/ccrs/${id}`); +} + async function getRFPs() { const { data } = await api.get(`/admin/rfps`); return data; @@ -218,6 +252,7 @@ const app = store({ stats: { userCount: 0, proposalCount: 0, + ccrPendingCount: 0, proposalPendingCount: 0, proposalNoArbiterCount: 0, proposalMilestonePayoutsCount: 0, @@ -282,6 +317,25 @@ const app = store({ proposalDetailCanceling: false, proposalDetailUpdating: false, proposalDetailUpdated: false, + proposalDetailChangingToAcceptedWithFunding: false, + + ccrs: { + page: createDefaultPageData('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('CREATED:DESC'), @@ -482,6 +536,53 @@ const app = store({ app.arbiterSaving = false; }, + // CCRS + + async fetchCCRs() { + return await pageFetch(app.ccrs, fetchCCRs); + }, + + setCCRPageQuery(params: Partial) { + 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() { @@ -536,7 +637,11 @@ const app = store({ } }, - async approveProposal(isApprove: 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); @@ -546,7 +651,12 @@ const app = store({ app.proposalDetailApproving = true; try { const { proposalId } = app.proposalDetail; - const res = await approveProposal(proposalId, isApprove, rejectReason); + const res = await approveProposal( + proposalId, + isAccepted, + withFunding, + rejectReason, + ); app.updateProposalInStore(res); } catch (e) { handleApiError(e); @@ -565,6 +675,19 @@ const app = store({ app.proposalDetailCanceling = false; }, + async changeProposalToAcceptedWithFunding(id: number) { + app.proposalDetailChangingToAcceptedWithFunding = true; + + try { + const res = await changeProposalToAcceptedWithFunding(id); + app.updateProposalInStore(res); + } catch (e) { + handleApiError(e); + } + + app.proposalDetailChangingToAcceptedWithFunding = false; + }, + async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { app.proposalDetailMarkingMilestonePaid = true; try { diff --git a/admin/src/types.ts b/admin/src/types.ts index 7129194d..dd73c51f 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -17,7 +17,8 @@ export interface Milestone { index: number; content: string; dateCreated: number; - dateEstimated: number; + dateEstimated?: number; + daysEstimated?: string; dateRequested: number; dateAccepted: number; dateRejected: number; @@ -41,18 +42,18 @@ export interface RFP { title: string; brief: string; content: string; - category: string; status: string; proposals: Proposal[]; matching: boolean; bounty: string | null; dateCloses: number | null; + isVersionTwo: boolean; + ccr?: CCR; } export interface RFPArgs { title: string; brief: string; content: string; - category: string; matching: boolean; dateCloses: number | null | undefined; bounty: string | null | undefined; @@ -102,7 +103,6 @@ export interface Proposal { title: string; content: string; stage: PROPOSAL_STAGE; - category: string; milestones: Milestone[]; currentMilestone?: Milestone; team: User[]; @@ -116,6 +116,8 @@ export interface Proposal { rfpOptIn: null | boolean; rfp?: RFP; arbiter: ProposalArbiter; + acceptedWithFunding: boolean | null; + isVersionTwo: boolean; } export interface Comment { id: number; @@ -199,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[]; diff --git a/admin/src/util/filters.ts b/admin/src/util/filters.ts index f861d1fa..c726296b 100644 --- a/admin/src/util/filters.ts +++ b/admin/src/util/filters.ts @@ -5,6 +5,7 @@ import { PROPOSAL_ARBITER_STATUSES, MILESTONE_STAGES, PROPOSAL_STAGES, + CCR_STATUSES, } from './statuses'; export interface Filter { @@ -59,7 +60,21 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({ color: s.tagColor, group: 'Milestone', })), - ); + ) + .concat([ + { + id: 'ACCEPTED_WITH_FUNDING', + display: 'Accepted With Funding', + color: '#2D2A26', + group: 'Funding', + }, + { + id: 'ACCEPTED_WITHOUT_FUNDING', + display: 'Accepted Without Funding', + color: '#108ee9', + group: 'Funding', + }, + ]); export const proposalFilters: Filters = { list: PROPOSAL_FILTERS, @@ -80,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 => ({ @@ -87,17 +116,20 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({ display: `Status: ${s.tagDisplay}`, color: s.tagColor, group: 'Status', -})).concat([{ - id: 'REFUNDABLE', - display: 'Refundable', - color: '#afd500', - group: 'Refundable', -}, { - id: 'DONATION', - display: 'Donations', - color: '#afd500', - group: 'Donations', -}]); +})).concat([ + { + id: 'REFUNDABLE', + display: 'Refundable', + color: '#afd500', + group: 'Refundable', + }, + { + id: 'DONATION', + display: 'Donations', + color: '#afd500', + group: 'Donations', + }, +]); export const contributionFilters: Filters = { list: CONTRIBUTION_FILTERS, diff --git a/admin/src/util/formatters.ts b/admin/src/util/formatters.ts new file mode 100644 index 00000000..1e1b0848 --- /dev/null +++ b/admin/src/util/formatters.ts @@ -0,0 +1,72 @@ + +const toFixed = (num: string, digits: number = 3) => { + const [integerPart, fractionPart = ''] = num.split('.'); + if (fractionPart.length === digits) { + return num; + } + if (fractionPart.length < digits) { + return `${integerPart}.${fractionPart.padEnd(digits, '0')}`; + } + + let decimalPoint = integerPart.length; + + const formattedFraction = fractionPart.slice(0, digits); + + const integerArr = `${integerPart}${formattedFraction}`.split('').map(str => +str); + + let carryOver = Math.floor((+fractionPart[digits] + 5) / 10); + + // grade school addition / rounding + for (let i = integerArr.length - 1; i >= 0; i--) { + const currVal = integerArr[i] + carryOver; + const newVal = currVal % 10; + carryOver = Math.floor(currVal / 10); + integerArr[i] = newVal; + if (i === 0 && carryOver > 0) { + integerArr.unshift(0); + decimalPoint++; + i++; + } + } + + const strArr = integerArr.map(n => n.toString()); + + strArr.splice(decimalPoint, 0, '.'); + + if (strArr[strArr.length - 1] === '.') { + strArr.pop(); + } + + return strArr.join(''); +}; + +export function formatNumber(num: string, digits?: number): string { + const parts = toFixed(num, digits).split('.'); + + // Remove trailing zeroes on decimal (If there is a decimal) + if (parts[1]) { + parts[1] = parts[1].replace(/0+$/, ''); + + // If there's nothing left, remove decimal altogether + if (!parts[1]) { + parts.pop(); + } + } + + // Commafy the whole numbers + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + return parts.join('.'); +} + +export function formatUsd( + amount: number | string | undefined | null, + includeDollarSign: boolean = true, + digits: number = 0, +) { + if (!amount) return includeDollarSign ? '$0' : '0'; + const a = typeof amount === 'number' ? amount.toString() : amount; + const str = formatNumber(a, digits); + return includeDollarSign ? `$${str}` : str; +} + diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index 967fffa8..c001044b 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -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> = [ }, ]; +export const CCR_STATUSES: Array> = [ + { + 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> = [ { id: PROPOSAL_STATUS.APPROVED, @@ -77,14 +118,14 @@ export const PROPOSAL_STATUSES: Array> = [ id: PROPOSAL_STATUS.PENDING, tagDisplay: 'Awaiting Approval', tagColor: '#ffaa00', - hint: 'User is waiting for admin to approve or reject this Proposal.', + hint: 'User is waiting for admin to approve or request changes to this Proposal.', }, { id: PROPOSAL_STATUS.REJECTED, - tagDisplay: 'Approval Rejected', + tagDisplay: 'Changes Requested', tagColor: '#eb4118', hint: - 'Admin has rejected this proposal. User may adjust it and resubmit for approval.', + 'Admin has requested changes for this proposal. User may adjust it and resubmit for approval.', }, { id: PROPOSAL_STATUS.STAKING, diff --git a/backend/.env.example b/backend/.env.example index 94801610..b5f09516 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/" PROPOSAL_STAKING_AMOUNT=0.025 # Maximum amount for a proposal target, keep in sync with frontend .env -PROPOSAL_TARGET_MAX=10000 +PROPOSAL_TARGET_MAX=999999 diff --git a/backend/README.md b/backend/README.md index f585f594..fecc3b42 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index b1455aff..f4f9f24b 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -149,6 +149,10 @@ example_email_args = { 'proposal': proposal, 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', }, + 'milestone_deadline': { + 'proposal': proposal, + 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', + }, 'milestone_reject': { 'proposal': proposal, 'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.', @@ -178,4 +182,13 @@ example_email_args = { 'proposal': proposal, 'proposal_url': 'https://grants-admin.zfnd.org/proposals/999', }, + 'followed_proposal_milestone': { + "proposal": proposal, + "milestone": milestone, + "proposal_url": "http://someproposal.com", + }, + 'followed_proposal_update': { + "proposal": proposal, + "proposal_url": "http://someproposal.com", + }, } diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index e9916a1b..6fee7186 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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() @@ -145,6 +149,7 @@ def stats(): .filter(Proposal.status == ProposalStatus.LIVE) \ .filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \ .filter(Proposal.stage != ProposalStage.CANCELED) \ + .filter(Proposal.accepted_with_funding == True) \ .scalar() proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \ .join(Proposal.milestones) \ @@ -159,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, @@ -313,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 @@ -352,45 +358,48 @@ def delete_proposal(id): return {"message": "Not implemented."}, 400 -@blueprint.route('/proposals/', methods=['PUT']) +@blueprint.route('/proposals//accept', methods=['PUT']) @body({ - "contributionMatching": fields.Int(required=False, missing=None), - "contributionBounty": fields.Str(required=False, missing=None) -}) -@admin.admin_auth_required -def update_proposal(id, contribution_matching, contribution_bounty): - proposal = Proposal.query.filter(Proposal.id == id).first() - if not proposal: - return {"message": f"Could not find proposal with id {id}"}, 404 - - if contribution_matching is not None: - proposal.set_contribution_matching(contribution_matching) - - if contribution_bounty is not None: - proposal.set_contribution_bounty(contribution_bounty) - - db.session.add(proposal) - db.session.commit() - - return proposal_schema.dump(proposal) - - -@blueprint.route('/proposals//approve', methods=['PUT']) -@body({ - "isApprove": fields.Bool(required=True), + "isAccepted": fields.Bool(required=True), + "withFunding": fields.Bool(required=True), "rejectReason": fields.Str(required=False, missing=None) }) @admin.admin_auth_required -def approve_proposal(id, is_approve, reject_reason=None): +def approve_proposal(id, is_accepted, with_funding, reject_reason=None): proposal = Proposal.query.filter_by(id=id).first() if proposal: - proposal.approve_pending(is_approve, reject_reason) + proposal.approve_pending(is_accepted, with_funding, reject_reason) + + if is_accepted and with_funding: + Milestone.set_v2_date_estimates(proposal) + db.session.commit() return proposal_schema.dump(proposal) return {"message": "No proposal found."}, 404 +@blueprint.route('/proposals//accept/fund', methods=['PUT']) +@admin.admin_auth_required +def change_proposal_to_accepted_with_funding(id): + proposal = Proposal.query.filter_by(id=id).first() + if not proposal: + return {"message": "No proposal found."}, 404 + if proposal.accepted_with_funding: + return {"message": "Proposal already accepted with funding."}, 404 + if proposal.version != '2': + return {"message": "Only version two proposals can be accepted with funding"}, 404 + if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.APPROVED: + return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404 + + proposal.update_proposal_with_funding() + Milestone.set_v2_date_estimates(proposal) + db.session.add(proposal) + db.session.commit() + + return proposal_schema.dump(proposal) + + @blueprint.route('/proposals//cancel', methods=['PUT']) @admin.admin_auth_required def cancel_proposal(id): @@ -417,12 +426,14 @@ def paid_milestone_payout_request(id, mid, tx_id): return {"message": "Proposal is not fully funded"}, 400 for ms in proposal.milestones: if ms.id == int(mid): + is_final_milestone = False ms.mark_paid(tx_id) db.session.add(ms) db.session.flush() # check if this is the final ms, and update proposal.stage num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0) if num_paid == len(proposal.milestones): + is_final_milestone = True proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED db.session.add(proposal) db.session.flush() @@ -437,6 +448,18 @@ def paid_milestone_payout_request(id, mid, tx_id): 'tx_explorer_url': make_explore_url(tx_id), 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), }) + + # email FOLLOWERS that milestone was accepted + proposal.send_follower_email( + "followed_proposal_milestone", + email_args={"milestone": ms}, + url_suffix="?tab=milestones", + ) + + if not is_final_milestone: + Milestone.set_v2_date_estimates(proposal) + db.session.commit() + return proposal_schema.dump(proposal), 200 return {"message": "No milestone matching id"}, 404 @@ -455,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/', 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/', 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//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 @@ -470,7 +551,6 @@ def get_rfps(): "title": fields.Str(required=True), "brief": fields.Str(required=True), "content": fields.Str(required=True), - "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())), "bounty": fields.Str(required=False, missing=0), "matching": fields.Bool(required=False, missing=False), "dateCloses": fields.Int(required=False, missing=None) @@ -502,13 +582,12 @@ def get_rfp(rfp_id): "brief": fields.Str(required=True), "status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())), "content": fields.Str(required=True), - "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())), "bounty": fields.Str(required=False, allow_none=True, missing=None), "matching": fields.Bool(required=False, default=False, missing=False), "dateCloses": fields.Int(required=False, missing=None), }) @admin.admin_auth_required -def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status): +def update_rfp(rfp_id, title, brief, content, bounty, matching, date_closes, status): rfp = RFP.query.filter(RFP.id == rfp_id).first() if not rfp: return {"message": "No RFP matching that id"}, 404 @@ -517,7 +596,6 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c rfp.title = title rfp.brief = brief rfp.content = content - rfp.category = category rfp.matching = matching rfp.bounty = bounty rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None @@ -587,8 +665,8 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id): db.session.add(contribution) db.session.flush() + # TODO: should this stay? contribution.proposal.set_pending_when_ready() - contribution.proposal.set_funded_when_ready() db.session.commit() return admin_proposal_contribution_schema.dump(contribution), 200 @@ -660,8 +738,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_ db.session.add(contribution) db.session.flush() + # TODO: should this stay? contribution.proposal.set_pending_when_ready() - contribution.proposal.set_funded_when_ready() db.session.commit() return admin_proposal_contribution_schema.dump(contribution), 200 @@ -711,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): @@ -743,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( ''' diff --git a/backend/grant/app.py b/backend/grant/app.py index 1bbd4212..c833b582 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -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 +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) @@ -138,6 +154,7 @@ def register_blueprints(app): app.register_blueprint(blockchain.views.blueprint) app.register_blueprint(task.views.blueprint) app.register_blueprint(rfp.views.blueprint) + app.register_blueprint(home.views.blueprint) if E2E_TESTING and DEBUG: print('Warning: e2e end-points are open, this should only be the case for development or testing') app.register_blueprint(e2e.views.blueprint) @@ -162,5 +179,7 @@ def register_commands(app): app.cli.add_command(commands.reset_db_chain_data) app.cli.add_command(proposal.commands.create_proposal) 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) diff --git a/backend/grant/ccr/__init__.py b/backend/grant/ccr/__init__.py new file mode 100644 index 00000000..3b1476bb --- /dev/null +++ b/backend/grant/ccr/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import views diff --git a/backend/grant/ccr/models.py b/backend/grant/ccr/models.py new file mode 100644 index 00000000..4b982d39 --- /dev/null +++ b/backend/grant/ccr/models.py @@ -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) diff --git a/backend/grant/ccr/views.py b/backend/grant/ccr/views.py new file mode 100644 index 00000000..28c1077a --- /dev/null +++ b/backend/grant/ccr/views.py @@ -0,0 +1,113 @@ +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("/", 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_by(user_id=g.current_user.id) + .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("/", 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("/", 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("//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 diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index 395be41b..55c82e9e 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -4,10 +4,19 @@ from functools import reduce from grant.extensions import ma, db from grant.utils.ma_fields import UnixDate from grant.utils.misc import gen_random_id -from sqlalchemy.orm import raiseload +from sqlalchemy.orm import raiseload, column_property +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import func, select HIDDEN_CONTENT = '~~comment removed by admin~~' +comment_liker = db.Table( + "comment_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("comment_id", db.Integer, db.ForeignKey("comment.id")), +) + class Comment(db.Model): __tablename__ = "comment" @@ -25,6 +34,15 @@ class Comment(db.Model): author = db.relationship("User", back_populates="comments") replies = db.relationship("Comment") + likes = db.relationship( + "User", secondary=comment_liker, back_populates="liked_comments" + ) + likes_count = column_property( + select([func.count(comment_liker.c.comment_id)]) + .where(comment_liker.c.comment_id == id) + .correlate_except(comment_liker) + ) + def __init__(self, proposal_id, user_id, parent_comment_id, content): self.id = gen_random_id(Comment) self.proposal_id = proposal_id @@ -49,6 +67,28 @@ class Comment(db.Model): self.hidden = hidden db.session.add(self) + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(comment_liker) + .filter_by(user_id=authed.id, comment_id=self.id) + .count() + ) + if res: + return True + return False + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() # are all of the replies hidden? def all_hidden(replies): @@ -74,6 +114,8 @@ class CommentSchema(ma.Schema): "replies", "reported", "hidden", + "authed_liked", + "likes_count" ) content = ma.Method("get_content") diff --git a/backend/grant/comment/views.py b/backend/grant/comment/views.py index 59af911b..55aa002e 100644 --- a/backend/grant/comment/views.py +++ b/backend/grant/comment/views.py @@ -1,4 +1,26 @@ -from flask import Blueprint +from flask import Blueprint, g + +from grant.utils.auth import requires_auth +from grant.parser import body +from marshmallow import fields +from .models import Comment, db, comment_schema blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment") + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_comment(comment_id, is_liked): + + user = g.current_user + # Make sure comment exists + comment = Comment.query.filter_by(id=comment_id).first() + if not comment: + return {"message": "No comment matching id"}, 404 + + comment.like(user, is_liked) + db.session.commit() + + return comment_schema.dump(comment), 201 + diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index bc89282b..3d1d7fd3 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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,18 +69,34 @@ 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 been rejected', - 'title': 'Your proposal has been rejected', - 'preview': '{} has been rejected'.format(email_args['proposal'].title), + 'subject': 'Your proposal has changes requested', + 'title': 'Your proposal has changes requested', + 'preview': '{} has changes requested'.format(email_args['proposal'].title), 'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL } @@ -245,6 +262,17 @@ def milestone_request(email_args): } +def milestone_deadline(email_args): + p = email_args['proposal'] + ms = p.current_milestone + return { + 'subject': f'Milestone deadline reached for {p.title} - {ms.title}', + 'title': f'Milestone deadline reached', + 'preview': f'The estimated deadline for milestone {ms.title} has been reached.', + 'subscription': EmailSubscription.ARBITER, + } + + def milestone_reject(email_args): p = email_args['proposal'] ms = p.current_milestone @@ -289,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}', @@ -307,6 +344,27 @@ def admin_payout(email_args): } +def followed_proposal_milestone(email_args): + p = email_args["proposal"] + ms = email_args["milestone"] + return { + "subject": f"Milestone accepted for {p.title}", + "title": f"Milestone Accepted", + "preview": f"Followed proposal {p.title} has passed a milestone", + "subscription": EmailSubscription.FOLLOWED_PROPOSAL, + } + + +def followed_proposal_update(email_args): + p = email_args["proposal"] + return { + "subject": f"Proposal update for {p.title}", + "title": f"Proposal Update", + "preview": f"Followed proposal {p.title} has an update", + "subscription": EmailSubscription.FOLLOWED_PROPOSAL, + } + + get_info_lookup = { 'signup': signup_info, 'team_invite': team_invite_info, @@ -314,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, @@ -330,12 +390,16 @@ get_info_lookup = { 'comment_reply': comment_reply, 'proposal_arbiter': proposal_arbiter, 'milestone_request': milestone_request, + 'milestone_deadline': milestone_deadline, 'milestone_reject': milestone_reject, '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 + 'admin_payout': admin_payout, + 'followed_proposal_milestone': followed_proposal_milestone, + 'followed_proposal_update': followed_proposal_update } diff --git a/backend/grant/email/subscription_settings.py b/backend/grant/email/subscription_settings.py index 3a8f5482..745fcfd3 100644 --- a/backend/grant/email/subscription_settings.py +++ b/backend/grant/email/subscription_settings.py @@ -65,6 +65,14 @@ class EmailSubscription(Enum): 'bit': 14, 'key': 'admin_payout' } + FOLLOWED_PROPOSAL = { + 'bit': 15, + 'key': 'followed_proposal' + } + ADMIN_APPROVAL_CCR = { + 'bit': 16, + 'key': 'admin_approval_ccr' + } def is_email_sub_key(k: str): diff --git a/backend/grant/home/__init__.py b/backend/grant/home/__init__.py new file mode 100644 index 00000000..14cd5bd9 --- /dev/null +++ b/backend/grant/home/__init__.py @@ -0,0 +1 @@ +from . import views diff --git a/backend/grant/home/views.py b/backend/grant/home/views.py new file mode 100644 index 00000000..bfffd657 --- /dev/null +++ b/backend/grant/home/views.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from flask import Blueprint +from sqlalchemy import or_ + +from grant.proposal.models import Proposal, proposals_schema +from grant.rfp.models import RFP, rfps_schema +from grant.utils.enums import ProposalStatus, ProposalStage, RFPStatus + +blueprint = Blueprint("home", __name__, url_prefix="/api/v1/home") + + +@blueprint.route("/latest", methods=["GET"]) +def get_home_content(): + latest_proposals = ( + Proposal.query.filter_by(status=ProposalStatus.LIVE) + .filter(Proposal.stage != ProposalStage.CANCELED) + .filter(Proposal.stage != ProposalStage.FAILED) + .order_by(Proposal.date_created.desc()) + .limit(3) + .all() + ) + latest_rfps = ( + RFP.query.filter_by(status=RFPStatus.LIVE) + .filter(or_(RFP.date_closes == None, RFP.date_closes > datetime.now())) + .order_by(RFP.date_opened) + .limit(3) + .all() + ) + + return { + "latest_proposals": proposals_schema.dump(latest_proposals), + "latest_rfps": rfps_schema.dump(latest_rfps), + } diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index 695a90b6..48040922 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -5,6 +5,7 @@ from grant.utils.enums import MilestoneStage from grant.utils.exceptions import ValidationException from grant.utils.ma_fields import UnixDate from grant.utils.misc import gen_random_id +from grant.task.jobs import MilestoneDeadline class MilestoneException(Exception): @@ -22,7 +23,8 @@ class Milestone(db.Model): content = db.Column(db.Text, nullable=False) payout_percent = db.Column(db.String(255), nullable=False) immediate_payout = db.Column(db.Boolean) - date_estimated = db.Column(db.DateTime, nullable=False) + date_estimated = db.Column(db.DateTime, nullable=True) + days_estimated = db.Column(db.String(255), nullable=True) stage = db.Column(db.String(255), nullable=False) @@ -46,7 +48,7 @@ class Milestone(db.Model): index: int, title: str, content: str, - date_estimated: datetime, + days_estimated: str, payout_percent: str, immediate_payout: bool, stage: str = MilestoneStage.IDLE, @@ -56,13 +58,14 @@ class Milestone(db.Model): self.title = title[:255] self.content = content[:255] self.stage = stage - self.date_estimated = date_estimated + self.days_estimated = days_estimated[:255] self.payout_percent = payout_percent[:255] self.immediate_payout = immediate_payout self.proposal_id = proposal_id self.date_created = datetime.datetime.now() self.index = index + @staticmethod def make(milestones_data, proposal): if milestones_data: @@ -72,7 +75,7 @@ class Milestone(db.Model): m = Milestone( title=milestone_data["title"][:255], content=milestone_data["content"][:255], - date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]), + days_estimated=str(milestone_data["days_estimated"])[:255], payout_percent=str(milestone_data["payout_percent"])[:255], immediate_payout=milestone_data["immediate_payout"], proposal_id=proposal.id, @@ -80,6 +83,55 @@ class Milestone(db.Model): ) db.session.add(m) + # The purpose of this method is to set the `date_estimated` property on all milestones in a proposal. This works + # by figuring out a starting point for each milestone (the `base_date` below) and adding `days_estimated`. + # + # As proposal creators now estimate their milestones in days (instead of picking months), this method allows us to + # keep `date_estimated` in sync throughout the lifecycle of a proposal. For example, if a user misses their + # first milestone deadline by a week, this method would take the actual completion date of that milestone and + # adjust the `date_estimated` of the remaining milestones accordingly. + # + @staticmethod + def set_v2_date_estimates(proposal): + if not proposal.date_approved: + raise MilestoneException(f'Cannot estimate milestone dates because proposal has no date_approved set') + + # The milestone being actively worked on + current_milestone = proposal.current_milestone + + if current_milestone.stage == MilestoneStage.PAID: + raise MilestoneException(f'Cannot estimate milestone dates because they are all completed') + + # The starting point for `date_estimated` calculation for each uncompleted milestone + # We add `days_estimated` to `base_date` to calculate `date_estimated` + base_date = None + + for index, milestone in enumerate(proposal.milestones): + if index == 0: + # If it's the first milestone, use the proposal approval date as a `base_date` + base_date = proposal.date_approved + + if milestone.date_paid: + # If milestone has been paid, set `base_date` for the next milestone and noop out + base_date = milestone.date_paid + continue + + days_estimated = milestone.days_estimated if not milestone.immediate_payout else "0" + date_estimated = base_date + datetime.timedelta(days=int(days_estimated)) + milestone.date_estimated = date_estimated + + # Set the `base_date` for the next milestone using the estimate completion date of the current milestone + base_date = date_estimated + db.session.add(milestone) + + # Skip task creation if current milestone has an immediate payout + if current_milestone.immediate_payout: + return + + # Create MilestoneDeadline task for the current milestone so arbiters will be alerted if the deadline is missed + task = MilestoneDeadline(proposal, current_milestone) + task.make_task() + def request_payout(self, user_id: int): if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]: raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage') @@ -140,6 +192,7 @@ class MilestoneSchema(ma.Schema): "date_rejected", "date_accepted", "date_paid", + "days_estimated" ) date_created = UnixDate(attribute='date_created') diff --git a/backend/grant/proposal/commands.py b/backend/grant/proposal/commands.py index 83490c49..04ca4d41 100644 --- a/backend/grant/proposal/commands.py +++ b/backend/grant/proposal/commands.py @@ -7,7 +7,7 @@ from flask.cli import with_appcontext from .models import Proposal, db from grant.milestone.models import Milestone from grant.comment.models import Comment -from grant.utils.enums import ProposalStatus, Category, ProposalStageEnum +from grant.utils.enums import ProposalStatus, Category, ProposalStage from grant.user.models import User @@ -35,9 +35,9 @@ def create_proposals(count): user = User.query.filter_by().first() for i in range(count): if i < 5: - stage = ProposalStageEnum.FUNDING_REQUIRED + stage = ProposalStage.WIP else: - stage = ProposalStageEnum.COMPLETED + stage = ProposalStage.COMPLETED p = Proposal.create( stage=stage, status=ProposalStatus.LIVE, @@ -51,6 +51,10 @@ def create_proposals(count): ) p.date_published = datetime.datetime.now() p.team.append(user) + p.date_approved = datetime.datetime.now() + p.accepted_with_funding = True + p.version = '2' + p.fully_fund_contibution_bounty() db.session.add(p) db.session.flush() num_ms = randint(1, 9) @@ -58,7 +62,7 @@ def create_proposals(count): m = Milestone( title=f'Fake MS {j}', content=f'Fake milestone #{j} on fake proposal #{i}!', - date_estimated=datetime.datetime.now(), + days_estimated='10', payout_percent=str(floor(1 / num_ms * 100)), immediate_payout=j == 0, proposal_id=p.id, @@ -74,5 +78,119 @@ def create_proposals(count): ) db.session.add(c) + Milestone.set_v2_date_estimates(p) + db.session.add(p) + db.session.commit() print(f'Added {count} LIVE fake proposals') + + +@click.command() +@click.argument('dry', required=False) +@with_appcontext +def retire_v1_proposals(dry): + now = datetime.datetime.now() + proposals_funding_required = Proposal.query.filter_by(stage="FUNDING_REQUIRED").all() + proposals_draft = Proposal.query.filter_by(status=ProposalStatus.DRAFT).all() + proposals_pending = Proposal.query.filter_by(status=ProposalStatus.PENDING).all() + proposals_staking = Proposal.query.filter_by(status=ProposalStatus.STAKING).all() + modified_funding_required_count = 0 + modified_draft_count = 0 + modified_pending_count = 0 + modified_staking_count = 0 + deleted_draft_count = 0 + + if not proposals_funding_required and not proposals_draft and not proposals_pending and not proposals_staking: + print("No proposals found. Exiting...") + return + + print(f"Found {len(proposals_funding_required)} 'FUNDING_REQUIRED' proposals to modify") + print(f"Found {len(proposals_draft)} 'DRAFT' proposals to modify") + print(f"Found {len(proposals_pending)} 'PENDING' proposals to modify") + print(f"Found {len(proposals_staking)} 'STAKING' proposals to modify") + + if dry: + print(f"This is a dry run. Changes will not be committed to the database") + + confirm = input("Continue? (y/n) ") + + if confirm != "y": + print("Exiting...") + return + + # move 'FUNDING_REQUIRED' proposals to a failed state + for p in proposals_funding_required: + if not dry: + new_deadline = (now - p.date_published).total_seconds() + p.stage = ProposalStage.FAILED + p.deadline_duration = int(new_deadline) + db.session.add(p) + modified_funding_required_count += 1 + + print(f"Modified 'FUNDING_REQUIRED' proposal {p.id} - {p.title}") + + # reset proposal to draft state + def convert_proposal_to_v2_draft(proposal): + milestones = Milestone.query.filter_by(proposal_id=proposal.id).all() + + if not dry: + # reset target because v2 estimates are in USD + proposal.target = '0' + proposal.version = '2' + proposal.stage = ProposalStage.PREVIEW + proposal.status = ProposalStatus.DRAFT + db.session.add(proposal) + + for m in milestones: + # clear date estimated because v2 proposals use days_estimated (date_estimated is dynamically set) + m.date_estimated = None + db.session.add(m) + + print(f"Modified {len(milestones)} milestones on proposal {p.id}") + + # delete drafts that have no content + def delete_stale_draft(proposal): + if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0": + return False + + if proposal.payout_address or proposal.milestones: + return False + + if not dry: + db.session.delete(proposal) + + return True + + for p in proposals_draft: + is_stale = delete_stale_draft(p) + if is_stale: + deleted_draft_count += 1 + print(f"Deleted stale 'DRAFT' proposal {p.id} - {p.title}") + continue + + convert_proposal_to_v2_draft(p) + modified_draft_count += 1 + print(f"Modified 'DRAFT' proposal {p.id} - {p.title}") + + for p in proposals_pending: + convert_proposal_to_v2_draft(p) + modified_pending_count += 1 + print(f"Modified 'PENDING' proposal {p.id} - {p.title}") + + for p in proposals_staking: + convert_proposal_to_v2_draft(p) + modified_staking_count += 1 + print(f"Modified 'STAKING' proposal {p.id} - {p.title}") + + if not dry: + print(f"Committing changes to database") + db.session.commit() + + print("") + print(f"Modified {modified_funding_required_count} 'FUNDING_REQUIRED' proposals") + print(f"Modified {modified_draft_count} 'DRAFT' proposals") + print(f"Modified {modified_pending_count} 'PENDING' proposals") + print(f"Modified {modified_staking_count} 'STAKING' proposals") + print(f"Deleted {deleted_draft_count} stale 'DRAFT' proposals") + + diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 2ca822de..c008de18 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -1,13 +1,13 @@ import datetime +from typing import Optional from decimal import Decimal, ROUND_DOWN from functools import reduce -from flask import current_app from marshmallow import post_dump -from sqlalchemy import func, or_ +from sqlalchemy import func, or_, select from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import column_property -from flask import current_app from grant.comment.models import Comment from grant.email.send import send_email from grant.extensions import ma, db @@ -32,6 +32,20 @@ proposal_team = db.Table( db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id')) ) +proposal_follower = db.Table( + "proposal_follower", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), +) + +proposal_liker = db.Table( + "proposal_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), +) + class ProposalTeamInvite(db.Model): __tablename__ = "proposal_team_invite" @@ -145,6 +159,8 @@ class ProposalContribution(db.Model): raise ValidationException('Proposal ID is required') # User ID (must belong to an existing user) if user_id: + from grant.user.models import User + user = User.query.filter(User.id == user_id).first() if not user: raise ValidationException('No user matching that ID') @@ -212,32 +228,72 @@ class ProposalArbiter(db.Model): raise ValidationException('User is not arbiter') +def default_proposal_content(): + return """# Applicant background + +Summarize you and/or your team’s background and experience. Demonstrate that you have the skills and expertise necessary for the project that you’re proposing. Institutional bona fides are not required, but we want to hear about your track record. + +# Motivation and overview + +What are your high-level goals? Why are they important? How is your project connected to [ZF’s mission](https://www.zfnd.org/about/#mission) and priorities? Whose needs will it serve? + +# Technical approach + +Dive into the _how_ of your project. Describe your approaches, components, workflows, methodology, etc. Bullet points and diagrams are appreciated! + +# Execution risks + +What obstacles do you expect? What is most likely to go wrong? Which unknown factors or dependencies could jeopardize success? What are your contingency plans? Will subsequent activities be required to maximize impact? + +# Downsides + +What are the negative ramifications if your project is successful? Consider usability, stability, privacy, integrity, availability, decentralization, interoperability, maintainability, technical debt, requisite education, etc. + +# Evaluation plan + +What will your project look like if successful? How will we be able to tell? Include quantifiable metrics if possible. + +# Tasks and schedule + +What is your timeline for the project? Include concrete milestones and the major tasks required to complete each milestone. + +# Budget and justification + +How much funding do you need, and how will it be allocated (e.g., compensation for your effort, specific equipment, specific external services)? Specify a total cost, break it up into budget items, and explain the rationale for each. Feel free to present multiple options in terms of scope and cost. + +""" + + class Proposal(db.Model): __tablename__ = "proposal" id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime) rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True) + version = db.Column(db.String(255), nullable=True) # Content info status = db.Column(db.String(255), nullable=False) 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) - category = db.Column(db.String(255), 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) reject_reason = db.Column(db.String()) + accepted_with_funding = db.Column(db.Boolean(), nullable=True) # Payment info target = 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=True) contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0")) contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'")) rfp_opt_in = db.Column(db.Boolean(), nullable=True) contributed = db.column_property() + tip_jar_address = db.Column(db.String(255), nullable=True) + tip_jar_view_key = db.Column(db.String(255), nullable=True) # Relations team = db.relationship("User", secondary=proposal_team) @@ -248,13 +304,29 @@ class Proposal(db.Model): order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan") arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan") + followers = db.relationship( + "User", secondary=proposal_follower, back_populates="followed_proposals" + ) + followers_count = column_property( + select([func.count(proposal_follower.c.proposal_id)]) + .where(proposal_follower.c.proposal_id == id) + .correlate_except(proposal_follower) + ) + likes = db.relationship( + "User", secondary=proposal_liker, back_populates="liked_proposals" + ) + likes_count = column_property( + select([func.count(proposal_liker.c.proposal_id)]) + .where(proposal_liker.c.proposal_id == id) + .correlate_except(proposal_liker) + ) def __init__( self, 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 = '', @@ -272,18 +344,16 @@ class Proposal(db.Model): self.payout_address = payout_address self.deadline_duration = deadline_duration self.stage = stage + self.version = '2' @staticmethod def simple_validate(proposal): # Validate fields to be database save-able. # Stricter validation is done in validate_publishable. stage = proposal.get('stage') - category = proposal.get('category') if stage and not ProposalStage.includes(stage): raise ValidationException("Proposal stage {} is not a valid stage".format(stage)) - if category and not Category.includes(category): - raise ValidationException("Category {} not a valid category".format(category)) def validate_publishable_milestones(self): payout_total = 0.0 @@ -316,7 +386,7 @@ class Proposal(db.Model): self.validate_publishable_milestones() # Require certain fields - required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address'] + required_fields = ['title', 'content', 'brief', 'target', 'payout_address'] for field in required_fields: if not hasattr(self, field): raise ValidationException("Proposal must have a {}".format(field)) @@ -329,13 +399,15 @@ class Proposal(db.Model): if len(self.content) > 250000: raise ValidationException("Content cannot be longer than 250,000 characters") if Decimal(self.target) > PROPOSAL_TARGET_MAX: - raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX)) - if Decimal(self.target) < 0.0001: - raise ValidationException("Target cannot be less than 0.0001") + raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX)) + if Decimal(self.target) < 0: + raise ValidationException("Target cannot be less than 0") + if not self.target.isdigit(): + raise ValidationException("Target must be a whole number") if self.deadline_duration > 7776000: raise ValidationException("Deadline duration cannot be more than 90 days") - # Check with node that the address is kosher + # Check with node that the payout address is kosher try: res = blockchain_get('/validate/address', {'address': self.payout_address}) except: @@ -344,16 +416,37 @@ class Proposal(db.Model): if not res['valid']: raise ValidationException("Payout address is not a valid Zcash address") + if self.tip_jar_address: + # Check with node that the tip jar address is kosher + try: + res = blockchain_get('/validate/address', {'address': self.tip_jar_address}) + except: + raise ValidationException( + "Could not validate your tipping address due to an internal server error, please try again later") + if not res['valid']: + raise ValidationException("Tipping address is not a valid Zcash address") + # Then run through regular validation Proposal.simple_validate(vars(self)) - # only do this when user submits for approval, there is a chance the dates will - # be passed by the time admin approval / user publishing occurs - def validate_milestone_dates(self): - present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + def validate_milestone_days(self): for milestone in self.milestones: - if present > milestone.date_estimated: - raise ValidationException("Milestone date estimate must be in the future ") + if milestone.immediate_payout: + continue + + try: + p = float(milestone.days_estimated) + if not p.is_integer(): + raise ValidationException("Milestone days estimated must be whole numbers, no decimals") + if p <= 0: + raise ValidationException("Milestone days estimated must be greater than zero") + if p > 365: + raise ValidationException("Milestone days estimated must be less than 365") + + except ValueError: + raise ValidationException("Milestone days estimated must be a number") + return @staticmethod def create(**kwargs): @@ -396,6 +489,7 @@ class Proposal(db.Model): content: str = '', target: str = '0', payout_address: str = '', + tip_jar_address: Optional[str] = None, deadline_duration: int = 5184000 # 60 days ): self.title = title[:255] @@ -404,18 +498,12 @@ class Proposal(db.Model): self.content = content[:300000] self.target = target[:255] if target != '' else '0' self.payout_address = payout_address[:255] + self.tip_jar_address = tip_jar_address[:255] if tip_jar_address is not None else None self.deadline_duration = deadline_duration Proposal.simple_validate(vars(self)) def update_rfp_opt_in(self, opt_in: bool): self.rfp_opt_in = opt_in - # add/remove matching and/or bounty values from RFP - if opt_in and self.rfp: - self.set_contribution_matching(1 if self.rfp.matching else 0) - self.set_contribution_bounty(self.rfp.bounty or '0') - else: - self.set_contribution_matching(0) - self.set_contribution_bounty('0') def create_contribution( self, @@ -469,19 +557,15 @@ 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_dates() + self.validate_milestone_days() allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED] # 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: @@ -489,31 +573,44 @@ 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) db.session.flush() - # state: status PENDING -> (APPROVED || REJECTED) - def approve_pending(self, is_approve, reject_reason=None): + # state: status PENDING -> (LIVE || REJECTED) + def approve_pending(self, is_approve, with_funding, reject_reason=None): self.validate_publishable() # specific validation if not self.status == ProposalStatus.PENDING: raise ValidationException(f"Proposal must be pending to approve or reject") if is_approve: - self.status = ProposalStatus.APPROVED + self.status = ProposalStatus.LIVE self.date_approved = datetime.datetime.now() + self.accepted_with_funding = with_funding + + # also update date_published and stage since publish() is no longer called by user + self.date_published = datetime.datetime.now() + self.stage = ProposalStage.WIP + + if with_funding: + self.fully_fund_contibution_bounty() 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': 'Congratulations! Your proposal has been approved.' + 'admin_note': admin_note }) else: if not reject_reason: @@ -528,6 +625,10 @@ class Proposal(db.Model): 'admin_note': reject_reason }) + def update_proposal_with_funding(self): + self.accepted_with_funding = True + self.fully_fund_contibution_bounty() + # state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED def publish(self): self.validate_publishable() @@ -536,28 +637,7 @@ class Proposal(db.Model): raise ValidationException(f"Proposal status must be approved") self.date_published = datetime.datetime.now() self.status = ProposalStatus.LIVE - self.stage = ProposalStage.FUNDING_REQUIRED - # If we had a bounty that pushed us into funding, skip straight into WIP - self.set_funded_when_ready() - - def set_funded_when_ready(self): - if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded: - self.set_funded() - - # state: stage FUNDING_REQUIRED -> WIP - def set_funded(self): - if self.status != ProposalStatus.LIVE: - raise ValidationException(f"Proposal status must be live in order transition to funded state") - if self.stage != ProposalStage.FUNDING_REQUIRED: - raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state") - if not self.is_funded: - raise ValidationException(f"Proposal is not fully funded, cannot set to funded state") - self.send_admin_email('admin_arbiter') self.stage = ProposalStage.WIP - db.session.add(self) - db.session.flush() - # check the first step, if immediate payout bump it to accepted - self.current_milestone.accept_immediate() def set_contribution_bounty(self, bounty: str): # do not allow changes on funded/WIP proposals @@ -567,20 +647,9 @@ class Proposal(db.Model): self.contribution_bounty = str(Decimal(bounty)) db.session.add(self) db.session.flush() - self.set_funded_when_ready() - def set_contribution_matching(self, matching: float): - # do not allow on funded/WIP proposals - if self.is_funded: - raise ValidationException("Cannot set contribution matching on fully-funded proposal") - # enforce 1 or 0 for now - if matching == 0.0 or matching == 1.0: - self.contribution_matching = matching - db.session.add(self) - db.session.flush() - self.set_funded_when_ready() - else: - raise ValidationException("Bad value for contribution_matching, must be 1 or 0") + def fully_fund_contibution_bounty(self): + self.set_contribution_bounty(self.target) def cancel(self): if self.status != ProposalStatus.LIVE: @@ -603,6 +672,33 @@ class Proposal(db.Model): 'account_settings_url': make_url('/profile/settings?tab=account') }) + def follow(self, user, is_follow): + if is_follow: + self.followers.append(user) + else: + self.followers.remove(user) + db.session.flush() + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() + + def send_follower_email(self, type: str, email_args={}, url_suffix=""): + for u in self.followers: + send_email( + u.email_address, + type, + { + "user": u, + "proposal": self, + "proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"), + **email_args, + }, + ) + @hybrid_property def contributed(self): contributions = ProposalContribution.query \ @@ -635,12 +731,7 @@ class Proposal(db.Model): @hybrid_property def is_staked(self): - # Don't use self.contributed since that ignores stake contributions - contributions = ProposalContribution.query \ - .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \ - .all() - funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) - return Decimal(funded) >= PROPOSAL_STAKING_AMOUNT + return True @hybrid_property def is_funded(self): @@ -670,6 +761,48 @@ class Proposal(db.Model): d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED} return d.values() + @hybrid_property + def authed_follows(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(proposal_follower) + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() + ) + if res: + return True + return False + + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(proposal_liker) + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() + ) + if res: + return True + return False + + @hybrid_property + def get_tip_jar_view_key(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if authed not in self.team: + return None + else: + return self.tip_jar_view_key + class ProposalSchema(ma.Schema): class Meta: @@ -694,7 +827,6 @@ class ProposalSchema(ma.Schema): "updates", "milestones", "current_milestone", - "category", "team", "payout_address", "deadline_duration", @@ -703,13 +835,23 @@ class ProposalSchema(ma.Schema): "invites", "rfp", "rfp_opt_in", - "arbiter" + "arbiter", + "accepted_with_funding", + "is_version_two", + "authed_follows", + "followers_count", + "authed_liked", + "likes_count", + "tip_jar_address", + "tip_jar_view_key" ) date_created = ma.Method("get_date_created") date_approved = ma.Method("get_date_approved") date_published = ma.Method("get_date_published") proposal_id = ma.Method("get_proposal_id") + is_version_two = ma.Method("get_is_version_two") + tip_jar_view_key = ma.Method("get_tip_jar_view_key") updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) @@ -731,6 +873,11 @@ class ProposalSchema(ma.Schema): def get_date_published(self, obj): return dt_to_unix(obj.date_published) if obj.date_published else None + def get_is_version_two(self, obj): + return True if obj.version == '2' else False + + def get_tip_jar_view_key(self, obj): + return obj.get_tip_jar_view_key proposal_schema = ProposalSchema() proposals_schema = ProposalSchema(many=True) @@ -748,6 +895,10 @@ user_fields = [ "date_published", "reject_reason", "team", + "accepted_with_funding", + "is_version_two", + "authed_follows", + "authed_liked" ] user_proposal_schema = ProposalSchema(only=user_fields) user_proposals_schema = ProposalSchema(many=True, only=user_fields) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index eba7ed00..90b460bc 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -1,4 +1,5 @@ from decimal import Decimal +from datetime import datetime from flask import Blueprint, g, request, current_app from marshmallow import fields, validate @@ -13,7 +14,7 @@ from grant.milestone.models import Milestone from grant.parser import body, query, paginated_fields from grant.rfp.models import RFP from grant.settings import PROPOSAL_STAKING_AMOUNT -from grant.task.jobs import ProposalDeadline +from grant.task.jobs import ProposalDeadline, PruneDraft from grant.user.models import User from grant.utils import pagination from grant.utils.auth import ( @@ -24,8 +25,9 @@ from grant.utils.auth import ( get_authed_user, internal_webhook ) +from grant.utils.requests import validate_blockchain_get from grant.utils.enums import Category -from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus +from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, RFPStatus from grant.utils.exceptions import ValidationException from grant.utils.misc import is_email, make_url, from_zat, make_explore_url from .models import ( @@ -108,6 +110,9 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id): if not proposal: return {"message": "No proposal matching id"}, 404 + if proposal.status != ProposalStatus.LIVE: + return {"message": "Proposal must be live to comment"}, 400 + # Make sure the parent comment exists parent = None if parent_comment_id: @@ -187,10 +192,16 @@ def make_proposal_draft(rfp_id): rfp = RFP.query.filter_by(id=rfp_id).first() if not rfp: return {"message": "The request this proposal was made for doesn’t exist"}, 400 - proposal.category = rfp.category + if datetime.now() > rfp.date_closes: + return {"message": "The request this proposal was made for has expired"}, 400 + if rfp.status == RFPStatus.CLOSED: + return {"message": "The request this proposal was made for has been closed"}, 400 rfp.proposals.append(proposal) db.session.add(rfp) + task = PruneDraft(proposal) + task.make_task() + db.session.add(proposal) db.session.commit() return proposal_schema.dump(proposal), 201 @@ -219,11 +230,10 @@ def get_proposal_drafts(): # Length checks are to prevent database errors, not actual user limits imposed "title": fields.Str(required=True), "brief": fields.Str(required=True), - "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])), "content": fields.Str(required=True), "target": fields.Str(required=True), "payoutAddress": fields.Str(required=True), - "deadlineDuration": fields.Int(required=True), + "tipJarAddress": fields.Str(required=False, missing=None), "milestones": fields.List(fields.Dict(), required=True), "rfpOptIn": fields.Bool(required=False, missing=None), }) @@ -251,6 +261,26 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs): return proposal_schema.dump(g.current_proposal), 200 +@blueprint.route("//tips", methods=["PUT"]) +@requires_team_member_auth +@body({ + "address": fields.Str(required=False, missing=None), + "viewKey": fields.Str(required=False, missing=None) +}) +def update_proposal_tip_jar(proposal_id, address, view_key): + if address is not None: + if address is not '': + validate_blockchain_get('/validate/address', {'address': address}) + + g.current_proposal.tip_jar_address = address + if view_key is not None: + g.current_proposal.tip_jar_view_key = view_key + + db.session.add(g.current_proposal) + db.session.commit() + return proposal_schema.dump(g.current_proposal), 200 + + @blueprint.route("//rfp", methods=["DELETE"]) @requires_team_member_auth def unlink_proposal_from_rfp(proposal_id): @@ -293,17 +323,6 @@ def submit_for_approval_proposal(proposal_id): return proposal_schema.dump(g.current_proposal), 200 -@blueprint.route("//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("//publish", methods=["PUT"]) @requires_team_member_auth def publish_proposal(proposal_id): @@ -367,6 +386,11 @@ def post_proposal_update(proposal_id, title, content): 'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'), }) + # Send email to all followers + g.current_proposal.send_follower_email( + "followed_proposal_update", url_suffix="?tab=updates" + ) + dumped_update = proposal_update_schema.dump(update) return dumped_update, 201 @@ -566,9 +590,6 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): 'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '', }) - # on funding target reached. - contribution.proposal.set_funded_when_ready() - db.session.commit() return {"message": "ok"}, 200 @@ -662,3 +683,37 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason): return proposal_schema.dump(g.current_proposal), 200 return {"message": "No milestone matching id"}, 404 + + +@blueprint.route("//follow", methods=["PUT"]) +@requires_auth +@body({"isFollow": fields.Bool(required=True)}) +def follow_proposal(proposal_id, is_follow): + user = g.current_user + # Make sure proposal exists + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + + proposal.follow(user, is_follow) + db.session.commit() + return {"message": "ok"}, 200 + + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_proposal(proposal_id, is_liked): + user = g.current_user + # Make sure proposal exists + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + + if not proposal.status == ProposalStatus.LIVE: + return {"message": "Cannot like a proposal that's not live"}, 404 + + proposal.like(user, is_liked) + db.session.commit() + return {"message": "ok"}, 200 + diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 2a13b4ae..03eba413 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -2,10 +2,19 @@ from datetime import datetime from decimal import Decimal from grant.extensions import ma, db from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import func, select +from sqlalchemy.orm import column_property from grant.utils.enums import RFPStatus from grant.utils.misc import dt_to_unix, gen_random_id from grant.utils.enums import Category +rfp_liker = db.Table( + "rfp_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("rfp_id", db.Integer, db.ForeignKey("rfp.id")), +) + class RFP(db.Model): __tablename__ = "rfp" @@ -16,13 +25,16 @@ class RFP(db.Model): title = db.Column(db.String(255), nullable=False) brief = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) - category = db.Column(db.String(255), nullable=False) + category = db.Column(db.String(255), nullable=True) status = db.Column(db.String(255), nullable=False) matching = db.Column(db.Boolean, default=False, nullable=False) _bounty = db.Column("bounty", db.String(255), nullable=True) date_closes = db.Column(db.DateTime, nullable=True) date_opened = db.Column(db.DateTime, nullable=True) 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( @@ -38,6 +50,15 @@ class RFP(db.Model): cascade="all, delete-orphan", ) + likes = db.relationship( + "User", secondary=rfp_liker, back_populates="liked_rfps" + ) + likes_count = column_property( + select([func.count(rfp_liker.c.rfp_id)]) + .where(rfp_liker.c.rfp_id == id) + .correlate_except(rfp_liker) + ) + @hybrid_property def bounty(self): return self._bounty @@ -49,29 +70,50 @@ class RFP(db.Model): else: self._bounty = None + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(rfp_liker) + .filter_by(user_id=authed.id, rfp_id=self.id) + .count() + ) + if res: + return True + return False + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() + def __init__( self, title: str, brief: str, content: str, - category: str, bounty: str, date_closes: datetime, matching: bool = False, status: str = RFPStatus.DRAFT, ): assert RFPStatus.includes(status) - assert Category.includes(category) self.id = gen_random_id(RFP) self.date_created = datetime.now() self.title = title[:255] self.brief = brief[:255] self.content = content - self.category = category self.bounty = bounty self.date_closes = date_closes self.matching = matching self.status = status + self.version = '2' class RFPSchema(ma.Schema): @@ -83,7 +125,6 @@ class RFPSchema(ma.Schema): "title", "brief", "content", - "category", "status", "matching", "bounty", @@ -92,13 +133,19 @@ class RFPSchema(ma.Schema): "date_opened", "date_closed", "accepted_proposals", + "authed_liked", + "likes_count", + "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") date_closed = ma.Method("get_date_closed") accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"]) + is_version_two = ma.Method("get_is_version_two") def get_status(self, obj): # Force it into closed state if date_closes is in the past @@ -115,6 +162,9 @@ class RFPSchema(ma.Schema): def get_date_closed(self, obj): return dt_to_unix(obj.date_closed) if obj.date_closed else None + def get_is_version_two(self, obj): + return True if obj.version == '2' else False + rfp_schema = RFPSchema() rfps_schema = RFPSchema(many=True) @@ -129,7 +179,6 @@ class AdminRFPSchema(ma.Schema): "title", "brief", "content", - "category", "status", "matching", "bounty", @@ -138,14 +187,18 @@ class AdminRFPSchema(ma.Schema): "date_opened", "date_closed", "proposals", + "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") date_opened = ma.Method("get_date_opened") date_closed = ma.Method("get_date_closed") proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"]) + is_version_two = ma.Method("get_is_version_two") def get_status(self, obj): # Force it into closed state if date_closes is in the past @@ -165,6 +218,9 @@ class AdminRFPSchema(ma.Schema): def get_date_closed(self, obj): return dt_to_unix(obj.date_closes) if obj.date_closes else None + def get_is_version_two(self, obj): + return True if obj.version == '2' else False + admin_rfp_schema = AdminRFPSchema() admin_rfps_schema = AdminRFPSchema(many=True) diff --git a/backend/grant/rfp/views.py b/backend/grant/rfp/views.py index f60da4d8..c8d5d046 100644 --- a/backend/grant/rfp/views.py +++ b/backend/grant/rfp/views.py @@ -1,8 +1,11 @@ -from flask import Blueprint +from flask import Blueprint, g from sqlalchemy import or_ from grant.utils.enums import RFPStatus -from .models import RFP, rfp_schema, rfps_schema +from grant.utils.auth import requires_auth +from grant.parser import body +from .models import RFP, rfp_schema, rfps_schema, db +from marshmallow import fields blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps") @@ -25,3 +28,20 @@ def get_rfp(rfp_id): if not rfp or rfp.status == RFPStatus.DRAFT: return {"message": "No RFP with that ID"}, 404 return rfp_schema.dump(rfp) + + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_rfp(rfp_id, is_liked): + user = g.current_user + # Make sure rfp exists + rfp = RFP.query.filter_by(id=rfp_id).first() + if not rfp: + return {"message": "No RFP matching id"}, 404 + if not rfp.status == RFPStatus.LIVE: + return {"message": "RFP is not live"}, 404 + + rfp.like(user, is_liked) + db.session.commit() + return {"message": "ok"}, 200 diff --git a/backend/grant/settings.py b/backend/grant/settings.py index 66701d8f..687fdc92 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -60,11 +60,14 @@ LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET") BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL") BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET") +STAGING_PASSWORD = env.str("STAGING_PASSWORD", default=None) + EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/") PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT")) PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX")) + UI = { 'NAME': 'ZF Grants', 'PRIMARY': '#CF8A00', diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index ae148f1e..f317d554 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from grant.extensions import db from grant.email.send import send_email -from grant.utils.enums import ProposalStage, ContributionStatus +from grant.utils.enums import ProposalStage, ContributionStatus, ProposalStatus from grant.utils.misc import make_url from flask import current_app @@ -126,8 +126,117 @@ class ContributionExpired: }) +class PruneDraft: + JOB_TYPE = 4 + PRUNE_TIME = 259200 # 72 hours in seconds + + def __init__(self, proposal): + self.proposal = proposal + + def blobify(self): + return { + "proposal_id": self.proposal.id, + } + + def make_task(self): + from .models import Task + + task = Task( + job_type=self.JOB_TYPE, + blob=self.blobify(), + execute_after=self.proposal.date_created + timedelta(seconds=self.PRUNE_TIME), + ) + db.session.add(task) + db.session.commit() + + @staticmethod + def process_task(task): + 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 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: + return + + # Otherwise, delete the empty proposal + db.session.delete(proposal) + db.session.commit() + + +class MilestoneDeadline: + JOB_TYPE = 5 + + def __init__(self, proposal, milestone): + self.proposal = proposal + self.milestone = milestone + + def blobify(self): + from grant.proposal.models import ProposalUpdate + + update_count = len(ProposalUpdate.query.filter_by(proposal_id=self.proposal.id).all()) + return { + "proposal_id": self.proposal.id, + "milestone_id": self.milestone.id, + "update_count": update_count + } + + def make_task(self): + from .models import Task + task = Task( + job_type=self.JOB_TYPE, + blob=self.blobify(), + execute_after=self.milestone.date_estimated, + ) + db.session.add(task) + db.session.commit() + + @staticmethod + def process_task(task): + from grant.proposal.models import Proposal, ProposalUpdate + from grant.milestone.models import Milestone + + proposal_id = task.blob["proposal_id"] + milestone_id = task.blob["milestone_id"] + update_count = task.blob["update_count"] + + proposal = Proposal.query.filter_by(id=proposal_id).first() + milestone = Milestone.query.filter_by(id=milestone_id).first() + current_update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all()) + + # if proposal was deleted or cancelled, noop out + if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED: + return + + # if milestone was deleted, noop out + if not milestone: + return + + # if milestone payout has been requested or an update has been posted, noop out + if current_update_count > update_count or milestone.date_requested: + return + + # send email to arbiter notifying milestone deadline has been missed + send_email(proposal.arbiter.user.email_address, 'milestone_deadline', { + 'proposal': proposal, + 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), + }) + + JOBS = { 1: ProposalReminder.process_task, 2: ProposalDeadline.process_task, 3: ContributionExpired.process_task, + 4: PruneDraft.process_task, + 5: MilestoneDeadline.process_task } diff --git a/backend/grant/templates/emails/admin_approval_ccr.html b/backend/grant/templates/emails/admin_approval_ccr.html new file mode 100644 index 00000000..45ab8f58 --- /dev/null +++ b/backend/grant/templates/emails/admin_approval_ccr.html @@ -0,0 +1,32 @@ +

+ + {{ args.ccr.title }} + is awaiting approval. As an admin you can help out by reviewing it. +

+ + + + + +
+ + + + +
+ + Review Request + +
+
diff --git a/backend/grant/templates/emails/admin_approval_ccr.txt b/backend/grant/templates/emails/admin_approval_ccr.txt new file mode 100644 index 00000000..16289bb2 --- /dev/null +++ b/backend/grant/templates/emails/admin_approval_ccr.txt @@ -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 }} diff --git a/backend/grant/templates/emails/ccr_approved.html b/backend/grant/templates/emails/ccr_approved.html new file mode 100644 index 00000000..42a942cc --- /dev/null +++ b/backend/grant/templates/emails/ccr_approved.html @@ -0,0 +1,12 @@ +

+ 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 %} \ No newline at end of file diff --git a/backend/grant/templates/emails/ccr_approved.txt b/backend/grant/templates/emails/ccr_approved.txt new file mode 100644 index 00000000..5dc43e75 --- /dev/null +++ b/backend/grant/templates/emails/ccr_approved.txt @@ -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 }} \ No newline at end of file diff --git a/backend/grant/templates/emails/ccr_rejected.html b/backend/grant/templates/emails/ccr_rejected.html new file mode 100644 index 00000000..9d5f0db3 --- /dev/null +++ b/backend/grant/templates/emails/ccr_rejected.html @@ -0,0 +1,19 @@ +

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

\ No newline at end of file diff --git a/backend/grant/templates/emails/ccr_rejected.txt b/backend/grant/templates/emails/ccr_rejected.txt new file mode 100644 index 00000000..6a542201 --- /dev/null +++ b/backend/grant/templates/emails/ccr_rejected.txt @@ -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. \ No newline at end of file diff --git a/backend/grant/templates/emails/followed_proposal_milestone.html b/backend/grant/templates/emails/followed_proposal_milestone.html new file mode 100644 index 00000000..8e0f4d3e --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_milestone.html @@ -0,0 +1,31 @@ +

+ Your followed proposal {{ args.proposal.title }} has had its + {{ args.milestone.title }} + milestone accepted! +

+ + + + + +
+ + + + +
+ + Check it out + +
+
diff --git a/backend/grant/templates/emails/followed_proposal_milestone.txt b/backend/grant/templates/emails/followed_proposal_milestone.txt new file mode 100644 index 00000000..56d13cb4 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_milestone.txt @@ -0,0 +1,3 @@ +Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted! + +Check it out: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/templates/emails/followed_proposal_update.html b/backend/grant/templates/emails/followed_proposal_update.html new file mode 100644 index 00000000..f5d3f280 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_update.html @@ -0,0 +1,29 @@ +

+ Your followed proposal {{ args.proposal.title }} has an update! +

+ + + + + +
+ + + + +
+ + Check it out + +
+
diff --git a/backend/grant/templates/emails/followed_proposal_update.txt b/backend/grant/templates/emails/followed_proposal_update.txt new file mode 100644 index 00000000..df11b955 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_update.txt @@ -0,0 +1,3 @@ +Your followed proposal {{ args.proposal.title }} has an update! + +Check it out: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/templates/emails/milestone_deadline.html b/backend/grant/templates/emails/milestone_deadline.html new file mode 100644 index 00000000..e70a8688 --- /dev/null +++ b/backend/grant/templates/emails/milestone_deadline.html @@ -0,0 +1,32 @@ +

+ The estimated deadline has been reached for proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}. +

+ + + + + +
+ + + + +
+ + View the milestone + +
+
diff --git a/backend/grant/templates/emails/milestone_deadline.txt b/backend/grant/templates/emails/milestone_deadline.txt new file mode 100644 index 00000000..be948c6f --- /dev/null +++ b/backend/grant/templates/emails/milestone_deadline.txt @@ -0,0 +1,3 @@ +The estimated deadline has been reached for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}". + +View the milestone: {{ args.proposal_milestones_url }} diff --git a/backend/grant/templates/emails/proposal_approved.html b/backend/grant/templates/emails/proposal_approved.html index df35f3f8..84f07632 100644 --- a/backend/grant/templates/emails/proposal_approved.html +++ b/backend/grant/templates/emails/proposal_approved.html @@ -1,7 +1,5 @@

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

{% if args.admin_note %} @@ -13,22 +11,3 @@

{% endif %} - - - - -
- - - - -
- - Publish your proposal - -
-
\ No newline at end of file diff --git a/backend/grant/templates/emails/proposal_approved.txt b/backend/grant/templates/emails/proposal_approved.txt index f22daa7a..7080ed43 100644 --- a/backend/grant/templates/emails/proposal_approved.txt +++ b/backend/grant/templates/emails/proposal_approved.txt @@ -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: diff --git a/backend/grant/templates/emails/proposal_canceled.html b/backend/grant/templates/emails/proposal_canceled.html index 18d7fe7e..14dd4670 100644 --- a/backend/grant/templates/emails/proposal_canceled.html +++ b/backend/grant/templates/emails/proposal_canceled.html @@ -1,7 +1,6 @@

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.

diff --git a/backend/grant/templates/emails/proposal_canceled.txt b/backend/grant/templates/emails/proposal_canceled.txt index 30fe36be..c3aa9a3f 100644 --- a/backend/grant/templates/emails/proposal_canceled.txt +++ b/backend/grant/templates/emails/proposal_canceled.txt @@ -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 }} \ No newline at end of file diff --git a/backend/grant/templates/emails/proposal_rejected.html b/backend/grant/templates/emails/proposal_rejected.html index f05dc04d..ea363c62 100644 --- a/backend/grant/templates/emails/proposal_rejected.html +++ b/backend/grant/templates/emails/proposal_rejected.html @@ -1,5 +1,5 @@

- Your proposal has unfortunately been rejected. You're free to modify it + Your proposal has changes requested. You're free to modify it and try submitting again.

diff --git a/backend/grant/templates/emails/proposal_rejected.txt b/backend/grant/templates/emails/proposal_rejected.txt index af45c565..39488aee 100644 --- a/backend/grant/templates/emails/proposal_rejected.txt +++ b/backend/grant/templates/emails/proposal_rejected.txt @@ -1,4 +1,4 @@ -Your proposal has unfortunately been rejected. You're free to modify it +Your proposal has changes requested. You're free to modify it and try submitting again. {% if args.admin_note %} diff --git a/backend/grant/user/commands.py b/backend/grant/user/commands.py index c4036a91..6472919b 100644 --- a/backend/grant/user/commands.py +++ b/backend/grant/user/commands.py @@ -1,7 +1,9 @@ import click from flask.cli import with_appcontext -from .models import User, db +from .models import User, db, SocialMedia +from grant.task.models import Task +from grant.settings import STAGING_PASSWORD # @click.command() @@ -23,7 +25,6 @@ from .models import User, db # 'account address, or email address of an ' \ # 'existing user.') - @click.command() @click.argument('identity') @with_appcontext @@ -36,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') @@ -43,3 +45,28 @@ def set_admin(identity): raise click.BadParameter('''Invalid user identity. Must be a userid, 'account address, or email address of an 'existing user.''') + + +@click.command() +@with_appcontext +def mangle_users(): + if STAGING_PASSWORD: + print("Mangling all users") + for i, user in enumerate(User.query.all()): + user.email_address = "random" + str(i) + "@grant.io" + user.password = STAGING_PASSWORD + # DELETE TOTP SECRET + user.totp_secret = None + # DELETE BACKUP CODES + user.backup_codes = None + db.session.add(user) + + # DELETE ALL TASKS + for task in Task.query.all(): + db.session.delete(task) + + # REMOVE ALL SOCIAL MEDIA + for social in SocialMedia.query.all(): + db.session.delete(social) + + db.session.commit() diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index de479a17..572edcf2 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -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 ( @@ -58,6 +59,8 @@ class UserSettings(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) _email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask refund_address = db.Column(db.String(255), unique=False, nullable=True) + tip_jar_address = db.Column(db.String(255), unique=False, nullable=True) + tip_jar_view_key = db.Column(db.String(255), unique=False, nullable=True) user = db.relationship("User", back_populates="settings") @@ -123,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") @@ -133,6 +137,18 @@ class User(db.Model, UserMixin): roles = db.relationship('Role', secondary='roles_users', backref=db.backref('users', lazy='dynamic')) arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user") + followed_proposals = db.relationship( + "Proposal", secondary="proposal_follower", back_populates="followers" + ) + liked_proposals = db.relationship( + "Proposal", secondary="proposal_liker", back_populates="likes" + ) + liked_comments = db.relationship( + "Comment", secondary="comment_liker", back_populates="likes" + ) + liked_rfps = db.relationship( + "RFP", secondary="rfp_liker", back_populates="likes" + ) def __init__( self, @@ -343,13 +359,15 @@ class UserSchema(ma.Schema): "avatar", "display_name", "userid", - "email_verified" + "email_verified", + "tip_jar_address" ) social_medias = ma.Nested("SocialMediaSchema", many=True) avatar = ma.Nested("AvatarSchema") userid = ma.Method("get_userid") email_verified = ma.Method("get_email_verified") + tip_jar_address = ma.Method("get_tip_jar_address") def get_userid(self, obj): return obj.id @@ -357,6 +375,9 @@ class UserSchema(ma.Schema): def get_email_verified(self, obj): return obj.email_verification.has_verified + def get_tip_jar_address(self, obj): + return obj.settings.tip_jar_address + user_schema = UserSchema() users_schema = UserSchema(many=True) @@ -399,6 +420,8 @@ class UserSettingsSchema(ma.Schema): fields = ( "email_subscriptions", "refund_address", + "tip_jar_address", + "tip_jar_view_key" ) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 4f43a7c9..ea4cb74d 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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) @@ -349,9 +364,11 @@ def get_user_settings(user_id): @body({ "emailSubscriptions": fields.Dict(required=False, missing=None), "refundAddress": fields.Str(required=False, missing=None, - validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})) + validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})), + "tipJarAddress": fields.Str(required=False, missing=None), + "tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here }) -def set_user_settings(user_id, email_subscriptions, refund_address): +def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_address, tip_jar_view_key): if email_subscriptions: try: email_subscriptions = keys_to_snake_case(email_subscriptions) @@ -364,6 +381,14 @@ def set_user_settings(user_id, email_subscriptions, refund_address): if refund_address: g.current_user.settings.refund_address = refund_address + if tip_jar_address is not None: + if tip_jar_address is not '': + validate_blockchain_get('/validate/address', {'address': tip_jar_address}) + + g.current_user.settings.tip_jar_address = tip_jar_address + if tip_jar_view_key is not None: + g.current_user.settings.tip_jar_view_key = tip_jar_view_key + db.session.commit() return user_settings_schema.dump(g.current_user.settings) diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 88fb6985..7ca53b8d 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -1,13 +1,12 @@ -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.proposal.models import Proposal + from grant.settings import BLOCKCHAIN_API_SECRET -from grant.user.models import User class AuthException(Exception): @@ -28,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() @@ -41,6 +40,8 @@ def is_email_verified(): def auth_user(email, password): + from grant.user.models import User + existing_user = User.get_by_email(email) if not existing_user: raise AuthException("No user exists with that email") @@ -85,6 +86,8 @@ def requires_auth(f): def requires_same_user_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.user.models import User + user_id = kwargs["user_id"] if not user_id: return jsonify(message="Decorator requires_same_user_auth requires path variable "), 500 @@ -114,6 +117,8 @@ def requires_email_verified_auth(f): def requires_team_member_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.proposal.models import Proposal + proposal_id = kwargs["proposal_id"] if not proposal_id: return jsonify(message="Decorator requires_team_member_auth requires path variable "), 500 @@ -131,9 +136,33 @@ 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 "), 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): + from grant.proposal.models import Proposal + proposal_id = kwargs["proposal_id"] if not proposal_id: return jsonify(message="Decorator requires_arbiter_auth requires path variable "), 500 diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index aad33e24..71a380ea 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -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' @@ -34,7 +46,6 @@ ProposalSort = ProposalSortEnum() class ProposalStageEnum(CustomEnum): PREVIEW = 'PREVIEW' - FUNDING_REQUIRED = 'FUNDING_REQUIRED' WIP = 'WIP' COMPLETED = 'COMPLETED' FAILED = 'FAILED' diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 3d02e0eb..d29e1c5c 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -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 @@ -58,6 +60,7 @@ class ProposalPagination(Pagination): self.FILTERS.extend([f'CAT_{c}' for c in Category.list()]) self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()]) self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()]) + self.FILTERS.extend(['ACCEPTED_WITH_FUNDING', 'ACCEPTED_WITHOUT_FUNDING']) self.PAGE_SIZE = 9 self.SORT_MAP = { 'CREATED:DESC': Proposal.date_created.desc(), @@ -67,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' @@ -102,6 +105,10 @@ class ProposalPagination(Pagination): if milestone_filters: query = query.join(Proposal.milestones) \ .filter(Milestone.stage.in_(milestone_filters)) + if 'ACCEPTED_WITH_FUNDING' in filters: + query = query.filter(Proposal.accepted_with_funding == True) + if 'ACCEPTED_WITHOUT_FUNDING' in filters: + query = query.filter(Proposal.accepted_with_funding == False) # SORT (see self.SORT_MAP) if sort: @@ -137,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' @@ -162,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) @@ -174,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) @@ -217,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' @@ -273,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' @@ -315,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 diff --git a/backend/grant/utils/requests.py b/backend/grant/utils/requests.py index cd63b52d..06c1cd82 100644 --- a/backend/grant/utils/requests.py +++ b/backend/grant/utils/requests.py @@ -30,6 +30,9 @@ def blockchain_get(path, params=None): def validate_blockchain_get(path, params=None): + if path == '/validate/address' and params and params['address'] and params['address'][0] == 't': + raise ValidationException('T addresses are not allowed') + try: res = blockchain_get(path, params) except Exception: diff --git a/backend/migrations/versions/0ba15ddf5053_.py b/backend/migrations/versions/0ba15ddf5053_.py new file mode 100644 index 00000000..49e0b8d2 --- /dev/null +++ b/backend/migrations/versions/0ba15ddf5053_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 0ba15ddf5053 +Revises: 2013e180c438 +Create Date: 2019-11-13 17:26:36.584040 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '0ba15ddf5053' +down_revision = '2013e180c438' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('milestone', sa.Column('days_estimated', sa.String(length=255), nullable=True)) + op.alter_column('milestone', 'date_estimated', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('milestone', 'date_estimated', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_column('milestone', 'days_estimated') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/1e1460456ce4_.py b/backend/migrations/versions/1e1460456ce4_.py new file mode 100644 index 00000000..11ea69f1 --- /dev/null +++ b/backend/migrations/versions/1e1460456ce4_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 1e1460456ce4 +Revises: c55f96720196 +Create Date: 2019-11-21 20:36:37.504400 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1e1460456ce4' +down_revision = 'c55f96720196' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('rfp', sa.Column('version', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rfp', 'version') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/2013e180c438_.py b/backend/migrations/versions/2013e180c438_.py new file mode 100644 index 00000000..1a1f38de --- /dev/null +++ b/backend/migrations/versions/2013e180c438_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 2013e180c438 +Revises: 7fea7427e9d6 +Create Date: 2019-11-05 15:53:00.533347 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2013e180c438' +down_revision = '7fea7427e9d6' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('proposal', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=True) + op.alter_column('rfp', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('rfp', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=False) + op.alter_column('proposal', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=False) + # ### end Alembic commands ### diff --git a/backend/migrations/versions/2721189b0c8f_.py b/backend/migrations/versions/2721189b0c8f_.py new file mode 100644 index 00000000..ee7992a2 --- /dev/null +++ b/backend/migrations/versions/2721189b0c8f_.py @@ -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 ### diff --git a/backend/migrations/versions/515abdefed7a_.py b/backend/migrations/versions/515abdefed7a_.py new file mode 100644 index 00000000..57529e6d --- /dev/null +++ b/backend/migrations/versions/515abdefed7a_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 515abdefed7a +Revises: 4505f00c4ebd +Create Date: 2019-10-17 16:41:58.519224 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '515abdefed7a' +down_revision = '4505f00c4ebd' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_subscribers', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.add_column('proposal', sa.Column('accepted_with_funding', sa.Boolean(), nullable=True)) + op.add_column('proposal', sa.Column('version', sa.String(length=255), nullable=True)) + op.alter_column('proposal', 'deadline_duration', + existing_type=sa.INTEGER(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('proposal', 'deadline_duration', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('proposal', 'version') + op.drop_column('proposal', 'accepted_with_funding') + op.drop_table('proposal_subscribers') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/7fea7427e9d6_.py b/backend/migrations/versions/7fea7427e9d6_.py new file mode 100644 index 00000000..3f88ba08 --- /dev/null +++ b/backend/migrations/versions/7fea7427e9d6_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: 7fea7427e9d6 +Revises: f24d53f211ef +Create Date: 2019-10-24 12:18:39.734758 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7fea7427e9d6' +down_revision = 'f24d53f211ef' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('rfp_liker', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('rfp_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.create_table('proposal_liker', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.create_table('comment_liker', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('comment_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['comment_id'], ['comment.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_table('comment_liker') + op.drop_table('proposal_liker') + op.drop_table('rfp_liker') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/9d2f7db5b5a6_.py b/backend/migrations/versions/9d2f7db5b5a6_.py new file mode 100644 index 00000000..af433250 --- /dev/null +++ b/backend/migrations/versions/9d2f7db5b5a6_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 9d2f7db5b5a6 +Revises: 0ba15ddf5053 +Create Date: 2019-11-13 17:29:46.810554 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9d2f7db5b5a6' +down_revision = '0ba15ddf5053' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_settings', sa.Column('tip_jar_address', sa.String(length=255), nullable=True)) + op.add_column('user_settings', sa.Column('tip_jar_view_key', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_settings', 'tip_jar_view_key') + op.drop_column('user_settings', 'tip_jar_address') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/c55f96720196_.py b/backend/migrations/versions/c55f96720196_.py new file mode 100644 index 00000000..5839e5cd --- /dev/null +++ b/backend/migrations/versions/c55f96720196_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: c55f96720196 +Revises: 9d2f7db5b5a6 +Create Date: 2019-11-08 17:34:55.828331 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c55f96720196' +down_revision = '9d2f7db5b5a6' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('proposal', sa.Column('tip_jar_address', sa.String(length=255), nullable=True)) + op.add_column('proposal', sa.Column('tip_jar_view_key', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('proposal', 'tip_jar_view_key') + op.drop_column('proposal', 'tip_jar_address') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/f24d53f211ef_.py b/backend/migrations/versions/f24d53f211ef_.py new file mode 100644 index 00000000..89219882 --- /dev/null +++ b/backend/migrations/versions/f24d53f211ef_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: f24d53f211ef +Revises: 515abdefed7a +Create Date: 2019-10-23 16:32:02.161367 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f24d53f211ef' +down_revision = '515abdefed7a' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_follower', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.drop_table('proposal_subscribers') + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_subscribers', + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('proposal_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name='proposal_subscribers_proposal_id_fkey'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='proposal_subscribers_user_id_fkey') + ) + op.drop_table('proposal_follower') + # ### end Alembic commands ### diff --git a/backend/tests/admin/test_admin_api.py b/backend/tests/admin/test_admin_api.py index 67f49f81..2b441732 100644 --- a/backend/tests/admin/test_admin_api.py +++ b/backend/tests/admin/test_admin_api.py @@ -242,30 +242,8 @@ class TestAdminAPI(BaseProposalCreatorConfig): # 2 proposals created by BaseProposalCreatorConfig self.assertEqual(len(resp.json['items']), 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=json.dumps({"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=json.dumps({"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=json.dumps({"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=json.dumps({"contributionMatching": 2})) - self.assert400(resp) - self.assertTrue(resp.json['message']) - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_approve_proposal(self, mock_get): + def test_accept_proposal_with_funding(self, mock_get): self.login_admin() # proposal needs to be PENDING @@ -273,11 +251,94 @@ class TestAdminAPI(BaseProposalCreatorConfig): # approve resp = self.app.put( - "/api/v1/admin/proposals/{}/approve".format(self.proposal.id), - data=json.dumps({"isApprove": True}) + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": True, "withFunding": True}) + ) + print(resp.json) + self.assert200(resp) + self.assertEqual(resp.json["status"], ProposalStatus.LIVE) + self.assertEqual(resp.json["acceptedWithFunding"], True) + self.assertEqual(resp.json["target"], resp.json["contributionBounty"]) + + # milestones should have estimated dates + for milestone in resp.json["milestones"]: + self.assertIsNotNone(milestone["dateEstimated"]) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_accept_proposal_without_funding(self, mock_get): + self.login_admin() + + # proposal needs to be PENDING + self.proposal.status = ProposalStatus.PENDING + + # approve + resp = self.app.put( + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": True, "withFunding": False}) + ) + print(resp.json) + self.assert200(resp) + self.assertEqual(resp.json["status"], ProposalStatus.LIVE) + self.assertEqual(resp.json["acceptedWithFunding"], False) + self.assertEqual(resp.json["contributionBounty"], "0") + + # milestones should not have estimated dates + for milestone in resp.json["milestones"]: + self.assertIsNone(milestone["dateEstimated"]) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_change_proposal_to_accepted_with_funding(self, mock_get): + self.login_admin() + + # proposal needs to be PENDING + self.proposal.status = ProposalStatus.PENDING + + # accept without funding + resp = self.app.put( + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": True, "withFunding": False}) ) self.assert200(resp) - self.assertEqual(resp.json["status"], ProposalStatus.APPROVED) + self.assertEqual(resp.json["acceptedWithFunding"], False) + + # change to accepted with funding + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert200(resp) + self.assertEqual(resp.json["acceptedWithFunding"], True) + + # milestones should have estimated dates + for milestone in resp.json["milestones"]: + self.assertIsNotNone(milestone["dateEstimated"]) + + # should fail if proposal is already accepted with funding + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert404(resp) + self.assertEqual(resp.json['message'], "Proposal already accepted with funding.") + self.proposal.accepted_with_funding = False + + # should fail if proposal is not version two + self.proposal.version = '' + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert404(resp) + self.assertEqual(resp.json['message'], "Only version two proposals can be accepted with funding") + self.proposal.version = '2' + + # should failed if proposal is not LIVE or APPROVED + self.proposal.status = ProposalStatus.PENDING + self.proposal.accepted_with_funding = False + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert404(resp) + self.assertEqual(resp.json["message"], 'Only live or approved proposals can be modified by this endpoint') + + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_reject_proposal(self, mock_get): @@ -288,8 +349,8 @@ class TestAdminAPI(BaseProposalCreatorConfig): # reject resp = self.app.put( - "/api/v1/admin/proposals/{}/approve".format(self.proposal.id), - data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."}) + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."}) ) self.assert200(resp) self.assertEqual(resp.json["status"], ProposalStatus.REJECTED) @@ -325,19 +386,3 @@ class TestAdminAPI(BaseProposalCreatorConfig): }) ) self.assert200(resp) - - def test_create_rfp_fails_with_bad_category(self): - self.login_admin() - - resp = self.app.post( - "/api/v1/admin/rfps", - data=json.dumps({ - "brief": "Some brief", - "category": "NOT_CORE_DEV", - "content": "CONTENT", - "dateCloses": 1553980004, - "status": "DRAFT", - "title": "TITLE" - }) - ) - self.assert400(resp) diff --git a/backend/tests/ccr/__init__.py b/backend/tests/ccr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/ccr/test_ccr_api.py b/backend/tests/ccr/test_ccr_api.py new file mode 100644 index 00000000..b95a45a1 --- /dev/null +++ b/backend/tests/ccr/test_ccr_api.py @@ -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) diff --git a/backend/tests/config.py b/backend/tests/config.py index bdf196b3..83f86cb7 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -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): @@ -138,14 +139,14 @@ class BaseProposalCreatorConfig(BaseUserConfig): { "title": "Milestone 1", "content": "Content 1", - "date_estimated": (datetime.now() + timedelta(days=364)).timestamp(), # random unix time in the future + "days_estimated": "30", "payout_percent": 50, "immediate_payout": True }, { "title": "Milestone 2", "content": "Content 2", - "date_estimated": (datetime.now() + timedelta(days=365)).timestamp(), # random unix time in the future + "days_estimated": "20", "payout_percent": 50, "immediate_payout": False } @@ -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() diff --git a/backend/tests/milestone/__init__.py b/backend/tests/milestone/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/milestone/test_milestone_methods.py b/backend/tests/milestone/test_milestone_methods.py new file mode 100644 index 00000000..3e9315b8 --- /dev/null +++ b/backend/tests/milestone/test_milestone_methods.py @@ -0,0 +1,151 @@ +import json +import datetime +from mock import patch +from grant.proposal.models import Proposal, db, proposal_schema +from grant.milestone.models import Milestone +from grant.task.models import Task +from grant.task.jobs import MilestoneDeadline +from grant.utils.enums import ProposalStatus, Category, MilestoneStage +from ..config import BaseUserConfig +from ..test_data import test_team, mock_blockchain_api_requests + + +test_milestones = [ + { + "title": "first milestone", + "content": "content", + "daysEstimated": "30", + "payoutPercent": "25", + "immediatePayout": False + }, + { + "title": "second milestone", + "content": "content", + "daysEstimated": "10", + "payoutPercent": "25", + "immediatePayout": False + }, + { + "title": "third milestone", + "content": "content", + "daysEstimated": "20", + "payoutPercent": "25", + "immediatePayout": False + }, + { + "title": "fourth milestone", + "content": "content", + "daysEstimated": "30", + "payoutPercent": "25", + "immediatePayout": False + } +] + +test_proposal = { + "team": test_team, + "content": "## My Proposal", + "title": "Give Me Money", + "brief": "$$$", + "milestones": test_milestones, + "category": Category.ACCESSIBILITY, + "target": "12345", + "payoutAddress": "123", +} + + +class TestMilestoneMethods(BaseUserConfig): + + def init_proposal(self, proposal_data): + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/drafts" + ) + self.assertStatus(resp, 201) + proposal_id = resp.json["proposalId"] + + resp = self.app.put( + f"/api/v1/proposals/{proposal_id}", + data=json.dumps(proposal_data), + content_type='application/json' + ) + self.assert200(resp) + + proposal = Proposal.query.get(proposal_id) + proposal.status = ProposalStatus.PENDING + + # accept with funding + proposal.approve_pending(True, True) + Milestone.set_v2_date_estimates(proposal) + + db.session.add(proposal) + db.session.commit() + + print(proposal_schema.dump(proposal)) + return proposal + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_v2_date_estimates(self, mock_get): + proposal_data = test_proposal.copy() + proposal = self.init_proposal(proposal_data) + total_days_estimated = 0 + + # make sure date_estimated has been populated on all milestones + for milestone in proposal.milestones: + total_days_estimated += int(milestone.days_estimated) + self.assertIsNotNone(milestone.date_estimated) + + # check the proposal `date_approved` has been used for first milestone calculation + first_milestone = proposal.milestones[0] + expected_base_date = proposal.date_approved + expected_days_estimated = first_milestone.days_estimated + expected_date_estimated = expected_base_date + datetime.timedelta(days=int(expected_days_estimated)) + + self.assertEqual(first_milestone.date_estimated, expected_date_estimated) + + # check that the `date_estimated` of the final milestone has been calculated with the cumulative + # `days_estimated` of the previous milestones + last_milestone = proposal.milestones[-1] + expected_date_estimated = expected_base_date + datetime.timedelta(days=int(total_days_estimated)) + self.assertEqual(last_milestone.date_estimated, expected_date_estimated) + + # check to see a task has been created + tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all() + self.assertEqual(len(tasks), 1) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_v2_date_estimates_immediate_payout(self, mock_get): + proposal_data = test_proposal.copy() + proposal_data["milestones"][0]["immediate_payout"] = True + + self.init_proposal(proposal_data) + tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all() + + # ensure MilestoneDeadline task not created when immediate payout is set + self.assertEqual(len(tasks), 0) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_v2_date_estimates_deadline_recalculation(self, mock_get): + proposal_data = test_proposal.copy() + proposal = self.init_proposal(proposal_data) + + first_ms = proposal.milestones[0] + second_ms = proposal.milestones[1] + + first_ms.stage = MilestoneStage.PAID + first_ms.date_paid = datetime.datetime.now() + + expected_base_date = datetime.datetime.now() + datetime.timedelta(days=42) + second_ms.stage = MilestoneStage.PAID + second_ms.date_paid = expected_base_date + + db.session.add(proposal) + db.session.commit() + + Milestone.set_v2_date_estimates(proposal) + + proposal = Proposal.query.get(proposal.id) + third_ms = proposal.milestones[2] + expected_date_estimated = expected_base_date + datetime.timedelta(days=int(third_ms.days_estimated)) + + # ensure `date_estimated` was recalculated as expected + self.assertEqual(third_ms.date_estimated, expected_date_estimated) diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 6f5b05a0..5a08a2d5 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -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,14 +169,115 @@ 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( + f"/api/v1/proposals/{self.proposal.id}/follow", + data=json.dumps({"isFollow": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + self.proposal.status = ProposalStatus.LIVE + + resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}") + self.assert200(resp) + self.assertEqual(resp.json["authedFollows"], False) + + # follow + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/follow", + data=json.dumps({"isFollow": True}), + content_type="application/json", + ) + self.assert200(resp) + + resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}") + self.assert200(resp) + self.assertEqual(resp.json["authedFollows"], True) + + self.assertEqual(self.proposal.followers[0].id, self.user.id) + self.assertEqual(self.user.followed_proposals[0].id, self.proposal.id) + + # un-follow + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/follow", + data=json.dumps({"isFollow": False}), + content_type="application/json", + ) + self.assert200(resp) + + resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}") + self.assert200(resp) + self.assertEqual(resp.json["authedFollows"], False) + + self.assertEqual(len(self.proposal.followers), 0) + self.assertEqual(len(self.user.followed_proposals), 0) + + def test_like_proposal(self): + # not logged in + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + + # proposal not yet live + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + self.assertEquals(resp.json["message"], "Cannot like a proposal that's not live") + + # proposal is live + self.proposal.status = ProposalStatus.LIVE + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert200(resp) + self.assertTrue(self.user in self.proposal.likes) + + resp = self.app.get( + f"/api/v1/proposals/{self.proposal.id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + + # test unliking a proposal + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assert200(resp) + self.assertTrue(self.user not in self.proposal.likes) + + resp = self.app.get( + f"/api/v1/proposals/{self.proposal.id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) diff --git a/backend/tests/proposal/test_comment_api.py b/backend/tests/proposal/test_comment_api.py index 9dbdeef1..e73e7933 100644 --- a/backend/tests/proposal/test_comment_api.py +++ b/backend/tests/proposal/test_comment_api.py @@ -1,6 +1,6 @@ import json -from grant.proposal.models import Proposal, db +from grant.proposal.models import Proposal, Comment, db from grant.utils.enums import ProposalStatus from ..config import BaseUserConfig from ..test_data import test_comment, test_reply, test_comment_large @@ -148,3 +148,59 @@ class TestProposalCommentAPI(BaseUserConfig): self.assertStatus(comment_res, 403) self.assertIn('silenced', comment_res.json['message']) + + def test_like_comment(self): + proposal = Proposal(status=ProposalStatus.LIVE) + db.session.add(proposal) + + comment = Comment( + proposal_id=proposal.id, + user_id=self.other_user.id, + parent_comment_id=None, + content=test_comment["comment"] + ) + comment_id = comment.id + db.session.add(comment) + db.session.commit() + + # comment not found + resp = self.app.put( + f"/api/v1/comment/123456789/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # not logged in + resp = self.app.put( + f"/api/v1/comment/{comment_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + resp = self.app.put( + f"/api/v1/comment/{comment_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + + self.assertStatus(resp, 201) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + comment = Comment.query.get(comment_id) + self.assertTrue(self.user in comment.likes) + + # test unliking a proposal + resp = self.app.put( + f"/api/v1/comment/{comment.id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assertStatus(resp, 201) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) + comment = Comment.query.get(comment_id) + self.assertTrue(self.user not in comment.likes) diff --git a/backend/tests/proposal/test_tips_api.py b/backend/tests/proposal/test_tips_api.py new file mode 100644 index 00000000..dc2d88c5 --- /dev/null +++ b/backend/tests/proposal/test_tips_api.py @@ -0,0 +1,63 @@ +import json +from grant.proposal.models import Proposal, ProposalStatus, db +from ..config import BaseProposalCreatorConfig +from mock import patch +from ..test_data import mock_blockchain_api_requests + +address_json = { + "address": "valid_address" +} + +view_key_json = { + "viewKey": "valid_view_key" +} + + +class TestProposalInviteAPI(BaseProposalCreatorConfig): + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_proposal_tip_address(self, mock_get): + self.login_default_user() + res = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/tips", + data=json.dumps(address_json), + content_type='application/json' + ) + self.assertStatus(res, 200) + proposal = Proposal.query.get(self.proposal.id) + self.assertEqual(proposal.tip_jar_address, address_json["address"]) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_proposal_tip_view_key(self, mock_get): + self.login_default_user() + res = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/tips", + data=json.dumps(view_key_json), + content_type='application/json' + ) + self.assertStatus(res, 200) + proposal = Proposal.query.get(self.proposal.id) + self.assertEqual(proposal.tip_jar_view_key, view_key_json["viewKey"]) + + # test to make sure a user on the proposal team can see the view key + res = self.app.get( + f"/api/v1/proposals/{self.proposal.id}", + data=json.dumps(view_key_json), + content_type='application/json' + ) + self.assert200(res) + self.assertEqual(res.json["tipJarViewKey"], view_key_json["viewKey"]) + + # test to make sure a user not on the proposal team can't see the view key + self.proposal.status = ProposalStatus.LIVE + db.session.add(self.proposal) + db.session.commit() + + self.login_other_user() + res = self.app.get( + f"/api/v1/proposals/{self.proposal.id}", + data=json.dumps(view_key_json), + content_type='application/json' + ) + self.assert200(res) + self.assertIsNone(res.json["tipJarViewKey"]) diff --git a/backend/tests/rfp/__init__.py b/backend/tests/rfp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/rfp/test_rfp_api.py b/backend/tests/rfp/test_rfp_api.py new file mode 100644 index 00000000..eb086492 --- /dev/null +++ b/backend/tests/rfp/test_rfp_api.py @@ -0,0 +1,84 @@ + +import json +import datetime +from ..config import BaseProposalCreatorConfig +from grant.rfp.models import RFP, RFPStatus, db, Category + + +class TestRfpApi(BaseProposalCreatorConfig): + def test_rfp_like(self): + rfp = RFP( + title="title", + brief="brief", + content="content", + date_closes=datetime.datetime(2030, 1, 1), + bounty="10", + status=RFPStatus.DRAFT, + ) + rfp_id = rfp.id + db.session.add(rfp) + db.session.commit() + + # not logged in + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in, but rfp does not exist + self.login_default_user() + resp = self.app.put( + "/api/v1/rfps/123456789/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + + # RFP is not live + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + self.assertEqual(resp.json["message"], "RFP is not live") + + # set RFP live, test like + rfp = RFP.query.get(rfp_id) + rfp.status = RFPStatus.LIVE + db.session.add(rfp) + db.session.commit() + + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert200(resp) + rfp = RFP.query.get(rfp_id) + self.assertTrue(self.user in rfp.likes) + resp = self.app.get( + f"/api/v1/rfps/{rfp_id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + + # test unliking + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assert200(resp) + rfp = RFP.query.get(rfp_id) + self.assertTrue(self.user not in rfp.likes) + resp = self.app.get( + f"/api/v1/rfps/{rfp_id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) + diff --git a/backend/tests/task/test_api.py b/backend/tests/task/test_api.py index 7dd0cbe2..d878e04c 100644 --- a/backend/tests/task/test_api.py +++ b/backend/tests/task/test_api.py @@ -1,11 +1,71 @@ -from datetime import datetime +import json -from grant.task.models import Task +from grant.utils import totp_2fa +from grant.task.jobs import MilestoneDeadline +from datetime import datetime, timedelta + +from grant.task.models import Task, db +from grant.task.jobs import PruneDraft +from grant.milestone.models import Milestone +from grant.proposal.models import Proposal, ProposalUpdate +from grant.utils.enums import ProposalStatus, ProposalStage, Category from ..config import BaseProposalCreatorConfig +from ..test_data import mock_blockchain_api_requests +from mock import patch, Mock + +test_update = { + "title": "Update Title", + "content": "Update content." +} + +milestones_data = [ + { + "title": "All the money straightaway", + "content": "cool stuff with it", + "days_estimated": 30, + "payout_percent": "100", + "immediate_payout": False + } +] class TestTaskAPI(BaseProposalCreatorConfig): + def p(self, path, data): + return self.app.post(path, data=json.dumps(data), content_type="application/json") + + def login_admin(self): + # set admin + self.user.set_admin(True) + db.session.commit() + + # login + r = self.p("/api/v1/admin/login", { + "username": self.user.email_address, + "password": self.user_password + }) + self.assert200(r) + + # 2fa on the natch + r = self.app.get("/api/v1/admin/2fa") + self.assert200(r) + + # ... init + r = self.app.get("/api/v1/admin/2fa/init") + self.assert200(r) + + codes = r.json['backupCodes'] + secret = r.json['totpSecret'] + uri = r.json['totpUri'] + + # ... enable/verify + r = self.p("/api/v1/admin/2fa/enable", { + "backupCodes": codes, + "totpSecret": secret, + "verifyCode": totp_2fa.current_totp(secret) + }) + self.assert200(r) + return r def test_proposal_reminder_task_is_created(self): tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all() @@ -22,3 +82,262 @@ class TestTaskAPI(BaseProposalCreatorConfig): tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all() self.assertEqual(tasks, []) + @patch('grant.task.views.datetime') + def test_proposal_pruning(self, mock_datetime): + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/drafts", + ) + proposal_id = resp.json['proposalId'] + + # make sure proposal was created + proposal = Proposal.query.get(proposal_id) + self.assertIsNotNone(proposal) + + # make sure the task was created + self.assertStatus(resp, 201) + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + task = tasks[0] + self.assertEqual(resp.json['proposalId'], task.blob['proposal_id']) + self.assertFalse(task.completed) + + # mock time so task will run when called + after_time = datetime.now() + timedelta(seconds=PruneDraft.PRUNE_TIME + 100) + mock_datetime.now = Mock(return_value=after_time) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran successfully + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + task = tasks[0] + self.assertTrue(task.completed) + proposal = Proposal.query.get(proposal_id) + self.assertIsNone(proposal) + + def test_proposal_pruning_noops(self): + # ensure all proposal noop states work as expected + + def status(p): + p.status = ProposalStatus.LIVE + + def title(p): + p.title = 'title' + + def brief(p): + p.brief = 'brief' + + def content(p): + p.content = 'content' + + def category(p): + p.category = Category.DEV_TOOL + + def target(p): + p.target = '100' + + def payout_address(p): + p.payout_address = 'address' + + def milestones(p): + Milestone.make(milestones_data, p) + + modifiers = [ + status, + title, + brief, + content, + category, + target, + payout_address, + milestones + ] + + for modifier in modifiers: + proposal = Proposal.create(status=ProposalStatus.DRAFT) + proposal_id = proposal.id + modifier(proposal) + + db.session.add(proposal) + db.session.commit() + + blob = { + "proposal_id": proposal_id, + } + + task = Task( + job_type=PruneDraft.JOB_TYPE, + blob=blob, + execute_after=datetime.now() + ) + + PruneDraft.process_task(task) + + proposal = Proposal.query.get(proposal_id) + self.assertIsNotNone(proposal) + + @patch('grant.task.jobs.send_email') + @patch('grant.task.views.datetime') + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_milestone_deadline(self, mock_get, mock_datetime, mock_send_email): + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 0) + + self.proposal.arbiter.user = self.user + db.session.add(self.proposal) + + # unset immediate_payout so task will be added + for milestone in self.proposal.milestones: + if milestone.immediate_payout: + milestone.immediate_payout = False + db.session.add(milestone) + + db.session.commit() + + self.login_admin() + + # proposal needs to be PENDING + self.proposal.status = ProposalStatus.PENDING + + # approve proposal with funding + resp = self.app.put( + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": True, "withFunding": True}) + ) + self.assert200(resp) + + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 1) + + # fast forward the clock so task will run + after_time = datetime.now() + timedelta(days=365) + mock_datetime.now = Mock(return_value=after_time) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 0) + mock_send_email.assert_called() + + @patch('grant.task.jobs.send_email') + def test_milestone_deadline_update_posted(self, mock_send_email): + tasks = Task.query.all() + self.assertEqual(len(tasks), 0) + + # set date_estimated on milestone to be in the past + milestone = self.proposal.milestones[0] + milestone.date_estimated = datetime.now() - timedelta(hours=1) + db.session.add(milestone) + db.session.commit() + + # make task + ms_deadline = MilestoneDeadline(self.proposal, milestone) + ms_deadline.make_task() + + # check make task + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + + # login and post proposal update + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/{}/updates".format(self.proposal.id), + data=json.dumps(test_update), + content_type='application/json' + ) + self.assertStatus(resp, 201) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran and did NOT send out an email + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 0) + mock_send_email.assert_not_called() + + @patch('grant.task.jobs.send_email') + def test_milestone_deadline_noops(self, mock_send_email): + # make sure all milestone deadline noop states work as expected + + def proposal_delete(p, m): + db.session.delete(p) + + def proposal_status(p, m): + p.status = ProposalStatus.DELETED + db.session.add(p) + + def proposal_stage(p, m): + p.stage = ProposalStage.CANCELED + db.session.add(p) + + def milestone_delete(p, m): + db.session.delete(m) + + def milestone_date_requested(p, m): + m.date_requested = datetime.now() + db.session.add(m) + + def update_posted(p, m): + # login and post proposal update + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/{}/updates".format(proposal.id), + data=json.dumps(test_update), + content_type='application/json' + ) + self.assertStatus(resp, 201) + + modifiers = [ + proposal_delete, + proposal_status, + proposal_stage, + milestone_delete, + milestone_date_requested, + update_posted + ] + + for modifier in modifiers: + # make proposal and milestone + proposal = Proposal.create(status=ProposalStatus.LIVE) + proposal.arbiter.user = self.other_user + proposal.team.append(self.user) + proposal_id = proposal.id + Milestone.make(milestones_data, proposal) + + db.session.add(proposal) + db.session.commit() + + # grab update count for blob + update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all()) + + # run modifications to trigger noop + proposal = Proposal.query.get(proposal_id) + milestone = proposal.milestones[0] + milestone_id = milestone.id + modifier(proposal, milestone) + db.session.commit() + + # make task + blob = { + "proposal_id": proposal_id, + "milestone_id": milestone_id, + "update_count": update_count + } + task = Task( + job_type=MilestoneDeadline.JOB_TYPE, + blob=blob, + execute_after=datetime.now() + ) + + # run task + MilestoneDeadline.process_task(task) + + # check to make sure noop occurred + mock_send_email.assert_not_called() diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index d7c323c3..a23e50fa 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -31,7 +31,7 @@ milestones = [ { "title": "All the money straightaway", "content": "cool stuff with it", - "dateEstimated": 1549505307, + "daysEstimated": "30", "payoutPercent": "100", "immediatePayout": False } @@ -44,11 +44,19 @@ test_proposal = { "brief": "$$$", "milestones": milestones, "category": Category.ACCESSIBILITY, - "target": "123.456", + "target": "12345", "payoutAddress": "123", "deadlineDuration": 100 } +test_ccr = { + "user_id": test_user, + "content": "## My Proposal", + "title": "Give Me Money", + "brief": "$$$", + "target": "123.456", +} + test_comment = { "comment": "Test comment" } diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index 24144ede..904696c9 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -8,7 +8,7 @@ from grant.user.models import User, user_schema, db from mock import patch from ..config import BaseUserConfig -from ..test_data import test_user +from ..test_data import test_user, mock_blockchain_api_requests class TestUserAPI(BaseUserConfig): @@ -385,3 +385,34 @@ class TestUserAPI(BaseUserConfig): content_type='application/json' ) self.assert400(resp) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_put_user_settings_tip_jar_address(self, mock_get): + address = "address" + + self.login_default_user() + resp = self.app.put( + "/api/v1/users/{}/settings".format(self.user.id), + data=json.dumps({'tipJarAddress': address}), + content_type='application/json' + ) + self.assert200(resp) + self.assertEqual(resp.json["tipJarAddress"], address) + user = User.query.get(self.user.id) + self.assertEqual(user.settings.tip_jar_address, address) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_put_user_settings_tip_jar_view_key(self, mock_get): + view_key = "view_key" + + self.login_default_user() + resp = self.app.put( + "/api/v1/users/{}/settings".format(self.user.id), + data=json.dumps({'tipJarViewKey': view_key}), + content_type='application/json' + ) + self.assert200(resp) + self.assertEqual(resp.json["tipJarViewKey"], view_key) + user = User.query.get(self.user.id) + self.assertEqual(user.settings.tip_jar_view_key, view_key) + diff --git a/frontend/.env.example b/frontend/.env.example index 83333a15..9de348c7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -26,4 +26,4 @@ DISABLE_SSL=true # TESTNET=true # Maximum amount for a proposal target, keep in sync with backend .env -PROPOSAL_TARGET_MAX=10000 +PROPOSAL_TARGET_MAX=999999 diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index c39d2d36..0dda8225 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -20,9 +20,13 @@ import 'styles/style.less'; const opts = { fallback: }; 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 Guide = loadable(() => import('pages/guide'), 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 +67,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: { @@ -165,6 +206,18 @@ const routeConfigs: RouteConfig[] = [ }, onlyLoggedIn: false, }, + { + // Terms of Service page + route: { + path: '/guide', + component: Guide, + exact: true, + }, + template: { + title: 'Guide', + }, + onlyLoggedIn: false, + }, { // About page route: { diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 5f150056..0fd5ae2c 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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; @@ -42,6 +44,24 @@ export function getProposal(proposalId: number | string): Promise<{ data: Propos }); } +export function followProposal(proposalId: number, isFollow: boolean) { + return axios.put(`/api/v1/proposals/${proposalId}/follow`, { isFollow }); +} + +export function likeProposal(proposalId: number, isLiked: boolean) { + return axios.put(`/api/v1/proposals/${proposalId}/like`, { isLiked }); +} + +export function likeRfp(rfpId: number, isLiked: boolean) { + return axios.put(`/api/v1/rfps/${rfpId}/like`, { isLiked }); +} + +export function likeComment(commentId: number, isLiked: boolean) { + return axios + .put(`/api/v1/comment/${commentId}/like`, { isLiked }) + .then(({ data }) => data); +} + export function getProposalComments(proposalId: number | string, params: PageParams) { return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params }); } @@ -70,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, @@ -137,6 +158,8 @@ export function getUserSettings( interface SettingsArgs { emailSubscriptions?: EmailSubscriptions; refundAddress?: string; + tipJarAddress?: string; + tipJarViewKey?: string; } export function updateUserSettings( userId: string | number, @@ -181,14 +204,18 @@ export function verifySocial(service: SOCIAL_SERVICE, code: string): Promise { - const res = await axios.get(process.env.CROWD_FUND_FACTORY_URL as string); - return res.data; +interface ProposalTipJarArgs { + address?: string; + viewKey?: string; } - -export async function fetchCrowdFundJSON(): Promise { - const res = await axios.get(process.env.CROWD_FUND_URL as string); - return res.data; +export function updateProposalTipJarSettings( + proposalId: string | number, + args?: ProposalTipJarArgs, +): Promise<{ data: Proposal }> { + return axios.put(`/api/v1/proposals/${proposalId}/tips`, args).then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); } export function postProposalUpdate( @@ -344,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); @@ -367,3 +388,49 @@ export function getRFP(rfpId: number | string): Promise<{ data: RFP }> { export function resendEmailVerification(): Promise<{ data: void }> { return axios.put(`/api/v1/users/me/resend-verification`); } + +export function getHomeLatest(): Promise<{ + data: { + latestProposals: Proposal[]; + latestRfps: RFP[]; + }; +}> { + return axios.get('/api/v1/home/latest').then(res => { + res.data = { + latestProposals: res.data.latestProposals.map(formatProposalFromGet), + latestRfps: res.data.latestRfps.map(formatRFPFromGet), + }; + 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 { + 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; + }); +} diff --git a/frontend/client/components/AuthButton.tsx b/frontend/client/components/AuthButton.tsx new file mode 100644 index 00000000..1cdcbb95 --- /dev/null +++ b/frontend/client/components/AuthButton.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Redirect, RouteProps } from 'react-router'; +import { Button } from 'antd'; +import { AppState } from 'store/reducers'; +import { authActions } from 'modules/auth'; +import { NativeButtonProps } from 'antd/lib/button/button'; +import { withRouter } from 'react-router-dom'; +import { compose } from 'recompose'; + +type OwnProps = NativeButtonProps; + +interface StateProps { + user: AppState['auth']['user']; +} + +interface DispatchProps { + setAuthForwardLocation: typeof authActions['setAuthForwardLocation']; +} + +type Props = OwnProps & RouteProps & StateProps & DispatchProps; + +const STATE = { + sendToAuth: false, +}; +type State = typeof STATE; + +class AuthButton extends React.Component { + state: State = { ...STATE }; + public render() { + const { location, children, loading } = this.props; + if (this.state.sendToAuth) { + return ; + } + return ( + + ); + } + private handleClick = (e: React.MouseEvent) => { + if (!this.props.onClick) { + return; + } + if (this.props.user) { + this.props.onClick(e); + } else { + const { location, setAuthForwardLocation } = this.props; + setAuthForwardLocation(location); + setTimeout(() => { + this.setState({ sendToAuth: true }); + }, 200); + } + }; +} + +const withConnect = connect( + (state: AppState) => ({ + user: state.auth.user, + }), + { setAuthForwardLocation: authActions.setAuthForwardLocation }, +); + +export default compose( + withRouter, + withConnect, +)(AuthButton); diff --git a/frontend/client/components/AuthFlow/SignUp.tsx b/frontend/client/components/AuthFlow/SignUp.tsx index a32de653..bafae13b 100644 --- a/frontend/client/components/AuthFlow/SignUp.tsx +++ b/frontend/client/components/AuthFlow/SignUp.tsx @@ -41,7 +41,7 @@ class SignUp extends React.Component { )}
- + {getFieldDecorator('title', { rules: [{ required: true, message: 'Please add your title' }], })( @@ -127,7 +127,8 @@ class SignUp extends React.Component { ev.preventDefault(); const { createUser } = this.props; this.props.form.validateFieldsAndScroll((err: any, values: any) => { - if (!err) { + const hasAgreed = this.props.form.getFieldValue('hasAgreed'); + if (!err && hasAgreed) { delete values.passwordConfirm; createUser(values); } diff --git a/frontend/client/components/CCRDraftList/index.tsx b/frontend/client/components/CCRDraftList/index.tsx new file mode 100644 index 00000000..b378bf1e --- /dev/null +++ b/frontend/client/components/CCRDraftList/index.tsx @@ -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; +} + +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 { + 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 ( +
+ +
+ ); + } + + if (!drafts || isCreatingDraft) { + return ; + } + + let draftsEl; + if (drafts.length) { + draftsEl = ( + { + const actions = [ + + Edit + , + this.deleteDraft(d.ccrId)} + > + Delete + , + ]; + return ( + + + + {d.title || Untitled Request} + {d.status === CCRSTATUS.REJECTED && (changes requested)} + + } + description={d.brief || No description} + /> + + + ); + }} + /> + ); + } else { + draftsEl = ( + + ); + } + + return ( +
+

Your Request Drafts

+ {draftsEl} + or + +
+ ); + } + + private createDraft = () => { + this.props.createCCRDraft(); + }; + + private deleteDraft = (ccrId: number) => { + this.props.deleteCCRDraft(ccrId); + this.setState({ deletingId: ccrId }); + }; +} + +export default connect( + 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); diff --git a/frontend/client/components/CCRDraftList/style.less b/frontend/client/components/CCRDraftList/style.less new file mode 100644 index 00000000..20a308d6 --- /dev/null +++ b/frontend/client/components/CCRDraftList/style.less @@ -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; + } +} \ No newline at end of file diff --git a/frontend/client/components/CCRFlow/Basics.tsx b/frontend/client/components/CCRFlow/Basics.tsx new file mode 100644 index 00000000..b9083d62 --- /dev/null +++ b/frontend/client/components/CCRFlow/Basics.tsx @@ -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; + + updateForm(form: Partial): void; +} + +type Props = OwnProps; + +interface State extends Partial { + title: string; + brief: string; + target: string; +} + +class CCRFlowBasics extends React.Component { + 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 ( + + + + + + + + + + + + + + ); + } + + private handleInputChange = ( + event: React.ChangeEvent, + ) => { + const { value, name } = event.currentTarget; + this.setState({ [name]: value } as any, () => { + this.props.updateForm(this.state); + }); + }; +} + +export default CCRFlowBasics; diff --git a/frontend/client/components/CCRFlow/CCRExplainer.less b/frontend/client/components/CCRFlow/CCRExplainer.less new file mode 100644 index 00000000..8126dafc --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRExplainer.less @@ -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; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/CCRFlow/CCRExplainer.tsx b/frontend/client/components/CCRFlow/CCRExplainer.tsx new file mode 100644 index 00000000..338ec02e --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRExplainer.tsx @@ -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 = ({ 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: , + }, + { + text: + "The request is reviewed by the Zcash Foundation. \nYou'll be notified should the Zcash Foundation choose to make your request public.", + icon: , + }, + ]; + + return ( +
+
+

Creating a Request

+
+ We can't wait to get your request! Before starting, here's what you should + know... +
+
+
+ {items.map((item, idx) => ( +
+
{item.icon}
+
{item.text}
+
+ ))} +
+
+ ls.set('noExplainCCR', ev.target.checked)}> + Don't show this again + + +
+
+ ); +}; + +export default withNamespaces()(CCRExplainer); diff --git a/frontend/client/components/CCRFlow/CCRFinal.less b/frontend/client/components/CCRFlow/CCRFinal.less new file mode 100644 index 00000000..281aa156 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRFinal.less @@ -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; + } +} diff --git a/frontend/client/components/CCRFlow/CCRFinal.tsx b/frontend/client/components/CCRFlow/CCRFinal.tsx new file mode 100644 index 00000000..816d8bea --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRFinal.tsx @@ -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 { + componentDidMount() { + this.submit(); + } + + render() { + const { submittedCCR, submitError, goBack } = this.props; + const ready = submittedCCR; + + let content; + if (submitError) { + content = ( +
+ +
+

+ Something went wrong during creation +

+
{submitError}
+ Click here to go back to the form and try again. +
+
+ ); + } else if (ready) { + content = ( + <> +
+ + +
+ Your request has been submitted! Check your{' '} + profile's pending tab to check its + status. +
+
+ + ); + } else { + content = ; + } + + return
{content}
; + } + + private submit = () => { + if (this.props.form) { + this.props.submitCCR(this.props.form); + } + }; +} + +export default connect( + (state: AppState) => ({ + form: state.ccr.form, + submittedCCR: state.ccr.submittedCCR, + submitError: state.ccr.submitError, + }), + { + submitCCR: ccrActions.submitCCR, + }, +)(CCRFinal); diff --git a/frontend/client/components/CCRFlow/CCRPreview.less b/frontend/client/components/CCRFlow/CCRPreview.less new file mode 100644 index 00000000..cd6c719d --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRPreview.less @@ -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; + } +} diff --git a/frontend/client/components/CCRFlow/CCRPreview.tsx b/frontend/client/components/CCRFlow/CCRPreview.tsx new file mode 100644 index 00000000..bd92ec85 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRPreview.tsx @@ -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 { + 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + 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{' '} + requests page. + + ), + type: 'success', + }, + } as any; + + const banner = statusBanner[previewData.status]; + + return ( +
+ {banner && ( +
+ +
+ )} + +
+ null) as any} + /> +
+
+ ); + } +} + +export default connect(state => ({ + form: state.ccr.form as CCRDraft, +}))(CCRFlowPreview); diff --git a/frontend/client/components/CCRFlow/CCRReview.less b/frontend/client/components/CCRFlow/CCRReview.less new file mode 100644 index 00000000..60de2eb1 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRReview.less @@ -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; + } + } + } +} diff --git a/frontend/client/components/CCRFlow/CCRReview.tsx b/frontend/client/components/CCRFlow/CCRReview.tsx new file mode 100644 index 00000000..959cadb3 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRReview.tsx @@ -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 { + render() { + const { form } = this.props; + const errors = getCCRErrors(this.props.form); + const sections: Section[] = [ + { + step: CCR_STEP.BASICS, + name: 'Basics', + fields: [ + { + key: 'title', + content:

{form.title}

, + error: errors.title, + }, + { + key: 'brief', + content: form.brief, + error: errors.brief, + }, + { + key: 'target', + content:
{formatUsd(form.target)}
, + error: errors.target, + }, + ], + }, + + { + step: CCR_STEP.DETAILS, + name: 'Details', + fields: [ + { + key: 'content', + content: , + error: errors.content, + }, + ], + }, + ]; + + return ( +
+ {sections.map(s => ( +
+ {s.fields.map( + f => + !f.isHide && ( +
+
+ {FIELD_NAME_MAP[f.key]} + {f.error && ( +
{f.error}
+ )} +
+
+ {this.isEmpty(form[f.key]) ? ( +
N/A
+ ) : ( + f.content + )} +
+
+ ), + )} +
+
+
+ +
+
+
+ ))} +
+ ); + } + + 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(state => ({ + form: state.ccr.form as CCRDraft, +}))(CCRReview); diff --git a/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less new file mode 100644 index 00000000..0663eb01 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less @@ -0,0 +1,13 @@ +.CCRSubmitWarningModal { + .ant-alert { + margin-bottom: 1rem; + + ul { + padding-top: 0.25rem; + } + + p:last-child { + margin-bottom: 0; + } + } +} diff --git a/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx new file mode 100644 index 00000000..aac20b0a --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx @@ -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 { + render() { + const { isVisible, handleClose, handleSubmit } = this.props; + + return ( + Confirm submission} + visible={isVisible} + okText={'Submit'} + cancelText="Never mind" + onOk={handleSubmit} + onCancel={handleClose} + > +
+

+ Are you sure you're ready to submit your request for approval? Once you’ve + done so, you won't be able to edit it. +

+
+
+ ); + } +} diff --git a/frontend/client/components/CCRFlow/Details.tsx b/frontend/client/components/CCRFlow/Details.tsx new file mode 100644 index 00000000..2234753a --- /dev/null +++ b/frontend/client/components/CCRFlow/Details.tsx @@ -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; + updateForm(form: Partial): void; +} + +export default class CreateFlowTeam extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + content: '', + ...(props.initialState || {}), + }; + } + + render() { + const errors = getCCRErrors(this.state, true); + + return ( +
+ + {errors.content && } + + ); + } + + private handleChange = (markdown: string) => { + if (markdown !== this.state.content) { + this.setState({ content: markdown }, () => { + this.props.updateForm(this.state); + }); + } + }; +} diff --git a/frontend/client/components/CCRFlow/index.less b/frontend/client/components/CCRFlow/index.less new file mode 100644 index 00000000..0b772d50 --- /dev/null +++ b/frontend/client/components/CCRFlow/index.less @@ -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%); + } +} diff --git a/frontend/client/components/CCRFlow/index.tsx b/frontend/client/components/CCRFlow/index.tsx new file mode 100644 index 00000000..3e4fe112 --- /dev/null +++ b/frontend/client/components/CCRFlow/index.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { compose } from 'recompose'; +import { Steps, Icon } from 'antd'; +import qs from 'query-string'; +import { withRouter, RouteComponentProps } from 'react-router'; +import { History } from 'history'; +import { debounce } from 'underscore'; +import Basics from './Basics'; +import Details from './Details'; +import Review from './CCRReview'; +import Preview from './CCRPreview'; +import Final from './CCRFinal'; +import CCRSubmitWarningModal from './CCRSubmitWarningModal'; +import { ccrActions } from 'modules/ccr'; +import { CCRDraft } from 'types'; +import { getCCRErrors } from 'modules/ccr/utils'; + +import { AppState } from 'store/reducers'; + +import './index.less'; +import ls from 'local-storage'; +import Explainer from './CCRExplainer'; + +export enum CCR_STEP { + BASICS = 'BASICS', + DETAILS = 'DETAILS', + REVIEW = 'REVIEW', +} + +const STEP_ORDER = [CCR_STEP.BASICS, CCR_STEP.DETAILS, CCR_STEP.REVIEW]; + +interface StepInfo { + short: string; + title: React.ReactNode; + subtitle: React.ReactNode; + help: React.ReactNode; + component: any; +} + +interface LSExplainer { + noExplainCCR: boolean; +} + +const STEP_INFO: { [key in CCR_STEP]: StepInfo } = { + [CCR_STEP.BASICS]: { + short: 'Basics', + title: 'Let’s start with the basics', + subtitle: 'Don’t worry, you can come back and change things before publishing', + help: + 'You don’t have to fill out everything at once right now, you can come back later.', + component: Basics, + }, + [CCR_STEP.DETAILS]: { + short: 'Details', + title: 'Dive into the details', + subtitle: 'Here’s your chance to lay out the full request, in all its glory', + help: `Make sure people know what you’re requesting, why it's needed, how they can accomplish it`, + component: Details, + }, + [CCR_STEP.REVIEW]: { + short: 'Review', + title: 'Review your request', + subtitle: 'Feel free to edit any field that doesn’t look right', + help: 'You’ll get a chance to preview your request next before you publish it', + component: Review, + }, +}; + +interface StateProps { + form: AppState['ccr']['form']; + isSavingDraft: AppState['ccr']['isSavingDraft']; + hasSavedDraft: AppState['ccr']['hasSavedDraft']; + saveDraftError: AppState['ccr']['saveDraftError']; +} + +interface DispatchProps { + updateCCRForm: typeof ccrActions['updateCCRForm']; +} + +type Props = StateProps & DispatchProps & RouteComponentProps; + +interface State { + step: CCR_STEP; + isPreviewing: boolean; + isShowingSubmitWarning: boolean; + isSubmitting: boolean; + isExample: boolean; + isExplaining: boolean; +} + +class CCRFlow extends React.Component { + private historyUnlisten: () => void; + private debouncedUpdateForm: (form: Partial) => 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('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 = ; + showFooter = false; + } else if (isPreviewing) { + content = ; + } else if (isExplaining) { + content = ; + showFooter = false; + } else { + // Antd definitions are missing `onClick` for step, even though it works. + const Step = Steps.Step as any; + content = ( +
+
+ + {STEP_ORDER.slice(0, 3).map(s => ( + this.setStep(s)} + style={{ cursor: 'pointer' }} + /> + ))} + +

{info.title}

+
{info.subtitle}
+
+
+ +
+
+ ); + } + + return ( +
+ {content} + {showFooter && ( +
+ {isLastStep ? ( + <> + + + + ) : ( + <> +
{info.help}
+ + + )} +
+ )} + {isSavingDraft ? ( +
Saving draft...
+ ) : ( + saveDraftError && ( +
+ Failed to save draft! +
+ {saveDraftError} +
+ ) + )} + +
+ ); + } + + private updateForm = (form: Partial) => { + 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( + (state: AppState) => ({ + form: state.ccr.form, + isSavingDraft: state.ccr.isSavingDraft, + hasSavedDraft: state.ccr.hasSavedDraft, + saveDraftError: state.ccr.saveDraftError, + }), + { + updateCCRForm: ccrActions.updateCCRForm, + }, +); + +export default compose( + withRouter, + withConnect, +)(CCRFlow); diff --git a/frontend/client/components/Card/index.tsx b/frontend/client/components/Card/index.tsx index 193578e1..087f6678 100644 --- a/frontend/client/components/Card/index.tsx +++ b/frontend/client/components/Card/index.tsx @@ -1,27 +1,22 @@ import React from 'react'; import moment from 'moment'; import classnames from 'classnames'; -import { Icon } from 'antd'; -import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants'; import './index.less'; import { Link } from 'react-router-dom'; +import { Proposal } from 'types'; +import Like from 'components/Like'; interface CardInfoProps { - category: PROPOSAL_CATEGORY; + proposal: Proposal; time: number; } -export const CardInfo: React.SFC = ({ category, time }) => ( +export const CardInfo: React.SFC = ({ proposal, time }) => (
-
- {CATEGORY_UI[category].label} -
-
- {moment(time).fromNow()} +
+
+
{moment(time).fromNow()}
); @@ -44,7 +39,7 @@ export class Card extends React.Component { {children}
- ) + ); } } diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index a2ada52f..43e97e3c 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -10,6 +10,7 @@ import { postProposalComment, reportProposalComment } from 'modules/proposals/ac import { getIsSignedIn } from 'modules/auth/selectors'; import { Comment as IComment } from 'types'; import { AppState } from 'store/reducers'; +import Like from 'components/Like'; import './style.less'; interface OwnProps { @@ -20,6 +21,7 @@ interface StateProps { isPostCommentPending: AppState['proposal']['isPostCommentPending']; postCommentError: AppState['proposal']['postCommentError']; isSignedIn: ReturnType; + detail: AppState['proposal']['detail']; } interface DispatchProps { @@ -71,19 +73,23 @@ class Comment extends React.Component {
- {isSignedIn && ( -
+
+
+ +
+ {isSignedIn && ( {isReplying ? 'Cancel' : 'Reply'} - {!comment.hidden && - !comment.reported && ( - - Report - - )} -
- )} + )} + {isSignedIn && + !comment.hidden && + !comment.reported && ( + + Report + + )} +
{(comment.replies.length || isReplying) && (
@@ -143,6 +149,7 @@ const ConnectedComment = connect( isPostCommentPending: state.proposal.isPostCommentPending, postCommentError: state.proposal.postCommentError, isSignedIn: getIsSignedIn(state), + detail: state.proposal.detail, }), { postProposalComment, diff --git a/frontend/client/components/Comment/style.less b/frontend/client/components/Comment/style.less index 09928863..d055ebb7 100644 --- a/frontend/client/components/Comment/style.less +++ b/frontend/client/components/Comment/style.less @@ -48,6 +48,7 @@ &-controls { display: flex; margin-left: -0.5rem; + align-items: center; &-button { font-size: 0.65rem; diff --git a/frontend/client/components/CopyInput.tsx b/frontend/client/components/CopyInput.tsx index 50a1cd50..3611b06d 100644 --- a/frontend/client/components/CopyInput.tsx +++ b/frontend/client/components/CopyInput.tsx @@ -3,6 +3,8 @@ import { Button, Form, Input, message } from 'antd'; import classnames from 'classnames'; import CopyToClipboard from 'react-copy-to-clipboard'; +import './ContributionModal/PaymentInfo.less'; + interface CopyInputProps { label: string; value: string | undefined; diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index ed2b4230..e6b2280b 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -1,12 +1,9 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Input, Form, Icon, Select, Alert, Popconfirm, message, Radio } from 'antd'; -import { SelectValue } from 'antd/lib/select'; +import { Input, Form, Alert, Popconfirm, message, Radio } from 'antd'; import { RadioChangeEvent } from 'antd/lib/radio'; -import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants'; import { ProposalDraft, RFP } from 'types'; import { getCreateErrors } from 'modules/create/utils'; -import { typedKeys } from 'utils/ts'; import { Link } from 'react-router-dom'; import { unlinkProposalRFP } from 'modules/create/actions'; import { AppState } from 'store/reducers'; @@ -31,7 +28,6 @@ type Props = OwnProps & StateProps & DispatchProps; interface State extends Partial { title: string; brief: string; - category?: PROPOSAL_CATEGORY; target: string; rfp?: RFP; } @@ -42,7 +38,6 @@ class CreateFlowBasics extends React.Component { this.state = { title: '', brief: '', - category: undefined, target: '', ...(props.initialState || {}), }; @@ -64,7 +59,10 @@ class CreateFlowBasics extends React.Component { render() { const { isUnlinkingProposalRFP } = this.props; - const { title, brief, category, target, rfp, rfpOptIn } = this.state; + const { title, brief, target, rfp, rfpOptIn } = this.state; + if (rfp && rfp.bounty && (target === null || target === '0')) { + this.setState({ target: rfp.bounty.toString() }); + } const errors = getCreateErrors(this.state, true); // Don't show target error at zero since it defaults to that @@ -73,9 +71,6 @@ class CreateFlowBasics extends React.Component { errors.target = undefined; } - const rfpOptInRequired = - rfp && (rfp.matching || (rfp.bounty && parseFloat(rfp.bounty.toString()) > 0)); - return (
{rfp && ( @@ -104,41 +99,29 @@ class CreateFlowBasics extends React.Component { /> )} - {rfpOptInRequired && ( - -
- This RFP offers either a bounty or matching. This will require ZFGrants - to fulfill{' '} - - KYC - {' '} - due dilligence. In the event your proposal is successful, you will need - to provide identifying information to ZFGrants. - - - Yes, I am willing to provide KYC information - - - No, I do not wish to provide KYC information and understand I - will not receive any matching or bounty funds from ZFGrants - - -
- - } - /> - )} + +
+ In the event your proposal is accepted with funding, you will need to + provide identifying information to the Zcash Foundation. + + + Yes, I am willing to provide KYC information + + + No, I do not wish to provide KYC information and understand my + proposal may still be posted on ZF Grants, but I will not be eligible + to funding from the Zcash Foundation. + + +
+ + } + /> { /> - - - - { type="number" value={target} onChange={this.handleInputChange} - addonAfter="ZEC" + addonBefore="$" maxLength={16} /> @@ -219,12 +186,6 @@ class CreateFlowBasics extends React.Component { }); }; - private handleCategoryChange = (value: SelectValue) => { - this.setState({ category: value as PROPOSAL_CATEGORY }, () => { - this.props.updateForm(this.state); - }); - }; - private handleRfpOptIn = (e: RadioChangeEvent) => { this.setState({ rfpOptIn: e.target.value }, () => { this.props.updateForm(this.state); diff --git a/frontend/client/components/CreateFlow/Details.tsx b/frontend/client/components/CreateFlow/Details.tsx index 070e504b..097f876a 100644 --- a/frontend/client/components/CreateFlow/Details.tsx +++ b/frontend/client/components/CreateFlow/Details.tsx @@ -30,7 +30,7 @@ export default class CreateFlowTeam extends React.Component { {errors.content && } diff --git a/frontend/client/components/CreateFlow/Explainer.less b/frontend/client/components/CreateFlow/Explainer.less new file mode 100644 index 00000000..31dbef27 --- /dev/null +++ b/frontend/client/components/CreateFlow/Explainer.less @@ -0,0 +1,86 @@ +@import '~styles/variables.less'; + +@small-query: ~'(max-width: 640px)'; + +.Explainer { + 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; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/CreateFlow/Explainer.tsx b/frontend/client/components/CreateFlow/Explainer.tsx new file mode 100644 index 00000000..b793c714 --- /dev/null +++ b/frontend/client/components/CreateFlow/Explainer.tsx @@ -0,0 +1,67 @@ +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 CommunityIcon from 'static/images/guide-community.svg'; +import './Explainer.less'; +import * as ls from 'local-storage'; +import { Button, Checkbox, Icon } from 'antd'; + +interface CreateProps { + startSteps: () => void; +} + +type Props = WithNamespaces & CreateProps; + +const Explainer: React.SFC = ({ t, startSteps }) => { + const items = [ + { + text: t('home.guide.submit'), + icon: , + }, + { + text: t('home.guide.review'), + icon: , + }, + { + text: t('home.guide.community'), + icon: , + }, + ]; + + return ( +
+
+

Creating a Proposal

+
+ We can't wait to get your request! Before starting, here's what you should + know... +
+
+
+ {items.map((item, idx) => ( +
+
{item.icon}
+
{item.text}
+
+ ))} +
+
+ ls.set('noExplain', ev.target.checked)}> + Don't show this again + + +
+
+ ); +}; + +export default withNamespaces()(Explainer); diff --git a/frontend/client/components/CreateFlow/Final.tsx b/frontend/client/components/CreateFlow/Final.tsx index 9d5ad601..fb09182c 100644 --- a/frontend/client/components/CreateFlow/Final.tsx +++ b/frontend/client/components/CreateFlow/Final.tsx @@ -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 { - state = STATE; +class CreateFinal extends React.Component { 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 { <>
- {staked && ( -
- Your proposal has been staked and submitted! Check your{' '} - profile's pending proposals tab{' '} - to check its status. -
- )} - {!staked && ( -
- Your proposal has been submitted! Please send the staking contribution of{' '} - {contribution && contribution.amount} ZEC using the instructions - below. -
- )} +
+ Your proposal has been submitted! Check your{' '} + profile's pending tab to check its + status. +
- {!staked && ( - <> -
- -

- If you cannot send the payment now, you may bring up these - instructions again by visiting your{' '} - profile's funded tab. -

-

- Once your payment has been sent and processed with 6 - confirmations, you will receive an email. Visit your{' '} - - profile's pending proposals tab - {' '} - at any time to check its status. -

- - } - contribution={contribution} - /> -
-

- I'm finished, take me to{' '} - my pending proposals! -

- - )} ); - } else if (contributionError) { - content = ( - - We were unable to get your staking contribution started. You can finish - staking from your profile, please try - again from there soon. - - } - /> - ); } else { content = ; } @@ -148,18 +72,6 @@ class CreateFinal extends React.Component { 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( diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 2e060621..0bb0ac76 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd'; -import moment from 'moment'; +import { Form, Input, Card, Icon, Alert, Checkbox, Button, Tooltip } from 'antd'; import { ProposalDraft, CreateMilestone } from 'types'; import { getCreateErrors } from 'modules/create/utils'; @@ -18,7 +17,7 @@ const DEFAULT_STATE: State = { { title: '', content: '', - dateEstimated: moment().unix(), + daysEstimated: '', payoutPercent: '', immediatePayout: false, }, @@ -78,11 +77,6 @@ export default class CreateFlowMilestones extends React.Component milestone={milestone} index={idx} error={errors.milestones && errors.milestones[idx]} - previousMilestoneDateEstimate={ - milestones[idx - 1] && milestones[idx - 1].dateEstimated - ? moment(milestones[idx - 1].dateEstimated * 1000) - : undefined - } onChange={this.handleMilestoneChange} onRemove={this.removeMilestone} /> @@ -101,7 +95,7 @@ export default class CreateFlowMilestones extends React.Component interface MilestoneFieldsProps { index: number; milestone: CreateMilestone; - previousMilestoneDateEstimate: moment.Moment | undefined; + // previousMilestoneDateEstimate: moment.Moment | undefined; error: Falsy | string; onChange(index: number, milestone: CreateMilestone): void; onRemove(index: number): void; @@ -113,7 +107,6 @@ const MilestoneFields = ({ error, onChange, onRemove, - previousMilestoneDateEstimate, }: MilestoneFieldsProps) => (
@@ -151,37 +144,27 @@ const MilestoneFields = ({ maxLength={255} />
+ {index > 0 && ( +
+ (Note: This number represents the number of days past the previous milestone day + estimate) +
+ )}
- - onChange(index, { ...milestone, dateEstimated: time.startOf('month').unix() }) - } + { - if (!previousMilestoneDateEstimate) { - return current - ? current < - moment() - .subtract(1, 'month') - .endOf('month') - : false; - } else { - return current - ? current < - moment() - .subtract(1, 'month') - .endOf('month') || current < previousMilestoneDateEstimate - : false; - } + placeholder="Estimated days to complete" + onChange={ev => { + return onChange(index, { + ...milestone, + daysEstimated: ev.currentTarget.value, + }); }} + addonAfter="days" + style={{ flex: 1, marginRight: '0.5rem' }} + maxLength={6} /> Payout Immediately + + +
)}
diff --git a/frontend/client/components/CreateFlow/Payment.tsx b/frontend/client/components/CreateFlow/Payment.tsx index 18bdfc3c..ff28de40 100644 --- a/frontend/client/components/CreateFlow/Payment.tsx +++ b/frontend/client/components/CreateFlow/Payment.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import { Input, Form, Radio } from 'antd'; -import { RadioChangeEvent } from 'antd/lib/radio'; +import { Input, Form } from 'antd'; import { ProposalDraft } from 'types'; import { getCreateErrors } from 'modules/create/utils'; -import { ONE_DAY } from 'utils/time'; import { DONATION } from 'utils/constants'; interface State { payoutAddress: string; - deadlineDuration: number; + tipJarAddress: string; } interface Props { @@ -21,19 +19,24 @@ export default class CreateFlowPayment extends React.Component { super(props); this.state = { payoutAddress: '', - deadlineDuration: ONE_DAY * 60, + tipJarAddress: '', ...(props.initialState || {}), }; } render() { - const { payoutAddress, deadlineDuration } = this.state; + const { payoutAddress, tipJarAddress } = this.state; const errors = getCreateErrors(this.state, true); const payoutHelp = errors.payoutAddress || ` This must be a Sapling Z address `; + const tipJarHelp = + errors.tipJarAddress || + ` + Allows your proposal to receive tips. Must be a Sapling Z address + `; return (
@@ -49,39 +52,30 @@ export default class CreateFlowPayment extends React.Component { placeholder={DONATION.ZCASH_SAPLING} type="text" value={payoutAddress} - onChange={this.handleInputChange} + onChange={this.handlePaymentInputChange} /> - - + - {deadlineDuration === 300 && ( - - 5 minutes - - )} - - 30 Days - - - 60 Days - - - 90 Days - - + name="tipJarAddress" + placeholder={DONATION.ZCASH_SAPLING} + type="text" + value={tipJarAddress} + onChange={this.handleTippingInputChange} + /> ); } - private handleInputChange = ( + private handlePaymentInputChange = ( event: React.ChangeEvent, ) => { const { value, name } = event.currentTarget; @@ -90,9 +84,11 @@ export default class CreateFlowPayment extends React.Component { }); }; - private handleRadioChange = (event: RadioChangeEvent) => { - const { value, name } = event.target; - this.setState({ [name as string]: value } as any, () => { + private handleTippingInputChange = ( + event: React.ChangeEvent, + ) => { + const { value, name } = event.currentTarget; + this.setState({ [name]: value } as any, () => { this.props.updateForm(this.state); }); }; diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index defe4417..b0e36a73 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Icon, Timeline } from 'antd'; -import moment from 'moment'; +import { Timeline } from 'antd'; import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils'; import Markdown from 'components/Markdown'; import UserAvatar from 'components/UserAvatar'; import { AppState } from 'store/reducers'; import { CREATE_STEP } from './index'; -import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; +import { formatUsd } from 'utils/formatters'; import './Review.less'; interface OwnProps { @@ -38,7 +37,6 @@ class CreateReview extends React.Component { render() { const { form } = this.props; const errors = getCreateErrors(this.props.form); - const catUI = CATEGORY_UI[form.category as PROPOSAL_CATEGORY] || ({} as any); const sections: Section[] = [ { step: CREATE_STEP.BASICS, @@ -53,25 +51,15 @@ class CreateReview extends React.Component { key: 'rfpOptIn', content:
{form.rfpOptIn ? 'Accepted' : 'Declined'}
, error: errors.rfpOptIn, - isHide: !form.rfp || (form.rfp && !form.rfp.matching && !form.rfp.bounty), }, { key: 'brief', content: form.brief, error: errors.brief, }, - { - key: 'category', - content: ( -
- {catUI.label} -
- ), - error: errors.category, - }, { key: 'target', - content:
{form.target} ZEC
, + content:
{formatUsd(form.target)}
, error: errors.target, }, ], @@ -118,12 +106,16 @@ class CreateReview extends React.Component { content: {form.payoutAddress}, error: errors.payoutAddress, }, + ], + }, + { + step: CREATE_STEP.PAYMENT, + name: 'Tipping', + fields: [ { - key: 'deadlineDuration', - content: `${Math.floor( - moment.duration((form.deadlineDuration || 0) * 1000).asDays(), - )} days`, - error: errors.deadlineDuration, + key: 'tipJarAddress', + content: {form.tipJarAddress}, + error: errors.tipJarAddress, }, ], }, @@ -131,8 +123,8 @@ class CreateReview extends React.Component { return (
- {sections.map(s => ( -
+ {sections.map((s, i) => ( +
{s.fields.map( f => !f.isHide && ( @@ -197,9 +189,9 @@ const ReviewMilestones = ({
{m.title || No title}
- {moment(m.dateEstimated * 1000).format('MMMM YYYY')} + {m.immediatePayout || !m.daysEstimated ? '0' : m.daysEstimated} days {' – '} - {m.payoutPercent}% of funds + {m.payoutPercent || '0'}% of funds
{m.content || No description} diff --git a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx index f2f077fa..84eb4f46 100644 --- a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx +++ b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx @@ -16,13 +16,11 @@ export default class SubmitWarningModal extends React.Component { const { proposal, isVisible, handleClose, handleSubmit } = this.props; const warnings = proposal ? getCreateWarnings(proposal) : []; - const staked = proposal && proposal.isStaked; - return ( 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 { } /> )} - {staked && ( -

- Are you sure you're ready to submit your proposal for approval? Once you’ve - done so, you won't be able to edit it. -

- )} - {!staked && ( -

- Are you sure you're ready to submit your proposal? You will be asked to send - a staking contribution of {process.env.PROPOSAL_STAKING_AMOUNT} ZEC. - Once confirmed, the proposal will be submitted for approval by site - administrators. -

- )} +

+ Are you sure you're ready to submit your proposal for approval? Once you’ve + done so, you won't be able to edit it. +

); diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx index 99d70c61..103496d0 100644 --- a/frontend/client/components/CreateFlow/Team.tsx +++ b/frontend/client/components/CreateFlow/Team.tsx @@ -81,7 +81,7 @@ class CreateFlowTeam extends React.Component {
)}
-

Add a team member

+

Add an optional team member

=> { - const cats = Object.keys(PROPOSAL_CATEGORY); - const category = cats[Math.floor(Math.random() * cats.length)] as PROPOSAL_CATEGORY; return { title: 'Grant.io T-Shirts', brief: "The most stylish wear, sporting your favorite brand's logo", - category, content: '![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000', target: '5', @@ -20,9 +15,7 @@ const createExampleProposal = (): Partial => { title: 'Initial Funding', content: 'This will be used to pay for a professional designer to hand-craft each letter on the shirt.', - dateEstimated: moment() - .add(1, 'month') - .unix(), + daysEstimated: '40', payoutPercent: '30', immediatePayout: true, }, @@ -30,9 +23,7 @@ const createExampleProposal = (): Partial => { title: 'Test Prints', content: "We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.", - dateEstimated: moment() - .add(2, 'month') - .unix(), + daysEstimated: '30', payoutPercent: '20', immediatePayout: false, }, @@ -40,14 +31,11 @@ const createExampleProposal = (): Partial => { title: 'All Shirts Printed', content: "All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.", - dateEstimated: moment() - .add(3, 'month') - .unix(), + daysEstimated: '30', payoutPercent: '50', immediatePayout: false, }, ], - deadlineDuration: 300, }; }; diff --git a/frontend/client/components/CreateFlow/index.less b/frontend/client/components/CreateFlow/index.less index 9d28fa23..dab3d79a 100644 --- a/frontend/client/components/CreateFlow/index.less +++ b/frontend/client/components/CreateFlow/index.less @@ -15,7 +15,7 @@ padding: 2.5rem 2rem 8rem; &-header { - max-width: 860px; + max-width: 1200px; padding: 0 1rem; margin: 1rem auto 3rem; diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 2c4142ea..5d9e61ea 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -14,11 +14,13 @@ import Payment from './Payment'; import Review from './Review'; import Preview from './Preview'; import Final from './Final'; +import Explainer from './Explainer'; import SubmitWarningModal from './SubmitWarningModal'; import createExampleProposal from './example'; import { createActions } from 'modules/create'; import { ProposalDraft } from 'types'; import { getCreateErrors } from 'modules/create/utils'; +import ls from 'local-storage'; import { AppState } from 'store/reducers'; @@ -49,6 +51,11 @@ interface StepInfo { help: React.ReactNode; component: any; } + +interface LSExplainer { + noExplain: boolean; +} + const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = { [CREATE_STEP.BASICS]: { short: 'Basics', @@ -84,10 +91,10 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = { }, [CREATE_STEP.PAYMENT]: { short: 'Payment', - title: 'Choose how you get paid', - subtitle: 'You’ll only be paid if your funding target is reached', + title: 'Set your payout and tip addresses', + subtitle: '', help: - 'Double check your address, and make sure it’s secure. Once sent, payments are irreversible!', + 'Double check your addresses, and make sure they’re secure. Once sent, transactions are irreversible!', component: Payment, }, [CREATE_STEP.REVIEW]: { @@ -117,6 +124,7 @@ interface State { isPreviewing: boolean; isShowingSubmitWarning: boolean; isSubmitting: boolean; + isExplaining: boolean; isExample: boolean; } @@ -132,12 +140,15 @@ class CreateFlow extends React.Component { queryStep && CREATE_STEP[queryStep] ? (CREATE_STEP[queryStep] as CREATE_STEP) : CREATE_STEP.BASICS; + const noExplain = !!ls('noExplain'); + 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); @@ -151,11 +162,18 @@ class CreateFlow extends React.Component { render() { const { isSavingDraft, saveDraftError } = this.props; - const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state; + 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 isLastStep = currentIndex === STEP_ORDER.length - 1; + const isSecondToLastStep = currentIndex === STEP_ORDER.length - 2; const StepComponent = info.component; let content; @@ -165,6 +183,9 @@ class CreateFlow extends React.Component { showFooter = false; } else if (isPreviewing) { content = ; + } else if (isExplaining) { + content = ; + showFooter = false; } else { // Antd definitions are missing `onClick` for step, even though it works. const Step = Steps.Step as any; @@ -172,7 +193,7 @@ class CreateFlow extends React.Component {
- {STEP_ORDER.slice(0, 5).map(s => ( + {STEP_ORDER.map(s => ( { key="next" onClick={this.nextStep} > - Continue + {isSecondToLastStep ? 'Review' : 'Continue'}{' '} + )} @@ -264,6 +286,10 @@ class CreateFlow extends React.Component { this.props.updateForm(form); }; + private startSteps = () => { + this.setState({ step: CREATE_STEP.BASICS, isExplaining: false }); + }; + private setStep = (step: CREATE_STEP, skipHistory?: boolean) => { this.setState({ step }); if (!skipHistory) { diff --git a/frontend/client/components/DraftList/index.tsx b/frontend/client/components/DraftList/index.tsx index e95aa6b1..e5cc38e6 100644 --- a/frontend/client/components/DraftList/index.tsx +++ b/frontend/client/components/DraftList/index.tsx @@ -44,13 +44,17 @@ interface State { deletingId: number | null; } +const EMAIL_VERIFIED_RELOAD_TIMEOUT = 10000; + class DraftList extends React.Component { state: State = { deletingId: null, }; + private reloadTimeout: number | null = null; + componentDidMount() { - const { createIfNone, createWithRfpId } = this.props; + const { createIfNone, createWithRfpId, isVerified } = this.props; if (createIfNone || createWithRfpId) { this.props.fetchAndCreateDrafts({ rfpId: createWithRfpId, @@ -59,6 +63,18 @@ class DraftList extends React.Component { } else { this.props.fetchDrafts(); } + + if (!isVerified) { + this.reloadTimeout = window.setTimeout(() => { + window.location.reload(); + }, EMAIL_VERIFIED_RELOAD_TIMEOUT); + } + } + + componentWillUnmount() { + if (this.reloadTimeout !== null) { + window.clearTimeout(this.reloadTimeout); + } } componentDidUpdate(prevProps: Props) { @@ -119,8 +135,8 @@ class DraftList extends React.Component { - {d.title || Untitled proposal} - {d.status === STATUS.REJECTED && (rejected)} + {d.title || Untitled Proposal} + {d.status === STATUS.REJECTED && (changes requested)} } description={d.brief || No description} @@ -142,7 +158,7 @@ class DraftList extends React.Component { return (
-

Your drafts

+

Your Proposal Drafts

{draftsEl} or + + ); + } + + private handleFollow = async () => { + const { proposalId, authedFollows } = this.props.proposal; + this.setState({ loading: true }); + try { + await followProposal(proposalId, !authedFollows); + await this.props.fetchProposal(proposalId); + } catch (error) { + // tslint:disable:no-console + console.error('Follow.handleFollow - unable to change follow state', error); + message.error('Unable to follow proposal'); + } + this.setState({ loading: false }); + }; +} + +const withConnect = connect( + state => ({ + authUser: state.auth.user, + }), + { + fetchProposal: proposalActions.fetchProposal, + }, +); + +export default withConnect(Follow); diff --git a/frontend/client/components/Header/Auth.less b/frontend/client/components/Header/Auth.less index c0d473ef..61f05f96 100644 --- a/frontend/client/components/Header/Auth.less +++ b/frontend/client/components/Header/Auth.less @@ -1,5 +1,6 @@ .AuthButton { transition: opacity 200ms ease; + padding-left: 0.7rem; &.is-loading { opacity: 0; diff --git a/frontend/client/components/Header/Auth.tsx b/frontend/client/components/Header/Auth.tsx index 47c6e9c0..83fcb3b4 100644 --- a/frontend/client/components/Header/Auth.tsx +++ b/frontend/client/components/Header/Auth.tsx @@ -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 { }; 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 { let isLoading; if (user) { avatar = ; - } else if (isAuthingUser || isCheckingUser) { + } else if (isAuthingUser || isCheckingUser || !hasCheckedUser) { isLoading = true; } @@ -83,16 +84,13 @@ class HeaderAuth extends React.Component { > {link} - ) - } - else { + ); + } else { content = link; } return ( -
- {content} -
+
{content}
); } @@ -120,4 +118,5 @@ export default connect(state => ({ user: state.auth.user, isAuthingUser: state.auth.isAuthingUser, isCheckingUser: state.auth.isCheckingUser, + hasCheckedUser: state.auth.hasCheckedUser, }))(HeaderAuth); diff --git a/frontend/client/components/Header/Drawer.tsx b/frontend/client/components/Header/Drawer.tsx index 31114393..836b5764 100644 --- a/frontend/client/components/Header/Drawer.tsx +++ b/frontend/client/components/Header/Drawer.tsx @@ -84,6 +84,14 @@ class HeaderDrawer extends React.Component { Start a proposal + + + Browse requests + + + Create a Request + + ); diff --git a/frontend/client/components/Header/index.tsx b/frontend/client/components/Header/index.tsx index 8a8adafc..122575c0 100644 --- a/frontend/client/components/Header/index.tsx +++ b/frontend/client/components/Header/index.tsx @@ -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 { +interface DispatchProps { + fetchCCRDrafts: typeof fetchCCRDrafts; + fetchDrafts: typeof fetchDrafts; +} + +type Props = StateProps & OwnProps & DispatchProps; + +class Header extends React.Component { 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,8 +67,8 @@ export default class Header extends React.Component { Requests - - Start a Proposal + + Guide
@@ -54,9 +82,30 @@ export default class Header extends React.Component { -
- -
+ {!hasCheckedUser && (ccrDrafts === null || proposalDrafts === null) ? null : ( +
+
+ + {Array.isArray(proposalDrafts) && proposalDrafts.length > 0 ? ( + + ) : ( + + )} + +
+
+ + {Array.isArray(ccrDrafts) && ccrDrafts.length > 0 ? ( + + ) : ( + + )} + +
+ + +
+ )} @@ -73,3 +122,20 @@ export default class Header extends React.Component { private openDrawer = () => this.setState({ isDrawerOpen: true }); private closeDrawer = () => this.setState({ isDrawerOpen: false }); } + +const withConnect = connect( + (state: AppState) => ({ + hasCheckedUser: state.auth.hasCheckedUser, + ccrDrafts: state.ccr.drafts, + proposalDrafts: state.create.drafts, + }), + { + fetchCCRDrafts: ccrActions.fetchCCRDrafts, + fetchDrafts: createActions.fetchDrafts, + }, +); + +export default compose( + withRouter, + withConnect, +)(Header); diff --git a/frontend/client/components/Header/style.less b/frontend/client/components/Header/style.less index 9b803586..68cd3351 100644 --- a/frontend/client/components/Header/style.less +++ b/frontend/client/components/Header/style.less @@ -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; diff --git a/frontend/client/components/Home/Intro.less b/frontend/client/components/Home/Intro.less index e6bcea83..51661f0a 100644 --- a/frontend/client/components/Home/Intro.less +++ b/frontend/client/components/Home/Intro.less @@ -1,13 +1,14 @@ @import '~styles/variables.less'; +@min-tablet-query: ~'(min-width: 920px)'; .HomeIntro { position: relative; display: flex; - justify-content: space-between; + justify-content: space-around; align-items: center; max-width: 1440px; padding: 0 4rem; - margin: 0 auto 6rem; + margin: 4rem auto; overflow: hidden; @media @thin-query { @@ -19,10 +20,6 @@ } &-content { - width: 50%; - min-width: 600px; - padding-right: 2rem; - margin: 0 auto; &-title { margin-bottom: 2rem; @@ -38,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); + } + } } } @@ -98,7 +146,7 @@ &-illustration { position: relative; width: 100%; - max-width: 640px; + max-width: 480px; background-size: contain; &:after { diff --git a/frontend/client/components/Home/Intro.tsx b/frontend/client/components/Home/Intro.tsx index e804acf1..4aa48186 100644 --- a/frontend/client/components/Home/Intro.tsx +++ b/frontend/client/components/Home/Intro.tsx @@ -19,17 +19,20 @@ const HomeIntro: React.SFC = ({ t, authUser }) => (

{t('home.intro.subtitle')}

{authUser ? ( - + {t('home.intro.browse')} ) : ( - + {t('home.intro.signup')} )} - - {t('home.intro.learn')} - + + {t('home.intro.ccr')} +
{ + state: State = { + latestProposals: [], + latestRfps: [], + isLoading: true, + error: null, + }; + + async componentDidMount() { + try { + const res = await getHomeLatest(); + this.setState({ + ...res.data, + error: null, + isLoading: false, + }); + } catch (err) { + // tslint:disable-next-line + console.error('Failed to load homepage content:', err); + this.setState({ + error: err.message, + isLoading: false, + }); + } + } + + render() { + const { t } = this.props; + const { latestProposals, latestRfps, isLoading } = this.state; + const numItems = latestProposals.length + latestRfps.length; + + let content; + if (isLoading) { + content = ( +
+ +
+ ); + } else if (numItems) { + const columns: ContentColumnProps[] = [ + { + title: t('home.latest.proposalsTitle'), + placeholder: t('home.latest.proposalsPlaceholder'), + path: 'proposals', + items: latestProposals, + }, + { + title: t('home.latest.requestsTitle'), + placeholder: t('home.latest.requestsPlaceholder'), + path: 'requests', + items: latestRfps, + }, + ]; + content = columns.filter(c => !!c.items.length).map((col, idx) => ( +
+ +
+ )); + } else { + return null; + } + + return ( +
+
{content}
+
+ ); + } +} + +interface ContentColumnProps { + title: string; + placeholder: string; + path: string; + items: Array; +} + +const ContentColumn: React.SFC = p => { + let content: React.ReactNode; + if (p.items.length) { + content = ( + <> + {p.items.map(item => { + const isProposal = (x: Proposal | RFP): x is Proposal => + (x as Proposal).proposalUrlId !== undefined; + const id = isProposal(item) ? item.proposalId : item.id; + const urlId = isProposal(item) ? item.proposalUrlId : item.urlId; + + return ( + +
+
{item.title}
+
{item.brief}
+
+ + ); + })} + + See more → + + + ); + } else { + content = ; + } + return ( +
+

{p.title}

+ {content} +
+ ); +}; + +export default withNamespaces()(HomeLatest); diff --git a/frontend/client/components/Home/index.tsx b/frontend/client/components/Home/index.tsx index a383088b..b1119080 100644 --- a/frontend/client/components/Home/index.tsx +++ b/frontend/client/components/Home/index.tsx @@ -5,6 +5,7 @@ import Intro from './Intro'; import Requests from './Requests'; import Guide from './Guide'; import Actions from './Actions'; +import Latest from './Latest'; import './style.less'; class Home extends React.Component { @@ -14,6 +15,7 @@ class Home extends React.Component {
+ diff --git a/frontend/client/components/Like/index.less b/frontend/client/components/Like/index.less new file mode 100644 index 00000000..c59d7832 --- /dev/null +++ b/frontend/client/components/Like/index.less @@ -0,0 +1,24 @@ +@import '~styles/variables.less'; + +@collapse-width: 800px; + +.Like { + white-space: nowrap; + + .ant-btn:focus, + .ant-btn:active { + border-color: inherit; + outline-color: inherit; + color: inherit; + } + + &-label { + @media (max-width: @collapse-width) { + display: none !important; + } + } + + &-count { + color: @text-color !important; + } +} diff --git a/frontend/client/components/Like/index.tsx b/frontend/client/components/Like/index.tsx new file mode 100644 index 00000000..5a12fa6c --- /dev/null +++ b/frontend/client/components/Like/index.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Icon, Button, Input, message } from 'antd'; +import { AppState } from 'store/reducers'; +import { proposalActions } from 'modules/proposals'; +import { rfpActions } from 'modules/rfps'; +import { Proposal } from 'types'; +import { Comment, RFP } from 'types'; +import { likeProposal, likeComment, likeRfp } from 'api/api'; +import AuthButton from 'components/AuthButton'; +import classnames from 'classnames'; +import './index.less'; + +interface OwnProps { + proposal?: Proposal; + proposal_card?: boolean; + comment?: Comment; + rfp?: RFP; + style?: React.CSSProperties; + className?: string; +} + +interface StateProps { + authUser: AppState['auth']['user']; +} + +interface DispatchProps { + fetchProposal: typeof proposalActions['fetchProposal']; + updateComment: typeof proposalActions['updateProposalComment']; + fetchRfp: typeof rfpActions['fetchRfp']; +} + +type Props = OwnProps & StateProps & DispatchProps; + +const STATE = { + loading: false, +}; +type State = typeof STATE; + +class Like extends React.Component { + state: State = { ...STATE }; + + render() { + const { likesCount, authedLiked } = this.deriveInfo(); + const { proposal, rfp, comment, style, proposal_card, className } = this.props; + const { loading } = this.state; + const zoom = comment || proposal_card ? 0.8 : 1; + const shouldShowLikeText = (!!proposal && !proposal_card) || !!rfp; + + // if like button is on a proposal card... + // 1) use regular button to prevent login redirect + const IconButton = proposal_card ? Button : AuthButton; + // 2) prevent mouseover effects + const pointerEvents = proposal_card ? 'none' : undefined; + // 3) make button click a noop + const handleIconButtonClick = proposal_card ? undefined : this.handleLike; + + return ( + + + + {shouldShowLikeText && ( + {authedLiked ? ' Unlike' : ' Like'} + )} + + + + ); + } + + private deriveInfo = () => { + let authedLiked = false; + let likesCount = 0; + + const { proposal, comment, rfp } = this.props; + + if (comment) { + authedLiked = comment.authedLiked; + likesCount = comment.likesCount; + } else if (proposal) { + authedLiked = proposal.authedLiked; + likesCount = proposal.likesCount; + } else if (rfp) { + authedLiked = rfp.authedLiked; + likesCount = rfp.likesCount; + } + + return { + authedLiked, + likesCount, + }; + }; + + private handleLike = () => { + if (this.state.loading) return; + const { proposal, rfp, comment } = this.props; + + if (proposal) { + return this.handleProposalLike(); + } + if (comment) { + return this.handleCommentLike(); + } + if (rfp) { + return this.handleRfpLike(); + } + }; + + private handleProposalLike = async () => { + if (!this.props.proposal) return; + + const { + proposal: { proposalId, authedLiked }, + fetchProposal, + } = this.props; + + this.setState({ loading: true }); + try { + await likeProposal(proposalId, !authedLiked); + await fetchProposal(proposalId); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleProposalLike - unable to change like state', error); + message.error('Unable to like proposal'); + } + this.setState({ loading: false }); + }; + + private handleCommentLike = async () => { + if (!this.props.comment) return; + + const { + comment: { id, authedLiked }, + updateComment, + } = this.props; + + this.setState({ loading: true }); + try { + const updatedComment = await likeComment(id, !authedLiked); + updateComment(id, updatedComment); + message.success(<>Comment {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleCommentLike - unable to change like state', error); + message.error('Unable to like comment'); + } + this.setState({ loading: false }); + }; + + private handleRfpLike = async () => { + if (!this.props.rfp) return; + + const { + rfp: { id, authedLiked }, + fetchRfp, + } = this.props; + + this.setState({ loading: true }); + try { + await likeRfp(id, !authedLiked); + await fetchRfp(id); + message.success(<>Request for proposal {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleRfpLike - unable to change like state', error); + message.error('Unable to like rfp'); + } + this.setState({ loading: false }); + }; +} + +const withConnect = connect( + state => ({ + authUser: state.auth.user, + }), + { + fetchProposal: proposalActions.fetchProposal, + updateComment: proposalActions.updateProposalComment, + fetchRfp: rfpActions.fetchRfp, + }, +); + +export default withConnect(Like); diff --git a/frontend/client/components/Profile/ProfileCCR.less b/frontend/client/components/Profile/ProfileCCR.less new file mode 100644 index 00000000..6ef99610 --- /dev/null +++ b/frontend/client/components/Profile/ProfileCCR.less @@ -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; + } + } +} diff --git a/frontend/client/components/Profile/ProfileCCR.tsx b/frontend/client/components/Profile/ProfileCCR.tsx new file mode 100644 index 00000000..535e3917 --- /dev/null +++ b/frontend/client/components/Profile/ProfileCCR.tsx @@ -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 { + render() { + const { title, brief, ccrId, author } = this.props.ccr; + return ( +
+
+ + {title} + +
{brief}
+
+
+

Author

+
+ +
+
+
+ ); + } +} diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx index 8d4e4d24..f453d4ae 100644 --- a/frontend/client/components/Profile/ProfilePending.tsx +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -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 { 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; @@ -54,10 +46,10 @@ class ProfilePending extends React.Component { }, [STATUS.REJECTED]: { color: 'red', - tag: 'Rejected', + tag: 'Changes requested', blurb: ( <> -
This proposal was rejected for the following reason:
+
This proposal has changes requested:
{rejectReason}
You may edit this proposal and re-submit it for approval.
@@ -68,7 +60,7 @@ class ProfilePending extends React.Component { tag: 'Staking', blurb: (
- 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.
@@ -89,23 +81,13 @@ class ProfilePending extends React.Component {
- {title} {st[status].tag} + {title} {st[status].tag} Proposal
{st[status].blurb}
- {STATUS.APPROVED === status && ( - - )} {STATUS.REJECTED === status && ( )} - {STATUS.STAKING === status && ( - - )} {
- - {STATUS.STAKING && ( - - For your proposal to be considered, please send a staking contribution of{' '} - {stakeContribution && stakeContribution.amount} ZEC using the - instructions below. Once your payment has been sent and received 6 - confirmations, you will receive an email. -

- } - /> - )}
); } - 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 { 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( @@ -213,6 +133,5 @@ export default connect( }), { deletePendingProposal, - publishPendingProposal, }, )(ProfilePending); diff --git a/frontend/client/components/Profile/ProfilePendingCCR.tsx b/frontend/client/components/Profile/ProfilePendingCCR.tsx new file mode 100644 index 00000000..bb8d8446 --- /dev/null +++ b/frontend/client/components/Profile/ProfilePendingCCR.tsx @@ -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 { + 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: 'Changes Requested', + blurb: ( + <> +
This request has changes requested for the following reason:
+ {rejectReason} +
You may edit this request and re-submit it for approval.
+ + ), + }, + [STATUS.PENDING]: { + color: 'purple', + tag: 'Pending Request', + blurb: ( +
+ You will receive an email when this request has completed the review process. +
+ ), + }, + } as { [key in STATUS]: { color: string; tag: string; blurb: ReactNode } }; + + return ( +
+
+ + {title} {st[status].tag} + +
+ {st[status].blurb} +
+
+
+ {CCRSTATUS.REJECTED === status && ( + + + + )} + + this.handleDelete()} + > + + +
+
+ ); + } + + 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( + state => ({ + user: state.auth.user, + }), + { + deletePendingRequest, + }, +)(ProfilePendingCCR); diff --git a/frontend/client/components/Profile/ProfilePendingList.tsx b/frontend/client/components/Profile/ProfilePendingList.tsx index ca26ab47..31d0f114 100644 --- a/frontend/client/components/Profile/ProfilePendingList.tsx +++ b/frontend/client/components/Profile/ProfilePendingList.tsx @@ -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 { - state = STATE; +class ProfilePendingList extends React.Component { render() { - const { proposals } = this.props; - const { publishedId } = this.state; + const { proposals, requests } = this.props; return ( <> {proposals.map(p => ( - + + ))} + {requests.map(r => ( + ))} - - this.setState({ publishedId: null })} - > -
- Your proposal is live!{' '} - Click here to check it out. -
-
); } - - private handlePublish = (publishedId: UserProposal['proposalId']) => { - this.setState({ publishedId }); - }; } export default ProfilePendingList; diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx index 68538d9f..b858e94d 100644 --- a/frontend/client/components/Profile/ProfileProposal.tsx +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -4,6 +4,8 @@ import { UserProposal } from 'types'; import './ProfileProposal.less'; import UserRow from 'components/UserRow'; import UnitDisplay from 'components/UnitDisplay'; +import { Tag } from 'antd'; +import { formatUsd } from 'utils/formatters' interface OwnProps { proposal: UserProposal; @@ -11,19 +13,47 @@ interface OwnProps { export default class Profile extends React.Component { render() { - const { title, brief, team, proposalId, funded, target } = this.props.proposal; + const { + title, + brief, + team, + proposalId, + funded, + target, + isVersionTwo, + acceptedWithFunding, + } = this.props.proposal; + + // pulled from `variables.less` + const infoColor = '#1890ff' + const secondaryColor = '#2D2A26' + + const tagColor = acceptedWithFunding + ? secondaryColor + : infoColor + const tagMessage = acceptedWithFunding + ? 'Funded by ZF' + : 'Open for Contributions' + return (
- {title} + {title} {isVersionTwo && ({tagMessage})}
{brief}
-
- {' '} - raised of{' '} - goal -
+ {!isVersionTwo && ( +
+ {' '} + raised of{' '} + goal +
+ )} + {isVersionTwo && ( +
+ {formatUsd(target.toString())} +
+ )}

Team

diff --git a/frontend/client/components/Profile/ProfileUser.less b/frontend/client/components/Profile/ProfileUser.less index 7e126fa3..5cc73eb5 100644 --- a/frontend/client/components/Profile/ProfileUser.less +++ b/frontend/client/components/Profile/ProfileUser.less @@ -2,8 +2,10 @@ display: flex; align-items: center; margin-bottom: 1.5rem; + flex-wrap: wrap; &-avatar { + margin-bottom: 2rem; position: relative; flex: 0 0 auto; height: 10.5rem; @@ -20,6 +22,8 @@ &-info { // no overflow of flexbox min-width: 0; + margin-bottom: 2rem; + flex-grow: 1; &-name { font-size: 1.6rem; @@ -30,6 +34,8 @@ font-size: 1rem; opacity: 0.7; margin-bottom: 0.3rem; + max-width: fit-content; + margin-right: 1rem; } &-address { diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx index e257b145..0564ec36 100644 --- a/frontend/client/components/Profile/ProfileUser.tsx +++ b/frontend/client/components/Profile/ProfileUser.tsx @@ -5,6 +5,7 @@ import { Button } from 'antd'; import { SocialMedia } from 'types'; import { UserState } from 'modules/users/reducers'; import UserAvatar from 'components/UserAvatar'; +import { TipJarBlock } from 'components/TipJar'; import { SOCIAL_INFO } from 'utils/social'; import { AppState } from 'store/reducers'; import './ProfileUser.less'; @@ -19,7 +20,15 @@ interface StateProps { type Props = OwnProps & StateProps; -class ProfileUser extends React.Component { +const STATE = { + tipJarModalOpen: false, +}; + +type State = typeof STATE; + +class ProfileUser extends React.Component { + state = STATE; + render() { const { authUser, @@ -52,6 +61,8 @@ class ProfileUser extends React.Component {
)}
+ {!isSelf && + user.tipJarAddress && }
); } diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx index 43256ef6..7050d7ec 100644 --- a/frontend/client/components/Profile/index.tsx +++ b/frontend/client/components/Profile/index.tsx @@ -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'; @@ -31,6 +32,7 @@ import './style.less'; interface StateProps { usersMap: AppState['users']['map']; authUser: AppState['auth']['user']; + hasCheckedUser: AppState['auth']['hasCheckedUser']; } interface DispatchProps { @@ -63,7 +65,7 @@ class Profile extends React.Component { } render() { - const { authUser, match, location } = this.props; + const { authUser, match, location, hasCheckedUser } = this.props; const { activeContribution } = this.state; const userLookupParam = match.params.id; @@ -76,7 +78,7 @@ class Profile extends React.Component { } const user = this.props.usersMap[userLookupParam]; - const waiting = !user || !user.hasFetched; + const waiting = !user || !user.hasFetched || !hasCheckedUser; const isAuthedUser = user && authUser && user.userid === authUser.userid; if (waiting) { @@ -90,6 +92,8 @@ class Profile extends React.Component { const { proposals, pendingProposals, + pendingRequests, + requests, contributions, comments, invites, @@ -97,8 +101,10 @@ class Profile extends React.Component { } = 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; @@ -107,8 +113,8 @@ class Profile extends React.Component { return (
@@ -127,33 +133,47 @@ class Profile extends React.Component { {isAuthedUser && (
- {nonePending && ( - - )} - + {noProposalsPending && + noRequestsPending && ( + + )} +
)} - +
- {noneCreated && ( - - )} + {noProposalsCreated && + noRequestsCreated && ( + + )} {proposals.map(p => ( ))} + {requests.map(c => ( + + ))}
@@ -271,6 +291,7 @@ const withConnect = connect( state => ({ usersMap: state.users.map, authUser: state.auth.user, + hasCheckedUser: state.auth.hasCheckedUser, }), { fetchUser: usersActions.fetchUser, diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 12123d22..fd446ce0 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,19 +1,17 @@ import React from 'react'; import moment from 'moment'; -import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd'; -import { RadioChangeEvent } from 'antd/lib/radio'; +import { Icon, Popover, Tooltip, Alert } from 'antd'; import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; -import { fromZat } from 'utils/units'; import { connect } from 'react-redux'; import { compose } from 'recompose'; import { AppState } from 'store/reducers'; import { withRouter } from 'react-router'; import UnitDisplay from 'components/UnitDisplay'; -import ContributionModal from 'components/ContributionModal'; import Loader from 'components/Loader'; -import { getAmountError } from 'utils/validators'; -import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants'; +import { PROPOSAL_STAGE } from 'api/constants'; +import { formatUsd } from 'utils/formatters'; +import ZFGrantsLogo from 'static/images/logo-name-light.svg'; import './style.less'; interface OwnProps { @@ -46,23 +44,23 @@ export class ProposalCampaignBlock extends React.Component { } render() { - const { proposal, isPreview, authUser } = this.props; - const { amountToRaise, amountError, isPrivate, isContributing } = this.state; - const amountFloat = parseFloat(amountToRaise) || 0; + const { proposal } = this.props; let content; if (proposal) { - const { target, funded, percentFunded } = proposal; + const { target, funded, percentFunded, isVersionTwo } = proposal; const datePublished = proposal.datePublished || Date.now() / 1000; const isRaiseGoalReached = funded.gte(target); - const deadline = (datePublished + proposal.deadlineDuration) * 1000; + const deadline = proposal.deadlineDuration + ? (datePublished + proposal.deadlineDuration) * 1000 + : 0; const isFrozen = proposal.stage === PROPOSAL_STAGE.FAILED || proposal.stage === PROPOSAL_STAGE.CANCELED; const isLive = proposal.status === STATUS.LIVE; - const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen; - const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview; - const remainingTargetNum = parseFloat(fromZat(target.sub(funded))); + const isFundingOver = deadline + ? isRaiseGoalReached || deadline < Date.now() || isFrozen + : null; // Get bounty from RFP. If it exceeds proposal target, show bounty as full amount let bounty; @@ -72,6 +70,9 @@ export class ProposalCampaignBlock extends React.Component { : proposal.contributionBounty; } + const isAcceptedWithFunding = proposal.acceptedWithFunding === true; + const isCanceled = proposal.stage === PROPOSAL_STAGE.CANCELED; + content = ( {isLive && ( @@ -82,153 +83,132 @@ export class ProposalCampaignBlock extends React.Component {
)} -
-
Category
-
- {' '} - {CATEGORY_UI[proposal.category].label} -
-
- {!isFundingOver && ( + {!isVersionTwo && + !isFundingOver && ( +
+
Deadline
+
+ {moment(deadline).fromNow()} +
+
+ )} + {!isVersionTwo && (
-
Deadline
+
Funding
- {moment(deadline).fromNow()} + /{' '} +
)} -
-
Funding
-
- / -
-
- {bounty && ( -
- Awarded with bounty + {isVersionTwo && ( +
+
+ {isAcceptedWithFunding ? 'Funding' : 'Requested Funding'} +
+
+ {formatUsd(target.toString(10))} + {isAcceptedWithFunding && ( + +   + + )} +
)} - {proposal.contributionMatching > 0 && ( -
- Funds are being matched x{proposal.contributionMatching + 1} - - Matching -
- Increase your impact! Contributions to this proposal are being matched - by the Zcash Foundation, up to the target amount. - - } + {bounty && + !isVersionTwo && ( +
+ Awarded with bounty +
+ )} + + {isVersionTwo && + isAcceptedWithFunding && ( +
+ Funded through   + +
+ )} + + {isVersionTwo && + !isAcceptedWithFunding && ( +
+ Open for Community Donations +
+ )} + + {!isVersionTwo && + proposal.contributionMatching > 0 && ( +
+ Funds are being matched x{proposal.contributionMatching + 1} + + Matching +
+ Increase your impact! Contributions to this proposal are being + matched by the Zcash Foundation, up to the target amount. + + } + > + +
+
+ )} + + {!isVersionTwo && + (isFundingOver ? ( +
- - -
- )} - - {isFundingOver ? ( -
- {proposal.stage === PROPOSAL_STAGE.CANCELED ? ( - <> - - Proposal was canceled - - ) : isRaiseGoalReached ? ( - <> - - Proposal has been funded - - ) : ( - <> - - Proposal didn’t get funded - - )} -
- ) : ( - <> -
-
+ {isCanceled ? ( + <> + + Proposal was canceled + + ) : isRaiseGoalReached ? ( + <> + + Proposal has been funded + + ) : ( + <> + + Proposal didn’t get funded + + )}
- - - - +
+
- - {amountToRaise && - !!authUser && ( - - - Contribute without attribution - - - - - - Attribute contribution publicly - - - - - - )} - - - - )} +
+ + ))} - + {isVersionTwo && + isCanceled && ( +
+ + Proposal was canceled +
+ )} ); } else { @@ -237,43 +217,24 @@ export class ProposalCampaignBlock extends React.Component { return (
+ {proposal && + proposal.isVersionTwo && + !proposal.acceptedWithFunding && + proposal.stage === PROPOSAL_STAGE.WIP && ( +
+ +
+ )}

Campaign

{content}
); } - - private handleAmountChange = ( - event: React.ChangeEvent, - ) => { - const { value } = event.currentTarget; - if (!value) { - this.setState({ amountToRaise: '', amountError: null }); - return; - } - - const { target, funded } = this.props.proposal; - const remainingTarget = target.sub(funded); - const amount = parseFloat(value); - let amountError = null; - - if (Number.isNaN(amount)) { - // They're entering some garbage, they’ll work it out - } else { - const remainingTargetNum = parseFloat(fromZat(remainingTarget)); - amountError = getAmountError(amount, remainingTargetNum); - } - - this.setState({ amountToRaise: value, amountError }); - }; - - private handleChangePrivate = (ev: RadioChangeEvent) => { - const isPrivate = ev.target.value === 'isPrivate'; - this.setState({ isPrivate }); - }; - - private openContributionModal = () => this.setState({ isContributing: true }); - private closeContributionModal = () => this.setState({ isContributing: false }); } function mapStateToProps(state: AppState) { diff --git a/frontend/client/components/Proposal/CampaignBlock/style.less b/frontend/client/components/Proposal/CampaignBlock/style.less index 50dad9fc..1bb36c06 100644 --- a/frontend/client/components/Proposal/CampaignBlock/style.less +++ b/frontend/client/components/Proposal/CampaignBlock/style.less @@ -57,6 +57,25 @@ margin-top: 0; } + &-with-funding, + &-without-funding { + margin: 0.5rem -1.5rem; + padding: 0.75rem 1rem; + font-size: 1rem; + color: #fff; + align-items: center; + display: flex; + justify-content: center; + } + + &-with-funding { + background: @secondary-color; + } + + &-without-funding { + background: @info-color; + } + &-popover { &-overlay { max-width: 400px; @@ -123,4 +142,8 @@ color: @success-color; } } + + &-tipJarWrapper { + margin-top: 1rem; + } } diff --git a/frontend/client/components/Proposal/CancelModal.tsx b/frontend/client/components/Proposal/CancelModal.tsx index c0bad93f..e0375fb1 100644 --- a/frontend/client/components/Proposal/CancelModal.tsx +++ b/frontend/client/components/Proposal/CancelModal.tsx @@ -11,7 +11,11 @@ interface Props { export default class CancelModal extends React.Component { render() { - const { isVisible, handleClose } = this.props; + const { + isVisible, + handleClose, + proposal: { isVersionTwo }, + } = this.props; return ( { onCancel={handleClose} >

- Are you sure you would like to cancel this proposal, and refund any - contributors? This cannot be undone. + Are you sure you would like to cancel this proposal + {isVersionTwo ? '' : ', and refund any contributors'}?{' '} + This cannot be undone.

- Canceled proposals cannot be deleted and will still be viewable by contributors - or anyone with a direct link. However, they will be de-listed everywhere else on - ZF Grants. + Canceled proposals cannot be deleted and will still be viewable by{' '} + {isVersionTwo ? '' : 'contributors or '} + anyone with a direct link. However, they will be de-listed everywhere else on ZF + Grants.

If you're sure you'd like to cancel, please{' '} diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index eceba29c..32ef7e8e 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -22,6 +22,8 @@ import { proposalActions } from 'modules/proposals'; import { ProposalDetail } from 'modules/proposals/reducers'; import './index.less'; import { Link } from 'react-router-dom'; +import { formatUsd } from 'utils/formatters'; +import { Zat } from 'utils/units'; enum STEP_STATUS { WAIT = 'wait', @@ -156,8 +158,15 @@ class ProposalMilestones extends React.Component { if (!proposal) { return ; } - const { milestones, currentMilestone, isRejectingPayout } = proposal; + const { + milestones, + currentMilestone, + isRejectingPayout, + isVersionTwo, + acceptedWithFunding, + } = proposal; const milestoneCount = milestones.length; + const milestonesDisabled = isVersionTwo ? !acceptedWithFunding : false; // arbiter reject modal const rejectModal = ( @@ -220,7 +229,12 @@ class ProposalMilestones extends React.Component { ['do-titles-overflow']: this.state.doTitlesOverflow, })} > - {!!milestoneSteps.length ? ( + {milestonesDisabled ? ( + + ) : !!milestoneSteps.length ? ( <> {milestoneSteps.map(mss => ( @@ -242,6 +256,7 @@ class ProposalMilestones extends React.Component { !!proposal.arbiter.user && proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED } + isVersionTwo={proposal.isVersionTwo} /> ) : ( @@ -317,10 +332,17 @@ interface MilestoneProps extends MSProps { isCurrent: boolean; proposalId: number; isFunded: boolean; + isVersionTwo: boolean; } const Milestone: React.SFC = p => { - const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY'); - const reward = ; + const estimatedDate = p.dateEstimated + ? moment(p.dateEstimated * 1000).format('MMMM YYYY') + : 'N/A'; + const reward = p.isVersionTwo ? ( + formatUsd(p.amount as string, true, 2) + ) : ( + + ); const getAlertProps = { [MILESTONE_STAGE.IDLE]: () => null, [MILESTONE_STAGE.REQUESTED]: () => ({ @@ -356,7 +378,7 @@ const Milestone: React.SFC = p => { type: 'success', message: ( - The team was awarded {reward}{' '} + The team was awarded {reward} {p.isVersionTwo && `in ZEC`} {p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}. ), diff --git a/frontend/client/components/Proposal/TippingBlock/index.tsx b/frontend/client/components/Proposal/TippingBlock/index.tsx new file mode 100644 index 00000000..01fc526c --- /dev/null +++ b/frontend/client/components/Proposal/TippingBlock/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Tooltip, Icon } from 'antd'; +import { Proposal } from 'types'; +import Loader from 'components/Loader'; +import { TipJarBlock } from 'components/TipJar'; +import { PROPOSAL_STAGE } from 'api/constants'; +import './style.less'; + +interface Props { + proposal: Proposal; +} + +const TippingBlock: React.SFC = ({ proposal }) => { + let content; + if (proposal) { + if (!proposal.tipJarAddress || proposal.stage === PROPOSAL_STAGE.CANCELED) { + return null; + } + content = ( + <> +

+
Tips Received
+
+ ???   + + + +
+
+
+ +
+ + ); + } else { + content = ; + } + + return ( +
+

Tipping

+
{content}
+
+ ); +}; + +export default TippingBlock; diff --git a/frontend/client/components/Proposal/TippingBlock/style.less b/frontend/client/components/Proposal/TippingBlock/style.less new file mode 100644 index 00000000..7bed56b6 --- /dev/null +++ b/frontend/client/components/Proposal/TippingBlock/style.less @@ -0,0 +1,32 @@ +@import '~styles/variables.less'; + +.TippingBlock { + &-info { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + overflow: hidden; + line-height: 1.7rem; + + &-label { + font-size: 0.95rem; + font-weight: 300; + opacity: 0.8; + letter-spacing: 0.05rem; + flex: 0 0 auto; + margin-right: 1.5rem; + } + + &-value { + font-size: 1.1rem; + flex-basis: 0; + flex-grow: 1; + overflow: hidden; + text-align: right; + } + } + + &-tipJarWrapper { + margin-top: 1rem; + } +} diff --git a/frontend/client/components/Proposal/index.less b/frontend/client/components/Proposal/index.less index c533ec65..9a68cd75 100644 --- a/frontend/client/components/Proposal/index.less +++ b/frontend/client/components/Proposal/index.less @@ -4,7 +4,6 @@ @single-col-width: 600px; @block-title-space: 3.75rem; - .Proposal { max-width: @max-width; margin: 0 auto; @@ -65,23 +64,47 @@ } &-title { - font-size: 2rem; - line-height: 3rem; - margin-bottom: 0.75rem; - margin-left: 0.5rem; - - @media (min-width: @collapse-width) { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - @media (max-width: @collapse-width) { - font-size: 1.8rem; - } + display: flex; @media (max-width: @single-col-width) { - font-size: 1.6rem; + flex-direction: column-reverse; + margin-top: -1rem; + } + + h1 { + font-size: 2rem; + line-height: 3rem; + margin-bottom: 0.75rem; + margin-left: 0.5rem; + margin-right: 1rem; + flex-grow: 1; + + @media (min-width: @collapse-width) { + overflow: hidden; + text-overflow: unset; + } + + @media (max-width: @collapse-width) { + font-size: 1.8rem; + } + + @media (max-width: @single-col-width) { + font-size: 1.6rem; + } + } + + &-menu { + display: flex; + align-items: center; + height: 3rem; + + & .ant-input-group { + width: inherit; + } + + &-item { + margin-left: 0.5rem; + } } } @@ -181,9 +204,16 @@ margin-left: 0; } - &:last-child { + &:nth-child(2) { + width: calc(50% - 1rem); + } + + &:nth-child(3) { + width: calc(50% - 1rem); + } + + &:nth-child(4) { width: calc(50% - 1rem); - margin-left: 1rem; } } } @@ -194,7 +224,9 @@ > * { &, &:first-child, - &:last-child { + &:nth-child(2), + &:nth-child(3), + &:nth-child(4) { width: auto; margin-left: 0; } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 9c3e9927..bd34e65c 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -14,6 +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 TeamBlock from './TeamBlock'; import RFPBlock from './RFPBlock'; import Milestones from './Milestones'; @@ -25,6 +26,9 @@ import CancelModal from './CancelModal'; import classnames from 'classnames'; 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 './index.less'; interface OwnProps { @@ -50,6 +54,7 @@ interface State { isBodyOverflowing: boolean; isUpdateOpen: boolean; isCancelOpen: boolean; + isTipJarOpen: boolean; } export class ProposalDetail extends React.Component { @@ -58,6 +63,7 @@ export class ProposalDetail extends React.Component { isBodyOverflowing: false, isUpdateOpen: false, isCancelOpen: false, + isTipJarOpen: false, }; bodyEl: HTMLElement | null = null; @@ -88,7 +94,13 @@ export class ProposalDetail extends React.Component { render() { const { user, detail: proposal, isPreview, detailError } = this.props; - const { isBodyExpanded, isBodyOverflowing, isCancelOpen, isUpdateOpen } = this.state; + const { + isBodyExpanded, + isBodyOverflowing, + isCancelOpen, + isUpdateOpen, + isTipJarOpen, + } = this.state; const showExpand = !isBodyExpanded && isBodyOverflowing; const wrongProposal = proposal && proposal.proposalId !== this.props.proposalId; @@ -101,9 +113,16 @@ export class ProposalDetail extends React.Component { const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid)); const isLive = proposal.status === STATUS.LIVE; + const milestonesDisabled = proposal.isVersionTwo + ? !proposal.acceptedWithFunding + : false; + const defaultTab = !milestonesDisabled || !isLive ? 'milestones' : 'discussions'; const adminMenu = ( + + Manage Tipping + Post an Update @@ -137,8 +156,8 @@ export class ProposalDetail extends React.Component { [STATUS.REJECTED]: { blurb: ( <> - Your proposal was rejected and is only visible to the team. Visit your{' '} - profile's pending tab for more + Your proposal has changes requested and is only visible to the team. Visit + your profile's pending tab for more information. ), @@ -184,9 +203,34 @@ export class ProposalDetail extends React.Component {
)}
-

- {proposal ? proposal.title :  } -

+
+

{proposal ? proposal.title :  }

+ {isLive && ( +
+ {isTrustee && ( + + + + )} + + +
+ )} +
+
(this.bodyEl = el)} @@ -206,23 +250,9 @@ export class ProposalDetail extends React.Component { )}
- {isLive && - isTrustee && ( -
- - - -
- )}
+ {proposal.rfp && } @@ -230,7 +260,7 @@ export class ProposalDetail extends React.Component {
- +
@@ -242,9 +272,11 @@ export class ProposalDetail extends React.Component { - - - + {!proposal.isVersionTwo && ( + + + + )}
@@ -260,6 +292,11 @@ export class ProposalDetail extends React.Component { isVisible={isCancelOpen} handleClose={this.closeCancelModal} /> + )}
@@ -286,6 +323,9 @@ export class ProposalDetail extends React.Component { } }; + private openTipJarModal = () => this.setState({ isTipJarOpen: true }); + private closeTipJarModal = () => this.setState({ isTipJarOpen: false }); + private openUpdateModal = () => this.setState({ isUpdateOpen: true }); private closeUpdateModal = () => this.setState({ isUpdateOpen: false }); diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx index 26933ef5..2e127fee 100644 --- a/frontend/client/components/Proposals/Filters/index.tsx +++ b/frontend/client/components/Proposals/Filters/index.tsx @@ -1,15 +1,8 @@ import React from 'react'; -import { Select, Checkbox, Radio, Card, Divider } from 'antd'; +import { Select, Radio, Card } from 'antd'; import { RadioChangeEvent } from 'antd/lib/radio'; import { SelectValue } from 'antd/lib/select'; -import { - PROPOSAL_SORT, - SORT_LABELS, - PROPOSAL_CATEGORY, - CATEGORY_UI, - 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'; @@ -39,21 +32,6 @@ export default class ProposalFilters extends React.Component {
Reset}> -

Category

- {typedKeys(PROPOSAL_CATEGORY).map(c => ( -
- - {CATEGORY_UI[c].label} - -
- ))} - - -

Proposal stage

{ PROPOSAL_STAGE.PREVIEW, PROPOSAL_STAGE.FAILED, PROPOSAL_STAGE.CANCELED, + PROPOSAL_STAGE.FUNDING_REQUIRED, ].includes(s as PROPOSAL_STAGE), ) // skip a few .map(s => ( @@ -91,19 +70,6 @@ export default class ProposalFilters extends React.Component { ); } - private handleCategoryChange = (ev: RadioChangeEvent) => { - const { filters } = this.props; - const cat = ev.target.value as PROPOSAL_CATEGORY; - const category = ev.target.checked - ? [...filters.category, cat] - : filters.category.filter(c => c !== cat); - - this.props.handleChangeFilters({ - ...filters, - category, - }); - }; - private handleStageChange = (ev: RadioChangeEvent) => { let stage = [] as PROPOSAL_STAGE[]; if (ev.target.value !== 'ALL') { diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index 2df2aa7b..edd9d879 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -1,11 +1,12 @@ import React from 'react'; +import { Redirect } from 'react-router-dom'; import classnames from 'classnames'; import { Progress } from 'antd'; -import { Redirect } from 'react-router-dom'; 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 './style.less'; export class ProposalCard extends React.Component { @@ -18,13 +19,13 @@ export class ProposalCard extends React.Component { title, proposalAddress, proposalUrlId, - category, datePublished, dateCreated, team, target, - funded, contributionMatching, + isVersionTwo, + funded, percentFunded, } = this.props; @@ -38,28 +39,40 @@ export class ProposalCard extends React.Component {
)} -
-
- raised of{' '} - goal + {isVersionTwo && ( +
+
+ {formatUsd(target.toString(10))} +
-
= 100, - })} - > - {percentFunded}% -
-
- = 100 ? 'success' : 'active'} - showInfo={false} - /> + )} + + {!isVersionTwo && ( + <> +
+
+ raised of{' '} + goal +
+
= 100, + })} + > + {percentFunded}% +
+
+ = 100 ? 'success' : 'active'} + showInfo={false} + /> + + )}
-
+
{team[0].displayName}{' '} {team.length > 1 && +{team.length - 1} other}
@@ -67,14 +80,14 @@ export class ProposalCard extends React.Component { {[...team].reverse().map((u, idx) => ( ))}
{proposalAddress}
- + ); } diff --git a/frontend/client/components/Proposals/ProposalCard/style.less b/frontend/client/components/Proposals/ProposalCard/style.less index cc4c5fc3..56b12fbd 100644 --- a/frontend/client/components/Proposals/ProposalCard/style.less +++ b/frontend/client/components/Proposals/ProposalCard/style.less @@ -40,6 +40,20 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 1rem; + + small { + opacity: 0.6; + font-size: 0.6rem; + font-weight: 500; + } + } + + &-name-v1 { + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; small { opacity: 0.6; @@ -54,6 +68,14 @@ margin-left: 1.25rem; &-avatar { + width: 2.5rem; + height: 2.5rem; + margin-left: -0.75rem; + border-radius: 100%; + border: 2px solid #fff; + } + + &-avatar-v1 { width: 1.8rem; height: 1.8rem; margin-left: -0.75rem; @@ -64,9 +86,32 @@ } &-funding { + line-height: 2.5rem; + + &-raised { + font-size: 2.2rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + small { + opacity: 0.6; + } + } + + &-percent { + font-size: 0.7rem; + padding-left: 0.25rem; + &.is-funded { + color: @success-color; + } + } + } + + &-funding-v1 { display: flex; justify-content: space-between; - line-height: 1.2rem; + line-height: 1.9rem; &-raised { font-size: 0.9rem; diff --git a/frontend/client/components/Proposals/index.tsx b/frontend/client/components/Proposals/index.tsx index e8e66938..2d4963c9 100644 --- a/frontend/client/components/Proposals/index.tsx +++ b/frontend/client/components/Proposals/index.tsx @@ -8,6 +8,7 @@ import { Input, Divider, Drawer, Icon, Button } from 'antd'; import ProposalResults from './Results'; import ProposalFilters from './Filters'; import { PROPOSAL_SORT } from 'api/constants'; +import ZCFLogo from 'static/images/zcf.svg'; import './style.less'; interface StateProps { @@ -60,47 +61,69 @@ class Proposals extends React.Component { ); return (
- {isFiltersDrawered ? ( - - {filtersComponent} - - - ) : ( -
{filtersComponent}
- )} + {filtersComponent} + + + ) : ( +
+
+ + +
+ {filtersComponent} +
+ )} -
-
- +
+
+ +
+
+

Zcash Foundation Proposals

+

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

+
+
+ + + -
- -
); diff --git a/frontend/client/components/Proposals/style.less b/frontend/client/components/Proposals/style.less index fb17174d..ec56b67a 100644 --- a/frontend/client/components/Proposals/style.less +++ b/frontend/client/components/Proposals/style.less @@ -2,7 +2,47 @@ .Proposals { display: flex; - flex-direction: row; + flex-direction: column; + + &-content { + display: flex; + flex-direction: row; + } + + &-about { + display: flex; + align-content: center; + + &-logo { + flex-shrink: 0; + width: 120px; + padding-right: 1.5rem; + border-right: 1px solid rgba(0, 0, 0, 0.05); + margin-right: 1.5rem; + + svg { + width: 100%; + height: auto; + } + } + + &-text { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + + &-title { + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + &-desc { + font-size: 1rem; + } + } + } &-filters { width: 220px; @@ -21,6 +61,7 @@ &-search { display: flex; + margin-bottom: 1rem; &-filterButton.ant-btn { display: none; diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less index 74513ef7..95181234 100644 --- a/frontend/client/components/RFP/index.less +++ b/frontend/client/components/RFP/index.less @@ -21,6 +21,10 @@ &-date { opacity: 0.7; } + + & .ant-input-group { + width: inherit; + } } &-brief { @@ -97,8 +101,8 @@ // Only has this class while affixed .ant-affix { - box-shadow: 0 0 10px 10px #FFF; - background: #FFF; + box-shadow: 0 0 10px 10px #fff; + background: #fff; } } -} \ No newline at end of file +} diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index 1278f825..4207c11f 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -13,6 +13,9 @@ import Markdown from 'components/Markdown'; import ProposalCard from 'components/Proposals/ProposalCard'; import UnitDisplay from 'components/UnitDisplay'; import HeaderDetails from 'components/HeaderDetails'; +import Like from 'components/Like'; +import { RFP_STATUS } from 'api/constants'; +import { formatUsd } from 'utils/formatters'; import './index.less'; interface OwnProps { @@ -31,7 +34,7 @@ interface DispatchProps { type Props = OwnProps & StateProps & DispatchProps; -class RFPDetail extends React.Component { +export class RFPDetail extends React.Component { componentDidMount() { this.props.fetchRfp(this.props.rfpId); } @@ -48,6 +51,7 @@ class RFPDetail extends React.Component { } } + const isLive = rfp.status === RFP_STATUS.LIVE; const tags = []; if (rfp.matching) { @@ -59,9 +63,25 @@ class RFPDetail extends React.Component { } if (rfp.bounty) { + if (rfp.isVersionTwo) { + tags.push( + + {formatUsd(rfp.bounty.toString(10))} bounty + , + ); + } else { + tags.push( + + bounty + , + ); + } + } + + if (!isLive) { tags.push( - - bounty + + Closed , ); } @@ -77,6 +97,7 @@ class RFPDetail extends React.Component {
Opened {moment(rfp.dateOpened * 1000).format('LL')}
+

{rfp.title}

@@ -86,14 +107,22 @@ class RFPDetail extends React.Component {
    - {rfp.bounty && ( -
  • - Accepted proposals will be funded up to{' '} - - - -
  • - )} + {rfp.bounty && + rfp.isVersionTwo && ( +
  • + Accepted proposals will be funded up to{' '} + {formatUsd(rfp.bounty.toString(10))} in ZEC +
  • + )} + {rfp.bounty && + !rfp.isVersionTwo && ( +
  • + Accepted proposals will be funded up to{' '} + + + +
  • + )} {rfp.matching && (
  • Contributions will have their funding matched by the @@ -105,6 +134,14 @@ class RFPDetail extends React.Component { Proposal submissions end {moment(rfp.dateCloses * 1000).format('LL')}
  • )} + {rfp.ccr && ( +
  • + Submitted by{' '} + + {rfp.ccr.author.displayName} + +
  • + )}
@@ -117,23 +154,25 @@ class RFPDetail extends React.Component {
)} -
- -
- Ready to take on this request?{' '} - - - -
-
-
+ {isLive && ( +
+ +
+ Ready to take on this request?{' '} + + + +
+
+
+ )}
); } diff --git a/frontend/client/components/RFPs/RFPItem.tsx b/frontend/client/components/RFPs/RFPItem.tsx index 8863227a..a32ef19c 100644 --- a/frontend/client/components/RFPs/RFPItem.tsx +++ b/frontend/client/components/RFPs/RFPItem.tsx @@ -5,6 +5,7 @@ import { Tag } from 'antd'; import { Link } from 'react-router-dom'; import UnitDisplay from 'components/UnitDisplay'; import { RFP } from 'types'; +import { formatUsd } from 'utils/formatters'; import './RFPItem.less'; interface Props { @@ -25,17 +26,27 @@ export default class RFPItem extends React.Component { dateClosed, bounty, matching, + isVersionTwo, + ccr, } = rfp; const closeDate = dateCloses || dateClosed; const tags = []; if (!isSmall) { if (bounty) { - tags.push( - - bounty - , - ); + if (isVersionTwo) { + tags.push( + + {formatUsd(bounty.toString(10))} bounty + , + ); + } else { + tags.push( + + bounty + , + ); + } } if (matching) { tags.push( @@ -44,6 +55,13 @@ export default class RFPItem extends React.Component { , ); } + if (ccr) { + tags.push( + + Community Created Request + , + ); + } } return ( @@ -65,6 +83,11 @@ export default class RFPItem extends React.Component {
{acceptedProposals.length} proposals approved
+ {ccr && ( +
+ Submitted by {ccr.author.displayName} +
+ )}
); diff --git a/frontend/client/components/RFPs/index.tsx b/frontend/client/components/RFPs/index.tsx index d8cf3fb2..4bb7c65b 100644 --- a/frontend/client/components/RFPs/index.tsx +++ b/frontend/client/components/RFPs/index.tsx @@ -75,9 +75,9 @@ class RFPs extends React.Component {

Zcash Foundation Requests

The Zcash Foundation periodically makes requests for proposals that solve - high-priority needs in the Zcash ecosystem. These proposals will typically - receive large or matched contributions, should they be approved by the - Foundation. + high-priority needs in the Zcash ecosystem. In addition to funding from the + Zcash Foundation, accepted proposals may receive supplemental donations from + the community when they have set a "tip address" for their proposal.

diff --git a/frontend/client/components/Settings/Account/RefundAddress.tsx b/frontend/client/components/Settings/Account/RefundAddress.tsx index 77b9d62c..54c0ab77 100644 --- a/frontend/client/components/Settings/Account/RefundAddress.tsx +++ b/frontend/client/components/Settings/Account/RefundAddress.tsx @@ -1,33 +1,55 @@ import React from 'react'; -import { connect } from 'react-redux'; import { Form, Input, Button, message } from 'antd'; -import { AppState } from 'store/reducers'; -import { updateUserSettings, getUserSettings } from 'api/api'; +import { updateUserSettings } from 'api/api'; import { isValidAddress } from 'utils/validators'; +import { UserSettings } from 'types'; -interface StateProps { +interface Props { + userSettings?: UserSettings; + isFetching: boolean; + errorFetching: boolean; userid: number; + onAddressSet: (refundAddress: UserSettings['refundAddress']) => void; } -type Props = StateProps; +interface State { + isSaving: boolean; + refundAddress: string | null; + refundAddressSet: string | null; +} -const STATE = { - refundAddress: '', - isFetching: false, - isSaving: false, -}; +export default class RefundAddress extends React.Component { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { userSettings } = nextProps; + const { refundAddress, refundAddressSet } = prevState; -type State = typeof STATE; + const ret: Partial = {}; -class RefundAddress extends React.Component { - state: State = { ...STATE }; + if (!userSettings || !userSettings.refundAddress) { + return ret; + } - componentDidMount() { - this.fetchRefundAddress(); + if (userSettings.refundAddress !== refundAddressSet) { + ret.refundAddressSet = userSettings.refundAddress; + + if (refundAddress === null) { + ret.refundAddress = userSettings.refundAddress; + } + } + + return ret; } + state: State = { + isSaving: false, + refundAddress: null, + refundAddressSet: null, + }; + render() { - const { refundAddress, isFetching, isSaving } = this.state; + const { isSaving, refundAddress, refundAddressSet } = this.state; + const { isFetching, errorFetching } = this.props; + const addressChanged = refundAddress !== refundAddressSet; let status: 'validating' | 'error' | undefined; let help; @@ -42,10 +64,10 @@ class RefundAddress extends React.Component {
@@ -53,7 +75,9 @@ class RefundAddress extends React.Component { type="primary" htmlType="submit" size="large" - disabled={!refundAddress || isSaving || !!status} + disabled={ + !refundAddress || isSaving || !!status || errorFetching || !addressChanged + } loading={isSaving} block > @@ -63,19 +87,6 @@ class RefundAddress extends React.Component { ); } - private async fetchRefundAddress() { - const { userid } = this.props; - this.setState({ isFetching: true }); - try { - const res = await getUserSettings(userid); - this.setState({ refundAddress: res.data.refundAddress || '' }); - } catch (err) { - console.error(err); - message.error('Failed to get refund address'); - } - this.setState({ isFetching: false }); - } - private handleChange = (ev: React.ChangeEvent) => { this.setState({ refundAddress: ev.currentTarget.value }); }; @@ -92,7 +103,9 @@ class RefundAddress extends React.Component { try { const res = await updateUserSettings(userid, { refundAddress }); message.success('Settings saved'); - this.setState({ refundAddress: res.data.refundAddress || '' }); + const refundAddressNew = res.data.refundAddress || ''; + this.setState({ refundAddress: refundAddressNew }); + this.props.onAddressSet(refundAddressNew); } catch (err) { console.error(err); message.error(err.message || err.toString(), 5); @@ -100,9 +113,3 @@ class RefundAddress extends React.Component { this.setState({ isSaving: false }); }; } - -const withConnect = connect(state => ({ - userid: state.auth.user ? state.auth.user.userid : 0, -})); - -export default withConnect(RefundAddress); diff --git a/frontend/client/components/Settings/Account/TipJarAddress.tsx b/frontend/client/components/Settings/Account/TipJarAddress.tsx new file mode 100644 index 00000000..68e494d4 --- /dev/null +++ b/frontend/client/components/Settings/Account/TipJarAddress.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { Form, Input, Button, message } from 'antd'; +import { updateUserSettings } from 'api/api'; +import { isValidAddress } from 'utils/validators'; +import { UserSettings } from 'types'; + +interface Props { + userSettings?: UserSettings; + isFetching: boolean; + errorFetching: boolean; + userid: number; + onAddressSet: (refundAddress: UserSettings['tipJarAddress']) => void; +} + +interface State { + isSaving: boolean; + tipJarAddress: string | null; + tipJarAddressSet: string | null; +} + +export default class TipJarAddress extends React.Component { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { userSettings } = nextProps; + const { tipJarAddress, tipJarAddressSet } = prevState; + + const ret: Partial = {}; + + if (!userSettings || userSettings.tipJarAddress === undefined) { + return ret; + } + + if (userSettings.tipJarAddress !== tipJarAddressSet) { + ret.tipJarAddressSet = userSettings.tipJarAddress; + + if (tipJarAddress === null) { + ret.tipJarAddress = userSettings.tipJarAddress; + } + } + + return ret; + } + + state: State = { + isSaving: false, + tipJarAddress: null, + tipJarAddressSet: null, + }; + + render() { + const { isSaving, tipJarAddress, tipJarAddressSet } = this.state; + const { isFetching, errorFetching, userSettings } = this.props; + const addressChanged = tipJarAddress !== tipJarAddressSet; + const hasViewKeySet = userSettings && userSettings.tipJarViewKey; + + let addressIsValid; + let status: 'validating' | 'error' | undefined; + let help; + + if (tipJarAddress !== null) { + addressIsValid = tipJarAddress === '' || isValidAddress(tipJarAddress); + } + + if (isFetching) { + status = 'validating'; + } else if (tipJarAddress !== null && !addressIsValid) { + status = 'error'; + help = 'That doesn’t look like a valid address'; + } + + if (tipJarAddress === '' && hasViewKeySet) { + status = 'error'; + help = 'You must unset your view key before unsetting your address'; + } + + return ( + + + + + + + + ); + } + + private handleChange = (ev: React.ChangeEvent) => { + this.setState({ tipJarAddress: ev.currentTarget.value }); + }; + + private handleSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const { userid } = this.props; + const { tipJarAddress } = this.state; + + if (tipJarAddress === null) return; + + this.setState({ isSaving: true }); + try { + const res = await updateUserSettings(userid, { tipJarAddress }); + message.success('Settings saved'); + const tipJarAddressNew = + res.data.tipJarAddress === undefined ? null : res.data.tipJarAddress; + this.setState({ tipJarAddress: tipJarAddressNew }); + this.props.onAddressSet(tipJarAddressNew); + } catch (err) { + console.error(err); + message.error(err.message || err.toString(), 5); + } + this.setState({ isSaving: false }); + }; +} diff --git a/frontend/client/components/Settings/Account/TipJarViewKey.tsx b/frontend/client/components/Settings/Account/TipJarViewKey.tsx new file mode 100644 index 00000000..1b44cf9c --- /dev/null +++ b/frontend/client/components/Settings/Account/TipJarViewKey.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Form, Input, Button, message } from 'antd'; +import { updateUserSettings } from 'api/api'; +import { UserSettings } from 'types'; + +interface Props { + userSettings?: UserSettings; + isFetching: boolean; + errorFetching: boolean; + userid: number; + onViewKeySet: (viewKey: UserSettings['tipJarViewKey']) => void; +} + +interface State { + isSaving: boolean; + tipJarViewKey: string | null; + tipJarViewKeySet: string | null; +} + +export default class TipJarViewKey extends React.Component { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { userSettings } = nextProps; + const { tipJarViewKey, tipJarViewKeySet } = prevState; + + const ret: Partial = {}; + + if (!userSettings || userSettings.tipJarViewKey === undefined) { + return ret; + } + + if (userSettings.tipJarViewKey !== tipJarViewKeySet) { + ret.tipJarViewKeySet = userSettings.tipJarViewKey; + + if (tipJarViewKey === null) { + ret.tipJarViewKey = userSettings.tipJarViewKey; + } + } + + return ret; + } + + state: State = { + isSaving: false, + tipJarViewKey: null, + tipJarViewKeySet: null, + }; + + render() { + const { isSaving, tipJarViewKey, tipJarViewKeySet } = this.state; + const { isFetching, errorFetching, userSettings } = this.props; + const viewKeyChanged = tipJarViewKey !== tipJarViewKeySet; + const viewKeyDisabled = !(userSettings && userSettings.tipJarAddress); + + // TODO: add view key validation + + // let status: 'validating' | 'error' | undefined; + // let help; + // if (isFetching) { + // status = 'validating'; + // } else if (tipJarAddress && !isValidAddress(tipJarAddress)) { + // status = 'error'; + // help = 'That doesn’t look like a valid address'; + // } + + return ( +
+ + + + + +
+ ); + } + + private handleChange = (ev: React.ChangeEvent) => { + this.setState({ tipJarViewKey: ev.currentTarget.value }); + }; + + private handleSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const { userid } = this.props; + const { tipJarViewKey } = this.state; + + if (tipJarViewKey === null) return; + + this.setState({ isSaving: true }); + try { + const res = await updateUserSettings(userid, { tipJarViewKey }); + message.success('Settings saved'); + const tipJarViewKeyNew = res.data.tipJarViewKey || ''; + this.setState({ tipJarViewKey: tipJarViewKeyNew }); + this.props.onViewKeySet(tipJarViewKeyNew); + } catch (err) { + console.error(err); + message.error(err.message || err.toString(), 5); + } + this.setState({ isSaving: false }); + }; +} diff --git a/frontend/client/components/Settings/Account/index.tsx b/frontend/client/components/Settings/Account/index.tsx index 58d9b197..2ba54322 100644 --- a/frontend/client/components/Settings/Account/index.tsx +++ b/frontend/client/components/Settings/Account/index.tsx @@ -1,16 +1,126 @@ import React from 'react'; -import { Divider } from 'antd'; +import { Divider, message } from 'antd'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { getUserSettings } from 'api/api'; import ChangeEmail from './ChangeEmail'; import RefundAddress from './RefundAddress'; +import TipJarAddress from './TipJarAddress'; +import ViewingKey from './TipJarViewKey'; +import { UserSettings } from 'types'; + +interface StateProps { + userid: number; +} + +type Props = StateProps; +interface State { + userSettings: UserSettings | undefined; + isFetching: boolean; + errorFetching: boolean; +} + +const STATE: State = { + userSettings: undefined, + isFetching: false, + errorFetching: false, +}; + +class AccountSettings extends React.Component { + state: State = { ...STATE }; + + componentDidMount() { + this.fetchUserSettings(); + } -export default class AccountSettings extends React.Component<{}> { render() { + const { userid } = this.props; + const { userSettings, isFetching, errorFetching } = this.state; + return (
- + + + + +
); } + + private async fetchUserSettings() { + const { userid } = this.props; + this.setState({ isFetching: true }); + try { + const res = await getUserSettings(userid); + this.setState({ userSettings: res.data || undefined }); + } catch (err) { + console.error(err); + message.error('Failed to get user settings'); + this.setState({ errorFetching: true }); + } + this.setState({ isFetching: false }); + } + + private handleRefundAddressSet = (refundAddress: UserSettings['refundAddress']) => { + const { userSettings } = this.state; + if (!userSettings) return; + + this.setState({ + userSettings: { + ...userSettings, + refundAddress, + }, + }); + }; + + private handleTipJarAddressSet = (tipJarAddress: UserSettings['tipJarAddress']) => { + const { userSettings } = this.state; + if (!userSettings) return; + + this.setState({ + userSettings: { + ...userSettings, + tipJarAddress, + }, + }); + }; + + private handleTipJarViewKeySet = (tipJarViewKey: UserSettings['tipJarViewKey']) => { + const { userSettings } = this.state; + if (!userSettings) return; + + this.setState({ + userSettings: { + ...userSettings, + tipJarViewKey, + }, + }); + }; } + +const withConnect = connect(state => ({ + userid: state.auth.user ? state.auth.user.userid : 0, +})); + +export default withConnect(AccountSettings); diff --git a/frontend/client/components/Template/index.tsx b/frontend/client/components/Template/index.tsx index fb2a7493..defb614a 100644 --- a/frontend/client/components/Template/index.tsx +++ b/frontend/client/components/Template/index.tsx @@ -36,6 +36,8 @@ export default class Template extends React.PureComponent { )}
+ {/* + // @ts-ignore */}
{content}
diff --git a/frontend/client/components/TipJar/TipJarBlock.less b/frontend/client/components/TipJar/TipJarBlock.less new file mode 100644 index 00000000..ee963259 --- /dev/null +++ b/frontend/client/components/TipJar/TipJarBlock.less @@ -0,0 +1,36 @@ +@import '~styles/variables.less'; + +.TipJarBlock { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .ant-form-item { + width: 100%; + } + + .ant-radio-wrapper { + margin-bottom: 0.5rem; + opacity: 0.7; + font-size: 0.8rem; + + &:hover { + opacity: 1; + } + + .anticon { + margin-left: 0.2rem; + color: @primary-color; + } + } + + &-title { + font-size: 1.4rem; + line-height: 2rem; + margin-bottom: 1rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} diff --git a/frontend/client/components/TipJar/TipJarBlock.tsx b/frontend/client/components/TipJar/TipJarBlock.tsx new file mode 100644 index 00000000..e3c2533d --- /dev/null +++ b/frontend/client/components/TipJar/TipJarBlock.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Button, Form, Input, Tooltip } from 'antd'; +import { TipJarModal } from './TipJarModal'; +import { getAmountErrorFromString } from 'utils/validators'; +import './TipJarBlock.less'; +import '../Proposal/index.less'; + +interface Props { + address?: string | null; + type: 'user' | 'proposal'; +} + +const STATE = { + tipAmount: '', + modalOpen: false, +}; + +type State = typeof STATE; + +export class TipJarBlock extends React.Component { + state = STATE; + + render() { + const { address, type } = this.props; + const { tipAmount, modalOpen } = this.state; + const amountError = tipAmount ? getAmountErrorFromString(tipAmount) : ''; + + const addressNotSet = !address; + const buttonTooltip = addressNotSet + ? `Tipping address has not been set for ${type}` + : ''; + const isDisabled = addressNotSet || !tipAmount || !!amountError; + + return ( +
+
+ + + + + + +
+ + {address && + tipAmount && ( + + )} +
+ ); + } + + private handleAmountChange = (e: React.ChangeEvent) => + this.setState({ + tipAmount: e.currentTarget.value, + }); + + private handleTipJarModalOpen = () => + this.setState({ + modalOpen: true, + }); + + private handleTipJarModalClose = () => + this.setState({ + modalOpen: false, + }); +} diff --git a/frontend/client/components/TipJar/TipJarModal.less b/frontend/client/components/TipJar/TipJarModal.less new file mode 100644 index 00000000..1096be42 --- /dev/null +++ b/frontend/client/components/TipJar/TipJarModal.less @@ -0,0 +1,64 @@ +@import '~styles/variables.less'; + +.TipJarModal { + + &-uri { + display: flex; + padding-bottom: 1.5rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid rgba(#000, 0.06); + + &-qr { + position: relative; + padding: 0.5rem; + margin-right: 1rem; + border-radius: 4px; + box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2); + + canvas { + display: flex; + flex-shrink: 1; + } + &.is-loading canvas { + opacity: 0; + } + + .ant-spin { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + &-info { + flex: 1; + } + } + + &-fields { + &-row { + display: flex; + + > * { + flex: 1; + margin-right: 0.75rem; + + &:last-child { + margin-right: 0; + } + } + + &-address { + min-width: 300px; + } + } + } + + // Ant overrides + input[readonly], + textarea[readonly] { + background: rgba(#000, 0.05); + } +} + diff --git a/frontend/client/components/TipJar/TipJarModal.tsx b/frontend/client/components/TipJar/TipJarModal.tsx new file mode 100644 index 00000000..2a2f30cc --- /dev/null +++ b/frontend/client/components/TipJar/TipJarModal.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Modal, Icon, Button, Form, Input } from 'antd'; +import classnames from 'classnames'; +import QRCode from 'qrcode.react'; +import { formatZcashCLI, formatZcashURI } from 'utils/formatters'; +import { getAmountErrorFromString } from 'utils/validators'; +import Loader from 'components/Loader'; +import './TipJarModal.less'; +import CopyInput from 'components/CopyInput'; + +interface Props { + isOpen: boolean; + onClose: () => void; + type: 'user' | 'proposal'; + address: string; + amount: string; +} + +interface State { + amount: string; +} + +export class TipJarModal extends React.Component { + static getDerivedStateFromProps = (nextProps: Props) => { + // while modal is closed, set amount state via props + return !nextProps.isOpen ? { amount: nextProps.amount } : {}; + }; + + state: State = { + amount: '', + }; + + render() { + const { isOpen, onClose, type, address } = this.props; + const { amount } = this.state; + + const amountError = getAmountErrorFromString(amount); + const amountIsValid = !amountError; + + const cli = amountIsValid ? formatZcashCLI(address, amount) : ''; + const uri = amountIsValid ? formatZcashURI(address, amount) : ''; + + const content = ( +
+
+
+
+ + + + {!uri && } +
+
+
+ + + + + +
+
+ +
+
+ +
+
+ +
+
+
+ ); + return ( + + Done + + } + afterClose={this.handleAfterClose} + > + {content} + + ); + } + + private handleAmountChange = (e: React.ChangeEvent) => + this.setState({ + amount: e.currentTarget.value, + }); + + private handleAfterClose = () => this.setState({ amount: '' }); +} diff --git a/frontend/client/components/TipJar/TipJarProposalSettingsModal.tsx b/frontend/client/components/TipJar/TipJarProposalSettingsModal.tsx new file mode 100644 index 00000000..eb3e331f --- /dev/null +++ b/frontend/client/components/TipJar/TipJarProposalSettingsModal.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { Modal, Button, Form, Input } from 'antd'; +import { Divider, message } from 'antd'; +import { bindActionCreators, Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { updateProposal, TUpdateProposal } from 'modules/proposals/actions'; +import { Proposal } from 'types'; +import { updateProposalTipJarSettings } from 'api/api'; +import { isValidAddress } from 'utils/validators'; + +interface OwnProps { + proposal: Proposal; + handleClose: () => void; + isVisible: boolean; +} + +interface DispatchProps { + updateProposal: TUpdateProposal; +} + +type Props = OwnProps & DispatchProps; + +interface State { + setViewKey: string | null; + setAddress: string | null; + address: string | null; + viewKey: string | null; + isSavingAddress: boolean; + isSavingViewKey: boolean; +} + +class TipJarProposalSettingsModalBase extends React.Component { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { proposal } = nextProps; + const { setAddress, setViewKey, address, viewKey } = prevState; + + const ret: Partial = {}; + + if (proposal.tipJarAddress !== setAddress) { + ret.setAddress = proposal.tipJarAddress; + + if (address === null) { + ret.address = proposal.tipJarAddress; + } + } + + if (proposal.tipJarViewKey !== setViewKey) { + ret.setViewKey = proposal.tipJarViewKey; + + if (viewKey === null) { + ret.viewKey = proposal.tipJarViewKey; + } + } + + return ret; + } + + state: State = { + setViewKey: null, + setAddress: null, + address: null, + viewKey: null, + isSavingAddress: false, + isSavingViewKey: false, + }; + + render() { + const { + address, + viewKey, + setAddress, + setViewKey, + isSavingAddress, + isSavingViewKey, + } = this.state; + + let addressIsValid; + let addressStatus: 'validating' | 'error' | undefined; + let addressHelp; + if (address !== null) { + addressIsValid = address === '' || isValidAddress(address); + } + if (address !== null && !addressIsValid) { + addressStatus = 'error'; + addressHelp = 'That doesn’t look like a valid address'; + } + + const addressHasChanged = address !== setAddress; + const viewKeyHasChanged = viewKey !== setViewKey; + + const addressInputDisabled = isSavingAddress; + const viewKeyInputDisabled = isSavingViewKey || !setAddress; + + const addressButtonDisabled = + !addressHasChanged || isSavingAddress || !addressIsValid; + const viewKeyButtonDisabled = !viewKeyHasChanged || isSavingViewKey || !setAddress; + + const content = ( + <> +
+ + + + +
+ + + +
+ + + + +
+ + ); + + return ( + + Done + + } + > + {content} + + ); + } + + handleTipJarViewKeyChange = (e: React.ChangeEvent) => + this.setState({ viewKey: e.currentTarget.value }); + + handleTipJarAddressChange = (e: React.ChangeEvent) => + this.setState({ address: e.currentTarget.value }); + + handleTipJarAddressSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const { + proposal: { proposalId }, + } = this.props; + const { address } = this.state; + + if (address === null) return; + + this.setState({ isSavingAddress: true }); + try { + const res = await updateProposalTipJarSettings(proposalId, { address }); + message.success('Address saved'); + this.props.updateProposal(res.data); + } catch (err) { + console.error(err); + message.error(err.message || err.toString(), 5); + } + this.setState({ isSavingAddress: false }); + }; + + handleTipJarViewKeySubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const { + proposal: { proposalId }, + } = this.props; + const { viewKey } = this.state; + + if (viewKey === null) return; + + this.setState({ isSavingViewKey: true }); + try { + const res = await updateProposalTipJarSettings(proposalId, { viewKey }); + message.success('View key saved'); + this.props.updateProposal(res.data); + } catch (err) { + console.error(err); + message.error(err.message || err.toString(), 5); + } + this.setState({ isSavingViewKey: false }); + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return bindActionCreators({ updateProposal }, dispatch); +} + +export const TipJarProposalSettingsModal = connect<{}, DispatchProps, OwnProps, AppState>( + null, + mapDispatchToProps, +)(TipJarProposalSettingsModalBase); diff --git a/frontend/client/components/TipJar/index.tsx b/frontend/client/components/TipJar/index.tsx new file mode 100644 index 00000000..8980f31f --- /dev/null +++ b/frontend/client/components/TipJar/index.tsx @@ -0,0 +1,3 @@ +export * from './TipJarBlock'; +export * from './TipJarModal'; +export * from './TipJarProposalSettingsModal'; diff --git a/frontend/client/modules/ccr/actions.ts b/frontend/client/modules/ccr/actions.ts new file mode 100644 index 00000000..cf5c7435 --- /dev/null +++ b/frontend/client/modules/ccr/actions.ts @@ -0,0 +1,68 @@ +import { Dispatch } from 'redux'; +import types from './types'; +import { CCRDraft } from 'types/ccr'; +import { putCCR, putCCRSubmitForApproval } from 'api/api'; + +export function initializeForm(ccrId: number) { + return { + type: types.INITIALIZE_CCR_FORM_PENDING, + payload: ccrId, + }; +} + +export function updateCCRForm(form: Partial) { + return (dispatch: Dispatch) => { + dispatch({ + type: types.UPDATE_CCR_FORM, + payload: form, + }); + dispatch(saveCCRDraft()); + }; +} + +export function saveCCRDraft() { + return { type: types.SAVE_CCR_DRAFT_PENDING }; +} + +export function fetchCCRDrafts() { + return { type: types.FETCH_CCR_DRAFTS_PENDING }; +} + +export function createCCRDraft() { + return { + type: types.CREATE_CCR_DRAFT_PENDING, + }; +} + +export function fetchAndCreateCCRDrafts() { + return { + type: types.FETCH_AND_CREATE_CCR_DRAFTS, + }; +} + +export function deleteCCRDraft(ccrId: number) { + return { + type: types.DELETE_CCR_DRAFT_PENDING, + payload: ccrId, + }; +} + +export function submitCCR(form: CCRDraft) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.SUBMIT_CCR_PENDING }); + try { + await putCCR(form); + const res = await putCCRSubmitForApproval(form); + dispatch({ + type: types.SUBMIT_CCR_FULFILLED, + payload: res.data, + }); + } catch (err) { + dispatch({ + type: types.SUBMIT_CCR_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + }; +} diff --git a/frontend/client/modules/ccr/index.ts b/frontend/client/modules/ccr/index.ts new file mode 100644 index 00000000..2446dd72 --- /dev/null +++ b/frontend/client/modules/ccr/index.ts @@ -0,0 +1,8 @@ +import reducers, { CCRState, INITIAL_STATE } from './reducers'; +import * as ccrActions from './actions'; +import * as ccrTypes from './types'; +import ccrSagas from './sagas'; + +export { ccrActions, ccrTypes, ccrSagas, CCRState, INITIAL_STATE }; + +export default reducers; diff --git a/frontend/client/modules/ccr/reducers.ts b/frontend/client/modules/ccr/reducers.ts new file mode 100644 index 00000000..82b298dd --- /dev/null +++ b/frontend/client/modules/ccr/reducers.ts @@ -0,0 +1,195 @@ +import types from './types'; +import { CCRDraft, CCR } from 'types'; + +export interface CCRState { + drafts: CCRDraft[] | null; + form: CCRDraft | null; + + isInitializingForm: boolean; + initializeFormError: string | null; + + isSavingDraft: boolean; + hasSavedDraft: boolean; + saveDraftError: string | null; + + isFetchingDrafts: boolean; + fetchDraftsError: string | null; + + isCreatingDraft: boolean; + createDraftError: string | null; + + isDeletingDraft: boolean; + deleteDraftError: string | null; + + submittedCCR: CCR | null; + isSubmitting: boolean; + submitError: string | null; + + publishedCCR: CCR | null; + isPublishing: boolean; + publishError: string | null; +} + +export const INITIAL_STATE: CCRState = { + drafts: null, + form: null, + + isInitializingForm: false, + initializeFormError: null, + + isSavingDraft: false, + hasSavedDraft: true, + saveDraftError: null, + + isFetchingDrafts: false, + fetchDraftsError: null, + + isCreatingDraft: false, + createDraftError: null, + + isDeletingDraft: false, + deleteDraftError: null, + + submittedCCR: null, + isSubmitting: false, + submitError: null, + + publishedCCR: null, + isPublishing: false, + publishError: null, +}; + +export default function createReducer( + state: CCRState = INITIAL_STATE, + action: any, +): CCRState { + switch (action.type) { + case types.UPDATE_CCR_FORM: + return { + ...state, + form: { + ...state.form, + ...action.payload, + }, + hasSavedDraft: false, + }; + + case types.INITIALIZE_CCR_FORM_PENDING: + return { + ...state, + form: null, + isInitializingForm: true, + initializeFormError: null, + }; + case types.INITIALIZE_CCR_FORM_FULFILLED: + return { + ...state, + form: { ...action.payload }, + isInitializingForm: false, + }; + case types.INITIALIZE_CCR_FORM_REJECTED: + return { + ...state, + isInitializingForm: false, + initializeFormError: action.payload, + }; + + case types.SAVE_CCR_DRAFT_PENDING: + return { + ...state, + isSavingDraft: true, + }; + case types.SAVE_CCR_DRAFT_FULFILLED: + return { + ...state, + isSavingDraft: false, + hasSavedDraft: true, + // Only clear error once save was a success + saveDraftError: null, + }; + case types.SAVE_CCR_DRAFT_REJECTED: + return { + ...state, + isSavingDraft: false, + hasSavedDraft: false, + saveDraftError: action.payload, + }; + + case types.FETCH_CCR_DRAFTS_PENDING: + return { + ...state, + isFetchingDrafts: true, + fetchDraftsError: null, + }; + case types.FETCH_CCR_DRAFTS_FULFILLED: + return { + ...state, + isFetchingDrafts: false, + drafts: action.payload, + }; + case types.FETCH_CCR_DRAFTS_REJECTED: + return { + ...state, + isFetchingDrafts: false, + fetchDraftsError: action.payload, + }; + + case types.CREATE_CCR_DRAFT_PENDING: + return { + ...state, + isCreatingDraft: true, + createDraftError: null, + }; + case types.CREATE_CCR_DRAFT_FULFILLED: + return { + ...state, + drafts: [...(state.drafts || []), action.payload], + isCreatingDraft: false, + }; + case types.CREATE_CCR_DRAFT_REJECTED: + return { + ...state, + createDraftError: action.payload, + isCreatingDraft: false, + }; + + case types.DELETE_CCR_DRAFT_PENDING: + return { + ...state, + isDeletingDraft: true, + deleteDraftError: null, + }; + case types.DELETE_CCR_DRAFT_FULFILLED: + return { + ...state, + isDeletingDraft: false, + }; + case types.DELETE_CCR_DRAFT_REJECTED: + return { + ...state, + isDeletingDraft: false, + deleteDraftError: action.payload, + }; + + case types.SUBMIT_CCR_PENDING: + return { + ...state, + submittedCCR: null, + isSubmitting: true, + submitError: null, + }; + case types.SUBMIT_CCR_FULFILLED: + return { + ...state, + submittedCCR: action.payload, + isSubmitting: false, + }; + case types.SUBMIT_CCR_REJECTED: + return { + ...state, + submitError: action.payload, + isSubmitting: false, + }; + } + return state; +} diff --git a/frontend/client/modules/ccr/sagas.ts b/frontend/client/modules/ccr/sagas.ts new file mode 100644 index 00000000..05ffe7a4 --- /dev/null +++ b/frontend/client/modules/ccr/sagas.ts @@ -0,0 +1,144 @@ +import { SagaIterator } from 'redux-saga'; +import { takeEvery, takeLatest, put, take, call, select } from 'redux-saga/effects'; +import { replace } from 'connected-react-router'; +import { + postCCRDraft, + getCCRDrafts, + putCCR, + deleteCCR as RDeleteCCRDraft, + getCCR, +} from 'api/api'; +import { getDrafts, getDraftById, getFormState } from './selectors'; +import { + createCCRDraft, + fetchCCRDrafts, + initializeForm, + deleteCCRDraft, +} from './actions'; +import types from './types'; + +export function* handleCreateDraft(): SagaIterator { + try { + const res: Yielded = yield call(postCCRDraft); + yield put({ + type: types.CREATE_CCR_DRAFT_FULFILLED, + payload: res.data, + }); + yield put(replace(`/ccrs/${res.data.ccrId}/edit`)); + } catch (err) { + yield put({ + type: types.CREATE_CCR_DRAFT_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export function* handleFetchDrafts(): SagaIterator { + try { + const res: Yielded = yield call(getCCRDrafts); + yield put({ + type: types.FETCH_CCR_DRAFTS_FULFILLED, + payload: res.data, + }); + } catch (err) { + yield put({ + type: types.FETCH_CCR_DRAFTS_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export function* handleSaveDraft(): SagaIterator { + try { + const draft: Yielded = yield select(getFormState); + if (!draft) { + throw new Error('No form state to save draft'); + } + yield call(putCCR, draft); + yield put({ type: types.SAVE_CCR_DRAFT_FULFILLED }); + } catch (err) { + yield put({ + type: types.SAVE_CCR_DRAFT_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export function* handleFetchAndCreateDrafts(): SagaIterator { + yield put(fetchCCRDrafts()); + yield take([types.FETCH_CCR_DRAFTS_FULFILLED, types.FETCH_CCR_DRAFTS_PENDING]); + const drafts: Yielded = yield select(getDrafts); + + // Back out if draft fetch failed and we don't have drafts + if (!drafts) { + console.warn('Fetch of drafts failed, not creating new draft'); + return; + } + + if (drafts.length === 0) { + yield put(createCCRDraft()); + } +} + +export function* handleDeleteDraft( + action: ReturnType, +): SagaIterator { + try { + yield call(RDeleteCCRDraft, action.payload); + put({ type: types.DELETE_CCR_DRAFT_FULFILLED }); + } catch (err) { + yield put({ + type: types.DELETE_CCR_DRAFT_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + return; + } + yield call(handleFetchDrafts); +} + +export function* handleInitializeForm( + action: ReturnType, +): SagaIterator { + try { + const ccrId = action.payload; + let draft: Yielded = yield select(getDraftById, ccrId); + if (!draft) { + yield call(handleFetchDrafts); + draft = yield select(getDraftById, ccrId); + if (!draft) { + // If it's a real ccr, just not in draft form, redirect to it + try { + yield call(getCCR, ccrId); + yield put({ type: types.INITIALIZE_CCR_FORM_REJECTED }); + yield put(replace(`/ccrs/${action.payload}`)); + return; + } catch (err) { + throw new Error('CCR not found'); + } + } + } + yield put({ + type: types.INITIALIZE_CCR_FORM_FULFILLED, + payload: draft, + }); + } catch (err) { + yield put({ + type: types.INITIALIZE_CCR_FORM_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export default function* ccrSagas(): SagaIterator { + yield takeEvery(types.CREATE_CCR_DRAFT_PENDING, handleCreateDraft); + yield takeLatest(types.FETCH_CCR_DRAFTS_PENDING, handleFetchDrafts); + yield takeLatest(types.SAVE_CCR_DRAFT_PENDING, handleSaveDraft); + yield takeEvery(types.DELETE_CCR_DRAFT_PENDING, handleDeleteDraft); + yield takeEvery(types.INITIALIZE_CCR_FORM_PENDING, handleInitializeForm); + yield takeEvery(types.FETCH_AND_CREATE_CCR_DRAFTS, handleFetchAndCreateDrafts); +} diff --git a/frontend/client/modules/ccr/selectors.ts b/frontend/client/modules/ccr/selectors.ts new file mode 100644 index 00000000..7de00239 --- /dev/null +++ b/frontend/client/modules/ccr/selectors.ts @@ -0,0 +1,11 @@ +import { AppState as S } from 'store/reducers'; + +export const getDrafts = (s: S) => s.ccr.drafts; +export const getDraftsFetchError = (s: S) => s.ccr.fetchDraftsError; + +export const getDraftById = (s: S, id: number) => { + const drafts = getDrafts(s) || []; + return drafts.find(d => d.ccrId === id); +}; + +export const getFormState = (s: S) => s.ccr.form; diff --git a/frontend/client/modules/ccr/types.ts b/frontend/client/modules/ccr/types.ts new file mode 100644 index 00000000..65fa2f55 --- /dev/null +++ b/frontend/client/modules/ccr/types.ts @@ -0,0 +1,36 @@ +enum CCRTypes { + UPDATE_CCR_FORM = 'UPDATE_CCR_FORM', + + INITIALIZE_CCR_FORM = 'INITIALIZE_CCR_FORM', + INITIALIZE_CCR_FORM_PENDING = 'INITIALIZE_CCR_FORM_PENDING', + INITIALIZE_CCR_FORM_FULFILLED = 'INITIALIZE_CCR_FORM_FULFILLED', + INITIALIZE_CCR_FORM_REJECTED = 'INITIALIZE_CCR_FORM_REJECTED', + + SAVE_CCR_DRAFT = 'SAVE_CCR_DRAFT', + SAVE_CCR_DRAFT_PENDING = 'SAVE_CCR_DRAFT_PENDING', + SAVE_CCR_DRAFT_FULFILLED = 'SAVE_CCR_DRAFT_FULFILLED', + SAVE_CCR_DRAFT_REJECTED = 'SAVE_CCR_DRAFT_REJECTED', + + FETCH_CCR_DRAFTS = 'FETCH_CCR_DRAFTS', + FETCH_CCR_DRAFTS_PENDING = 'FETCH_CCR_DRAFTS_PENDING', + FETCH_CCR_DRAFTS_FULFILLED = 'FETCH_CCR_DRAFTS_FULFILLED', + FETCH_CCR_DRAFTS_REJECTED = 'FETCH_CCR_DRAFTS_REJECTED', + + CREATE_CCR_DRAFT = 'CREATE_CCR_DRAFT', + CREATE_CCR_DRAFT_PENDING = 'CREATE_CCR_DRAFT_PENDING', + CREATE_CCR_DRAFT_FULFILLED = 'CREATE_CCR_DRAFT_FULFILLED', + CREATE_CCR_DRAFT_REJECTED = 'CREATE_CCR_DRAFT_REJECTED', + + FETCH_AND_CREATE_CCR_DRAFTS = 'FETCH_AND_CREATE_CCR_DRAFTS', + + DELETE_CCR_DRAFT = 'DELETE_CCR_DRAFT', + DELETE_CCR_DRAFT_PENDING = 'DELETE_CCR_DRAFT_PENDING', + DELETE_CCR_DRAFT_FULFILLED = 'DELETE_CCR_DRAFT_FULFILLED', + DELETE_CCR_DRAFT_REJECTED = 'DELETE_CCR_DRAFT_REJECTED', + + SUBMIT_CCR = 'SUBMIT_CCR', + SUBMIT_CCR_PENDING = 'SUBMIT_CCR_PENDING', + SUBMIT_CCR_FULFILLED = 'SUBMIT_CCR_FULFILLED', + SUBMIT_CCR_REJECTED = 'SUBMIT_CCR_REJECTED', +} +export default CCRTypes; diff --git a/frontend/client/modules/ccr/utils.ts b/frontend/client/modules/ccr/utils.ts new file mode 100644 index 00000000..9d50a92d --- /dev/null +++ b/frontend/client/modules/ccr/utils.ts @@ -0,0 +1,64 @@ +import { CCRDraft } from 'types'; +import { getAmountErrorUsd, getAmountErrorUsdFromString } from 'utils/validators'; + +interface CCRFormErrors { + title?: string; + brief?: string; + target?: string; + content?: string; +} + +export type KeyOfForm = keyof CCRFormErrors; +export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = { + title: 'Title', + brief: 'Brief', + target: 'Target amount', + content: 'Details', +}; + +const requiredFields = ['title', 'brief', 'target', 'content']; + +export function getCCRErrors( + form: Partial, + skipRequired?: boolean, +): CCRFormErrors { + const errors: CCRFormErrors = {}; + const { title, content, brief, target } = form; + + // Required fields with no extra validation + if (!skipRequired) { + for (const key of requiredFields) { + if (!form[key as KeyOfForm]) { + errors[key as KeyOfForm] = `${FIELD_NAME_MAP[key as KeyOfForm]} is required`; + } + } + } + + // Title + if (title && title.length > 60) { + errors.title = 'Title can only be 60 characters maximum'; + } + + // Brief + if (brief && brief.length > 140) { + errors.brief = 'Brief can only be 140 characters maximum'; + } + + // Content limit for our database's sake + if (content && content.length > 250000) { + errors.content = 'Details can only be 250,000 characters maximum'; + } + + // Amount to raise + const targetFloat = target ? parseFloat(target) : 0; + if (target && !Number.isNaN(targetFloat)) { + const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string); + const targetErr = + getAmountErrorUsd(targetFloat, limit) || getAmountErrorUsdFromString(target); + if (targetErr) { + errors.target = targetErr; + } + } + + return errors; +} diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index f10ee577..5995982b 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -3,18 +3,19 @@ import { STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS, - CreateMilestone, + CCRDraft, + RFP, } from 'types'; -import moment from 'moment'; -import { User } from 'types'; +import { User, CCR } from 'types'; import { - getAmountError, + getAmountErrorUsd, + getAmountErrorUsdFromString, isValidSaplingAddress, isValidTAddress, isValidSproutAddress, } from 'utils/validators'; -import { Zat, toZat } from 'utils/units'; -import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; +import { toUsd } from 'utils/units'; +import { PROPOSAL_STAGE, RFP_STATUS } from 'api/constants'; import { ProposalDetail, PROPOSAL_DETAIL_INITIAL_STATE, @@ -24,38 +25,28 @@ interface CreateFormErrors { rfpOptIn?: string; title?: string; brief?: string; - category?: string; target?: string; team?: string[]; content?: string; payoutAddress?: string; + tipJarAddress?: string; milestones?: string[]; - deadlineDuration?: string; } export type KeyOfForm = keyof CreateFormErrors; export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = { - rfpOptIn: 'RFP KYC', + rfpOptIn: 'KYC', title: 'Title', brief: 'Brief', - category: 'Category', target: 'Target amount', team: 'Team', content: 'Details', payoutAddress: 'Payout address', + tipJarAddress: 'Tip address', milestones: 'Milestones', - deadlineDuration: 'Funding deadline', }; -const requiredFields = [ - 'title', - 'brief', - 'category', - 'target', - 'content', - 'payoutAddress', - 'deadlineDuration', -]; +const requiredFields = ['title', 'brief', 'target', 'content', 'payoutAddress']; export function getCreateErrors( form: Partial, @@ -69,7 +60,7 @@ export function getCreateErrors( milestones, target, payoutAddress, - rfp, + tipJarAddress, rfpOptIn, brief, } = form; @@ -91,7 +82,7 @@ export function getCreateErrors( } // RFP opt-in - if (rfp && (rfp.bounty || rfp.matching) && rfpOptIn === null) { + if (rfpOptIn === null) { errors.rfpOptIn = 'Please accept or decline KYC'; } @@ -114,7 +105,8 @@ export function getCreateErrors( const targetFloat = target ? parseFloat(target) : 0; if (target && !Number.isNaN(targetFloat)) { const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string); - const targetErr = getAmountError(targetFloat, limit, 0.001); + const targetErr = + getAmountErrorUsd(targetFloat, limit) || getAmountErrorUsdFromString(target); if (targetErr) { errors.target = targetErr; } @@ -131,10 +123,20 @@ export function getCreateErrors( } } + // Tip Jar Address + if (tipJarAddress && !isValidSaplingAddress(tipJarAddress)) { + if (isValidSproutAddress(tipJarAddress)) { + errors.tipJarAddress = 'Must be a Sapling address, not a Sprout address'; + } else if (isValidTAddress(tipJarAddress)) { + errors.tipJarAddress = 'Must be a Sapling Z address, not a T address'; + } else { + errors.tipJarAddress = 'That doesn’t look like a valid Sapling address'; + } + } + // Milestones if (milestones) { let cumulativeMilestonePct = 0; - let lastMsEst: CreateMilestone['dateEstimated'] = 0; const milestoneErrors = milestones.map((ms, idx) => { // check payout first so we collect the cumulativePayout even if other fields are invalid if (!ms.payoutPercent) { @@ -164,22 +166,18 @@ export function getCreateErrors( return 'Description can only be 200 characters maximum'; } - if (!ms.dateEstimated) { - return 'Estimate date is required'; - } else { - // FE validation on milestone estimation - if ( - ms.dateEstimated < - moment(Date.now()) - .startOf('month') - .unix() - ) { - return 'Estimate date should be in the future'; + if (!ms.immediatePayout) { + if (!ms.daysEstimated) { + return 'Estimate in days is required'; + } else if (Number.isNaN(parseInt(ms.daysEstimated, 10))) { + return 'Days estimated must be a valid number'; + } else if (parseInt(ms.daysEstimated, 10) !== parseFloat(ms.daysEstimated)) { + return 'Days estimated must be a whole number, no decimals'; + } else if (parseInt(ms.daysEstimated, 10) <= 0) { + return 'Days estimated must be greater than 0'; + } else if (parseInt(ms.daysEstimated, 10) > 365) { + return 'Days estimated must be less than or equal to 365'; } - if (ms.dateEstimated <= lastMsEst) { - return 'Estimate date should be later than previous estimate date'; - } - lastMsEst = ms.dateEstimated; } if ( @@ -240,25 +238,31 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta dateCreated: Date.now() / 1000, datePublished: Date.now() / 1000, dateApproved: Date.now() / 1000, - deadlineDuration: 86400 * 60, - target: toZat(draft.target), - funded: Zat('0'), + target: toUsd(draft.target), + funded: toUsd('0'), contributionMatching: 0, - contributionBounty: Zat('0'), + contributionBounty: toUsd('0'), percentFunded: 0, stage: PROPOSAL_STAGE.PREVIEW, - category: draft.category || PROPOSAL_CATEGORY.CORE_DEV, isStaked: true, arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, }, + tipJarAddress: null, + tipJarViewKey: null, + acceptedWithFunding: false, + authedFollows: false, + followersCount: 0, + authedLiked: false, + likesCount: 0, + isVersionTwo: true, milestones: draft.milestones.map((m, idx) => ({ id: idx, index: idx, title: m.title, content: m.content, - amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)), - dateEstimated: m.dateEstimated, + amount: (target * (parseInt(m.payoutPercent, 10) / 100)).toFixed(2), + daysEstimated: m.daysEstimated, immediatePayout: m.immediatePayout, payoutPercent: m.payoutPercent.toString(), stage: MILESTONE_STAGE.IDLE, @@ -266,3 +270,28 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta ...PROPOSAL_DETAIL_INITIAL_STATE, }; } + +export function makeRfpPreviewFromCcrDraft(draft: CCRDraft): RFP { + const ccr: CCR = { + ...draft, + }; + const now = new Date().getTime(); + const { brief, content, title } = draft; + + return { + id: 0, + urlId: '', + status: RFP_STATUS.LIVE, + acceptedProposals: [], + bounty: draft.target ? toUsd(draft.target) : null, + matching: false, + dateOpened: now / 1000, + authedLiked: false, + likesCount: 0, + isVersionTwo: true, + ccr, + brief, + content, + title, + }; +} diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 47cdd719..d73682d0 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -119,6 +119,16 @@ export function fetchProposal(proposalId: Proposal['proposalId']) { }; } +export type TUpdateProposal = typeof updateProposal; +export function updateProposal(proposal: Proposal) { + return (dispatch: Dispatch, getState: GetState) => { + dispatch({ + type: types.PROPOSAL_DATA_FULFILLED, + payload: addProposalUserRoles(proposal, getState()), + }); + }; +} + export function fetchProposalComments(id?: number) { return async (dispatch: Dispatch, getState: GetState) => { const state = getState(); @@ -230,3 +240,17 @@ export function reportProposalComment( } }; } + +export function updateProposalComment( + commentId: Comment['id'], + commentUpdate: Partial, +) { + return (dispatch: Dispatch) => + dispatch({ + type: types.UPDATE_PROPOSAL_COMMENT, + payload: { + commentId, + commentUpdate, + }, + }); +} diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 15af0181..d3316241 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -375,6 +375,9 @@ export default (state = INITIAL_STATE, action: any) => { case types.REPORT_PROPOSAL_COMMENT_FULFILLED: return updateCommentInStore(state, payload.commentId, { reported: true }); + case types.UPDATE_PROPOSAL_COMMENT: + return updateCommentInStore(state, payload.commentId, payload.commentUpdate); + case types.PROPOSAL_UPDATES_PENDING: return { ...state, diff --git a/frontend/client/modules/proposals/types.ts b/frontend/client/modules/proposals/types.ts index 25e31b04..b2cc1f90 100644 --- a/frontend/client/modules/proposals/types.ts +++ b/frontend/client/modules/proposals/types.ts @@ -52,6 +52,8 @@ enum proposalTypes { REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING', REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED', REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED', + + UPDATE_PROPOSAL_COMMENT = 'UPDATE_PROPOSAL_COMMENT', } export default proposalTypes; diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts index 6a8c0133..1c762ca4 100644 --- a/frontend/client/modules/users/actions.ts +++ b/frontend/client/modules/users/actions.ts @@ -8,6 +8,7 @@ import { deleteProposalContribution, deleteProposalDraft, putProposalPublish, + deleteCCR, } from 'api/api'; import { Dispatch } from 'redux'; import { cleanClone } from 'utils/helpers'; @@ -93,7 +94,10 @@ export function respondToInvite( }; } -export function deleteContribution(userId: string | number, contributionId: string | number) { +export function deleteContribution( + userId: string | number, + contributionId: string | number, +) { // Fire and forget deleteProposalContribution(contributionId); return { @@ -126,3 +130,12 @@ export function publishPendingProposal(userId: number, proposalId: number) { }); }; } + +export function deletePendingRequest(userId: number, requestId: number) { + return async (dispatch: Dispatch) => { + await dispatch({ + type: types.USER_DELETE_REQUEST, + payload: deleteCCR(requestId).then(_ => ({ userId, requestId })), + }); + }; +} diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index 3dd319f4..8fe5b0eb 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -6,6 +6,7 @@ import { UserContribution, TeamInviteWithProposal, UserProposalArbiter, + UserCCR, } from 'types'; import types from './types'; @@ -21,8 +22,10 @@ export interface UserState extends User { isUpdating: boolean; updateError: string | null; pendingProposals: UserProposal[]; + pendingRequests: UserCCR[]; arbitrated: UserProposalArbiter[]; proposals: UserProposal[]; + requests: UserCCR[]; contributions: UserContribution[]; comments: UserComment[]; isFetchingInvites: boolean; @@ -53,8 +56,10 @@ export const INITIAL_USER_STATE: UserState = { isUpdating: false, updateError: null, pendingProposals: [], + pendingRequests: [], arbitrated: [], proposals: [], + requests: [], contributions: [], comments: [], isFetchingInvites: false, @@ -111,24 +116,6 @@ export default (state = INITIAL_STATE, action: any) => { updateError: errorMessage, }); // invites - case types.FETCH_USER_INVITES_PENDING: - return updateUserState(state, userFetchId, { - isFetchingInvites: true, - fetchErrorInvites: null, - }); - case types.FETCH_USER_INVITES_FULFILLED: - return updateUserState(state, userFetchId, { - isFetchingInvites: false, - hasFetchedInvites: true, - invites, - }); - case types.FETCH_USER_INVITES_REJECTED: - return updateUserState(state, userFetchId, { - isFetchingInvites: false, - hasFetchedInvites: true, - fetchErrorInvites: errorMessage, - }); - // invites case types.FETCH_USER_INVITES_PENDING: return updateUserState(state, userFetchId, { isFetchingInvites: true, diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts index 4e6858c8..e633bd9a 100644 --- a/frontend/client/modules/users/types.ts +++ b/frontend/client/modules/users/types.ts @@ -26,6 +26,12 @@ enum UsersActions { USER_PUBLISH_PROPOSAL = 'USER_PUBLISH_PROPOSAL', USER_PUBLISH_PROPOSAL_FULFILLED = 'USER_PUBLISH_PROPOSAL_FULFILLED', + + USER_DELETE_REQUEST = 'USER_DELETE_REQUEST', + USER_DELETE_REQUEST_FULFILLED = 'USER_DELETE_REQUEST_FULFILLED', + + USER_PUBLISH_REQUEST = 'USER_PUBLISH_REQUEST', + USER_PUBLISH_REQUEST_FULFILLED = 'USER_PUBLISH_REQUEST_FULFILLED', } export default UsersActions; diff --git a/frontend/client/pages/ccr.tsx b/frontend/client/pages/ccr.tsx new file mode 100644 index 00000000..fc8d6b6b --- /dev/null +++ b/frontend/client/pages/ccr.tsx @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; +import CcrPreview from 'components/CCRFlow/CCRPreview' +import { extractIdFromSlug } from 'utils/api'; + +import { withRouter, RouteComponentProps } from 'react-router'; + +type RouteProps = RouteComponentProps; + +class CcrPage extends Component { + render() { + const ccrId = extractIdFromSlug(this.props.match.params.id); + return ; + } +} + +export default withRouter(CcrPage); diff --git a/frontend/client/pages/create-request.tsx b/frontend/client/pages/create-request.tsx new file mode 100644 index 00000000..fd61306a --- /dev/null +++ b/frontend/client/pages/create-request.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router'; +import CreateRequestDraftList from 'components/CCRDraftList'; + +type Props = RouteComponentProps<{}>; + +class CreateRequestPage extends React.Component { + render() { + return ( + <> + + + + ); + } +} + +export default withRouter(CreateRequestPage); diff --git a/frontend/client/pages/email-verify.tsx b/frontend/client/pages/email-verify.tsx index 0a5da67c..f9d41cb8 100644 --- a/frontend/client/pages/email-verify.tsx +++ b/frontend/client/pages/email-verify.tsx @@ -28,7 +28,7 @@ class VerifyEmail extends React.Component { this.setState({ hasVerified: true, isVerifying: false, - }) + }); }) .catch(err => { this.setState({ @@ -41,7 +41,7 @@ class VerifyEmail extends React.Component { error: ` Missing code parameter from email. Make sure you copied the full link. - ` + `, }); } } @@ -51,19 +51,19 @@ class VerifyEmail extends React.Component { const actions = (
- + - +
); - + if (hasVerified) { return ( { } } -export default withRouter(VerifyEmail); \ No newline at end of file +export default withRouter(VerifyEmail); diff --git a/frontend/client/pages/guide.tsx b/frontend/client/pages/guide.tsx new file mode 100644 index 00000000..63d58a9b --- /dev/null +++ b/frontend/client/pages/guide.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import MarkdownPage from 'components/MarkdownPage'; +import GUIDE from 'static/markdown/GUIDE.md'; + +const Guide = () => ; + +export default Guide; diff --git a/frontend/client/pages/request-edit.tsx b/frontend/client/pages/request-edit.tsx new file mode 100644 index 00000000..73e6a567 --- /dev/null +++ b/frontend/client/pages/request-edit.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withRouter, RouteComponentProps } from 'react-router'; +import CCRFlow from 'components/CCRFlow'; +import { initializeForm } from 'modules/ccr/actions'; +import { AppState } from 'store/reducers'; +import Loader from 'components/Loader'; + +interface StateProps { + form: AppState['ccr']['form']; + isInitializingForm: AppState['ccr']['isInitializingForm']; + initializeFormError: AppState['ccr']['initializeFormError']; +} + +interface DispatchProps { + initializeForm: typeof initializeForm; +} + +type Props = StateProps & DispatchProps & RouteComponentProps<{ id: string }>; + +class RequestEdit extends React.Component { + componentWillMount() { + const proposalId = parseInt(this.props.match.params.id, 10); + this.props.initializeForm(proposalId); + } + + render() { + const { form, initializeFormError } = this.props; + if (form) { + return ; + } else if (initializeFormError) { + return

{initializeFormError}

; + } else { + return ; + } + } +} + +const ConnectedRequestEdit = connect( + state => ({ + form: state.ccr.form, + isInitializingForm: state.ccr.isInitializingForm, + initializeFormError: state.ccr.initializeFormError, + }), + { initializeForm }, +)(RequestEdit); + +export default withRouter(ConnectedRequestEdit); diff --git a/frontend/client/static/locales/en/common.json b/frontend/client/static/locales/en/common.json index 430d808a..ae905fbf 100644 --- a/frontend/client/static/locales/en/common.json +++ b/frontend/client/static/locales/en/common.json @@ -8,15 +8,23 @@ "intro": { "title": "Funding for Zcash ecosystem projects", - "subtitle": "Development, research, and community growth", + "subtitle": "Development, research, and community growth.\nHelp us build a better Zcash by creating a proposal or requesting an improvement!", "signup": "Sign up now", + "ccr": "Create a request", "browse": "Browse proposals", "learn": "or learn more below" }, + "latest": { + "proposalsTitle": "Latest proposals", + "proposalsPlaceholder": "No proposals found", + "requestsTitle": "Latest requests", + "requestsPlaceholder": "No requests found" + }, + "requests": { "title": "Open Requests from the ZF", - "description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties, or pledges of contribution matching.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the list of promising ideas<\/a>!", + "description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the list of promising ideas<\/a>!", "more": "See all requests", "emptyTitle": "No open requests at this time", "emptySubtitle": "But don’t let that stop you! Proposals can be submitted at any time." @@ -24,10 +32,10 @@ "guide": { "title": "How it works", - "submit": "Individuals and teams submit proposals against requests from the Zcash Foundation, or of their own ideas", - "review": "Their proposal is reviewed by the Zcash Foundation, and potentially rewarded a bounty or met with matching contributions", - "community": "The proposal is then opened up to the community to discuss, provide feedback, and help it reach its funding goal", - "complete": "Once successfully funded, the proposal creator(s) post updates with their progress and are paid out as they reach project milestones" + "submit": "Individuals and teams submit proposals against requests from the community or the Zcash Foundation, or submit one of their own ideas", + "review": "The proposal is reviewed by the Zcash Foundation, after which the proposal may be accepted with or without funding. In cases where the proposal is accepted without funding, the community may donate directly when the team has set a tip address.", + "community": "The proposal is then opened up to the community to discuss, provide feedback, and optionally donate to the team", + "complete": "The proposal creator(s) post updates with their progress, and if having received a bounty, are paid out as they reach project milestones" }, "actions": { diff --git a/frontend/client/static/markdown/GUIDE.md b/frontend/client/static/markdown/GUIDE.md new file mode 100644 index 00000000..b5aaad7d --- /dev/null +++ b/frontend/client/static/markdown/GUIDE.md @@ -0,0 +1 @@ +# Guide \ No newline at end of file diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx index de10b389..76577b98 100644 --- a/frontend/client/store/reducers.tsx +++ b/frontend/client/store/reducers.tsx @@ -4,6 +4,7 @@ import proposal, { ProposalState, INITIAL_STATE as proposalInitialState, } from 'modules/proposals'; +import ccr, { CCRState, INITIAL_STATE as ccrInitialState } from 'modules/ccr'; import create, { CreateState, INITIAL_STATE as createInitialState } from 'modules/create'; import auth, { AuthState, INITIAL_STATE as authInitialState } from 'modules/auth'; import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users'; @@ -13,6 +14,7 @@ import history from './history'; export interface AppState { proposal: ProposalState; create: CreateState; + ccr: CCRState; users: UsersState; auth: AuthState; rfps: RFPState; @@ -25,9 +27,11 @@ export const combineInitialState: Omit = { users: usersInitialState, auth: authInitialState, rfps: rfpsInitialState, + ccr: ccrInitialState, }; export default combineReducers({ + ccr, proposal, create, users, diff --git a/frontend/client/store/sagas.ts b/frontend/client/store/sagas.ts index 27348b0f..60a6e28e 100644 --- a/frontend/client/store/sagas.ts +++ b/frontend/client/store/sagas.ts @@ -1,8 +1,10 @@ import { fork } from 'redux-saga/effects'; import { authSagas } from 'modules/auth'; import { createSagas } from 'modules/create'; +import { ccrSagas } from 'modules/ccr'; export default function* rootSaga() { yield fork(authSagas); yield fork(createSagas); + yield fork(ccrSagas); } diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 984d1ffa..5508986b 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -10,7 +10,7 @@ import { } from 'types'; import { UserState } from 'modules/users/reducers'; import { AppState } from 'store/reducers'; -import { toZat } from './units'; +import { toZat, toUsd } from './units'; export function formatUserForPost(user: User) { return { @@ -21,8 +21,14 @@ export function formatUserForPost(user: User) { export function formatUserFromGet(user: UserState) { const bnUserProp = (p: any) => { - p.funded = toZat(p.funded); - p.target = toZat(p.target); + if (p.isVersionTwo) { + p.funded = toUsd(p.funded); + p.target = toUsd(p.target); + } else { + p.funded = toZat(p.funded); + p.target = toZat(p.target); + } + return p; }; if (user.pendingProposals) { @@ -84,17 +90,40 @@ export function formatProposalPageFromGet(page: any): ProposalPage { export function formatProposalFromGet(p: any): Proposal { const proposal = { ...p } as Proposal; proposal.proposalUrlId = generateSlugUrl(proposal.proposalId, proposal.title); - proposal.target = toZat(p.target); - proposal.funded = toZat(p.funded); - proposal.contributionBounty = toZat(p.contributionBounty); - proposal.percentFunded = proposal.target.isZero() - ? 0 - : proposal.funded.div(proposal.target.divn(100)).toNumber(); + + if (proposal.isVersionTwo) { + proposal.target = toUsd(p.target); + proposal.funded = toUsd(p.funded); + + // not used in v2 proposals, but populated for completeness + proposal.contributionBounty = toUsd(p.contributionBounty); + proposal.percentFunded = 0; + } else { + proposal.target = toZat(p.target); + proposal.funded = toZat(p.funded); + proposal.contributionBounty = toZat(p.contributionBounty); + proposal.percentFunded = proposal.target.isZero() + ? 0 + : proposal.funded.div(proposal.target.divn(100)).toNumber(); + } + if (proposal.milestones) { - const msToFe = (m: any) => ({ - ...m, - amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), - }); + const msToFe = (m: any) => { + let amount; + + if (proposal.isVersionTwo) { + const target = parseFloat(proposal.target.toString()); + const payoutPercent = parseFloat(m.payoutPercent); + amount = ((target * payoutPercent) / 100).toFixed(2); + } else { + amount = proposal.target.mul(new BN(m.payoutPercent)).divn(100); + } + + return { + ...m, + amount, + }; + }; proposal.milestones = proposal.milestones.map(msToFe); proposal.currentMilestone = msToFe(proposal.currentMilestone); } @@ -107,7 +136,7 @@ export function formatProposalFromGet(p: any): Proposal { export function formatRFPFromGet(rfp: RFP): RFP { rfp.urlId = generateSlugUrl(rfp.id, rfp.title); if (rfp.bounty) { - rfp.bounty = toZat(rfp.bounty as any); + rfp.bounty = rfp.isVersionTwo ? toUsd(rfp.bounty as any) : toZat(rfp.bounty as any); } if (rfp.acceptedProposals) { rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet); @@ -139,42 +168,53 @@ export function extractIdFromSlug(slug: string) { export function massageSerializedState(state: AppState) { // proposal detail if (state.proposal.detail) { + const { isVersionTwo } = state.proposal.detail + const base = isVersionTwo ? 10 : 16; + state.proposal.detail.target = new BN( (state.proposal.detail.target as any) as string, - 16, + base, ); state.proposal.detail.funded = new BN( (state.proposal.detail.funded as any) as string, - 16, + base, ); state.proposal.detail.contributionBounty = new BN((state.proposal.detail .contributionBounty as any) as string); state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({ ...m, - amount: new BN((m.amount as any) as string, 16), + amount: isVersionTwo + ? m.amount + : new BN((m.amount as any) as string, 16), })); if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) { state.proposal.detail.rfp.bounty = new BN( (state.proposal.detail.rfp.bounty as any) as string, - 16, + base, ); } } // proposals - state.proposal.page.items = state.proposal.page.items.map(p => ({ - ...p, - target: new BN((p.target as any) as string, 16), - funded: new BN((p.funded as any) as string, 16), - contributionBounty: new BN((p.contributionMatching as any) as string, 16), - milestones: p.milestones.map(m => ({ - ...m, - amount: new BN((m.amount as any) as string, 16), - })), - })); + state.proposal.page.items = state.proposal.page.items.map(p => { + const base = p.isVersionTwo ? 10 : 16; + return { + ...p, + target: new BN((p.target as any) as string, base), + funded: new BN((p.funded as any) as string, base), + contributionBounty: new BN((p.contributionMatching as any) as string, base), + milestones: p.milestones.map(m => ({ + ...m, + amount: p.isVersionTwo + ? m.amount + : new BN((m.amount as any) as string, 16), + })), + }; + }); // users const bnUserProp = (p: UserProposal) => { - p.funded = new BN(p.funded, 16); - p.target = new BN(p.target, 16); + const base = p.isVersionTwo ? 10 : 16; + p.funded = new BN(p.funded, base); + p.target = new BN(p.target, base); return p; }; Object.values(state.users.map).forEach(user => { @@ -190,8 +230,9 @@ export function massageSerializedState(state: AppState) { }); // RFPs state.rfps.rfps = state.rfps.rfps.map(rfp => { + const base = rfp.isVersionTwo ? 10 : 16; if (rfp.bounty) { - rfp.bounty = new BN(rfp.bounty, 16); + rfp.bounty = new BN(rfp.bounty, base); } return rfp; }); diff --git a/frontend/client/utils/email.tsx b/frontend/client/utils/email.tsx index c31b26d4..e061df06 100644 --- a/frontend/client/utils/email.tsx +++ b/frontend/client/utils/email.tsx @@ -48,7 +48,7 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = { // MY PROPOSAL myProposalApproval: { - description: 'is approved or rejected', + description: 'is approved or has changes requested', category: EMAIL_SUBSCRIPTION_CATEGORY.PROPOSAL, value: false, }, @@ -89,6 +89,16 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = { category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN, value: false, }, + followedProposal: { + description: `proposals you're following`, + category: EMAIL_SUBSCRIPTION_CATEGORY.PROPOSAL, + value: false, + }, + adminApprovalCcr: { + description: 'CCR needs review', + category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN, + value: false, + }, }; export const EMAIL_SUBSCRIPTION_CATEGORIES: { diff --git a/frontend/client/utils/formatters.ts b/frontend/client/utils/formatters.ts index 87de277c..c825032b 100644 --- a/frontend/client/utils/formatters.ts +++ b/frontend/client/utils/formatters.ts @@ -92,3 +92,14 @@ export function formatTxExplorerUrl(txid: string) { } throw new Error('EXPLORER_URL env variable needs to be set!'); } + +export function formatUsd( + amount: number | string | undefined | null, + includeDollarSign: boolean = true, + digits: number = 0, +) { + if (!amount) return includeDollarSign ? '$0' : '0'; + const a = typeof amount === 'number' ? amount.toString() : amount; + const str = formatNumber(a, digits); + return includeDollarSign ? `$${str}` : str; +} diff --git a/frontend/client/utils/units.ts b/frontend/client/utils/units.ts index d0f384e0..0966df12 100644 --- a/frontend/client/utils/units.ts +++ b/frontend/client/utils/units.ts @@ -9,6 +9,7 @@ export const Units = { }; export type Zat = BN; +export type Usd = BN; export type UnitKey = keyof typeof Units; export const handleValues = (input: string | BN) => { @@ -61,4 +62,14 @@ export const toZat = (value: string | number): Zat => { return Zat(zat); }; +export const toUsd = (value: string | number): Usd => { + value = value.toString(); + const hasDecimal = value.indexOf('.') !== -1; + + // decimals aren't allowed for proposal targets, + // but remove decimal if it exists + value = hasDecimal ? value.split('.')[0] : value; + return new BN(value, 10); +}; + export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1; diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts index 0fa80bf4..9ed97bbe 100644 --- a/frontend/client/utils/validators.ts +++ b/frontend/client/utils/validators.ts @@ -1,3 +1,5 @@ +import { formatUsd } from './formatters'; + export function getAmountError(amount: number, max: number = Infinity, min?: number) { if (amount < 0) { return 'Amount must be a positive number'; @@ -15,6 +17,40 @@ export function getAmountError(amount: number, max: number = Infinity, min?: num return null; } +export function getAmountErrorUsd(amount: number, max: number = Infinity, min?: number) { + if (amount < 0) { + return 'Amount must be a positive number'; + } else if (!Number.isInteger(amount)) { + return 'Amount must be a whole number'; + } else if (amount > max) { + return `Cannot exceed maximum (${formatUsd(max)})`; + } else if (min && amount < min) { + return `Must be at least ${formatUsd(min)}`; + } + + return null; +} + + +// Covers the edge case where whole decimals (eg. 100.00) is valid in getAmountErrorUsd +export function getAmountErrorUsdFromString(amount: string) { + return amount.indexOf('.') !== -1 + ? 'Amount must be a whole number' + : null +} + +export function getAmountErrorFromString(amount: string, max?: number, min?: number) { + const parsedAmount = parseFloat(amount); + if (Number.isNaN(parsedAmount)) { + return 'Not a valid number'; + } + // prevents "-0" from being valid... + if (amount[0] === '-') { + return 'Amount must be a positive number'; + } + return getAmountError(parsedAmount, max, min); +} + export function isValidEmail(email: string): boolean { return /\S+@\S+\.\S+/.test(email); } @@ -42,5 +78,5 @@ export function isValidSaplingAddress(address: string): boolean { } export function isValidAddress(a: string): boolean { - return isValidTAddress(a) || isValidSproutAddress(a) || isValidSaplingAddress(a); + return isValidSproutAddress(a) || isValidSaplingAddress(a); } diff --git a/frontend/config/webpack.config.js/loaders.js b/frontend/config/webpack.config.js/loaders.js index 464fd680..7aa29fe7 100644 --- a/frontend/config/webpack.config.js/loaders.js +++ b/frontend/config/webpack.config.js/loaders.js @@ -61,12 +61,16 @@ const cssLoaderClient = { test: /\.css$/, exclude: [/node_modules/], use: [ - isDev && 'style-loader', - !isDev && MiniCssExtractPlugin.loader, + { + loader: MiniCssExtractPlugin.loader, + options: { + hmr: isDev, + }, + }, { loader: 'css-loader', }, - ].filter(Boolean), + ] }; const lessLoaderClient = { @@ -188,21 +192,19 @@ const externalCssLoaderClient = { test: /\.css$/, include: [/node_modules/], use: [ - isDev && 'style-loader', - !isDev && MiniCssExtractPlugin.loader, + MiniCssExtractPlugin.loader, 'css-loader', - ].filter(Boolean), + ] }; const externalLessLoaderClient = { test: /\.less$/, include: [/node_modules/], use: [ - isDev && 'style-loader', - !isDev && MiniCssExtractPlugin.loader, + MiniCssExtractPlugin.loader, 'css-loader', lessLoader, - ].filter(Boolean), + ] }; // Server build needs a loader to handle external .css files diff --git a/frontend/package.json b/frontend/package.json index 5021c581..3242b2f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,12 +117,13 @@ "less": "3.9.0", "less-loader": "^4.1.0", "lint-staged": "^7.2.2", + "local-storage": "^2.0.0", "lodash": "^4.17.15", "lodash-es": "^4.17.15", "lodash.defaultsdeep": "^4.6.1", "lodash.mergewith": "^4.6.2", "markdown-loader": "^4.0.0", - "mini-css-extract-plugin": "^0.4.2", + "mini-css-extract-plugin": "^0.8.0", "moment": "^2.22.2", "node-sass": "^4.9.2", "nodemon": "^1.18.4", diff --git a/frontend/server/components/HTML.tsx b/frontend/server/components/HTML.tsx index ede71704..c6ecd701 100644 --- a/frontend/server/components/HTML.tsx +++ b/frontend/server/components/HTML.tsx @@ -22,6 +22,22 @@ const HTML: React.SFC = ({ extractor, }) => { const head = Helmet.renderStatic(); + const extractedStyleElements = extractor.getStyleElements(); + + // Move `bundle.css` to beginning of array so custom styles don't get overwritten + const bundleIndex = extractedStyleElements.findIndex(element => { + const devBundle = /^.*\/bundle\.css$/; + const prodBundle = /^.*\/bundle\..*\.css$/; + return ( + typeof element.key === 'string' && + (devBundle.test(element.key) || prodBundle.test(element.key)) + ); + }); + if (bundleIndex !== -1) { + const [bundle] = extractedStyleElements.splice(bundleIndex, 1); + extractedStyleElements.unshift(bundle); + } + return ( @@ -53,7 +69,8 @@ const HTML: React.SFC = ({ {css.map(href => { return ; })} - {extractor.getStyleElements()} + + {extractedStyleElements}