FE: rework milestones first pass

This commit is contained in:
Aaron 2019-02-11 15:22:40 -06:00
parent 4e5c0eaea7
commit ac5bef5c6f
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
9 changed files with 170 additions and 178 deletions

View File

@ -2,7 +2,7 @@ import lodash from 'lodash';
import React from 'react';
import moment from 'moment';
import { Alert, Steps } from 'antd';
import { Proposal, MILESTONE_STATE } from 'types';
import { Proposal, Milestone, ProposalMilestone, MILESTONE_STAGE } from 'types';
import UnitDisplay from 'components/UnitDisplay';
import Loader from 'components/Loader';
import { AppState } from 'store/reducers';
@ -10,22 +10,24 @@ import { connect } from 'react-redux';
import classnames from 'classnames';
import './style.less';
import Placeholder from 'components/Placeholder';
import { AlertProps } from 'antd/lib/alert';
import { StepProps } from 'antd/lib/steps';
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
// const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
enum STEP_STATUS {
WAIT = 'wait',
PROCESS = 'process',
FINISH = 'finish',
ERROR = 'error',
}
// 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,
};
// const milestoneStateToStepState = {
// [WAITING]: STEP_STATUS.WAIT,
// [ACTIVE]: STEP_STATUS.PROCESS,
// [PAID]: STEP_STATUS.FINISH,
// [REJECTED]: STEP_STATUS.ERROR,
// };
interface OwnProps {
proposal: Proposal;
@ -87,107 +89,20 @@ class ProposalMilestones extends React.Component<Props, State> {
return <Loader />;
}
const { milestones } = proposal;
const isTrustee = false; // TODO: Replace with being on the team
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 status: StepProps['status'] = 'wait';
// 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 * 1000).format('MMMM YYYY');
const reward = (
<UnitDisplay value={milestone.amount} symbol="ZEC" displayShortBalance={4} />
);
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.immediatePayout
? 'as an initial payout'
: // TODO: Add property for payout date on milestones
`on ${moment().format('MMM Do, YYYY')}`}
.
</span>
}
style={alertStyle}
/>
);
break;
case ACTIVE:
notification = (
<Alert
type="info"
message={`
The team has requested a payout for this milestone. It is
currently under review.
`}
style={alertStyle}
/>
);
break;
case REJECTED:
notification = (
<Alert
type="warning"
message={
<span>
Payout for this milestone was rejected on{' '}
{/* TODO: add property for payout rejection date on milestones */}
{moment().format('MMM Do, YYYY')}.{isTrustee ? ' You ' : ' The team '}{' '}
can request another review for payout at any time.
</span>
}
style={alertStyle}
/>
);
break;
}
const statuses = (
<div className="ProposalMilestones-milestone-status">
{!milestone.immediatePayout && (
<div>
Estimate: <strong>{estimatedDate}</strong>
</div>
)}
<div>
Reward: <strong>{reward}</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.content}
</div>
</div>
</div>
);
return { key: i, stepProps, content };
return { key: i, stepProps };
});
const stepSize = milestoneCount > 5 ? 'small' : 'default';
@ -198,7 +113,6 @@ class ProposalMilestones extends React.Component<Props, State> {
className={classnames({
['ProposalMilestones']: true,
['do-titles-overflow']: this.state.doTitlesOverflow,
[`is-count-${milestoneCount}`]: true,
})}
>
{!!milestoneSteps.length ? (
@ -208,7 +122,10 @@ class ProposalMilestones extends React.Component<Props, State> {
<Steps.Step key={mss.key} {...mss.stepProps} />
))}
</Steps>
{milestoneSteps[this.state.step].content}
<Milestone
{...proposal.milestones[this.state.step]}
isTeamMember={proposal.isTeamMember || false}
/>
</>
) : (
<Placeholder
@ -221,16 +138,17 @@ class ProposalMilestones extends React.Component<Props, State> {
}
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 milestones.indexOf(activeMilestone);
return 0;
// 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 milestones.indexOf(activeMilestone);
};
private updateDoTitlesOverflow = () => {
@ -268,6 +186,81 @@ class ProposalMilestones extends React.Component<Props, State> {
};
}
const Milestone: React.SFC<ProposalMilestone & { isTeamMember: boolean }> = p => {
const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY');
const reward = <UnitDisplay value={p.amount} symbol="ZEC" displayShortBalance={4} />;
const fmtDate = (n: undefined | number) =>
(n && moment(n * 1000).format('MMM Do, YYYY')) || undefined;
const getAlertProps = {
[MILESTONE_STAGE.IDLE]: () => null,
[MILESTONE_STAGE.REQUESTED]: () => ({
type: 'info',
message: (
<>
The team has requested a payout for this milestone. It is currently under
review.
</>
),
}),
[MILESTONE_STAGE.REJECTED]: () => ({
type: 'warning',
message: (
<span>
Payout for this milestone was rejected on {fmtDate(p.dateRejected)}.
{p.isTeamMember ? ' You ' : ' The team '} can request another review for payout
at any time.
</span>
),
}),
[MILESTONE_STAGE.ACCEPTED]: () => ({
type: 'info',
message: (
<span>
Payout for this milestone was accepted on {fmtDate(p.dateAccepted)}.
<strong>{reward}</strong> will be sent to{' '}
{p.isTeamMember ? ' you ' : ' the team '} soon.
</span>
),
}),
[MILESTONE_STAGE.PAID]: () => ({
type: 'success',
message: (
<span>
The team was awarded <strong>{reward}</strong>{' '}
{p.immediatePayout && ` as an initial payout `} on ${fmtDate(p.datePaid)}
`.
</span>
),
}),
} as { [key in MILESTONE_STAGE]: () => AlertProps | null };
const alertProps = getAlertProps[p.stage]();
return (
<div className="ProposalMilestones-milestone">
<div className="ProposalMilestones-milestone-body">
<div className="ProposalMilestones-milestone-description">
<h3 className="ProposalMilestones-milestone-title">{p.title}</h3>
<div className="ProposalMilestones-milestone-status">
{!p.immediatePayout && (
<div>
Estimate: <strong>{estimatedDate}</strong>
</div>
)}
<div>
Reward: <strong>{reward}</strong>
</div>
</div>
{alertProps && (
<Alert {...alertProps} className="ProposalMilestones-milestone-alert" />
)}
{p.content}
</div>
</div>
</div>
);
};
const ConnectedProposalMilestones = connect((state: AppState) => {
console.warn('TODO - new redux accounts/user-role-for-proposal', state);
return {

View File

@ -39,6 +39,11 @@
margin-left: 0;
}
&-alert {
width: fit-content;
margin: 0 0 1rem 0;
}
&-title {
display: none;
white-space: nowrap;

View File

@ -1,7 +1,7 @@
import { ProposalDraft, CreateMilestone, STATUS } from 'types';
import { ProposalDraft, CreateMilestone, STATUS, MILESTONE_STAGE } from 'types';
import { User } from 'types';
import { getAmountError, isValidAddress } from 'utils/validators';
import { MILESTONE_STATE, Proposal } from 'types';
import { Proposal } from 'types';
import { Zat, toZat } from 'utils/units';
import { ONE_DAY } from 'utils/time';
import { PROPOSAL_CATEGORY } from 'api/constants';
@ -199,9 +199,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)),
dateEstimated: m.dateEstimated,
immediatePayout: m.immediatePayout,
isPaid: false,
payoutPercent: m.payoutPercent.toString(),
state: MILESTONE_STATE.WAITING,
stage: MILESTONE_STAGE.IDLE,
})),
};
}

View File

@ -14,6 +14,19 @@ import { getProposalPageSettings } from './selectors';
type GetState = () => AppState;
function addProposalUserRoles(p: Proposal, state: AppState) {
if (state.auth.user) {
const authUserId = state.auth.user.userid;
// TODO: add arbiter roll
// user.arbitratedProposals...
console.warn('TODO: add user arbitration role to Proposal');
if (p.team.find(t => t.userid === authUserId)) {
p.isTeamMember = true;
}
}
return p;
}
// change page, sort, filter, search
export function setProposalPage(pageParams: Partial<ProposalPageParams>) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
@ -49,7 +62,7 @@ export function fetchProposals() {
export type TFetchProposal = typeof fetchProposal;
export function fetchProposal(proposalId: Proposal['proposalId']) {
return async (dispatch: Dispatch<any>) => {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({
type: types.PROPOSAL_DATA_PENDING,
payload: { proposalId },
@ -58,7 +71,7 @@ export function fetchProposal(proposalId: Proposal['proposalId']) {
const proposal = (await getProposal(proposalId)).data;
return dispatch({
type: types.PROPOSAL_DATA_FULFILLED,
payload: proposal,
payload: addProposalUserRoles(proposal, getState()),
});
} catch (error) {
dispatch({

View File

@ -6,7 +6,6 @@ import {
PageParams,
UserProposal,
RFP,
MILESTONE_STATE,
ProposalPage,
} from 'types';
import { UserState } from 'modules/users/reducers';
@ -88,16 +87,12 @@ export function formatProposalFromGet(p: any): Proposal {
? 0
: proposal.funded.div(proposal.target.divn(100)).toNumber();
if (proposal.milestones) {
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
return {
...m,
index,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
// TODO: Get data from backend
state: MILESTONE_STATE.WAITING,
isPaid: false,
};
const msToFe = (m: any) => ({
...m,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
});
proposal.milestones = proposal.milestones.map(msToFe);
proposal.currentMilestone = msToFe(proposal.currentMilestone);
}
return proposal;
}

View File

@ -5,18 +5,18 @@ import { Provider } from 'react-redux';
import { configureStore } from 'store/configure';
import { combineInitialState } from 'store/reducers';
import Milestones from 'components/Proposal/Milestones';
import { MILESTONE_STATE } from 'types';
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
import { MILESTONE_STAGE } from 'types';
const { IDLE, ACCEPTED, PAID, REJECTED } = MILESTONE_STAGE;
import 'styles/style.less';
import 'components/Proposal/style.less';
import 'components/Proposal/Governance/style.less';
import { generateProposal } from './props';
const msWaiting = { state: WAITING, isPaid: false };
const msPaid = { state: PAID, isPaid: true };
const msActive = { state: ACTIVE, isPaid: false };
const msRejected = { state: REJECTED, isPaid: false };
const msWaiting = { stage: IDLE };
const msPaid = { stage: PAID };
const msActive = { stage: ACCEPTED };
const msRejected = { stage: REJECTED };
const trustee = 'z123';
const contributor = 'z456';
@ -38,11 +38,7 @@ const cases: { [index: string]: any } = {
['first - not paid']: generateProposal({
amount: 5,
funded: 5,
milestoneOverrides: [
{ state: PAID, isPaid: false },
msWaiting,
msWaiting,
],
milestoneOverrides: [{ stage: PAID }, msWaiting, msWaiting],
}),
// trustee - second
@ -59,20 +55,12 @@ const cases: { [index: string]: any } = {
['second - not paid']: generateProposal({
amount: 5,
funded: 5,
milestoneOverrides: [
msPaid,
{ state: PAID, isPaid: false },
msWaiting,
],
milestoneOverrides: [msPaid, { stage: PAID }, msWaiting],
}),
['second - no vote']: generateProposal({
amount: 5,
funded: 5,
milestoneOverrides: [
msPaid,
{ state: ACTIVE, isPaid: false },
msWaiting,
],
milestoneOverrides: [msPaid, { stage: ACCEPTED }, msWaiting],
contributorOverrides: [{ milestoneNoVotes: [false, true, false] }],
}),
['second - rejected']: generateProposal({
@ -95,20 +83,12 @@ const cases: { [index: string]: any } = {
['final - not paid']: generateProposal({
amount: 5,
funded: 5,
milestoneOverrides: [
msPaid,
msPaid,
{ state: PAID, isPaid: false },
],
milestoneOverrides: [msPaid, msPaid, { stage: PAID }],
}),
['final - no vote']: generateProposal({
amount: 5,
funded: 5,
milestoneOverrides: [
msPaid,
msPaid,
{ state: ACTIVE, isPaid: false },
],
milestoneOverrides: [msPaid, msPaid, { stage: ACCEPTED }],
contributorOverrides: [{ milestoneNoVotes: [false, true, false] }],
}),
['final - rejected']: generateProposal({

View File

@ -1,11 +1,4 @@
import {
Contributor,
Milestone,
MILESTONE_STATE,
Proposal,
ProposalMilestone,
STATUS,
} from 'types';
import { Contributor, MILESTONE_STAGE, Proposal, ProposalMilestone, STATUS } from 'types';
import { PROPOSAL_CATEGORY } from 'api/constants';
import BN from 'bn.js';
import moment from 'moment';
@ -40,7 +33,7 @@ export function generateProposal({
funded?: number;
created?: number;
deadline?: number;
milestoneOverrides?: Array<Partial<Milestone>>;
milestoneOverrides?: Array<Partial<ProposalMilestone>>;
contributorOverrides?: Array<Partial<Contributor>>;
milestoneCount?: number;
}) {
@ -115,9 +108,8 @@ export function generateProposal({
dateEstimated: moment().unix(),
immediatePayout: true,
index: 0,
state: MILESTONE_STATE.WAITING,
stage: MILESTONE_STAGE.IDLE,
amount: amountBn,
isPaid: false,
payoutPercent: '33',
};
return { ...defaults, ...overrides };

View File

@ -7,13 +7,25 @@ export enum MILESTONE_STATE {
PAID = 'PAID',
}
// NOTE: sync with /backend/grand/utils/enums.py MilestoneStage
export enum MILESTONE_STAGE {
IDLE = 'IDLE',
REQUESTED = 'REQUESTED',
REJECTED = 'REJECTED',
ACCEPTED = 'ACCEPTED',
PAID = 'PAID',
}
export interface Milestone {
index: number;
state: MILESTONE_STATE;
stage: MILESTONE_STAGE;
amount: Zat;
isPaid: boolean;
immediatePayout: boolean;
dateEstimated: number;
dateRequested?: number;
dateRejected?: number;
dateAccepted?: number;
datePaid?: number;
}
export interface ProposalMilestone extends Milestone {

View File

@ -47,8 +47,11 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
percentFunded: number;
contributionMatching: number;
milestones: ProposalMilestone[];
currentMilestone?: ProposalMilestone;
datePublished: number | null;
dateApproved: number | null;
isTeamMember?: boolean; // FE derived
isArbiter?: boolean; // FE derived
}
export interface TeamInviteWithProposal extends TeamInvite {