Temporary checkin, fuckup all of the types on the frontend.

This commit is contained in:
Will O'Beirne 2018-11-14 11:43:00 -05:00
parent 95c765834a
commit 8245795306
No known key found for this signature in database
GPG Key ID: 44C190DB5DEAF9F6
25 changed files with 275 additions and 279 deletions

View File

@ -124,6 +124,30 @@ class Proposal(db.Model):
return Proposal(
**kwargs
)
def update(
self,
title: str = '',
brief: str = '',
category: str = '',
details: str = '',
target: str = '0',
payout_address: str = '',
trustees: List[str] = [],
deadline_duration: int = 5184000, # 60 days
vote_duration: int = 604800 # 7 days
):
self.title = title
self.brief = brief
self.category = category
self.content = details
self.target = target
self.payout_address = payout_address
self.trustees = ','.join(trustees)
self.deadline_duration = deadline_duration
self.vote_duration = vote_duration
Proposal.validate(vars(self))
def publish(self):
# Require certain fields
@ -149,19 +173,24 @@ class ProposalSchema(ma.Schema):
"stage",
"date_created",
"title",
"brief",
"proposal_id",
"proposal_address",
"body",
"content",
"comments",
"updates",
"milestones",
"category",
"team"
"team",
"trustees",
"payout_address",
"deadline_duration",
"vote_duration"
)
date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id")
body = ma.Method("get_body")
trustees = ma.Method("get_trustees")
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
@ -177,6 +206,10 @@ class ProposalSchema(ma.Schema):
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_trustees(self, obj):
print(obj.trustees)
return obj.trustees.split(',')
proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True)

View File

@ -109,7 +109,7 @@ def get_proposal_drafts():
parameter('title', type=str),
parameter('brief', type=str),
parameter('category', type=str),
parameter('content', type=str),
parameter('details', type=str),
parameter('target', type=str),
parameter('payoutAddress', type=str),
parameter('trustees', type=list),
@ -117,14 +117,10 @@ def get_proposal_drafts():
parameter('voteDuration', type=int),
parameter('milestones', type=list)
)
def update_proposal(milestones, trustees, **kwargs):
def update_proposal(milestones, proposal_id, **kwargs):
# Update the base proposal fields
for key, value in kwargs.items():
g.current_proposal[key] = value
if trustees:
g.current_proposal.trustees = ','.join(trustees)
try:
Proposal.validate(g.current_proposal)
g.current_proposal.update(**kwargs)
except ValidationException as e:
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
db.session.add(g.current_proposal)
@ -147,7 +143,8 @@ def update_proposal(milestones, trustees, **kwargs):
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>", methods=["PUT"])
@blueprint.route("/<proposal_id>", methods=["DELETE"])
@requires_team_member_auth
@endpoint.api()
def delete_proposal_draft():
@ -157,6 +154,7 @@ def delete_proposal_draft():
db.session.commit()
return None, 202
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth
@endpoint.api(

View File

@ -5,7 +5,6 @@ import {
formatTeamMemberFromGet,
generateProposalUrl,
} from 'utils/api';
import { PROPOSAL_CATEGORY } from './constants';
export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/v1/proposals/').then(res => {
@ -34,16 +33,7 @@ export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
}
export function postProposal(payload: {
// TODO type Milestone
accountAddress: string;
crowdFundContractAddress: string;
content: string;
title: string;
category: PROPOSAL_CATEGORY;
milestones: object[];
team: TeamMember[];
}) {
export function postProposal(payload: ProposalDraft) {
return axios.post(`/api/v1/proposals/`, {
...payload,
// Team has a different shape for POST
@ -114,3 +104,9 @@ export function getProposalDrafts(): Promise<{ data: ProposalDraft[] }> {
export function postProposalDraft(): Promise<{ data: ProposalDraft }> {
return axios.post('/api/v1/proposals/drafts');
}
export function putProposal(proposal: ProposalDraft): Promise<{ data: ProposalDraft }> {
// Exclude some keys
const { proposalId, stage, dateCreated, ...rest } = proposal;
return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest);
}

View File

@ -2,20 +2,20 @@ import React from 'react';
import { Input, Form, Icon, Select } from 'antd';
import { SelectValue } from 'antd/lib/select';
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import { CreateFormState } from 'types';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
import { typedKeys } from 'utils/ts';
interface State {
interface State extends Partial<ProposalDraft> {
title: string;
brief: string;
category: PROPOSAL_CATEGORY | null;
amountToRaise: string;
category?: PROPOSAL_CATEGORY;
target: string;
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
export default class CreateFlowBasics extends React.Component<Props, State> {
@ -24,8 +24,8 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
this.state = {
title: '',
brief: '',
category: null,
amountToRaise: '',
category: undefined,
target: '',
...(props.initialState || {}),
};
}
@ -46,7 +46,7 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
};
render() {
const { title, brief, category, amountToRaise } = this.state;
const { title, brief, category, target } = this.state;
const errors = getCreateErrors(this.state, true);
return (
@ -101,17 +101,15 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
<Form.Item
label="Target amount"
validateStatus={errors.amountToRaise ? 'error' : undefined}
help={
errors.amountToRaise || 'This cannot be changed once your proposal starts'
}
validateStatus={errors.target ? 'error' : undefined}
help={errors.target || 'This cannot be changed once your proposal starts'}
>
<Input
size="large"
name="amountToRaise"
name="target"
placeholder="1.5"
type="number"
value={amountToRaise}
value={target}
onChange={this.handleInputChange}
addonAfter="ETH"
/>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Form } from 'antd';
import MarkdownEditor from 'components/MarkdownEditor';
import { CreateFormState } from 'types';
import { ProposalDraft } from 'types';
interface State {
details: string;
@ -9,7 +9,7 @@ interface State {
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {

View File

@ -17,7 +17,6 @@ interface StateProps {
interface DispatchProps {
createProposal: typeof createActions['createProposal'];
resetForm: typeof createActions['resetForm'];
}
type Props = StateProps & DispatchProps;
@ -27,12 +26,6 @@ class CreateFinal extends React.Component<Props> {
this.create();
}
componentDidUpdate(prevProps: Props) {
if (!prevProps.crowdFundCreatedAddress && this.props.crowdFundCreatedAddress) {
this.props.resetForm();
}
}
render() {
const { crowdFundError, crowdFundCreatedAddress, createdProposal } = this.props;
let content;
@ -70,7 +63,9 @@ class CreateFinal extends React.Component<Props> {
}
private create = () => {
this.props.createProposal(this.props.form);
if (this.props.form) {
this.props.createProposal(this.props.form);
}
};
}
@ -86,6 +81,5 @@ export default connect<StateProps, DispatchProps, {}, AppState>(
}),
{
createProposal: createActions.createProposal,
resetForm: createActions.resetForm,
},
)(CreateFinal);

View File

@ -1,52 +1,52 @@
import React from 'react';
import { Input, Form, Icon, Button, Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { CreateFormState } from 'types';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
import { ONE_DAY } from 'utils/time';
import { DONATION } from 'utils/constants';
interface State {
payOutAddress: string;
payoutAddress: string;
trustees: string[];
deadline: number;
milestoneDeadline: number;
deadlineDuration: number;
voteDuration: number;
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
payOutAddress: '',
payoutAddress: '',
trustees: [],
deadline: ONE_DAY * 60,
milestoneDeadline: ONE_DAY * 7,
deadlineDuration: ONE_DAY * 60,
voteDuration: ONE_DAY * 7,
...(props.initialState || {}),
};
}
render() {
const { payOutAddress, trustees, deadline, milestoneDeadline } = this.state;
const { payoutAddress, trustees, deadlineDuration, voteDuration } = this.state;
const errors = getCreateErrors(this.state, true);
return (
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
<Form.Item
label="Payout address"
validateStatus={errors.payOutAddress ? 'error' : undefined}
help={errors.payOutAddress}
validateStatus={errors.payoutAddress ? 'error' : undefined}
help={errors.payoutAddress}
>
<Input
size="large"
name="payOutAddress"
name="payoutAddress"
placeholder={DONATION.ETH}
type="text"
value={payOutAddress}
value={payoutAddress}
onChange={this.handleInputChange}
/>
</Form.Item>
@ -57,7 +57,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
size="large"
type="text"
disabled
value={payOutAddress}
value={payoutAddress}
/>
</Form.Item>
{trustees.map((address, idx) => (
@ -82,13 +82,13 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
<Form.Item label="Funding Deadline">
<Radio.Group
name="deadline"
value={deadline}
name="deadlineDuration"
value={deadlineDuration}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
{deadline === 300 && (
{deadlineDuration === 300 && (
<Radio.Button style={{ flex: 1 }} value={300}>
5 minutes
</Radio.Button>
@ -107,13 +107,13 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
<Form.Item label="Milestone Voting Period">
<Radio.Group
name="milestoneDeadline"
value={milestoneDeadline}
name="voteDuration"
value={voteDuration}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
{milestoneDeadline === 60 && (
{voteDuration === 60 && (
<Radio.Button style={{ flex: 1 }} value={60}>
60 Seconds
</Radio.Button>

View File

@ -1,25 +1,25 @@
import React from 'react';
import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd';
import moment from 'moment';
import { CreateFormState, CreateMilestone } from 'types';
import { ProposalDraft, CreateMilestone } from 'types';
import { getCreateErrors } from 'modules/create/utils';
interface State {
milestones: CreateMilestone[];
milestones: ProposalDraft['milestones'];
}
interface Props {
initialState: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
const DEFAULT_STATE: State = {
milestones: [
{
title: '',
description: '',
date: '',
payoutPercent: 100,
content: '',
dateEstimated: '',
payoutPercent: '100',
immediatePayout: false,
},
],
@ -53,17 +53,17 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
addMilestone = () => {
const { milestones: oldMilestones } = this.state;
const lastMilestone = oldMilestones[oldMilestones.length - 1];
const halfPayout = lastMilestone.payoutPercent / 2;
const halfPayout = parseInt(lastMilestone.payoutPercent, 10) / 2;
const milestones = [
...oldMilestones,
{
...DEFAULT_STATE.milestones[0],
payoutPercent: halfPayout,
payoutPercent: halfPayout.toString(),
},
];
milestones[milestones.length - 2] = {
...lastMilestone,
payoutPercent: halfPayout,
payoutPercent: halfPayout.toString(),
};
this.setState({ milestones });
};
@ -148,9 +148,9 @@ const MilestoneFields = ({
rows={3}
name="body"
placeholder="Description of the deliverable"
value={milestone.description}
value={milestone.content}
onChange={ev =>
onChange(index, { ...milestone, description: ev.currentTarget.value })
onChange(index, { ...milestone, content: ev.currentTarget.value })
}
/>
</div>
@ -159,10 +159,14 @@ const MilestoneFields = ({
<DatePicker.MonthPicker
style={{ flex: 1, marginRight: '0.5rem' }}
placeholder="Expected completion date"
value={milestone.date ? moment(milestone.date, 'MMMM YYYY') : undefined}
value={
milestone.dateEstimated
? moment(milestone.dateEstimated, 'MMMM YYYY')
: undefined
}
format="MMMM YYYY"
allowClear={false}
onChange={(_, date) => onChange(index, { ...milestone, date })}
onChange={(_, dateEstimated) => onChange(index, { ...milestone, dateEstimated })}
/>
<Input
min={1}
@ -172,7 +176,7 @@ const MilestoneFields = ({
onChange={ev =>
onChange(index, {
...milestone,
payoutPercent: parseInt(ev.currentTarget.value, 10) || 0,
payoutPercent: ev.currentTarget.value || '0',
})
}
addonAfter="%"

View File

@ -3,10 +3,11 @@ import { connect } from 'react-redux';
import { Alert } from 'antd';
import { ProposalDetail } from 'components/Proposal';
import { AppState } from 'store/reducers';
import { makeProposalPreviewFromForm } from 'modules/create/utils';
import { makeProposalPreviewFromDraft } from 'modules/create/utils';
import { ProposalDraft } from 'types';
interface StateProps {
form: AppState['create']['form'];
form: ProposalDraft;
}
type Props = StateProps;
@ -14,7 +15,7 @@ type Props = StateProps;
class CreateFlowPreview extends React.Component<Props> {
render() {
const { form } = this.props;
const proposal = makeProposalPreviewFromForm(form);
const proposal = makeProposalPreviewFromDraft(form);
return (
<>
<Alert
@ -37,5 +38,5 @@ class CreateFlowPreview extends React.Component<Props> {
}
export default connect<StateProps, {}, {}, AppState>(state => ({
form: state.create.form,
form: state.create.form as ProposalDraft,
}))(CreateFlowPreview);

View File

@ -4,18 +4,19 @@ import { Icon, Timeline } from 'antd';
import moment from 'moment';
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
import Markdown from 'components/Markdown';
import UserAvatar from 'components/UserAvatar';
import { AppState } from 'store/reducers';
import { CREATE_STEP } from './index';
import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants';
import { ProposalDraft } from 'types';
import './Review.less';
import UserAvatar from 'components/UserAvatar';
interface OwnProps {
setStep(step: CREATE_STEP): void;
}
interface StateProps {
form: AppState['create']['form'];
form: ProposalDraft;
}
type Props = OwnProps & StateProps;
@ -62,9 +63,9 @@ class CreateReview extends React.Component<Props> {
error: errors.category,
},
{
key: 'amountToRaise',
content: <div style={{ fontSize: '1.2rem' }}>{form.amountToRaise} ETH</div>,
error: errors.amountToRaise,
key: 'target',
content: <div style={{ fontSize: '1.2rem' }}>{form.target} ETH</div>,
error: errors.target,
},
],
},
@ -106,9 +107,9 @@ class CreateReview extends React.Component<Props> {
name: 'Governance',
fields: [
{
key: 'payOutAddress',
content: <code>{form.payOutAddress}</code>,
error: errors.payOutAddress,
key: 'payoutAddress',
content: <code>{form.payoutAddress}</code>,
error: errors.payoutAddress,
},
{
key: 'trustees',
@ -120,18 +121,18 @@ class CreateReview extends React.Component<Props> {
error: errors.trustees && errors.trustees.join(' '),
},
{
key: 'deadline',
key: 'deadlineDuration',
content: `${Math.floor(
moment.duration((form.deadline || 0) * 1000).asDays(),
moment.duration((form.deadlineDuration || 0) * 1000).asDays(),
)} days`,
error: errors.deadline,
error: errors.deadlineDuration,
},
{
key: 'milestoneDeadline',
key: 'voteDuration',
content: `${Math.floor(
moment.duration((form.milestoneDeadline || 0) * 1000).asDays(),
moment.duration((form.voteDuration || 0) * 1000).asDays(),
)} days`,
error: errors.milestoneDeadline,
error: errors.voteDuration,
},
],
},
@ -183,13 +184,13 @@ class CreateReview extends React.Component<Props> {
}
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
form: state.create.form,
form: state.create.form as ProposalDraft,
}))(CreateReview);
const ReviewMilestones = ({
milestones,
}: {
milestones: AppState['create']['form']['milestones'];
milestones: ProposalDraft['milestones'];
}) => (
<Timeline>
{milestones.map(m => (
@ -197,18 +198,18 @@ const ReviewMilestones = ({
<div className="ReviewMilestone">
<div className="ReviewMilestone-title">{m.title}</div>
<div className="ReviewMilestone-info">
{moment(m.date, 'MMMM YYYY').format('MMMM YYYY')}
{moment(m.dateEstimated, 'MMMM YYYY').format('MMMM YYYY')}
{' '}
{m.payoutPercent}% of funds
</div>
<div className="ReviewMilestone-description">{m.description}</div>
<div className="ReviewMilestone-description">{m.content}</div>
</div>
</Timeline.Item>
))}
</Timeline>
);
const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => (
const ReviewTeam = ({ team }: { team: ProposalDraft['team'] }) => (
<div className="ReviewTeam">
{team.map((u, idx) => (
<div className="ReviewTeam-member" key={idx}>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { Icon } from 'antd';
import { CreateFormState, TeamMember } from 'types';
import { TeamMember, ProposalDraft } from 'types';
import TeamMemberComponent from './TeamMember';
import './Team.less';
import { AppState } from 'store/reducers';
@ -16,7 +16,7 @@ interface StateProps {
interface OwnProps {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
type Props = OwnProps & StateProps;

View File

@ -1,5 +1,5 @@
import { PROPOSAL_CATEGORY } from 'api/constants';
import { SOCIAL_TYPE, CreateFormState } from 'types';
import { SOCIAL_TYPE, ProposalDraft } from 'types';
function generateRandomAddress() {
return (
@ -20,9 +20,9 @@ function generateRandomAddress() {
}
const createExampleProposal = (
payOutAddress: string,
payoutAddress: string,
trustees: string[],
): CreateFormState => {
): ProposalDraft => {
return {
title: 'Grant.io T-Shirts',
brief: "The most stylish wear, sporting your favorite brand's logo",
@ -34,7 +34,7 @@ const createExampleProposal = (
avatarUrl: `https://randomuser.me/api/portraits/men/${Math.floor(
Math.random() * 80,
)}.jpg`,
ethAddress: payOutAddress,
ethAddress: payoutAddress,
emailAddress: 'test@grant.io',
socialAccounts: {
[SOCIAL_TYPE.GITHUB]: 'dternyak',
@ -57,37 +57,41 @@ const createExampleProposal = (
],
details:
'![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000',
amountToRaise: '5',
payOutAddress,
target: '5',
payoutAddress,
trustees,
milestones: [
{
title: 'Initial Funding',
description:
content:
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
date: 'October 2018',
payoutPercent: 30,
dateEstimated: 'October 2018',
payoutPercent: '30',
immediatePayout: true,
},
{
title: 'Test Prints',
description:
content:
"We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.",
date: 'November 2018',
payoutPercent: 20,
dateEstimated: 'November 2018',
payoutPercent: '20',
immediatePayout: false,
},
{
title: 'All Shirts Printed',
description:
content:
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
date: 'December 2018',
payoutPercent: 50,
dateEstimated: 'December 2018',
payoutPercent: '50',
immediatePayout: false,
},
],
deadline: 300,
milestoneDeadline: 60,
deadlineDuration: 300,
voteDuration: 60,
// Unused
proposalId: 123456789,
dateCreated: 0,
stage: '',
};
};

View File

@ -16,7 +16,7 @@ import Preview from './Preview';
import Final from './Final';
import createExampleProposal from './example';
import { createActions } from 'modules/create';
import { CreateFormState } from 'types';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
@ -126,7 +126,7 @@ interface State {
class CreateFlow extends React.Component<Props, State> {
private historyUnlisten: () => void;
private debouncedUpdateForm: (form: Partial<CreateFormState>) => void;
private debouncedUpdateForm: (form: Partial<ProposalDraft>) => void;
constructor(props: Props) {
super(props);
@ -247,7 +247,7 @@ class CreateFlow extends React.Component<Props, State> {
);
}
private updateForm = (form: Partial<CreateFormState>) => {
private updateForm = (form: Partial<ProposalDraft>) => {
this.props.updateForm(form);
};
@ -275,6 +275,9 @@ class CreateFlow extends React.Component<Props, State> {
};
private checkFormErrors = () => {
if (!this.props.form) {
return true;
}
const errors = getCreateErrors(this.props.form);
return !!Object.keys(errors).length;
};

View File

@ -208,7 +208,7 @@ class ProposalMilestones extends React.Component<Props, State> {
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
{statuses}
{notification}
{milestone.body}
{milestone.content}
</div>
{this.state.activeMilestoneIdx === i &&
!wasRefunded && (

View File

@ -1,17 +1,11 @@
import { Dispatch } from 'redux';
import { CreateFormState } from 'types';
import { getProposalDrafts } from 'api/api';
import { sleep } from 'utils/helpers';
import { ProposalDraft } from 'types';
import { AppState } from 'store/reducers';
import { createCrowdFund } from 'modules/web3/actions';
import { formToBackendData, formToContractData } from './utils';
import types, { CreateDraftOptions } from './types';
type GetState = () => AppState;
// TODO: Replace with server side storage
const LS_DRAFT_KEY = 'CREATE_PROPOSAL_DRAFT';
export function initializeForm(proposalId: number) {
return {
type: types.INITIALIZE_FORM_PENDING,
@ -19,7 +13,7 @@ export function initializeForm(proposalId: number) {
};
}
export function updateForm(form: Partial<CreateFormState>) {
export function updateForm(form: Partial<ProposalDraft>) {
return (dispatch: Dispatch<any>) => {
dispatch({
type: types.UPDATE_FORM,
@ -30,15 +24,7 @@ export function updateForm(form: Partial<CreateFormState>) {
}
export function saveDraft() {
return async (dispatch: Dispatch<any>, getState: GetState) => {
const { form } = getState().create;
dispatch({ type: types.SAVE_DRAFT_PENDING });
await sleep(1000);
// TODO: Replace with server side save
localStorage.setItem(LS_DRAFT_KEY, JSON.stringify(form));
dispatch({ type: types.SAVE_DRAFT_FULFILLED });
};
return { type: types.SAVE_DRAFT_PENDING };
}
export function fetchDrafts() {
@ -52,15 +38,13 @@ export function createDraft(opts: CreateDraftOptions = {}) {
};
}
export function createProposal(form: CreateFormState) {
export function createProposal(form: ProposalDraft) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
const state = getState();
// TODO: Handle if contract is unavailable
const contract = state.web3.contracts[0];
// TODO: Move more of the backend handling into this action.
dispatch(
createCrowdFund(contract, formToContractData(form), formToBackendData(form)),
);
dispatch(createCrowdFund(contract, form));
// TODO: dispatch reset conditionally, if crowd fund is success
};
}

View File

@ -1,9 +1,9 @@
import types from './types';
import { CreateFormState, ProposalDraft } from 'types';
import { ProposalDraft } from 'types';
export interface CreateState {
drafts: ProposalDraft[] | null;
form: CreateFormState | null;
form: ProposalDraft | null;
isInitializingForm: boolean;
initializeFormError: string | null;

View File

@ -1,9 +1,9 @@
import { SagaIterator } from 'redux-saga';
import { takeEvery, takeLatest, put, call, select } from 'redux-saga/effects';
import { push } from 'connected-react-router';
import { postProposalDraft, getProposalDrafts } from 'api/api';
import { getDraftById } from './selectors';
import { createDraft, fetchDrafts, initializeForm } from './actions';
import { postProposalDraft, getProposalDrafts, putProposal } from 'api/api';
import { getDraftById, getFormState } from './selectors';
import { createDraft, initializeForm } from './actions';
import types from './types';
export function* handleCreateDraft(action: ReturnType<typeof createDraft>): SagaIterator {
@ -42,6 +42,23 @@ export function* handleFetchDrafts(): SagaIterator {
}
}
export function* handleSaveDraft(): SagaIterator {
try {
const draft: Yielded<typeof getFormState> = yield select(getFormState);
if (!draft) {
throw new Error('No form state to save draft');
}
yield call(putProposal, draft);
yield put({ type: types.SAVE_DRAFT_FULFILLED });
} catch (err) {
yield put({
type: types.SAVE_DRAFT_REJECTED,
payload: err.message || err.toString(),
error: true,
});
}
}
export function* handleInitializeForm(
action: ReturnType<typeof initializeForm>,
): SagaIterator {
@ -70,5 +87,6 @@ export function* handleInitializeForm(
export default function* createSagas(): SagaIterator {
yield takeEvery(types.CREATE_DRAFT_PENDING, handleCreateDraft);
yield takeLatest(types.FETCH_DRAFTS_PENDING, handleFetchDrafts);
yield takeLatest(types.SAVE_DRAFT_PENDING, handleSaveDraft);
yield takeEvery(types.INITIALIZE_FORM_PENDING, handleInitializeForm);
}

View File

@ -6,3 +6,5 @@ export const getDraftById = (s: S, id: number) => {
}
return s.create.drafts.find(d => d.proposalId === id);
};
export const getFormState = (s: S) => s.create.form;

View File

@ -1,8 +1,8 @@
import { CreateFormState, CreateMilestone } from 'types';
import { ProposalDraft, CreateMilestone } from 'types';
import { TeamMember } from 'types';
import { isValidEthAddress, getAmountError } from 'utils/validators';
import { MILESTONE_STATE, ProposalWithCrowdFund } from 'types';
import { ProposalContractData, ProposalBackendData } from 'modules/web3/actions';
import { ProposalContractData } from 'modules/web3/actions';
import { Wei, toWei } from 'utils/units';
import { ONE_DAY } from 'utils/time';
import { PROPOSAL_CATEGORY } from 'api/constants';
@ -14,43 +14,49 @@ interface CreateFormErrors {
title?: string;
brief?: string;
category?: string;
amountToRaise?: string;
target?: string;
team?: string[];
details?: string;
payOutAddress?: string;
payoutAddress?: string;
trustees?: string[];
milestones?: string[];
deadline?: string;
milestoneDeadline?: string;
deadlineDuration?: string;
voteDuration?: string;
}
export type KeyOfForm = keyof CreateFormState;
export type KeyOfForm = keyof Partial<ProposalDraft>;
export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
title: 'Title',
brief: 'Brief',
category: 'Category',
amountToRaise: 'Target amount',
target: 'Target amount',
team: 'Team',
details: 'Details',
payOutAddress: 'Payout address',
payoutAddress: 'Payout address',
trustees: 'Trustees',
milestones: 'Milestones',
deadline: 'Funding deadline',
milestoneDeadline: 'Milestone deadline',
deadlineDuration: 'Funding deadline',
voteDuration: 'Milestone deadline',
// Unused, but required by the type definition
proposalId: '',
dateCreated: '',
stage: '',
};
export function getCreateErrors(
form: Partial<CreateFormState>,
form: Partial<ProposalDraft>,
skipRequired?: boolean,
): CreateFormErrors {
const errors: CreateFormErrors = {};
const { title, team, milestones, amountToRaise, payOutAddress, trustees } = form;
const { title, team, milestones, target, payoutAddress, trustees } = form;
// Required fields with no extra validation
if (!skipRequired) {
for (const key in form) {
if (!form[key as KeyOfForm]) {
errors[key as KeyOfForm] = `${FIELD_NAME_MAP[key as KeyOfForm]} is required`;
(errors as any)[key as KeyOfForm] = `${
FIELD_NAME_MAP[key as KeyOfForm]
} is required`;
}
}
@ -68,17 +74,17 @@ export function getCreateErrors(
}
// Amount to raise
const amountFloat = amountToRaise ? parseFloat(amountToRaise) : 0;
if (amountToRaise && !Number.isNaN(amountFloat)) {
const amountError = getAmountError(amountFloat, TARGET_ETH_LIMIT);
if (amountError) {
errors.amountToRaise = amountError;
const targetFloat = target ? parseFloat(target) : 0;
if (target && !Number.isNaN(targetFloat)) {
const targetErr = getAmountError(targetFloat, TARGET_ETH_LIMIT);
if (targetErr) {
errors.target = targetErr;
}
}
// Payout address
if (payOutAddress && !isValidEthAddress(payOutAddress)) {
errors.payOutAddress = 'That doesnt look like a valid address';
if (payoutAddress && !isValidEthAddress(payoutAddress)) {
errors.payoutAddress = 'That doesnt look like a valid address';
}
// Trustees
@ -94,7 +100,7 @@ export function getCreateErrors(
err = 'That doesnt look like a valid address';
} else if (trustees.indexOf(address) !== idx) {
err = 'That address is already a trustee';
} else if (payOutAddress === address) {
} else if (payoutAddress === address) {
err = 'That address is already a trustee';
}
@ -111,7 +117,7 @@ export function getCreateErrors(
let didMilestoneError = false;
let cumulativeMilestonePct = 0;
const milestoneErrors = milestones.map((ms, idx) => {
if (!ms.title || !ms.description || !ms.date || !ms.payoutPercent) {
if (!ms.title || !ms.content || !ms.dateEstimated || !ms.payoutPercent) {
didMilestoneError = true;
return '';
}
@ -119,12 +125,12 @@ export function getCreateErrors(
let err = '';
if (ms.title.length > 40) {
err = 'Title length can only be 40 characters maximum';
} else if (ms.description.length > 200) {
} else if (ms.content.length > 200) {
err = 'Description can only be 200 characters maximum';
}
// Last one shows percentage errors
cumulativeMilestonePct += ms.payoutPercent;
cumulativeMilestonePct += parseInt(ms.payoutPercent, 10);
if (idx === milestones.length - 1 && cumulativeMilestonePct !== 100) {
err = `Payout percentages doesnt add up to 100% (currently ${cumulativeMilestonePct}%)`;
}
@ -173,11 +179,11 @@ export function getCreateTeamMemberError(user: TeamMember) {
}
function milestoneToMilestoneAmount(milestone: CreateMilestone, raiseGoal: Wei) {
return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent.toString()));
return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent));
}
export function formToContractData(form: CreateFormState): ProposalContractData {
const targetInWei = toWei(form.amountToRaise, 'ether');
export function proposalToContractData(form: ProposalDraft): ProposalContractData {
const targetInWei = toWei(form.target, 'ether');
const milestoneAmounts = form.milestones.map(m =>
milestoneToMilestoneAmount(m, targetInWei),
);
@ -185,51 +191,41 @@ export function formToContractData(form: CreateFormState): ProposalContractData
return {
ethAmount: targetInWei,
payOutAddress: form.payOutAddress,
payoutAddress: form.payoutAddress,
trusteesAddresses: form.trustees,
milestoneAmounts,
milestones: form.milestones,
durationInMinutes: form.deadline || ONE_DAY * 60,
milestoneVotingPeriodInMinutes: form.milestoneDeadline || ONE_DAY * 7,
durationInMinutes: form.deadlineDuration || ONE_DAY * 60,
milestoneVotingPeriodInMinutes: form.voteDuration || ONE_DAY * 7,
immediateFirstMilestonePayout,
};
}
export function formToBackendData(form: CreateFormState): ProposalBackendData {
return {
title: form.title,
category: form.category as PROPOSAL_CATEGORY,
content: form.details,
team: form.team,
};
}
// This is kind of a disgusting function, sorry.
export function makeProposalPreviewFromForm(
form: CreateFormState,
export function makeProposalPreviewFromDraft(
draft: ProposalDraft,
): ProposalWithCrowdFund {
const target = parseFloat(form.amountToRaise);
const target = parseFloat(draft.target);
return {
proposalId: 0,
proposalUrlId: '0-title',
proposalAddress: '0x0',
dateCreated: Date.now(),
title: form.title,
body: form.details,
title: draft.title,
body: draft.details,
stage: 'preview',
category: form.category || PROPOSAL_CATEGORY.DAPP,
team: form.team,
milestones: form.milestones.map((m, idx) => ({
category: draft.category || PROPOSAL_CATEGORY.DAPP,
team: draft.team,
milestones: draft.milestones.map((m, idx) => ({
index: idx,
title: m.title,
body: m.description,
content: m.description,
amount: toWei(target * (m.payoutPercent / 100), 'ether'),
body: m.content,
content: m.content,
amount: toWei(target * (parseInt(m.payoutPercent, 10) / 100), 'ether'),
amountAgainstPayout: Wei('0'),
percentAgainstPayout: 0,
payoutRequestVoteDeadline: Date.now(),
dateEstimated: m.date,
dateEstimated: m.dateEstimated,
immediatePayout: m.immediatePayout,
isImmediatePayout: m.immediatePayout,
isPaid: false,
@ -238,15 +234,15 @@ export function makeProposalPreviewFromForm(
stage: MILESTONE_STATE.WAITING,
})),
crowdFund: {
immediateFirstMilestonePayout: form.milestones[0].immediatePayout,
immediateFirstMilestonePayout: draft.milestones[0].immediatePayout,
balance: Wei('0'),
funded: Wei('0'),
percentFunded: 0,
target: toWei(target, 'ether'),
amountVotingForRefund: Wei('0'),
percentVotingForRefund: 0,
beneficiary: form.payOutAddress,
trustees: form.trustees,
beneficiary: draft.payoutAddress,
trustees: draft.trustees,
deadline: Date.now() + 100000,
contributors: [],
milestones: [],

View File

@ -1,15 +1,14 @@
import types from './types';
import { Dispatch } from 'redux';
import getWeb3 from 'lib/getWeb3';
import { postProposal } from 'api/api';
import getContract, { WrongNetworkError } from 'lib/getContract';
import { sleep } from 'utils/helpers';
import { web3ErrorToString } from 'utils/web3';
import { fetchProposal, fetchProposals } from 'modules/proposals/actions';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { proposalToContractData } from 'modules/create/utils';
import { AppState } from 'store/reducers';
import { Wei } from 'utils/units';
import { TeamMember, AuthSignatureData } from 'types';
import { AuthSignatureData, ProposalDraft } from 'types';
type GetState = () => AppState;
@ -95,38 +94,18 @@ export function setAccounts() {
}
// TODO: Move these to a better place?
interface MilestoneData {
title: string;
description: string;
date: string;
payoutPercent: number;
immediatePayout: boolean;
}
export interface ProposalContractData {
ethAmount: Wei;
payOutAddress: string;
payoutAddress: string;
trusteesAddresses: string[];
milestoneAmounts: Wei[];
milestones: MilestoneData[];
durationInMinutes: number;
milestoneVotingPeriodInMinutes: number;
immediateFirstMilestonePayout: boolean;
}
export interface ProposalBackendData {
title: string;
content: string;
category: PROPOSAL_CATEGORY;
team: TeamMember[];
}
export type TCreateCrowdFund = typeof createCrowdFund;
export function createCrowdFund(
CrowdFundFactoryContract: any,
contractData: ProposalContractData,
backendData: ProposalBackendData,
) {
export function createCrowdFund(CrowdFundFactoryContract: any, proposal: ProposalDraft) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({
type: types.CROWD_FUND_PENDING,
@ -134,16 +113,13 @@ export function createCrowdFund(
const {
ethAmount,
payOutAddress,
payoutAddress,
trusteesAddresses,
milestoneAmounts,
milestones,
durationInMinutes,
milestoneVotingPeriodInMinutes,
immediateFirstMilestonePayout,
} = contractData;
const { content, title, category, team } = backendData;
} = proposalToContractData(proposal);
const state = getState();
const accounts = state.web3.accounts;
@ -152,8 +128,8 @@ export function createCrowdFund(
await CrowdFundFactoryContract.methods
.createCrowdFund(
ethAmount,
payOutAddress,
[payOutAddress, ...trusteesAddresses],
payoutAddress,
[payoutAddress, ...trusteesAddresses],
milestoneAmounts,
durationInMinutes,
milestoneVotingPeriodInMinutes,
@ -163,15 +139,8 @@ export function createCrowdFund(
.once('confirmation', async (_: any, receipt: any) => {
const crowdFundContractAddress =
receipt.events.ContractCreated.returnValues.newAddress;
await postProposal({
accountAddress: accounts[0],
crowdFundContractAddress,
content,
title,
milestones,
category,
team,
});
// TODO: Publish proposal
// await postProposal(proposal);
dispatch({
type: types.CROWD_FUND_CREATED,
payload: crowdFundContractAddress,

View File

@ -32,7 +32,11 @@ class ProposalEdit extends React.Component<Props> {
return (
<Web3Container
renderLoading={() => <Spin />}
render={({ accounts }) => <CreateFlow accounts={accounts} />}
render={({ accounts }) => (
<div style={{ paddingTop: '3rem', paddingBottom: '8rem' }}>
<CreateFlow accounts={accounts} />
</div>
)}
/>
);
} else if (initializeFormError) {

View File

@ -1,16 +0,0 @@
import { PROPOSAL_CATEGORY } from 'api/constants';
import { TeamMember, CreateMilestone } from 'types';
export interface CreateFormState {
title: string;
brief: string;
category: PROPOSAL_CATEGORY | null;
amountToRaise: string;
details: string;
payOutAddress: string;
trustees: string[];
milestones: CreateMilestone[];
team: TeamMember[];
deadline: number | null;
milestoneDeadline: number | null;
}

View File

@ -1,6 +1,5 @@
export * from './user';
export * from './social';
export * from './create';
export * from './comment';
export * from './milestone';
export * from './update';

View File

@ -18,9 +18,7 @@ export interface Milestone {
isImmediatePayout: boolean;
}
// TODO - have backend camelCase keys before response
export interface ProposalMilestone extends Milestone {
body: string;
content: string;
immediatePayout: boolean;
dateEstimated: string;
@ -31,8 +29,8 @@ export interface ProposalMilestone extends Milestone {
export interface CreateMilestone {
title: string;
description: string;
date: string;
payoutPercent: number;
content: string;
dateEstimated: string;
payoutPercent: string;
immediatePayout: boolean;
}

View File

@ -1,8 +1,13 @@
import { TeamMember } from 'types';
import { Wei } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { Comment } from 'types';
import { Milestone, ProposalMilestone, Update } from 'types';
import {
CreateMilestone,
ProposalMilestone,
Update,
TeamMember,
Milestone,
Comment,
} from 'types';
export interface Contributor {
address: string;
@ -36,10 +41,15 @@ export interface ProposalDraft {
dateCreated: number;
title: string;
brief: string;
body: string;
category: PROPOSAL_CATEGORY;
details: string;
stage: string;
category?: PROPOSAL_CATEGORY;
milestones: ProposalMilestone[];
target: string;
payoutAddress: string;
trustees: string[];
deadlineDuration: number;
voteDuration: number;
milestones: CreateMilestone[];
team: TeamMember[];
}