diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index b0dafdb7..80752ea9 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -62,6 +62,11 @@ export default [ title: 'Proposal failed', description: 'Sent to the proposal team when the deadline is reached and it didn’t get fully funded', }, + { + id: 'proposal_canceled', + title: 'Proposal canceled', + description: 'Sent to the proposal team when an admin cancels the proposal after funding', + }, { id: 'contribution_confirmed', title: 'Contribution confirmed', @@ -82,6 +87,11 @@ export default [ title: 'Contribution proposal failed', description: 'Sent to contributors when the deadline is reached and the proposal didn’t get fully funded', }, + { + id: 'contribution_proposal_canceled', + title: 'Contribution proposal canceled', + description: 'Sent to contributors when an admin cancels the proposal after funding', + }, { id: 'comment_reply', title: 'Comment reply', diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 3a0b525f..421b4b8c 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -61,6 +61,10 @@ class ProposalDetailNaked extends React.Component { PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status && p.status === PROPOSAL_STATUS.LIVE && !p.isFailed; + const refundablePct = p.milestones.reduce((prev, m) => { + return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev; + }, 100); + const renderDeleteControl = () => ( { ); + const renderCancelControl = () => ( + + Are you sure you want to cancel proposal and begin +
+ the refund process? This cannot be undone. +

+ )} + placement="left" + cancelText="cancel" + okText="confirm" + okButtonProps={{ loading: store.proposalDetailCanceling }} + onConfirm={this.handleCancel} + > + +
+ ); + const renderArbiterControl = () => ( { ); const renderMilestoneAccepted = () => { + if (p.stage === PROPOSAL_STAGE.FAILED || p.stage === PROPOSAL_STAGE.CANCELED) { + return; + } if ( !( p.status === PROPOSAL_STATUS.LIVE && @@ -319,12 +357,21 @@ class ProposalDetailNaked extends React.Component { - This proposal failed to reach its funding goal of {p.target} ZEC by{' '} - {formatDateSeconds(p.datePublished + p.deadlineDuration)}. - + p.stage === PROPOSAL_STAGE.FAILED ? ( + <> + This proposal failed to reach its funding goal of {p.target} ZEC by{' '} + {formatDateSeconds(p.datePublished + p.deadlineDuration)}. All contributors + will need to be refunded. + + ) : ( + <> + This proposal was canceled by an admin, and will be refunding contributors + {' '}{refundablePct}% of their contributions. + + ) + } /> ); @@ -371,6 +418,7 @@ class ProposalDetailNaked extends React.Component { {/* ACTIONS */} {renderDeleteControl()} + {renderCancelControl()} {renderArbiterControl()} {renderMatchingControl()} {/* TODO - other actions */} @@ -445,6 +493,11 @@ class ProposalDetailNaked extends React.Component { store.deleteProposal(store.proposalDetail.proposalId); }; + private handleCancel = () => { + if (!store.proposalDetail) return; + store.cancelProposal(store.proposalDetail.proposalId); + }; + private handleApprove = () => { store.approveProposal(true); }; diff --git a/admin/src/components/Proposals/ProposalItem.less b/admin/src/components/Proposals/ProposalItem.less index e7d778bc..ff1052e4 100644 --- a/admin/src/components/Proposals/ProposalItem.less +++ b/admin/src/components/Proposals/ProposalItem.less @@ -5,8 +5,7 @@ & .ant-tag { vertical-align: text-top; - margin-top: 0.2rem; - margin-left: 0.5rem; + margin: 0.2rem 0 0 0.5rem; } } diff --git a/admin/src/components/Proposals/ProposalItem.tsx b/admin/src/components/Proposals/ProposalItem.tsx index 90ea8daa..7c7afa7f 100644 --- a/admin/src/components/Proposals/ProposalItem.tsx +++ b/admin/src/components/Proposals/ProposalItem.tsx @@ -3,8 +3,8 @@ import { view } from 'react-easy-state'; import { Popconfirm, Tag, Tooltip, List } from 'antd'; import { Link } from 'react-router-dom'; import store from 'src/store'; -import { Proposal } from 'src/types'; -import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses'; +import { Proposal, PROPOSAL_STATUS } from 'src/types'; +import { PROPOSAL_STATUSES, PROPOSAL_STAGES, getStatusById } from 'util/statuses'; import { formatDateSeconds } from 'util/time'; import './ProposalItem.less'; @@ -15,6 +15,7 @@ class ProposalItemNaked extends React.Component { render() { const p = this.props; const status = getStatusById(PROPOSAL_STATUSES, p.status); + const stage = getStatusById(PROPOSAL_STAGES, p.stage); const actions = [ { {status.tagDisplay} + {p.status === PROPOSAL_STATUS.LIVE && + + {stage.tagDisplay} + + }

Created: {formatDateSeconds(p.dateCreated)}

{p.brief}

diff --git a/admin/src/store.ts b/admin/src/store.ts index 83ea4a45..893e95a8 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -132,6 +132,11 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st return data; } +async function cancelProposal(id: number) { + const { data } = await api.put(`/admin/proposals/${id}/cancel`); + return data; +} + async function fetchComments(params: Partial) { const { data } = await api.get('/admin/comments', { params }); return data; @@ -243,6 +248,7 @@ const app = store({ proposalDetailFetching: false, proposalDetailApproving: false, proposalDetailMarkingMilestonePaid: false, + proposalDetailCanceling: false, comments: { page: createDefaultPageData('CREATED:DESC'), @@ -500,6 +506,17 @@ const app = store({ app.proposalDetailApproving = false; }, + async cancelProposal(id: number) { + app.proposalDetailCanceling = true; + try { + const res = await cancelProposal(id); + app.updateProposalInStore(res); + } catch (e) { + handleApiError(e); + } + app.proposalDetailCanceling = 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 1a481d11..eae184c1 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -85,6 +85,8 @@ export enum PROPOSAL_STAGE { FUNDING_REQUIRED = 'FUNDING_REQUIRED', WIP = 'WIP', COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + CANCELED = 'CANCELED', } export interface Proposal { proposalId: number; diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index a470c8b6..ef96a633 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -118,6 +118,18 @@ export const PROPOSAL_STAGES: Array> = [ tagColor: '#108ee9', hint: 'Proposal was accepted, published, funded and all funds paid out.', }, + { + id: PROPOSAL_STAGE.FAILED, + tagDisplay: 'Failed', + tagColor: '#eb4118', + hint: 'Proposal failed to meet target and is currently refunding all contributors.', + }, + { + id: PROPOSAL_STAGE.CANCELED, + tagDisplay: 'Canceled', + tagColor: '#eb4118', + hint: 'Proposal was canceled by an admin and is currently refunding all contributors.', + }, ]; export const PROPOSAL_ARBITER_STATUSES: Array> = [ diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index 2dcebcd1..5d08bf94 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -93,6 +93,10 @@ example_email_args = { 'proposal_failed': { 'proposal': proposal, }, + 'proposal_canceled': { + 'proposal': proposal, + 'support_url': 'http://linktosupport.com', + }, 'contribution_confirmed': { 'proposal': proposal, 'contribution': contribution, @@ -114,6 +118,12 @@ example_email_args = { 'refund_address': 'ztqdzvnK2SE27FCWg69EdissCBn7twnfd1XWLrftiZaT4rSFCkp7eQGQDSWXBF43sM5cyA4c8qyVjBP9Cf4zTcFJxf71ve8', 'account_settings_url': 'http://accountsettingsurl.com/', }, + 'contribution_proposal_canceled': { + 'proposal': proposal, + 'contribution': contribution, + 'refund_address': 'ztqdzvnK2SE27FCWg69EdissCBn7twnfd1XWLrftiZaT4rSFCkp7eQGQDSWXBF43sM5cyA4c8qyVjBP9Cf4zTcFJxf71ve8', + 'account_settings_url': 'http://accountsettingsurl.com/', + }, 'comment_reply': { 'author': user, 'proposal': proposal, diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 3dce4316..0b2b2665 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -159,8 +159,12 @@ def stats(): contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \ .filter(ProposalContribution.refund_tx_id == None) \ .filter(ProposalContribution.staking == False) \ + .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .join(Proposal) \ - .filter(Proposal.stage == ProposalStage.REFUNDING) \ + .filter(or_( + Proposal.stage == ProposalStage.FAILED, + Proposal.stage == ProposalStage.CANCELED, + )) \ .join(ProposalContribution.user) \ .join(UserSettings) \ .filter(UserSettings.refund_address != None) \ @@ -372,14 +376,16 @@ def delete_proposal(id): @admin.admin_auth_required def update_proposal(id, contribution_matching): proposal = Proposal.query.filter(Proposal.id == id).first() - if proposal: - if contribution_matching is not None: - proposal.set_contribution_matching(contribution_matching) + if not proposal: + return {"message": f"Could not find proposal with id {id}"}, 404 - db.session.commit() - return proposal_schema.dump(proposal) + if contribution_matching is not None: + proposal.set_contribution_matching(contribution_matching) - return {"message": f"Could not find proposal with id {id}"}, 404 + db.session.add(proposal) + db.session.commit() + + return proposal_schema.dump(proposal) @blueprint.route('/proposals//approve', methods=['PUT']) @@ -398,6 +404,20 @@ def approve_proposal(id, is_approve, reject_reason=None): return {"message": "No proposal found."}, 404 +@blueprint.route('/proposals//cancel', methods=['PUT']) +@endpoint.api() +@admin.admin_auth_required +def cancel_proposal(id): + proposal = Proposal.query.filter_by(id=id).first() + if not proposal: + return {"message": "No proposal found."}, 404 + + proposal.cancel() + db.session.add(proposal) + db.session.commit() + return proposal_schema.dump(proposal) + + @blueprint.route("/proposals//milestone//paid", methods=["PUT"]) @endpoint.api( parameter('txId', type=str, required=True), @@ -623,8 +643,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_ return {"message": "No contribution matching that id"}, 404 had_refund = contribution.refund_tx_id - # do not allow editing contributions once a proposal has become funded - if contribution.proposal.is_funded: + # do not allow editing certain fields on contributions once a proposal has become funded + if (proposal_id or user_id or status or amount or tx_id) and contribution.proposal.is_funded: return {"message": "Cannot edit contributions to fully-funded proposals"}, 400 # Proposal ID (must belong to an existing proposal) diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 49458939..6022a23d 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -117,6 +117,16 @@ def proposal_failed(email_args): } +def proposal_canceled(email_args): + return { + 'subject': 'Your proposal has been canceled', + 'title': 'Proposal canceled', + 'preview': 'Your proposal entitled {} has been canceled, and your contributors will be refunded'.format( + email_args['proposal'].title, + ), + } + + def staking_contribution_confirmed(email_args): subject = 'Your proposal has been staked!' if \ email_args['fully_staked'] else \ @@ -178,6 +188,17 @@ def contribution_proposal_failed(email_args): } +def contribution_proposal_canceled(email_args): + return { + 'subject': 'A proposal you contributed to has been canceled', + 'title': 'Proposal canceled', + 'preview': 'The proposal entitled {} has been canceled, here’s how to get a refund'.format( + email_args['proposal'].title, + ), + 'subscription': EmailSubscription.FUNDED_PROPOSAL_FUNDED, + } + + def comment_reply(email_args): return { 'subject': 'New reply from {}'.format(email_args['author'].display_name), @@ -254,11 +275,13 @@ get_info_lookup = { 'proposal_contribution': proposal_contribution, 'proposal_comment': proposal_comment, 'proposal_failed': proposal_failed, + 'proposal_canceled': proposal_canceled, 'staking_contribution_confirmed': staking_contribution_confirmed, 'contribution_confirmed': contribution_confirmed, 'contribution_update': contribution_update, 'contribution_refunded': contribution_refunded, 'contribution_proposal_failed': contribution_proposal_failed, + 'contribution_proposal_canceled': contribution_proposal_canceled, 'comment_reply': comment_reply, 'proposal_arbiter': proposal_arbiter, 'milestone_request': milestone_request, diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 7e025e35..3fccf7cb 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -438,7 +438,7 @@ class Proposal(db.Model): self.set_funded_when_ready() def set_funded_when_ready(self): - if self.status == ProposalStatus.LIVE and self.is_funded: + if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded: self.set_funded() # state: stage FUNDING_REQUIRED -> WIP @@ -468,6 +468,28 @@ class Proposal(db.Model): else: raise ValidationException("Bad value for contribution_matching, must be 1 or 0") + def cancel(self): + print(self.status) + if self.status != ProposalStatus.LIVE: + raise ValidationException("Cannot cancel a proposal until it's live") + + self.stage = ProposalStage.CANCELED + db.session.add(self) + db.session.flush() + # Send emails to team & contributors + for u in self.team: + send_email(u.email_address, 'proposal_canceled', { + 'proposal': self, + 'support_url': make_url('/contact'), + }) + for c in self.contributions: + send_email(c.user.email_address, 'contribution_proposal_canceled', { + 'contribution': c, + 'proposal': self, + 'refund_address': c.user.settings.refund_address, + 'account_settings_url': make_url('/profile/settings?tab=account') + }) + @hybrid_property def contributed(self): contributions = ProposalContribution.query \ @@ -507,6 +529,8 @@ class Proposal(db.Model): def is_failed(self): if not self.status == ProposalStatus.LIVE or not self.date_published: return False + if self.stage == ProposalStage.FAILED or self.stage == ProposalStage.CANCELED: + return True deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration) passed = deadline < datetime.datetime.now() return passed and not self.is_funded diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 6d632dce..308be669 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -166,9 +166,12 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id): ) def get_proposals(page, filters, search, sort): filters_workaround = request.args.getlist('filters[]') + query = Proposal.query.filter_by(status=ProposalStatus.LIVE) \ + .filter(Proposal.stage != ProposalStage.CANCELED) \ + .filter(Proposal.stage != ProposalStage.FAILED) page = pagination.proposal( schema=proposals_schema, - query=Proposal.query.filter_by(status=ProposalStatus.LIVE), + query=query, page=page, filters=filters_workaround, search=search, diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index 4f763ee9..7a5108f4 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -67,8 +67,8 @@ class ProposalDeadline: if not proposal or proposal.is_funded: return - # Otherwise, mark it as refunding and inform everyone - proposal.stage = ProposalStage.REFUNDING + # Otherwise, mark it as failed and inform everyone + proposal.stage = ProposalStage.FAILED db.session.add(proposal) db.session.commit() diff --git a/backend/grant/templates/emails/contribution_proposal_canceled.html b/backend/grant/templates/emails/contribution_proposal_canceled.html new file mode 100644 index 00000000..dd08ac71 --- /dev/null +++ b/backend/grant/templates/emails/contribution_proposal_canceled.html @@ -0,0 +1,30 @@ +

+ We're sorry to inform you that a proposal you contributed to, + {{ args.proposal.title }}, has been canceled. + Contributors will be refunded the remaining amount of money that + hadn't already been paid out to the team. +

+ +{% if args.refund_address %} +

+ It looks like you've configured refunds to be sent to: +

+

+ {{ args.refund_address }} +

+

+ If you would like to change it, you + can configure your refund address on your + account settings page. +

+{% else %} +

+ You'll need to configure a refund address to receive your refund. You can + do that from the account settings page. + You should expect to receive the refund within a few days of setting an address. +

+{% endif %} + +

+ We hope you continue to contribute to improving the Zcash ecosystem! +

diff --git a/backend/grant/templates/emails/contribution_proposal_canceled.txt b/backend/grant/templates/emails/contribution_proposal_canceled.txt new file mode 100644 index 00000000..7e35a8a6 --- /dev/null +++ b/backend/grant/templates/emails/contribution_proposal_canceled.txt @@ -0,0 +1,22 @@ +We're sorry to inform you that a proposal you contributed to, "{{ args.proposal.title }}", +has been canceled. Contributors will be refunded the remaining amount of money that +hadn't already been paid out to the team. + +{% if args.refund_address %} +It looks like you've configured refunds to be sent to: + +{{ args.refund_address }} + +If you would like to change it, you can configure your refund address on your account settings page: + +{{ args.account_settings_url }} +{% else %} +You'll need to configure a refund address to receive your refund. You can do +that from the account settings page: + +{{ args.account_settings_url }} + +You should expect to receive the refund within a few days of setting an address. +{% endif %} + +We hope you continue to contribute to improving the Zcash ecosystem! \ No newline at end of file diff --git a/backend/grant/templates/emails/contribution_proposal_failed.html b/backend/grant/templates/emails/contribution_proposal_failed.html index aef7a2f0..fa2cd816 100644 --- a/backend/grant/templates/emails/contribution_proposal_failed.html +++ b/backend/grant/templates/emails/contribution_proposal_failed.html @@ -25,6 +25,5 @@ {% endif %}

- We hope you continue to try to improve the Zcash ecosystem, and that your next - proposal gets fully funded! + We hope you continue to contribute to improving the Zcash ecosystem!

diff --git a/backend/grant/templates/emails/contribution_proposal_failed.txt b/backend/grant/templates/emails/contribution_proposal_failed.txt index 6da9d975..751abdf3 100644 --- a/backend/grant/templates/emails/contribution_proposal_failed.txt +++ b/backend/grant/templates/emails/contribution_proposal_failed.txt @@ -19,5 +19,4 @@ that from the account settings page: You should expect to receive the refund within a few days of setting an address. {% endif %} -We hope you continue to try to improve the Zcash ecosystem, and that your next -proposal gets fully funded! +We hope you continue to contribute to improving the Zcash ecosystem! diff --git a/backend/grant/templates/emails/proposal_canceled.html b/backend/grant/templates/emails/proposal_canceled.html new file mode 100644 index 00000000..18d7fe7e --- /dev/null +++ b/backend/grant/templates/emails/proposal_canceled.html @@ -0,0 +1,12 @@ +

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

+ +

+ If you have any further questions, please + contact support + for more information. +

+ \ No newline at end of file diff --git a/backend/grant/templates/emails/proposal_canceled.txt b/backend/grant/templates/emails/proposal_canceled.txt new file mode 100644 index 00000000..30fe36be --- /dev/null +++ b/backend/grant/templates/emails/proposal_canceled.txt @@ -0,0 +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. + +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/utils/enums.py b/backend/grant/utils/enums.py index d938dc09..aad33e24 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -37,7 +37,8 @@ class ProposalStageEnum(CustomEnum): FUNDING_REQUIRED = 'FUNDING_REQUIRED' WIP = 'WIP' COMPLETED = 'COMPLETED' - REFUNDING = 'REFUNDING' + FAILED = 'FAILED' + CANCELED = 'CANCELED' ProposalStage = ProposalStageEnum() diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 18f0397d..fd4e23a9 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -153,8 +153,12 @@ class ContributionPagination(Pagination): if 'REFUNDABLE' in filters: query = query.filter(ProposalContribution.refund_tx_id == None) \ .filter(ProposalContribution.staking == False) \ + .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .join(Proposal) \ - .filter(Proposal.stage == ProposalStage.REFUNDING) \ + .filter(or_( + Proposal.stage == ProposalStage.FAILED, + Proposal.stage == ProposalStage.CANCELED, + )) \ .join(ProposalContribution.user) \ .join(UserSettings) \ .filter(UserSettings.refund_address != None) \ diff --git a/backend/migrations/versions/7c7cecfe5e6c_.py b/backend/migrations/versions/7c7cecfe5e6c_.py new file mode 100644 index 00000000..33b9aebe --- /dev/null +++ b/backend/migrations/versions/7c7cecfe5e6c_.py @@ -0,0 +1,26 @@ +"""Convert REFUNDING stage to FAILED + +Revision ID: 7c7cecfe5e6c +Revises: 9ad68ecf85aa +Create Date: 2019-02-22 13:15:44.997884 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7c7cecfe5e6c' +down_revision = '9ad68ecf85aa' +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + connection.execute("UPDATE proposal SET stage = 'FAILED' WHERE stage = 'REFUNDING'") + + +def downgrade(): + connection = op.get_bind() + connection.execute("UPDATE proposal SET stage = 'REFUNDING' WHERE stage = 'FAILED'") \ No newline at end of file diff --git a/blockchain/src/server/index.ts b/blockchain/src/server/index.ts index 568d501b..83960f10 100644 --- a/blockchain/src/server/index.ts +++ b/blockchain/src/server/index.ts @@ -19,10 +19,11 @@ import log from '../log'; // Configure server const app = express(); +const limit = '50mb'; app.set('port', env.PORT); app.use(cors()); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ limit })); +app.use(bodyParser.urlencoded({ extended: true, limit })); app.use(authMiddleware); // Routes diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts index 49226af4..cc1a22ae 100644 --- a/frontend/client/api/constants.ts +++ b/frontend/client/api/constants.ts @@ -55,6 +55,8 @@ export enum PROPOSAL_STAGE { FUNDING_REQUIRED = 'FUNDING_REQUIRED', WIP = 'WIP', COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + CANCELED = 'CANCELED', } interface StageUI { @@ -79,6 +81,15 @@ export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = { label: 'Completed', color: '#27ae60', }, + // Never used + FAILED: { + label: 'Failed', + color: '#000', + }, + CANCELED: { + label: 'Canceled', + color: '#000', + }, }; export enum RFP_STATUS { diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index ef2f65fd..e6d053ab 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -12,7 +12,7 @@ import UnitDisplay from 'components/UnitDisplay'; import ContributionModal from 'components/ContributionModal'; import Loader from 'components/Loader'; import { getAmountError } from 'utils/validators'; -import { CATEGORY_UI } from 'api/constants'; +import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants'; import './style.less'; interface OwnProps { @@ -52,9 +52,9 @@ export class ProposalCampaignBlock extends React.Component { const datePublished = proposal.datePublished || Date.now() / 1000; const isRaiseGoalReached = funded.gte(target); const deadline = (datePublished + proposal.deadlineDuration) * 1000; - // TODO: Get values from proposal - console.warn('TODO: Get isFrozen from proposal data'); - const isFrozen = false; + 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; @@ -137,7 +137,12 @@ export class ProposalCampaignBlock extends React.Component { ['is-success']: isRaiseGoalReached, })} > - {isRaiseGoalReached ? ( + {proposal.stage === PROPOSAL_STAGE.CANCELED ? ( + <> + + Proposal was canceled + + ) : isRaiseGoalReached ? ( <> Proposal has been funded diff --git a/frontend/client/components/Proposal/CancelModal.tsx b/frontend/client/components/Proposal/CancelModal.tsx index d40e9740..c0bad93f 100644 --- a/frontend/client/components/Proposal/CancelModal.tsx +++ b/frontend/client/components/Proposal/CancelModal.tsx @@ -1,85 +1,42 @@ import React from 'react'; -import { connect } from 'react-redux'; -import { Modal, Alert } from 'antd'; +import { Modal } from 'antd'; +import { Link } from 'react-router-dom'; import { Proposal } from 'types'; -import { AppState } from 'store/reducers'; -interface OwnProps { +interface Props { proposal: Proposal; isVisible: boolean; handleClose(): void; } -interface StateProps { - isRefundActionPending: boolean; - refundActionError: string; -} - -type Props = StateProps & OwnProps; - -class CancelModal extends React.Component { - componentDidUpdate() { - // TODO: Close on success of action - } - +export default class CancelModal extends React.Component { render() { - const { isVisible, isRefundActionPending, refundActionError } = this.props; - const hasContributors = false; // TODO: Determine if it has contributors from proposal - const disabled = isRefundActionPending; + const { isVisible, handleClose } = this.props; return ( Cancel proposal} visible={isVisible} - okText="Confirm" - cancelText="Never mind" - onOk={this.cancelProposal} - onCancel={this.closeModal} - okButtonProps={{ type: 'danger', loading: disabled }} - cancelButtonProps={{ disabled }} + okText="OK" + cancelText="Cancel" + onOk={handleClose} + onCancel={handleClose} >

- Are you sure you would like to cancel this proposal?{' '} - This cannot be undone. + Are you sure you would like to cancel this proposal, 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.

- {hasContributors && ( -

- Should you choose to cancel, we highly recommend posting an update to let your - contributors know why you’ve decided to do so. -

- )} - {refundActionError && ( - - )} +

+ If you're sure you'd like to cancel, please{' '} + contact support to let us know. Canceling can only be + done by site admins. +

); } - - private closeModal = () => { - if (!this.props.isRefundActionPending) { - this.props.handleClose(); - } - }; - - private cancelProposal = () => { - console.warn('TODO - implement cancelProposal'); - }; } - -export default connect(state => { - console.warn('TODO - redux isRefundActionPending/refundActionError?', state); - return { - isRefundActionPending: false, - refundActionError: '', - }; -})(CancelModal); diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 292aca90..2fc836da 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -98,13 +98,7 @@ export class ProposalDetail extends React.Component { return ; } - const deadline = 0; // TODO: Use actual date for deadline - // TODO: isTrustee - determine rework to isAdmin? - // for now: check if authed user in member of proposal team const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid)); - const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding - const isProposalActive = !hasBeenFunded && deadline > Date.now(); - const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet const isLive = proposal.status === STATUS.LIVE; const adminMenu = ( @@ -112,17 +106,7 @@ export class ProposalDetail extends React.Component { Post an Update - alert('Sorry, not yet implemented!')} - disabled={!isProposalActive} - > - Edit proposal - - + Cancel proposal diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx index aa3689ea..26933ef5 100644 --- a/frontend/client/components/Proposals/Filters/index.tsx +++ b/frontend/client/components/Proposals/Filters/index.tsx @@ -66,7 +66,14 @@ export default class ProposalFilters extends React.Component { {typedKeys(PROPOSAL_STAGE) - .filter(s => s !== PROPOSAL_STAGE.PREVIEW) // skip this one + .filter( + s => + ![ + PROPOSAL_STAGE.PREVIEW, + PROPOSAL_STAGE.FAILED, + PROPOSAL_STAGE.CANCELED, + ].includes(s as PROPOSAL_STAGE), + ) // skip a few .map(s => (