Cancel proposal (#253)

* Backend setup for proposal canceling.

* Cancelable in admin, update all states to properly reflect a canceled proposal.

* Replace REFUNDING stage with CANCELED and FAILED to distinguish between the 2

* Fix pending contributions showing up as needing refunds.

* Fix some refund cases.

* Dont show failed & canceled proposals on list view. Hide their filters.

* Show when proposal was canceled.

* Remove edit action, make cancel an explanation to contact support.

* Fix PR comments

* Fix issues from develop merge.
This commit is contained in:
William O'Beirne 2019-02-23 16:38:06 -05:00 committed by GitHub
parent 4c026f5645
commit 8bf7013b0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 355 additions and 112 deletions

View File

@ -62,6 +62,11 @@ export default [
title: 'Proposal failed',
description: 'Sent to the proposal team when the deadline is reached and it didnt 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 didnt 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',

View File

@ -61,6 +61,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
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 = () => (
<Popconfirm
@ -75,6 +79,37 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Popconfirm>
);
const renderCancelControl = () => (
<Popconfirm
title={(
<p>
Are you sure you want to cancel proposal and begin
<br />
the refund process? This cannot be undone.
</p>
)}
placement="left"
cancelText="cancel"
okText="confirm"
okButtonProps={{ loading: store.proposalDetailCanceling }}
onConfirm={this.handleCancel}
>
<Button
icon="close-circle"
className="ProposalDetail-controls-control"
loading={store.proposalDetailCanceling}
disabled={
p.status !== PROPOSAL_STATUS.LIVE ||
p.stage === PROPOSAL_STAGE.FAILED ||
p.stage === PROPOSAL_STAGE.CANCELED
}
block
>
Cancel & refund
</Button>
</Popconfirm>
);
const renderArbiterControl = () => (
<ArbiterControl
{...p}
@ -267,6 +302,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
);
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<Props, State> {
<Alert
showIcon
type="error"
message="Funding failed"
message={p.stage === PROPOSAL_STAGE.FAILED ? 'Proposal failed' : 'Proposal canceled'}
description={
<>
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>.
</>
p.stage === PROPOSAL_STAGE.FAILED ? (
<>
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>. All contributors
will need to be refunded.
</>
) : (
<>
This proposal was canceled by an admin, and will be refunding contributors
{' '}<b>{refundablePct}%</b> of their contributions.
</>
)
}
/>
);
@ -371,6 +418,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{/* ACTIONS */}
<Card size="small" className="ProposalDetail-controls">
{renderDeleteControl()}
{renderCancelControl()}
{renderArbiterControl()}
{renderMatchingControl()}
{/* TODO - other actions */}
@ -445,6 +493,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
store.deleteProposal(store.proposalDetail.proposalId);
};
private handleCancel = () => {
if (!store.proposalDetail) return;
store.cancelProposal(store.proposalDetail.proposalId);
};
private handleApprove = () => {
store.approveProposal(true);
};

View File

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

View File

@ -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<Proposal> {
render() {
const p = this.props;
const status = getStatusById(PROPOSAL_STATUSES, p.status);
const stage = getStatusById(PROPOSAL_STAGES, p.stage);
const actions = [
<Popconfirm
key="delete"
@ -36,6 +37,11 @@ class ProposalItemNaked extends React.Component<Proposal> {
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
{p.status === PROPOSAL_STATUS.LIVE &&
<Tooltip title={stage.hint}>
<Tag color={stage.tagColor}>{stage.tagDisplay}</Tag>
</Tooltip>
}
</h2>
<p>Created: {formatDateSeconds(p.dateCreated)}</p>
<p>{p.brief}</p>

View File

@ -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<PageQuery>) {
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<Comment>('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 {

View File

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

View File

@ -118,6 +118,18 @@ export const PROPOSAL_STAGES: Array<StatusSoT<PROPOSAL_STAGE>> = [
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<StatusSoT<PROPOSAL_ARBITER_STATUS>> = [

View File

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

View File

@ -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/<id>/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/<id>/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/<id>/milestone/<mid>/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)

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
<p style="margin: 0 0 20px;">
We're sorry to inform you that a proposal you contributed to,
<strong>{{ args.proposal.title }}</strong>, has been canceled.
Contributors will be refunded the remaining amount of money that
hadn't already been paid out to the team.
</p>
{% if args.refund_address %}
<p style="margin: 0 0 20px;">
It looks like you've configured refunds to be sent to:
</p>
<p style="margin: 0 0 20px">
<code style="word-break: break-all; margin: 10px 0;">{{ args.refund_address }}</code>
</p>
<p style="margin: 0 0 20px;">
If you would like to change it, you
can configure your refund address on your
<a href="{{ args.account_settings_url }}">account settings page</a>.
</p>
{% else %}
<p style="margin: 0 0 20px;">
You'll need to configure a refund address to receive your refund. You can
do that from the <a href="{{ args.account_settings_url }}">account settings page</a>.
You should expect to receive the refund within a few days of setting an address.
</p>
{% endif %}
<p style="margin: 0;">
We hope you continue to contribute to improving the Zcash ecosystem!
</p>

View File

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

View File

@ -25,6 +25,5 @@
{% endif %}
<p style="margin: 0;">
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!
</p>

View File

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

View File

@ -0,0 +1,12 @@
<p style="margin: 0 0 20px;">
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
has been canceled. We've let your contributors know, and they should be expecting refunds
shortly.
</p>
<p style="margin: 0;">
If you have any further questions, please
<a href="{{ args.support_url }}">contact support</a>
for more information.
</p>

View File

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

View File

@ -37,7 +37,8 @@ class ProposalStageEnum(CustomEnum):
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
WIP = 'WIP'
COMPLETED = 'COMPLETED'
REFUNDING = 'REFUNDING'
FAILED = 'FAILED'
CANCELED = 'CANCELED'
ProposalStage = ProposalStageEnum()

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Props, State> {
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<Props, State> {
['is-success']: isRaiseGoalReached,
})}
>
{isRaiseGoalReached ? (
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
<>
<Icon type="close-circle-o" />
<span>Proposal was canceled</span>
</>
) : isRaiseGoalReached ? (
<>
<Icon type="check-circle-o" />
<span>Proposal has been funded</span>

View File

@ -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<Props> {
componentDidUpdate() {
// TODO: Close on success of action
}
export default class CancelModal extends React.Component<Props> {
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 (
<Modal
title={<>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}
>
<p>
Are you sure you would like to cancel this proposal?{' '}
<strong>This cannot be undone</strong>.
Are you sure you would like to cancel this proposal, and refund any
contributors? <strong>This cannot be undone</strong>.
</p>
<p>
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.
</p>
{hasContributors && (
<p>
Should you choose to cancel, we highly recommend posting an update to let your
contributors know why youve decided to do so.
</p>
)}
{refundActionError && (
<Alert
type="error"
message="Failed to cancel proposal"
description={refundActionError}
showIcon
/>
)}
<p>
If you're sure you'd like to cancel, please{' '}
<Link to="/contact">contact support</Link> to let us know. Canceling can only be
done by site admins.
</p>
</Modal>
);
}
private closeModal = () => {
if (!this.props.isRefundActionPending) {
this.props.handleClose();
}
};
private cancelProposal = () => {
console.warn('TODO - implement cancelProposal');
};
}
export default connect<StateProps, {}, OwnProps, AppState>(state => {
console.warn('TODO - redux isRefundActionPending/refundActionError?', state);
return {
isRefundActionPending: false,
refundActionError: '',
};
})(CancelModal);

View File

@ -98,13 +98,7 @@ export class ProposalDetail extends React.Component<Props, State> {
return <Loader size="large" />;
}
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<Props, State> {
<Menu.Item disabled={!isLive} onClick={this.openUpdateModal}>
Post an Update
</Menu.Item>
<Menu.Item
onClick={() => alert('Sorry, not yet implemented!')}
disabled={!isProposalActive}
>
Edit proposal
</Menu.Item>
<Menu.Item
style={{ color: canCancel ? '#e74c3c' : undefined }}
onClick={this.openCancelModal}
disabled={!canCancel}
>
<Menu.Item disabled={!isLive} onClick={this.openCancelModal}>
Cancel proposal
</Menu.Item>
</Menu>

View File

@ -66,7 +66,14 @@ export default class ProposalFilters extends React.Component<Props> {
</Radio>
</div>
{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 => (
<div key={s} style={{ marginBottom: '0.25rem' }}>
<Radio