Cancel / Refund proposal (#100)

* Cancel / refund modal for proposals. Fix some states where frozen contract still allowed interaction.

* Add more refund states. Move styles to less.

* Fix tsc, simplify logic
This commit is contained in:
William O'Beirne 2018-09-25 15:41:47 -04:00 committed by Daniel Ternyak
parent 8be518fff7
commit 75f0b72022
14 changed files with 334 additions and 84 deletions

View File

@ -25,6 +25,7 @@ class CreateFlowPreview extends React.Component<Props> {
banner
/>
<ProposalDetail
account="0x0"
proposalId="preview"
fetchProposal={() => null}
proposal={proposal}

View File

@ -3,6 +3,7 @@
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
border: 2px dashed #d9d9d9;
padding: 3rem;
border-radius: 8px;

View File

@ -89,7 +89,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
if (proposal) {
const { crowdFund } = proposal;
const isFundingOver =
crowdFund.isRaiseGoalReached || crowdFund.deadline < Date.now();
crowdFund.isRaiseGoalReached ||
crowdFund.deadline < Date.now() ||
crowdFund.isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
const remainingEthNum = parseFloat(
web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
@ -150,7 +152,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
) : (
<>
<Icon type="close-circle-o" />
<span>Proposal didnt reach target</span>
<span>Proposal didnt get funded</span>
</>
)}
</div>

View File

@ -0,0 +1,105 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, Alert } from 'antd';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
interface OwnProps {
proposal: ProposalWithCrowdFund;
isVisible: boolean;
handleClose(): void;
}
interface StateProps {
isRefundActionPending: AppState['web3']['isRefundActionPending'];
refundActionError: AppState['web3']['refundActionError'];
}
interface DispatchProps {
triggerRefund: typeof web3Actions['triggerRefund'];
}
type Props = StateProps & DispatchProps & OwnProps;
class CancelModal extends React.Component<Props> {
componentDidUpdate() {
if (this.props.proposal.crowdFund.isFrozen) {
this.props.handleClose();
}
}
render() {
const { proposal, isVisible, isRefundActionPending, refundActionError } = this.props;
const hasBeenFunded = proposal.crowdFund.isRaiseGoalReached;
const hasContributors = !!proposal.crowdFund.contributors.length;
const disabled = isRefundActionPending;
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 }}
>
{hasBeenFunded ? (
<p>
Are you sure you would like to issue a refund?{' '}
<strong>This cannot be undone</strong>. Once you issue a refund, all
contributors will be able to receive a refund of the remaining proposal
balance.
</p>
) : (
<p>
Are you sure you would like to cancel this proposal?{' '}
<strong>This cannot be undone</strong>. Once you cancel it, all contributors
will be able to receive refunds.
</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
Grant.io.
</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 ${hasBeenFunded ? 'refund' : 'cancel'} proposal`}
description={refundActionError}
showIcon
/>
)}
</Modal>
);
}
private closeModal = () => {
if (!this.props.isRefundActionPending) {
this.props.handleClose();
}
};
private cancelProposal = () => {
this.props.triggerRefund(this.props.proposal.crowdFundContract);
};
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
isRefundActionPending: state.web3.isRefundActionPending,
refundActionError: state.web3.refundActionError,
}),
{
triggerRefund: web3Actions.triggerRefund,
},
)(CancelModal);

View File

@ -7,6 +7,7 @@ import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import UnitDisplay from 'components/UnitDisplay';
import Placeholder from 'components/Placeholder';
interface OwnProps {
proposal: ProposalWithCrowdFund;
@ -38,6 +39,19 @@ export class Milestones extends React.Component<Props> {
milestoneActionError,
} = this.props;
const { crowdFund } = proposal;
if (!crowdFund.isRaiseGoalReached) {
return (
<Placeholder
title="Milestone governance isnt available yet"
subtitle={`
Milestone history and voting status will be displayed here
once the project has been funded
`}
/>
);
}
const contributor = crowdFund.contributors.find(c => c.address === accounts[0]);
const isTrustee = crowdFund.trustees.includes(accounts[0]);
const firstMilestone = crowdFund.milestones[0];

View File

@ -6,6 +6,7 @@ import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
import classnames from 'classnames';
import Placeholder from 'components/Placeholder';
interface OwnProps {
proposal: ProposalWithCrowdFund;
@ -32,16 +33,63 @@ class GovernanceRefunds extends React.Component<Props> {
render() {
const { proposal, account, isRefundActionPending, refundActionError } = this.props;
const { crowdFund } = proposal;
const isStillFunding =
!crowdFund.isRaiseGoalReached && crowdFund.deadline > Date.now();
if (isStillFunding && !crowdFund.isFrozen) {
return (
<Placeholder
title="Refund governance isnt available yet"
subtitle={`
Refund voting and status will be displayed here once the
project has been funded, or if it the project is canceled
or fails to receive funding
`}
/>
);
}
// TODO: Cyclomatic complexity is too damn high here. This state should be
// figured out on the backend and enumerated, not calculated in this component.
const contributor = crowdFund.contributors.find(c => c.address === account);
const isTrustee = crowdFund.trustees.includes(account);
const hasVotedForRefund = contributor && contributor.refundVote;
const hasRefunded = contributor && contributor.refunded;
const refundPct = crowdFund.percentVotingForRefund;
const didFundingFail =
!crowdFund.isRaiseGoalReached && crowdFund.deadline < Date.now();
let text;
let button;
if (!isTrustee && contributor) {
if (refundPct < 50) {
let canRefund = false;
if (hasRefunded) {
return (
<Alert
type="success"
message="Your refund has been processed"
description={`
We apologize for any inconvenience this propsal has caused you. Please
let us know if there's anything we could have done to improve your
experience.
`}
showIcon
/>
);
} else if (refundPct > 50) {
text = `
The majority of funders have voted for a refund. Click below
to receive your refund.
`;
canRefund = true;
} else if (didFundingFail || crowdFund.isFrozen) {
text = `
The project was either canceled, or failed to reach its funding
target before the deadline. Click below to receive a refund of
your contribution.
`;
canRefund = true;
} else {
text = `
As a funder of this project, you have the right to vote for a refund. If the
amount of funds contributed by refund voters exceeds half of the project's
@ -60,47 +108,38 @@ class GovernanceRefunds extends React.Component<Props> {
onClick: () => this.voteRefund(true),
};
}
} else {
if (hasRefunded) {
return (
<Alert
type="success"
message="Your refund has been processed"
description={`
We apologize for any inconvenience this propsal has caused you. Please
let us know if there's anything we could have done to improve your
experience.
`}
showIcon
/>
);
} else {
text = (
<>
The majority of funders have voted for a refund. Click below to receive your
refund.
{!crowdFund.isFrozen && (
<Alert
style={{ marginTop: '1rem' }}
type="info"
message={`
}
if (canRefund) {
text = (
<>
{text}
{!crowdFund.isFrozen && (
<Alert
style={{ marginTop: '1rem' }}
type="info"
showIcon
message={`
This will require multiple transactions to process, sorry
for the inconvenience
`}
showIcon
/>
)}
</>
);
button = {
text: 'Get your refund',
type: 'primary',
onClick: () => this.withdrawRefund(),
};
}
/>
)}
</>
);
button = {
text: 'Get your refund',
type: 'primary',
onClick: () => this.withdrawRefund(),
};
}
} else {
if (refundPct < 50) {
if (crowdFund.isFrozen || didFundingFail) {
text = `
The project failed to receive funding or was canceled. Contributors are
open to refund their contributions.
`;
} else if (refundPct < 50) {
text = `
Funders can vote to request refunds. If the amount of funds contributed by
refund voters exceeds half of the funds contributed, all funders will be able

View File

@ -2,7 +2,6 @@ import React from 'react';
import GovernanceMilestones from './Milestones';
import GovernanceRefunds from './Refunds';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import Placeholder from 'components/Placeholder';
import './style.less';
interface Props {
@ -12,29 +11,15 @@ interface Props {
export default class ProposalGovernance extends React.Component<Props> {
render() {
const { proposal } = this.props;
if (!proposal.crowdFund.isRaiseGoalReached) {
return (
<Placeholder
style={{ minHeight: '220px' }}
title="Governance isnt available yet"
subtitle={`
Milestone history and voting will be displayed here once the
project has been funded
`}
/>
);
}
return (
<div className="ProposalGovernance">
<div style={{ flex: 1 }}>
<h2 style={{ marginBottom: '1rem' }}>Milestone Voting</h2>
<div className="ProposalGovernance-section">
<h2 className="ProposalGovernance-section-title">Milestone Voting</h2>
<GovernanceMilestones proposal={proposal} />
</div>
<div className="ProposalGovernance-divider" />
<div style={{ flex: 1 }}>
<h2 style={{ marginBottom: '1rem' }}>Refunds</h2>
<div className="ProposalGovernance-section">
<h2 className="ProposalGovernance-section-title">Refunds</h2>
<GovernanceRefunds proposal={proposal} />
</div>
</div>

View File

@ -8,6 +8,15 @@
flex-direction: column;
}
&-section {
flex: 1;
&-title {
font-weight: bold;
margin-bottom: 1rem;
}
}
&-divider {
width: 1px;
background: rgba(0, 0, 0, 0.05);

View File

@ -7,16 +7,16 @@ import { bindActionCreators, Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { getProposal } from 'modules/proposals/selectors';
import { Spin, Tabs, Icon } from 'antd';
import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd';
import CampaignBlock from './CampaignBlock';
import TeamBlock from './TeamBlock';
import Milestones from './Milestones';
import CommentsTab from './Comments';
import UpdatesTab from './Updates';
import GovernanceTab from './Governance';
import ContributorsTab from './Contributors';
// import CommunityTab from './Community';
import CancelModal from './CancelModal';
import './style.less';
import classnames from 'classnames';
import { withRouter } from 'react-router';
@ -36,11 +36,16 @@ interface DispatchProps {
fetchProposal: proposalActions.TFetchProposal;
}
type Props = StateProps & DispatchProps & OwnProps;
interface Web3Props {
account: string;
}
type Props = StateProps & DispatchProps & Web3Props & OwnProps;
interface State {
isBodyExpanded: boolean;
isBodyOverflowing: boolean;
isCancelOpen: boolean;
bodyId: string;
}
@ -48,6 +53,7 @@ export class ProposalDetail extends React.Component<Props, State> {
state: State = {
isBodyExpanded: false,
isBodyOverflowing: false,
isCancelOpen: false,
bodyId: `body-${Math.floor(Math.random() * 1000000)}`,
};
@ -71,14 +77,37 @@ export class ProposalDetail extends React.Component<Props, State> {
}
render() {
const { proposal, isPreview } = this.props;
const { isBodyExpanded, isBodyOverflowing, bodyId } = this.state;
const { proposal, isPreview, account } = this.props;
const { isBodyExpanded, isBodyOverflowing, isCancelOpen, bodyId } = this.state;
const showExpand = !isBodyExpanded && isBodyOverflowing;
if (!proposal) {
return <Spin />;
} else {
const { crowdFund } = proposal;
const isTrustee = crowdFund.trustees.includes(account);
const hasBeenFunded = crowdFund.isRaiseGoalReached;
const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now();
const canRefund = (hasBeenFunded || isProposalActive) && !crowdFund.isFrozen;
const adminMenu = isTrustee && (
<Menu>
<Menu.Item
onClick={() => alert('Sorry, not yet implemented!')}
disabled={!isProposalActive}
>
Edit proposal
</Menu.Item>
<Menu.Item
style={{ color: canRefund ? '#e74c3c' : undefined }}
onClick={this.openCancelModal}
disabled={!canRefund}
>
{hasBeenFunded ? 'Refund contributors' : 'Cancel proposal'}
</Menu.Item>
</Menu>
);
return (
<div className="Proposal">
<div className="Proposal-top">
@ -105,6 +134,20 @@ export class ProposalDetail extends React.Component<Props, State> {
</button>
)}
</div>
{isTrustee && (
<div className="Proposal-top-main-menu">
<Dropdown
overlay={adminMenu}
trigger={['click']}
placement="bottomRight"
>
<Button>
<span>Actions</span>
<Icon type="down" style={{ marginRight: '-0.25rem' }} />
</Button>
</Dropdown>
</div>
)}
</div>
<div className="Proposal-top-side">
<CampaignBlock proposal={proposal} isPreview={isPreview} />
@ -134,6 +177,13 @@ export class ProposalDetail extends React.Component<Props, State> {
</Tabs.TabPane>
</Tabs>
)}
{isTrustee && (
<CancelModal
proposal={proposal}
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
)}
</div>
);
}
@ -161,6 +211,9 @@ export class ProposalDetail extends React.Component<Props, State> {
this.setState({ isBodyOverflowing: true });
}
};
private openCancelModal = () => this.setState({ isCancelOpen: true });
private closeCancelModal = () => this.setState({ isCancelOpen: false });
}
function mapStateToProps(state: AppState, ownProps: OwnProps) {
@ -178,7 +231,7 @@ const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
mapDispatchToProps,
);
const ConnectedProposal = compose<Props, OwnProps>(
const ConnectedProposal = compose<Props, OwnProps & Web3Props>(
withRouter,
withConnect,
)(ProposalDetail);
@ -194,6 +247,6 @@ export default (props: OwnProps) => (
</div>
</div>
)}
render={() => <ConnectedProposal {...props} />}
render={({ accounts }) => <ConnectedProposal account={accounts[0]} {...props} />}
/>
);

View File

@ -15,6 +15,7 @@
}
&-main {
position: relative;
display: flex;
flex-direction: column;
width: calc(100% - 19rem);
@ -104,6 +105,12 @@
}
}
}
&-menu {
position: absolute;
top: 1rem;
right: 0;
}
}
&-side {

View File

@ -14,7 +14,7 @@ export interface Web3RenderProps {
}
interface OwnProps {
render(props: Web3RenderProps & any): React.ReactNode;
render(props: Web3RenderProps & { props: any }): React.ReactNode;
renderLoading(): React.ReactNode;
}

View File

@ -332,6 +332,49 @@ export function voteRefund(crowdFundContract: any, vote: boolean) {
};
}
async function freezeContract(crowdFundContract: any, account: string) {
let isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
// Already frozen, all good here
if (isFrozen) {
return;
}
await new Promise((resolve, reject) => {
crowdFundContract.methods
.refund()
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
resolve();
})
.catch((err: Error) => reject(err));
});
if (!isFrozen) {
throw new Error('Proposal isnt in a refundable state yet.');
}
}
export function triggerRefund(crowdFundContract: any) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
try {
await freezeContract(crowdFundContract, account);
await dispatch(fetchProposal(crowdFundContract._address));
dispatch({ type: types.TRIGGER_REFUND_FULFILLED });
} catch (err) {
dispatch({
type: types.TRIGGER_REFUND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
}
};
}
export function withdrawRefund(crowdFundContract: any, address: string) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
@ -339,24 +382,7 @@ export function withdrawRefund(crowdFundContract: any, address: string) {
const account = state.web3.accounts[0];
try {
let isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
if (!isFrozen) {
await new Promise(resolve => {
crowdFundContract.methods
.refund()
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
resolve();
});
});
}
if (!isFrozen) {
throw new Error('Proposal isnt in a refundable state yet.');
}
await freezeContract(crowdFundContract, account);
await crowdFundContract.methods
.withdraw(address)
.send({ from: account })

View File

@ -214,6 +214,7 @@ export default (state = INITIAL_STATE, action: any): Web3State => {
case types.VOTE_REFUND_PENDING:
case types.WITHDRAW_REFUND_PENDING:
case types.TRIGGER_REFUND_PENDING:
return {
...state,
isRefundActionPending: true,
@ -221,12 +222,14 @@ export default (state = INITIAL_STATE, action: any): Web3State => {
};
case types.VOTE_REFUND_FULFILLED:
case types.WITHDRAW_REFUND_FULFILLED:
case types.TRIGGER_REFUND_FULFILLED:
return {
...state,
isRefundActionPending: false,
};
case types.VOTE_REFUND_REJECTED:
case types.WITHDRAW_REFUND_REJECTED:
case types.TRIGGER_REFUND_REJECTED:
return {
...state,
refundActionError: payload,

View File

@ -46,6 +46,11 @@ enum web3Types {
WITHDRAW_REFUND_REJECTED = 'WITHDRAW_REFUND_REJECTED',
WITHDRAW_REFUND_PENDING = 'WITHDRAW_REFUND_PENDING',
TRIGGER_REFUND = 'TRIGGER_REFUND',
TRIGGER_REFUND_FULFILLED = 'TRIGGER_REFUND_FULFILLED',
TRIGGER_REFUND_REJECTED = 'TRIGGER_REFUND_REJECTED',
TRIGGER_REFUND_PENDING = 'TRIGGER_REFUND_PENDING',
ACCOUNTS = 'ACCOUNTS',
ACCOUNTS_FULFILLED = 'ACCOUNTS_FULFILLED',
ACCOUNTS_REJECTED = 'ACCOUNTS_REJECTED',