zcash-grant-system/frontend/client/components/Proposal/Milestones/index.tsx

302 lines
9.0 KiB
TypeScript

import lodash from 'lodash';
import React from 'react';
import moment from 'moment';
import { Alert, Steps, Spin } from 'antd';
import { ProposalWithCrowdFund, MILESTONE_STATE } from 'types';
import UnitDisplay from 'components/UnitDisplay';
import MilestoneAction from './MilestoneAction';
import { AppState } from 'store/reducers';
import { connect } from 'react-redux';
import classnames from 'classnames';
import './style.less';
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
enum STEP_STATUS {
WAIT = 'wait',
PROCESS = 'process',
FINISH = 'finish',
ERROR = 'error',
}
const milestoneStateToStepState = {
[WAITING]: STEP_STATUS.WAIT,
[ACTIVE]: STEP_STATUS.PROCESS,
[PAID]: STEP_STATUS.FINISH,
[REJECTED]: STEP_STATUS.ERROR,
};
interface OwnProps {
proposal: ProposalWithCrowdFund;
}
interface StateProps {
accounts: AppState['web3']['accounts'];
}
type Props = OwnProps & StateProps;
interface State {
step: number;
activeMilestoneIdx: number;
doTitlesOverflow: boolean;
}
class ProposalMilestones extends React.Component<Props, State> {
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
ref: React.RefObject<HTMLDivElement>;
throttledUpdateDoTitlesOverflow: () => void;
constructor(props: Props) {
super(props);
this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef());
this.ref = React.createRef();
this.throttledUpdateDoTitlesOverflow = lodash.throttle(
this.updateDoTitlesOverflow,
500,
);
this.state = {
step: 0,
activeMilestoneIdx: 0,
doTitlesOverflow: true,
};
}
componentDidMount() {
if (this.props.proposal) {
const activeMilestoneIdx = this.getActiveMilestoneIdx();
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
}
this.updateDoTitlesOverflow();
window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow);
}
componentWillUnmount() {
window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow);
}
componentDidUpdate(_: Props, prevState: State) {
const activeMilestoneIdx = this.getActiveMilestoneIdx();
if (prevState.activeMilestoneIdx !== activeMilestoneIdx) {
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
}
}
render() {
const { proposal } = this.props;
if (!proposal) {
return <Spin />;
}
const {
milestones,
crowdFund,
crowdFund: { milestoneVotingPeriod, percentVotingForRefund },
} = proposal;
const { accounts } = this.props;
const wasRefunded = percentVotingForRefund > 50;
const isTrustee = crowdFund.trustees.includes(accounts[0]);
const milestoneCount = milestones.length;
const milestoneSteps = milestones.map((milestone, i) => {
const status =
this.state.activeMilestoneIdx === i && milestone.state === WAITING
? STEP_STATUS.PROCESS
: milestoneStateToStepState[milestone.state];
const className = this.state.step === i ? 'is-active' : 'is-inactive';
const estimatedDate = moment(milestone.dateEstimated).format('MMMM YYYY');
const reward = (
<UnitDisplay value={milestone.amount} symbol="ETH" displayShortBalance={4} />
);
const approvalPeriod = milestone.isImmediatePayout
? 'Immediate'
: moment.duration(milestoneVotingPeriod).humanize();
const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' };
const stepProps = {
title: <div ref={this.stepTitleRefs[i]}>{milestone.title}</div>,
status,
className,
onClick: () => this.setState({ step: i }),
};
let notification;
switch (milestone.state) {
case PAID:
notification = (
<Alert
type="success"
message={
<span>
The team was awarded <strong>{reward}</strong>{' '}
{milestone.isImmediatePayout
? 'as an initial payout'
: `on ${moment(milestone.payoutRequestVoteDeadline).format(
'MMM Do, YYYY',
)}`}
.
</span>
}
style={alertStyle}
/>
);
break;
case ACTIVE:
notification = (
<Alert
type="info"
message={
<span>
Payout vote is in progress! The approval period ends{' '}
{moment(milestone.payoutRequestVoteDeadline).from(new Date())}.
</span>
}
style={alertStyle}
/>
);
break;
case REJECTED:
notification = (
<Alert
type="warning"
message={
<span>
Payout was voted against on{' '}
{moment(milestone.payoutRequestVoteDeadline).format('MMM Do, YYYY')}.
{isTrustee ? ' You ' : ' The team '} can request another payout vote at
any time.
</span>
}
style={alertStyle}
/>
);
break;
}
if (wasRefunded) {
notification = (
<Alert
type="error"
message={
<span>A majority of the funders of this project voted for a refund.</span>
}
style={alertStyle}
/>
);
}
const statuses = (
<div className="ProposalMilestones-milestone-status">
{!milestone.isImmediatePayout && (
<div>
Estimate: <strong>{estimatedDate}</strong>
</div>
)}
<div>
Reward: <strong>{reward}</strong>
</div>
<div>
Approval period: <strong>{approvalPeriod}</strong>
</div>
</div>
);
const Content = (
<div className="ProposalMilestones-milestone">
<div className="ProposalMilestones-milestone-body">
<div className="ProposalMilestones-milestone-description">
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
{statuses}
{notification}
{milestone.body}
</div>
{this.state.activeMilestoneIdx === i &&
!wasRefunded && (
<>
<div className="ProposalMilestones-milestone-divider" />
<div className="ProposalMilestones-milestone-action">
<MilestoneAction proposal={proposal} />
</div>
</>
)}
</div>
</div>
);
return { key: i, stepProps, Content };
});
const stepSize = milestoneCount > 5 ? 'small' : 'default';
return (
<div
ref={this.ref}
className={classnames({
['ProposalMilestones']: true,
['do-titles-overflow']: this.state.doTitlesOverflow,
[`is-count-${milestoneCount}`]: true,
})}
>
<Steps current={this.state.step} size={stepSize}>
{milestoneSteps.map(mss => (
<Steps.Step key={mss.key} {...mss.stepProps} />
))}
</Steps>
{milestoneSteps[this.state.step].Content}
</div>
);
}
private getActiveMilestoneIdx = () => {
const { milestones } = this.props.proposal;
const activeMilestone =
milestones.find(
m =>
m.state === WAITING ||
m.state === ACTIVE ||
(m.state === PAID && !m.isPaid) ||
m.state === REJECTED,
) || milestones[0];
return activeMilestone.index;
};
private updateDoTitlesOverflow = () => {
// hmr can sometimes muck up refs, let's make sure they all exist
if (!this.ref || !this.ref.current || !this.stepTitleRefs) {
return;
}
if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a, true)) {
return;
}
let doTitlesOverflow = false;
const stepCount = this.stepTitleRefs.length;
if (stepCount > 1) {
// avoiding style calculation here by hardcoding antd icon width + padding + margin
const iconWidths = stepCount * 56;
const totalWidth = this.ref.current.clientWidth;
const last = this.stepTitleRefs[stepCount - 1].current;
if (last) {
// last title gets full space
const lastWidth = last.clientWidth;
const remainingWidth = totalWidth - (lastWidth + iconWidths);
const remainingWidthSingle = remainingWidth / (stepCount - 1);
// first titles have to share remaining space
doTitlesOverflow = this.stepTitleRefs
.slice(0, stepCount - 1)
.reduce(
(prev, r) =>
prev || (r.current ? r.current.clientWidth : 0) > remainingWidthSingle,
false,
);
}
}
this.setState({ doTitlesOverflow });
};
}
const ConnectedProposalMilestones = connect((state: AppState) => ({
accounts: state.web3.accounts,
}))(ProposalMilestones);
export default ConnectedProposalMilestones;