Merge pull request #211 from grant-project/server-side-drafts
Overhaul create to provide server side drafts, team invites, and a bunch of refactors
This commit is contained in:
commit
ad6173b376
|
@ -85,7 +85,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
||||||
};
|
};
|
||||||
render() {
|
render() {
|
||||||
const p = this.props;
|
const p = this.props;
|
||||||
const body = showdownConverter.makeHtml(p.body);
|
const body = showdownConverter.makeHtml(p.content);
|
||||||
return (
|
return (
|
||||||
<div key={p.proposalId} className="Proposals-proposal">
|
<div key={p.proposalId} className="Proposals-proposal">
|
||||||
<div>
|
<div>
|
||||||
|
@ -181,7 +181,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
||||||
<span>(payoutPercent)</span>
|
<span>(payoutPercent)</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{ms.body}
|
{ms.content}
|
||||||
<span>(body)</span>
|
<span>(body)</span>
|
||||||
</div>
|
</div>
|
||||||
{/* <small>content</small>
|
{/* <small>content</small>
|
||||||
|
|
|
@ -3,7 +3,6 @@ export interface SocialMedia {
|
||||||
socialMediaLink: string;
|
socialMediaLink: string;
|
||||||
}
|
}
|
||||||
export interface Milestone {
|
export interface Milestone {
|
||||||
body: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
dateCreated: string;
|
dateCreated: string;
|
||||||
dateEstimated: string;
|
dateEstimated: string;
|
||||||
|
@ -17,7 +16,7 @@ export interface Proposal {
|
||||||
proposalAddress: string;
|
proposalAddress: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
content: string;
|
||||||
stage: string;
|
stage: string;
|
||||||
category: string;
|
category: string;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
|
|
|
@ -30,16 +30,10 @@ class CommentSchema(ma.Schema):
|
||||||
"content",
|
"content",
|
||||||
"proposal_id",
|
"proposal_id",
|
||||||
"date_created",
|
"date_created",
|
||||||
"body",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
body = ma.Method("get_body")
|
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
|
|
||||||
def get_body(self, obj):
|
|
||||||
return obj.content
|
|
||||||
|
|
||||||
def get_date_created(self, obj):
|
def get_date_created(self, obj):
|
||||||
return dt_to_unix(obj.date_created)
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
|
|
@ -9,34 +9,46 @@ default_template_args = {
|
||||||
'unsubscribe_url': 'https://grant.io/unsubscribe',
|
'unsubscribe_url': 'https://grant.io/unsubscribe',
|
||||||
}
|
}
|
||||||
|
|
||||||
email_template_args = {
|
def signup_info(email_args):
|
||||||
'signup': {
|
return {
|
||||||
'subject': 'Confirm your email on Grant.io',
|
'subject': 'Confirm your email on Grant.io',
|
||||||
'title': 'Welcome to Grant.io!',
|
'title': 'Welcome to Grant.io!',
|
||||||
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
|
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
|
||||||
},
|
}
|
||||||
|
|
||||||
|
def team_invite_info(email_args):
|
||||||
|
return {
|
||||||
|
'subject': '{} has invited you to a project'.format(email_args.inviter.display_name),
|
||||||
|
'title': 'You’ve been invited!',
|
||||||
|
'preview': 'You’ve been invited to the "{}" project team'.format(email_args.proposal.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
get_info_lookup = {
|
||||||
|
'signup': signup_info,
|
||||||
|
'team_invite': team_invite_info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_email(to, type, email_args):
|
def send_email(to, type, email_args):
|
||||||
try:
|
try:
|
||||||
|
info = get_info_lookup[type](email_args)
|
||||||
body_text = render_template('emails/%s.txt' % (type), args=email_args)
|
body_text = render_template('emails/%s.txt' % (type), args=email_args)
|
||||||
body_html = render_template('emails/%s.html' % (type), args=email_args)
|
body_html = render_template('emails/%s.html' % (type), args=email_args)
|
||||||
|
|
||||||
html = render_template('emails/template.html', args={
|
html = render_template('emails/template.html', args={
|
||||||
**default_template_args,
|
**default_template_args,
|
||||||
**email_template_args[type],
|
**info,
|
||||||
'body': Markup(body_html),
|
'body': Markup(body_html),
|
||||||
})
|
})
|
||||||
text = render_template('emails/template.txt', args={
|
text = render_template('emails/template.txt', args={
|
||||||
**default_template_args,
|
**default_template_args,
|
||||||
**email_template_args[type],
|
**info,
|
||||||
'body': body_text,
|
'body': body_text,
|
||||||
})
|
})
|
||||||
|
|
||||||
res = mail.send_email(
|
res = mail.send_email(
|
||||||
to_email=to,
|
to_email=to,
|
||||||
subject=email_template_args[type]['subject'],
|
subject=info['subject'],
|
||||||
text=text,
|
text=text,
|
||||||
html=html,
|
html=html,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
|
||||||
NOT_REQUESTED = 'NOT_REQUESTED'
|
NOT_REQUESTED = 'NOT_REQUESTED'
|
||||||
ONGOING_VOTE = 'ONGOING_VOTE'
|
ONGOING_VOTE = 'ONGOING_VOTE'
|
||||||
|
@ -42,6 +43,11 @@ class Milestone(db.Model):
|
||||||
self.immediate_payout = immediate_payout
|
self.immediate_payout = immediate_payout
|
||||||
self.proposal_id = proposal_id
|
self.proposal_id = proposal_id
|
||||||
self.date_created = datetime.datetime.now()
|
self.date_created = datetime.datetime.now()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate(milestone):
|
||||||
|
if len(milestone.title) > 60:
|
||||||
|
raise ValidationException("Milestone title must be no more than 60 chars")
|
||||||
|
|
||||||
|
|
||||||
class MilestoneSchema(ma.Schema):
|
class MilestoneSchema(ma.Schema):
|
||||||
|
@ -50,7 +56,6 @@ class MilestoneSchema(ma.Schema):
|
||||||
# Fields to expose
|
# Fields to expose
|
||||||
fields = (
|
fields = (
|
||||||
"title",
|
"title",
|
||||||
"body",
|
|
||||||
"content",
|
"content",
|
||||||
"stage",
|
"stage",
|
||||||
"date_estimated",
|
"date_estimated",
|
||||||
|
@ -59,11 +64,6 @@ class MilestoneSchema(ma.Schema):
|
||||||
"date_created",
|
"date_created",
|
||||||
)
|
)
|
||||||
|
|
||||||
body = ma.Method("get_body")
|
|
||||||
|
|
||||||
def get_body(self, obj):
|
|
||||||
return obj.content
|
|
||||||
|
|
||||||
|
|
||||||
milestone_schema = MilestoneSchema()
|
milestone_schema = MilestoneSchema()
|
||||||
milestones_schema = MilestoneSchema(many=True)
|
milestones_schema = MilestoneSchema(many=True)
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.misc import dt_to_unix
|
from grant.utils.misc import dt_to_unix
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
|
||||||
|
DRAFT = 'DRAFT'
|
||||||
|
PENDING = 'PENDING'
|
||||||
|
LIVE = 'LIVE'
|
||||||
|
DELETED = 'DELETED'
|
||||||
|
STATUSES = [DRAFT, PENDING, LIVE, DELETED]
|
||||||
|
|
||||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||||
COMPLETED = 'COMPLETED'
|
COMPLETED = 'COMPLETED'
|
||||||
|
@ -17,16 +26,36 @@ ACCESSIBILITY = "ACCESSIBILITY"
|
||||||
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
|
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
|
||||||
|
|
||||||
|
|
||||||
class ValidationException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
proposal_team = db.Table(
|
proposal_team = db.Table(
|
||||||
'proposal_team', db.Model.metadata,
|
'proposal_team', db.Model.metadata,
|
||||||
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
|
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
|
||||||
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ProposalTeamInvite(db.Model):
|
||||||
|
__tablename__ = "proposal_team_invite"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
date_created = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||||
|
address = db.Column(db.String(255), nullable=False)
|
||||||
|
accepted = db.Column(db.Boolean)
|
||||||
|
|
||||||
|
def __init__(self, proposal_id: int, address: str, accepted: bool = None):
|
||||||
|
self.proposal_id = proposal_id
|
||||||
|
self.address = address
|
||||||
|
self.accepted = accepted
|
||||||
|
self.date_created = datetime.datetime.now()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_pending_for_user(user):
|
||||||
|
return ProposalTeamInvite.query.filter(
|
||||||
|
ProposalTeamInvite.accepted == None,
|
||||||
|
(func.lower(user.account_address) == func.lower(ProposalTeamInvite.address)) |
|
||||||
|
(func.lower(user.email_address) == func.lower(ProposalTeamInvite.address))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
class ProposalUpdate(db.Model):
|
class ProposalUpdate(db.Model):
|
||||||
__tablename__ = "proposal_update"
|
__tablename__ = "proposal_update"
|
||||||
|
@ -78,51 +107,116 @@ class Proposal(db.Model):
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
date_created = db.Column(db.DateTime)
|
date_created = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
# Database info
|
||||||
|
status = db.Column(db.String(255), nullable=False)
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
proposal_address = db.Column(db.String(255), unique=True, nullable=False)
|
brief = db.Column(db.String(255), nullable=False)
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
category = db.Column(db.String(255), nullable=False)
|
category = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
# Contract info
|
||||||
|
target = db.Column(db.String(255), nullable=False)
|
||||||
|
payout_address = db.Column(db.String(255), nullable=False)
|
||||||
|
trustees = db.Column(db.String(1024), nullable=False)
|
||||||
|
deadline_duration = db.Column(db.Integer(), nullable=False)
|
||||||
|
vote_duration = db.Column(db.Integer(), nullable=False)
|
||||||
|
proposal_address = db.Column(db.String(255), unique=True, nullable=True)
|
||||||
|
|
||||||
|
# Relations
|
||||||
team = db.relationship("User", secondary=proposal_team)
|
team = db.relationship("User", secondary=proposal_team)
|
||||||
comments = db.relationship(Comment, backref="proposal", lazy=True)
|
comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True)
|
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True)
|
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
|
milestones = db.relationship("Milestone", backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
|
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stage: str,
|
status: str = 'DRAFT',
|
||||||
proposal_address: str,
|
title: str = '',
|
||||||
title: str,
|
brief: str = '',
|
||||||
content: str,
|
content: str = '',
|
||||||
category: str
|
stage: str = '',
|
||||||
|
target: str = '0',
|
||||||
|
payout_address: str = '',
|
||||||
|
trustees: List[str] = [],
|
||||||
|
deadline_duration: int = 5184000, # 60 days
|
||||||
|
vote_duration: int = 604800, # 7 days
|
||||||
|
proposal_address: str = None,
|
||||||
|
category: str = ''
|
||||||
):
|
):
|
||||||
self.stage = stage
|
self.date_created = datetime.datetime.now()
|
||||||
self.proposal_address = proposal_address
|
self.status = status
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.brief = brief
|
||||||
self.content = content
|
self.content = content
|
||||||
self.category = category
|
self.category = category
|
||||||
self.date_created = datetime.datetime.now()
|
self.target = target
|
||||||
|
self.payout_address = payout_address
|
||||||
|
self.trustees = ','.join(trustees)
|
||||||
|
self.proposal_address = proposal_address
|
||||||
|
self.deadline_duration = deadline_duration
|
||||||
|
self.vote_duration = vote_duration
|
||||||
|
self.stage = stage
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate(
|
def validate(proposal):
|
||||||
stage: str,
|
title = proposal.get('title')
|
||||||
proposal_address: str,
|
stage = proposal.get('stage')
|
||||||
title: str,
|
category = proposal.get('category')
|
||||||
content: str,
|
if title and len(title) > 60:
|
||||||
category: str):
|
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
||||||
if stage not in PROPOSAL_STAGES:
|
if stage and stage not in PROPOSAL_STAGES:
|
||||||
raise ValidationException("{} not in {}".format(stage, PROPOSAL_STAGES))
|
raise ValidationException("Proposal stage {} not in {}".format(stage, PROPOSAL_STAGES))
|
||||||
if category not in CATEGORIES:
|
if category and category not in CATEGORIES:
|
||||||
raise ValidationException("{} not in {}".format(category, CATEGORIES))
|
raise ValidationException("Category {} not in {}".format(category, CATEGORIES))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(**kwargs):
|
def create(**kwargs):
|
||||||
Proposal.validate(**kwargs)
|
Proposal.validate(kwargs)
|
||||||
return Proposal(
|
return Proposal(
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
category: str = '',
|
||||||
|
content: 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 = content
|
||||||
|
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
|
||||||
|
if not self.title:
|
||||||
|
raise ValidationException("Proposal must have a title")
|
||||||
|
if not self.content:
|
||||||
|
raise ValidationException("Proposal must have content")
|
||||||
|
if not self.proposal_address:
|
||||||
|
raise ValidationException("Proposal must a contract address")
|
||||||
|
|
||||||
|
# Then run through regular validation
|
||||||
|
Proposal.validate(vars(self))
|
||||||
|
self.status = 'LIVE'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ProposalSchema(ma.Schema):
|
class ProposalSchema(ma.Schema):
|
||||||
|
@ -133,29 +227,34 @@ class ProposalSchema(ma.Schema):
|
||||||
"stage",
|
"stage",
|
||||||
"date_created",
|
"date_created",
|
||||||
"title",
|
"title",
|
||||||
|
"brief",
|
||||||
"proposal_id",
|
"proposal_id",
|
||||||
"proposal_address",
|
"proposal_address",
|
||||||
"body",
|
"target",
|
||||||
|
"content",
|
||||||
"comments",
|
"comments",
|
||||||
"updates",
|
"updates",
|
||||||
"contributions",
|
"contributions",
|
||||||
"milestones",
|
"milestones",
|
||||||
"category",
|
"category",
|
||||||
"team"
|
"team",
|
||||||
|
"trustees",
|
||||||
|
"payout_address",
|
||||||
|
"deadline_duration",
|
||||||
|
"vote_duration",
|
||||||
|
"invites"
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
proposal_id = ma.Method("get_proposal_id")
|
proposal_id = ma.Method("get_proposal_id")
|
||||||
body = ma.Method("get_body")
|
trustees = ma.Method("get_trustees")
|
||||||
|
|
||||||
comments = ma.Nested("CommentSchema", many=True)
|
comments = ma.Nested("CommentSchema", many=True)
|
||||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
contributions = ma.Nested("ProposalContributionSchema", many=True)
|
contributions = ma.Nested("ProposalContributionSchema", many=True)
|
||||||
team = ma.Nested("UserSchema", many=True)
|
team = ma.Nested("UserSchema", many=True)
|
||||||
milestones = ma.Nested("MilestoneSchema", many=True)
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
||||||
|
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
||||||
def get_body(self, obj):
|
|
||||||
return obj.content
|
|
||||||
|
|
||||||
def get_proposal_id(self, obj):
|
def get_proposal_id(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
@ -163,6 +262,9 @@ class ProposalSchema(ma.Schema):
|
||||||
def get_date_created(self, obj):
|
def get_date_created(self, obj):
|
||||||
return dt_to_unix(obj.date_created)
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
def get_trustees(self, obj):
|
||||||
|
return [i for i in obj.trustees.split(',') if i != '']
|
||||||
|
|
||||||
|
|
||||||
proposal_schema = ProposalSchema()
|
proposal_schema = ProposalSchema()
|
||||||
proposals_schema = ProposalSchema(many=True)
|
proposals_schema = ProposalSchema(many=True)
|
||||||
|
@ -198,6 +300,46 @@ proposal_update_schema = ProposalUpdateSchema()
|
||||||
proposals_update_schema = ProposalUpdateSchema(many=True)
|
proposals_update_schema = ProposalUpdateSchema(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalTeamInviteSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = ProposalTeamInvite
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"date_created",
|
||||||
|
"address",
|
||||||
|
"accepted"
|
||||||
|
)
|
||||||
|
|
||||||
|
date_created = ma.Method("get_date_created")
|
||||||
|
|
||||||
|
def get_date_created(self, obj):
|
||||||
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
proposal_team_invite_schema = ProposalTeamInviteSchema()
|
||||||
|
proposal_team_invites_schema = ProposalTeamInviteSchema(many=True)
|
||||||
|
|
||||||
|
# TODO: Find a way to extend ProposalTeamInviteSchema instead of redefining
|
||||||
|
class InviteWithProposalSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = ProposalTeamInvite
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"date_created",
|
||||||
|
"address",
|
||||||
|
"accepted",
|
||||||
|
"proposal"
|
||||||
|
)
|
||||||
|
|
||||||
|
date_created = ma.Method("get_date_created")
|
||||||
|
proposal = ma.Nested("ProposalSchema")
|
||||||
|
|
||||||
|
def get_date_created(self, obj):
|
||||||
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
invite_with_proposal_schema = InviteWithProposalSchema()
|
||||||
|
invites_with_proposal_schema = InviteWithProposalSchema(many=True)
|
||||||
|
|
||||||
|
|
||||||
class ProposalContributionSchema(ma.Schema):
|
class ProposalContributionSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProposalContribution
|
model = ProposalContribution
|
||||||
|
@ -220,6 +362,5 @@ class ProposalContributionSchema(ma.Schema):
|
||||||
def get_date_created(self, obj):
|
def get_date_created(self, obj):
|
||||||
return dt_to_unix(obj.date_created)
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
|
||||||
proposal_contribution_schema = ProposalContributionSchema()
|
proposal_contribution_schema = ProposalContributionSchema()
|
||||||
proposals_contribution_schema = ProposalContributionSchema(many=True)
|
proposals_contribution_schema = ProposalContributionSchema(many=True)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from datetime import datetime
|
from dateutil.parser import parse
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, g
|
from flask import Blueprint, g
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
|
@ -7,8 +8,11 @@ from sqlalchemy.exc import IntegrityError
|
||||||
from grant.comment.models import Comment, comment_schema
|
from grant.comment.models import Comment, comment_schema
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.user.models import User, SocialMedia, Avatar
|
from grant.user.models import User, SocialMedia, Avatar
|
||||||
|
from grant.email.send import send_email
|
||||||
from grant.utils.auth import requires_sm, requires_team_member_auth
|
from grant.utils.auth import requires_sm, requires_team_member_auth
|
||||||
from grant.web3.proposal import read_proposal, validate_contribution_tx
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from grant.utils.misc import is_email
|
||||||
|
from grant.web3.proposal import read_proposal
|
||||||
from .models import(
|
from .models import(
|
||||||
Proposal,
|
Proposal,
|
||||||
proposals_schema,
|
proposals_schema,
|
||||||
|
@ -17,6 +21,9 @@ from .models import(
|
||||||
proposal_update_schema,
|
proposal_update_schema,
|
||||||
ProposalContribution,
|
ProposalContribution,
|
||||||
proposal_contribution_schema,
|
proposal_contribution_schema,
|
||||||
|
proposal_team,
|
||||||
|
ProposalTeamInvite,
|
||||||
|
proposal_team_invite_schema,
|
||||||
db
|
db
|
||||||
)
|
)
|
||||||
import traceback
|
import traceback
|
||||||
|
@ -82,13 +89,14 @@ def post_proposal_comments(proposal_id, user_id, content):
|
||||||
def get_proposals(stage):
|
def get_proposals(stage):
|
||||||
if stage:
|
if stage:
|
||||||
proposals = (
|
proposals = (
|
||||||
Proposal.query.filter_by(stage=stage)
|
Proposal.query.filter_by(status="LIVE", stage=stage)
|
||||||
.order_by(Proposal.date_created.desc())
|
.order_by(Proposal.date_created.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
||||||
dumped_proposals = proposals_schema.dump(proposals)
|
dumped_proposals = proposals_schema.dump(proposals)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for p in dumped_proposals:
|
for p in dumped_proposals:
|
||||||
proposal_contract = read_proposal(p['proposal_address'])
|
proposal_contract = read_proposal(p['proposal_address'])
|
||||||
|
@ -100,84 +108,98 @@ def get_proposals(stage):
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
return {"message": "Oops! Something went wrong."}, 500
|
return {"message": "Oops! Something went wrong."}, 500
|
||||||
|
|
||||||
@blueprint.route("/", methods=["POST"])
|
|
||||||
|
@blueprint.route("/drafts", methods=["POST"])
|
||||||
@requires_sm
|
@requires_sm
|
||||||
@endpoint.api(
|
@endpoint.api()
|
||||||
parameter('crowdFundContractAddress', type=str, required=True),
|
def make_proposal_draft():
|
||||||
parameter('content', type=str, required=True),
|
proposal = Proposal.create(status="DRAFT")
|
||||||
parameter('title', type=str, required=True),
|
proposal.team.append(g.current_user)
|
||||||
parameter('milestones', type=list, required=True),
|
|
||||||
parameter('category', type=str, required=True),
|
|
||||||
parameter('team', type=list, required=True)
|
|
||||||
)
|
|
||||||
def make_proposal(crowd_fund_contract_address, content, title, milestones, category, team):
|
|
||||||
existing_proposal = Proposal.query.filter_by(proposal_address=crowd_fund_contract_address).first()
|
|
||||||
if existing_proposal:
|
|
||||||
return {"message": "Oops! Something went wrong."}, 409
|
|
||||||
|
|
||||||
proposal = Proposal.create(
|
|
||||||
stage="FUNDING_REQUIRED",
|
|
||||||
proposal_address=crowd_fund_contract_address,
|
|
||||||
content=content,
|
|
||||||
title=title,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(proposal)
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(proposal), 201
|
||||||
|
|
||||||
if not len(team) > 0:
|
|
||||||
return {"message": "Team must be at least 1"}, 400
|
|
||||||
|
|
||||||
for team_member in team:
|
@blueprint.route("/drafts", methods=["GET"])
|
||||||
account_address = team_member.get("accountAddress")
|
@requires_sm
|
||||||
display_name = team_member.get("displayName")
|
@endpoint.api()
|
||||||
email_address = team_member.get("emailAddress")
|
def get_proposal_drafts():
|
||||||
title = team_member.get("title")
|
proposals = (
|
||||||
user = User.query.filter(
|
Proposal.query
|
||||||
(User.account_address == account_address) | (User.email_address == email_address)).first()
|
.filter_by(status="DRAFT")
|
||||||
if not user:
|
.join(proposal_team)
|
||||||
user = User(
|
.filter(proposal_team.c.user_id == g.current_user.id)
|
||||||
account_address=account_address,
|
.order_by(Proposal.date_created.desc())
|
||||||
email_address=email_address,
|
.all()
|
||||||
display_name=display_name,
|
)
|
||||||
title=title
|
return proposals_schema.dump(proposals), 200
|
||||||
)
|
|
||||||
db.session.add(user)
|
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
avatar_data = team_member.get("avatar")
|
|
||||||
if avatar_data:
|
|
||||||
avatar = Avatar(image_url=avatar_data.get('link'), user_id=user.id)
|
|
||||||
db.session.add(avatar)
|
|
||||||
|
|
||||||
social_medias = team_member.get("socialMedias")
|
|
||||||
if social_medias:
|
|
||||||
for social_media in social_medias:
|
|
||||||
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
|
|
||||||
db.session.add(sm)
|
|
||||||
|
|
||||||
proposal.team.append(user)
|
|
||||||
|
|
||||||
for each_milestone in milestones:
|
|
||||||
m = Milestone(
|
|
||||||
title=each_milestone["title"],
|
|
||||||
content=each_milestone["description"],
|
|
||||||
date_estimated=datetime.strptime(each_milestone["date"], '%B %Y'),
|
|
||||||
payout_percent=str(each_milestone["payoutPercent"]),
|
|
||||||
immediate_payout=each_milestone["immediatePayout"],
|
|
||||||
proposal_id=proposal.id
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(m)
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
||||||
|
@requires_team_member_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('title', type=str),
|
||||||
|
parameter('brief', type=str),
|
||||||
|
parameter('category', type=str),
|
||||||
|
parameter('content', type=str),
|
||||||
|
parameter('target', type=str),
|
||||||
|
parameter('payoutAddress', type=str),
|
||||||
|
parameter('trustees', type=list),
|
||||||
|
parameter('deadlineDuration', type=int),
|
||||||
|
parameter('voteDuration', type=int),
|
||||||
|
parameter('milestones', type=list)
|
||||||
|
)
|
||||||
|
def update_proposal(milestones, proposal_id, **kwargs):
|
||||||
|
# Update the base proposal fields
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
g.current_proposal.update(**kwargs)
|
||||||
except IntegrityError as e:
|
except ValidationException as e:
|
||||||
print(e)
|
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
|
||||||
return {"message": "Oops! Something went wrong."}, 409
|
db.session.add(g.current_proposal)
|
||||||
|
|
||||||
results = proposal_schema.dump(proposal)
|
# Delete & re-add milestones
|
||||||
return results, 201
|
[db.session.delete(x) for x in g.current_proposal.milestones]
|
||||||
|
if milestones:
|
||||||
|
for mdata in milestones:
|
||||||
|
m = Milestone(
|
||||||
|
title=mdata["title"],
|
||||||
|
content=mdata["content"],
|
||||||
|
date_estimated=parse(mdata["dateEstimated"]),
|
||||||
|
payout_percent=str(mdata["payoutPercent"]),
|
||||||
|
immediate_payout=mdata["immediatePayout"],
|
||||||
|
proposal_id=g.current_proposal.id
|
||||||
|
)
|
||||||
|
db.session.add(m)
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>", methods=["DELETE"])
|
||||||
|
@requires_team_member_auth
|
||||||
|
@endpoint.api()
|
||||||
|
def delete_proposal_draft(proposal_id):
|
||||||
|
if g.current_proposal.status != 'DRAFT':
|
||||||
|
return {"message": "Cannot delete non-draft proposals"}, 400
|
||||||
|
db.session.delete(g.current_proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return None, 202
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
||||||
|
@requires_team_member_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('contractAddress', type=str, required=True)
|
||||||
|
)
|
||||||
|
def publish_proposal(proposal_id, contract_address):
|
||||||
|
try:
|
||||||
|
g.current_proposal.proposal_address = contract_address
|
||||||
|
g.current_proposal.publish()
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
|
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
|
||||||
|
@ -207,7 +229,6 @@ def get_proposal_update(proposal_id, update_id):
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
|
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
@requires_sm
|
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('title', type=str, required=True),
|
parameter('title', type=str, required=True),
|
||||||
parameter('content', type=str, required=True)
|
parameter('content', type=str, required=True)
|
||||||
|
@ -224,6 +245,52 @@ def post_proposal_update(proposal_id, title, content):
|
||||||
dumped_update = proposal_update_schema.dump(update)
|
dumped_update = proposal_update_schema.dump(update)
|
||||||
return dumped_update, 201
|
return dumped_update, 201
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
||||||
|
@requires_team_member_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('address', type=str, required=True)
|
||||||
|
)
|
||||||
|
def post_proposal_team_invite(proposal_id, address):
|
||||||
|
invite = ProposalTeamInvite(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
address=address
|
||||||
|
)
|
||||||
|
db.session.add(invite)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
# TODO: Move this to some background task / after request action
|
||||||
|
email = address
|
||||||
|
user = User.get_by_identifier(email_address=address, account_address=address)
|
||||||
|
if user:
|
||||||
|
email = user.email_address
|
||||||
|
if is_email(email):
|
||||||
|
send_email(email, 'team_invite', {
|
||||||
|
'user': user,
|
||||||
|
'inviter': g.current_user,
|
||||||
|
'proposal': g.current_proposal
|
||||||
|
})
|
||||||
|
|
||||||
|
return proposal_team_invite_schema.dump(invite), 201
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/invite/<id_or_address>", methods=["DELETE"])
|
||||||
|
@requires_team_member_auth
|
||||||
|
@endpoint.api()
|
||||||
|
def delete_proposal_team_invite(proposal_id, id_or_address):
|
||||||
|
invite = ProposalTeamInvite.query.filter(
|
||||||
|
(ProposalTeamInvite.id == id_or_address) |
|
||||||
|
(ProposalTeamInvite.address == id_or_address)
|
||||||
|
).first()
|
||||||
|
if not invite:
|
||||||
|
return {"message": "No invite found given {}".format(id_or_address)}, 404
|
||||||
|
if invite.accepted:
|
||||||
|
return {"message": "Cannot delete an invite that has been accepted"}, 403
|
||||||
|
|
||||||
|
db.session.delete(invite)
|
||||||
|
db.session.commit()
|
||||||
|
return None, 202
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/contributions", methods=["GET"])
|
@blueprint.route("/<proposal_id>/contributions", methods=["GET"])
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
U invited
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
|
||||||
|
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;">
|
||||||
|
See invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1 @@
|
||||||
|
U invited
|
|
@ -3,6 +3,7 @@ from grant.comment.models import Comment
|
||||||
from grant.email.models import EmailVerification
|
from grant.email.models import EmailVerification
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.misc import make_url
|
from grant.utils.misc import make_url
|
||||||
|
from grant.utils.social import get_social_info_from_url
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,10 +42,10 @@ class User(db.Model):
|
||||||
display_name = db.Column(db.String(255), unique=False, nullable=True)
|
display_name = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
title = db.Column(db.String(255), unique=False, nullable=True)
|
title = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
|
||||||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True)
|
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
||||||
comments = db.relationship(Comment, backref="user", lazy=True)
|
comments = db.relationship(Comment, backref="user", lazy=True)
|
||||||
avatar = db.relationship(Avatar, uselist=False, back_populates="user")
|
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
||||||
email_verification = db.relationship(EmailVerification, uselist=False, back_populates="user", lazy=True)
|
email_verification = db.relationship(EmailVerification, uselist=False, back_populates="user", lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
# TODO - add create and validate methods
|
# TODO - add create and validate methods
|
||||||
|
|
||||||
|
@ -122,7 +123,26 @@ class SocialMediaSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SocialMedia
|
model = SocialMedia
|
||||||
# Fields to expose
|
# Fields to expose
|
||||||
fields = ("social_media_link",)
|
fields = (
|
||||||
|
"url",
|
||||||
|
"service",
|
||||||
|
"username",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = ma.Method("get_url")
|
||||||
|
service = ma.Method("get_service")
|
||||||
|
username = ma.Method("get_username")
|
||||||
|
|
||||||
|
def get_url(self, obj):
|
||||||
|
return obj.social_media_link
|
||||||
|
|
||||||
|
def get_service(self, obj):
|
||||||
|
info = get_social_info_from_url(obj.social_media_link)
|
||||||
|
return info['service']
|
||||||
|
|
||||||
|
def get_username(self, obj):
|
||||||
|
info = get_social_info_from_url(obj.social_media_link)
|
||||||
|
return info['username']
|
||||||
|
|
||||||
|
|
||||||
social_media_schema = SocialMediaSchema()
|
social_media_schema = SocialMediaSchema()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from flask import Blueprint, g, request
|
from flask import Blueprint, g, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
|
|
||||||
from grant.proposal.models import Proposal, proposal_team
|
from grant.proposal.models import Proposal, proposal_team, ProposalTeamInvite, invites_with_proposal_schema
|
||||||
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
|
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
|
||||||
from grant.utils.upload import save_avatar, send_upload, remove_avatar
|
from grant.utils.upload import save_avatar, send_upload, remove_avatar
|
||||||
from grant.settings import UPLOAD_URL
|
from grant.settings import UPLOAD_URL
|
||||||
|
@ -19,8 +19,13 @@ def get_users(proposal_id):
|
||||||
if not proposal:
|
if not proposal:
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
else:
|
else:
|
||||||
users = User.query.join(proposal_team).join(Proposal) \
|
users = (
|
||||||
.filter(proposal_team.c.proposal_id == proposal.id).all()
|
User.query
|
||||||
|
.join(proposal_team)
|
||||||
|
.join(Proposal)
|
||||||
|
.filter(proposal_team.c.proposal_id == proposal.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
result = users_schema.dump(users)
|
result = users_schema.dump(users)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@ -155,7 +160,7 @@ def delete_avatar(url):
|
||||||
parameter('displayName', type=str, required=True),
|
parameter('displayName', type=str, required=True),
|
||||||
parameter('title', type=str, required=True),
|
parameter('title', type=str, required=True),
|
||||||
parameter('socialMedias', type=list, required=True),
|
parameter('socialMedias', type=list, required=True),
|
||||||
parameter('avatar', type=dict, required=True)
|
parameter('avatar', type=str, required=True)
|
||||||
)
|
)
|
||||||
def update_user(user_identity, display_name, title, social_medias, avatar):
|
def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||||
user = g.current_user
|
user = g.current_user
|
||||||
|
@ -166,29 +171,52 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||||
if title is not None:
|
if title is not None:
|
||||||
user.title = title
|
user.title = title
|
||||||
|
|
||||||
|
db_socials = SocialMedia.query.filter_by(user_id=user.id).all()
|
||||||
|
for db_social in db_socials:
|
||||||
|
db.session.delete(db_social)
|
||||||
if social_medias is not None:
|
if social_medias is not None:
|
||||||
SocialMedia.query.filter_by(user_id=user.id).delete()
|
|
||||||
for social_media in social_medias:
|
for social_media in social_medias:
|
||||||
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
|
sm = SocialMedia(social_media_link=social_media, user_id=user.id)
|
||||||
db.session.add(sm)
|
db.session.add(sm)
|
||||||
else:
|
|
||||||
SocialMedia.query.filter_by(user_id=user.id).delete()
|
|
||||||
|
|
||||||
old_avatar = Avatar.query.filter_by(user_id=user.id).first()
|
db_avatar = Avatar.query.filter_by(user_id=user.id).first()
|
||||||
if avatar is not None:
|
if db_avatar:
|
||||||
Avatar.query.filter_by(user_id=user.id).delete()
|
db.session.delete(db_avatar)
|
||||||
avatar_link = avatar.get('link')
|
if avatar:
|
||||||
if avatar_link:
|
new_avatar = Avatar(image_url=avatar, user_id=user.id)
|
||||||
avatar_obj = Avatar(image_url=avatar_link, user_id=user.id)
|
db.session.add(new_avatar)
|
||||||
db.session.add(avatar_obj)
|
|
||||||
else:
|
|
||||||
Avatar.query.filter_by(user_id=user.id).delete()
|
|
||||||
|
|
||||||
old_avatar_url = old_avatar and old_avatar.image_url
|
old_avatar_url = db_avatar and db_avatar.image_url
|
||||||
new_avatar_url = avatar and avatar['link']
|
if old_avatar_url and old_avatar_url != new_avatar.image_url:
|
||||||
if old_avatar_url and old_avatar_url != new_avatar_url:
|
remove_avatar(old_avatar_url, user.id)
|
||||||
remove_avatar(old_avatar_url, user.id)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@blueprint.route("/<user_identity>/invites", methods=["GET"])
|
||||||
|
@requires_same_user_auth
|
||||||
|
@endpoint.api()
|
||||||
|
def get_user_invites(user_identity):
|
||||||
|
invites = ProposalTeamInvite.get_pending_for_user(g.current_user)
|
||||||
|
return invites_with_proposal_schema.dump(invites)
|
||||||
|
|
||||||
|
@blueprint.route("/<user_identity>/invites/<invite_id>/respond", methods=["PUT"])
|
||||||
|
@requires_same_user_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('response', type=bool, required=True)
|
||||||
|
)
|
||||||
|
def respond_to_invite(user_identity, invite_id, response):
|
||||||
|
invite = ProposalTeamInvite.query.filter_by(id=invite_id).first()
|
||||||
|
if not invite:
|
||||||
|
return {"message": "No invite found with id {}".format(invite_id)}, 404
|
||||||
|
|
||||||
|
invite.accepted = response
|
||||||
|
db.session.add(invite)
|
||||||
|
|
||||||
|
if invite.accepted:
|
||||||
|
invite.proposal.team.append(g.current_user)
|
||||||
|
db.session.add(invite)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return None, 200
|
||||||
|
|
|
@ -11,6 +11,7 @@ import sentry_sdk
|
||||||
from grant.settings import SECRET_KEY, AUTH_URL
|
from grant.settings import SECRET_KEY, AUTH_URL
|
||||||
from ..proposal.models import Proposal
|
from ..proposal.models import Proposal
|
||||||
from ..user.models import User
|
from ..user.models import User
|
||||||
|
from ..proposal.models import Proposal
|
||||||
|
|
||||||
TWO_WEEKS = 1209600
|
TWO_WEEKS = 1209600
|
||||||
|
|
||||||
|
@ -80,7 +81,6 @@ def requires_sm(f):
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
# Decorator that requires you to be the user you're interacting with
|
# Decorator that requires you to be the user you're interacting with
|
||||||
def requires_same_user_auth(f):
|
def requires_same_user_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
class ValidationException(Exception):
|
||||||
|
pass
|
|
@ -2,6 +2,7 @@ import datetime
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import re
|
||||||
from grant.settings import SITE_URL
|
from grant.settings import SITE_URL
|
||||||
|
|
||||||
epoch = datetime.datetime.utcfromtimestamp(0)
|
epoch = datetime.datetime.utcfromtimestamp(0)
|
||||||
|
@ -26,3 +27,6 @@ def gen_random_code(length=32):
|
||||||
|
|
||||||
def make_url(path: str):
|
def make_url(path: str):
|
||||||
return f'{SITE_URL}{path}'
|
return f'{SITE_URL}{path}'
|
||||||
|
|
||||||
|
def is_email(email: str):
|
||||||
|
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
username_regex = '([a-zA-Z0-9-_]*)'
|
||||||
|
|
||||||
|
social_patterns = {
|
||||||
|
'GITHUB': 'https://github.com/{}'.format(username_regex),
|
||||||
|
'TWITTER': 'https://twitter.com/{}'.format(username_regex),
|
||||||
|
'LINKEDIN': 'https://linkedin.com/in/{}'.format(username_regex),
|
||||||
|
'KEYBASE': 'https://keybase.io/{}'.format(username_regex),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_social_info_from_url(url: str):
|
||||||
|
for service, pattern in social_patterns.items():
|
||||||
|
match = re.match(pattern, url)
|
||||||
|
if match:
|
||||||
|
return {
|
||||||
|
'service': service,
|
||||||
|
'username': match.group(1)
|
||||||
|
}
|
|
@ -1,38 +0,0 @@
|
||||||
"""empty message
|
|
||||||
|
|
||||||
Revision ID: 1d06a5e43324
|
|
||||||
Revises: 312db8611967
|
|
||||||
Create Date: 2018-11-17 11:07:40.413141
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '1d06a5e43324'
|
|
||||||
down_revision = '312db8611967'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('proposal_contribution',
|
|
||||||
sa.Column('tx_id', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('date_created', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('from_address', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('amount', sa.String(length=255), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('tx_id')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('proposal_contribution')
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,38 +0,0 @@
|
||||||
"""empty message
|
|
||||||
|
|
||||||
Revision ID: 312db8611967
|
|
||||||
Revises: 95e93ff98cba
|
|
||||||
Create Date: 2018-11-06 11:07:33.205401
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '312db8611967'
|
|
||||||
down_revision = '95e93ff98cba'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# custom alter_column
|
|
||||||
with op.batch_alter_table('proposal') as bop:
|
|
||||||
bop.alter_column('proposal_id', new_column_name='proposal_address')
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
# op.add_column('proposal', sa.Column('proposal_address', sa.String(length=255), nullable=False))
|
|
||||||
# op.create_unique_constraint(None, 'proposal', ['proposal_address'])
|
|
||||||
# op.drop_column('proposal', 'proposal_id')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# custom alter_column
|
|
||||||
with op.batch_alter_table('proposal') as bop:
|
|
||||||
bop.alter_column('proposal_address', new_column_name='proposal_id')
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
# op.add_column('proposal', sa.Column('proposal_id', sa.VARCHAR(length=255), nullable=False))
|
|
||||||
# op.drop_constraint(None, 'proposal', type_='unique')
|
|
||||||
# op.drop_column('proposal', 'proposal_address')
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,35 +0,0 @@
|
||||||
"""empty message
|
|
||||||
|
|
||||||
Revision ID: 6e02ee4b9ca3
|
|
||||||
Revises: 5f38d8603897
|
|
||||||
Create Date: 2018-11-01 16:29:11.190975
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '6e02ee4b9ca3'
|
|
||||||
down_revision = '5f38d8603897'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('email_verification',
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('code', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('has_verified', sa.Boolean(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('user_id'),
|
|
||||||
sa.UniqueConstraint('code')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('email_verification')
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,8 +1,8 @@
|
||||||
"""empty message
|
"""empty message
|
||||||
|
|
||||||
Revision ID: 5f38d8603897
|
Revision ID: a3b15766d9ab
|
||||||
Revises:
|
Revises:
|
||||||
Create Date: 2018-09-24 20:20:47.181807
|
Create Date: 2018-11-26 18:32:35.322687
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
@ -10,7 +10,7 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '5f38d8603897'
|
revision = 'a3b15766d9ab'
|
||||||
down_revision = None
|
down_revision = None
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
@ -21,13 +21,20 @@ def upgrade():
|
||||||
op.create_table('proposal',
|
op.create_table('proposal',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('date_created', sa.DateTime(), nullable=True),
|
sa.Column('date_created', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=255), nullable=False),
|
||||||
sa.Column('title', sa.String(length=255), nullable=False),
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
sa.Column('proposal_id', sa.String(length=255), nullable=False),
|
sa.Column('brief', sa.String(length=255), nullable=False),
|
||||||
sa.Column('stage', sa.String(length=255), nullable=False),
|
sa.Column('stage', sa.String(length=255), nullable=False),
|
||||||
sa.Column('content', sa.Text(), nullable=False),
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
sa.Column('category', sa.String(length=255), nullable=False),
|
sa.Column('category', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('target', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('payout_address', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('trustees', sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column('deadline_duration', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vote_duration', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('proposal_address', sa.String(length=255), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.UniqueConstraint('proposal_id')
|
sa.UniqueConstraint('proposal_address')
|
||||||
)
|
)
|
||||||
op.create_table('user',
|
op.create_table('user',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
@ -56,6 +63,14 @@ def upgrade():
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
|
op.create_table('email_verification',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('has_verified', sa.Boolean(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('user_id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
op.create_table('milestone',
|
op.create_table('milestone',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('date_created', sa.DateTime(), nullable=False),
|
sa.Column('date_created', sa.DateTime(), nullable=False),
|
||||||
|
@ -69,12 +84,32 @@ def upgrade():
|
||||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
|
op.create_table('proposal_contribution',
|
||||||
|
sa.Column('tx_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('date_created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('from_address', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('amount', sa.String(length=255), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('tx_id')
|
||||||
|
)
|
||||||
op.create_table('proposal_team',
|
op.create_table('proposal_team',
|
||||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('proposal_id', sa.Integer(), nullable=True),
|
sa.Column('proposal_id', sa.Integer(), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
||||||
)
|
)
|
||||||
|
op.create_table('proposal_update',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date_created', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
op.create_table('social_media',
|
op.create_table('social_media',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('social_media_link', sa.String(length=255), nullable=True),
|
sa.Column('social_media_link', sa.String(length=255), nullable=True),
|
||||||
|
@ -88,8 +123,11 @@ def upgrade():
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('social_media')
|
op.drop_table('social_media')
|
||||||
|
op.drop_table('proposal_update')
|
||||||
op.drop_table('proposal_team')
|
op.drop_table('proposal_team')
|
||||||
|
op.drop_table('proposal_contribution')
|
||||||
op.drop_table('milestone')
|
op.drop_table('milestone')
|
||||||
|
op.drop_table('email_verification')
|
||||||
op.drop_table('comment')
|
op.drop_table('comment')
|
||||||
op.drop_table('avatar')
|
op.drop_table('avatar')
|
||||||
op.drop_table('user')
|
op.drop_table('user')
|
|
@ -1,8 +1,8 @@
|
||||||
"""empty message
|
"""empty message
|
||||||
|
|
||||||
Revision ID: 95e93ff98cba
|
Revision ID: e1e8573b7298
|
||||||
Revises: 6e02ee4b9ca3
|
Revises: a3b15766d9ab
|
||||||
Create Date: 2018-11-04 19:37:09.027109
|
Create Date: 2018-11-15 13:47:06.051522
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
@ -10,20 +10,20 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '95e93ff98cba'
|
revision = 'e1e8573b7298'
|
||||||
down_revision = '6e02ee4b9ca3'
|
down_revision = 'a3b15766d9ab'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('proposal_update',
|
op.create_table('proposal_team_invite',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('date_created', sa.DateTime(), nullable=True),
|
sa.Column('date_created', sa.DateTime(), nullable=True),
|
||||||
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('title', sa.String(length=255), nullable=False),
|
sa.Column('address', sa.String(length=255), nullable=False),
|
||||||
sa.Column('content', sa.Text(), nullable=False),
|
sa.Column('accepted', sa.Boolean()),
|
||||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
|
@ -32,5 +32,5 @@ def upgrade():
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('proposal_update')
|
op.drop_table('proposal_team_invite')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
|
@ -4,79 +4,40 @@ from mock import patch
|
||||||
from grant.proposal.models import Proposal
|
from grant.proposal.models import Proposal
|
||||||
from grant.user.models import SocialMedia, Avatar
|
from grant.user.models import SocialMedia, Avatar
|
||||||
from ..config import BaseUserConfig
|
from ..config import BaseUserConfig
|
||||||
from ..test_data import test_proposal
|
from ..test_data import test_proposal, test_user
|
||||||
|
|
||||||
|
|
||||||
class TestAPI(BaseUserConfig):
|
class TestAPI(BaseUserConfig):
|
||||||
def test_create_new_proposal(self):
|
def test_create_new_draft(self):
|
||||||
self.assertIsNone(Proposal.query.filter_by(
|
|
||||||
proposal_address=test_proposal["crowdFundContractAddress"]
|
|
||||||
).first())
|
|
||||||
|
|
||||||
resp = self.app.post(
|
resp = self.app.post(
|
||||||
"/api/v1/proposals/",
|
"/api/v1/proposals/drafts",
|
||||||
data=json.dumps(test_proposal),
|
data=json.dumps({}),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, 201)
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
|
||||||
proposal_db = Proposal.query.filter_by(
|
proposal_db = Proposal.query.filter_by(id=resp.json['proposalId'])
|
||||||
proposal_address=test_proposal["crowdFundContractAddress"]
|
self.assertIsNotNone(proposal_db)
|
||||||
).first()
|
|
||||||
self.assertEqual(proposal_db.title, test_proposal["title"])
|
|
||||||
|
|
||||||
# SocialMedia
|
|
||||||
social_media_db = SocialMedia.query.filter_by(user_id=self.user.id).first()
|
|
||||||
self.assertTrue(social_media_db)
|
|
||||||
|
|
||||||
# Avatar
|
|
||||||
avatar = Avatar.query.filter_by(user_id=self.user.id).first()
|
|
||||||
self.assertTrue(avatar)
|
|
||||||
|
|
||||||
def test_create_new_proposal_comment(self):
|
def test_create_new_proposal_comment(self):
|
||||||
proposal_res = self.app.post(
|
proposal = Proposal(
|
||||||
"/api/v1/proposals/",
|
status="LIVE"
|
||||||
data=json.dumps(test_proposal),
|
|
||||||
headers=self.headers,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
proposal_json = proposal_res.json
|
|
||||||
proposal_id = proposal_json["proposalId"]
|
|
||||||
proposal_user_id = proposal_json["team"][0]["userid"]
|
|
||||||
|
|
||||||
comment_res = self.app.post(
|
comment_res = self.app.post(
|
||||||
"/api/v1/proposals/{}/comments".format(proposal_id),
|
"/api/v1/proposals/{}/comments".format(proposal.id),
|
||||||
data=json.dumps({
|
data=json.dumps({ "content": "What a comment" }),
|
||||||
"userId": proposal_user_id,
|
headers=self.headers,
|
||||||
"content": "What a comment"
|
|
||||||
}),
|
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(comment_res.json)
|
self.assertTrue(comment_res.json)
|
||||||
|
|
||||||
def test_create_new_proposal_duplicate(self):
|
@patch('grant.web3.proposal.validate_contribution_tx', return_value=True)
|
||||||
self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(test_proposal),
|
|
||||||
headers=self.headers,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
proposal_res2 = self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(test_proposal),
|
|
||||||
headers=self.headers,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(proposal_res2.status_code, 409)
|
|
||||||
|
|
||||||
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
|
|
||||||
def test_create_proposal_contribution(self, mock_validate_contribution_tx):
|
def test_create_proposal_contribution(self, mock_validate_contribution_tx):
|
||||||
proposal_res = self.app.post(
|
proposal_res = self.app.post(
|
||||||
"/api/v1/proposals/",
|
"/api/v1/proposals/drafts",
|
||||||
data=json.dumps(test_proposal),
|
data=json.dumps(test_proposal),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
@ -106,10 +67,10 @@ class TestAPI(BaseUserConfig):
|
||||||
eq("amount")
|
eq("amount")
|
||||||
self.assertEqual(proposal_id, res["proposalId"])
|
self.assertEqual(proposal_id, res["proposalId"])
|
||||||
|
|
||||||
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
|
@patch('grant.web3.proposal.validate_contribution_tx', return_value=True)
|
||||||
def test_get_proposal_contribution(self, mock_validate_contribution_tx):
|
def test_get_proposal_contribution(self, mock_validate_contribution_tx):
|
||||||
proposal_res = self.app.post(
|
proposal_res = self.app.post(
|
||||||
"/api/v1/proposals/",
|
"/api/v1/proposals/drafts",
|
||||||
data=json.dumps(test_proposal),
|
data=json.dumps(test_proposal),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
@ -143,10 +104,10 @@ class TestAPI(BaseUserConfig):
|
||||||
eq("amount")
|
eq("amount")
|
||||||
self.assertEqual(proposal_id, res["proposalId"])
|
self.assertEqual(proposal_id, res["proposalId"])
|
||||||
|
|
||||||
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
|
@patch('grant.web3.proposal.validate_contribution_tx', return_value=True)
|
||||||
def test_get_proposal_contributions(self, mock_validate_contribution_tx):
|
def test_get_proposal_contributions(self, mock_validate_contribution_tx):
|
||||||
proposal_res = self.app.post(
|
proposal_res = self.app.post(
|
||||||
"/api/v1/proposals/",
|
"/api/v1/proposals/drafts",
|
||||||
data=json.dumps(test_proposal),
|
data=json.dumps(test_proposal),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
|
|
@ -3,7 +3,7 @@ import json
|
||||||
|
|
||||||
from animal_case import animalify
|
from animal_case import animalify
|
||||||
from grant.proposal.models import Proposal
|
from grant.proposal.models import Proposal
|
||||||
from grant.user.models import User, user_schema
|
from grant.user.models import User, user_schema, db
|
||||||
from mock import patch
|
from mock import patch
|
||||||
|
|
||||||
from ..config import BaseUserConfig
|
from ..config import BaseUserConfig
|
||||||
|
@ -11,182 +11,61 @@ from ..test_data import test_team, test_proposal, test_user
|
||||||
|
|
||||||
|
|
||||||
class TestAPI(BaseUserConfig):
|
class TestAPI(BaseUserConfig):
|
||||||
# TODO create second signed message default user
|
|
||||||
# @patch('grant.email.send.send_email')
|
|
||||||
# def test_create_new_user_via_proposal_by_account_address(self, mock_send_email):
|
|
||||||
# mock_send_email.return_value.ok = True
|
|
||||||
# self.remove_default_user()
|
|
||||||
# proposal_by_account = copy.deepcopy(test_proposal)
|
|
||||||
# del proposal_by_account["team"][0]["emailAddress"]
|
|
||||||
#
|
|
||||||
# resp = self.app.post(
|
|
||||||
# "/api/v1/proposals/",
|
|
||||||
# data=json.dumps(proposal_by_account),
|
|
||||||
# headers=self.headers,
|
|
||||||
# content_type='application/json'
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
# self.assertEqual(resp, 201)
|
|
||||||
#
|
|
||||||
# # User
|
|
||||||
# user_db = User.query.filter_by(account_address=proposal_by_account["team"][0]["accountAddress"]).first()
|
|
||||||
# self.assertEqual(user_db.display_name, proposal_by_account["team"][0]["displayName"])
|
|
||||||
# self.assertEqual(user_db.title, proposal_by_account["team"][0]["title"])
|
|
||||||
# self.assertEqual(user_db.account_address, proposal_by_account["team"][0]["accountAddress"])
|
|
||||||
|
|
||||||
# TODO create second signed message default user
|
|
||||||
# def test_create_new_user_via_proposal_by_email(self):
|
|
||||||
# self.remove_default_user()
|
|
||||||
# proposal_by_email = copy.deepcopy(test_proposal)
|
|
||||||
# del proposal_by_email["team"][0]["accountAddress"]
|
|
||||||
#
|
|
||||||
# resp = self.app.post(
|
|
||||||
# "/api/v1/proposals/",
|
|
||||||
# data=json.dumps(proposal_by_email),
|
|
||||||
# headers=self.headers,
|
|
||||||
# content_type='application/json'
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
# self.assertEqual(resp, 201)
|
|
||||||
#
|
|
||||||
# # User
|
|
||||||
# user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first()
|
|
||||||
# self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"])
|
|
||||||
# self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"])
|
|
||||||
|
|
||||||
def test_associate_user_via_proposal_by_email(self):
|
|
||||||
proposal_by_email = copy.deepcopy(test_proposal)
|
|
||||||
del proposal_by_email["team"][0]["accountAddress"]
|
|
||||||
|
|
||||||
resp = self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(proposal_by_email),
|
|
||||||
headers=self.headers,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 201)
|
|
||||||
|
|
||||||
# User
|
|
||||||
user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first()
|
|
||||||
self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"])
|
|
||||||
self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"])
|
|
||||||
proposal_db = Proposal.query.filter_by(
|
|
||||||
proposal_address=test_proposal["crowdFundContractAddress"]
|
|
||||||
).first()
|
|
||||||
self.assertEqual(proposal_db.team[0].id, user_db.id)
|
|
||||||
|
|
||||||
def test_associate_user_via_proposal_by_email_when_user_already_exists(self):
|
|
||||||
proposal_by_user_email = copy.deepcopy(test_proposal)
|
|
||||||
del proposal_by_user_email["team"][0]["accountAddress"]
|
|
||||||
|
|
||||||
resp = self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(proposal_by_user_email),
|
|
||||||
headers=self.headers,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 201)
|
|
||||||
|
|
||||||
# User
|
|
||||||
self.assertEqual(self.user.display_name, proposal_by_user_email["team"][0]["displayName"])
|
|
||||||
self.assertEqual(self.user.title, proposal_by_user_email["team"][0]["title"])
|
|
||||||
proposal_db = Proposal.query.filter_by(
|
|
||||||
proposal_address=test_proposal["crowdFundContractAddress"]
|
|
||||||
).first()
|
|
||||||
self.assertEqual(proposal_db.team[0].id, self.user.id)
|
|
||||||
|
|
||||||
new_proposal_by_email = copy.deepcopy(test_proposal)
|
|
||||||
new_proposal_by_email["crowdFundContractAddress"] = "0x2222"
|
|
||||||
del new_proposal_by_email["team"][0]["accountAddress"]
|
|
||||||
|
|
||||||
self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(new_proposal_by_email),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
user_db = User.query.filter_by(email_address=new_proposal_by_email["team"][0]["emailAddress"]).first()
|
|
||||||
self.assertEqual(user_db.display_name, new_proposal_by_email["team"][0]["displayName"])
|
|
||||||
self.assertEqual(user_db.title, new_proposal_by_email["team"][0]["title"])
|
|
||||||
proposal_db = Proposal.query.filter_by(
|
|
||||||
proposal_address=test_proposal["crowdFundContractAddress"]
|
|
||||||
).first()
|
|
||||||
self.assertEqual(proposal_db.team[0].id, user_db.id)
|
|
||||||
|
|
||||||
def test_get_all_users(self):
|
|
||||||
self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(test_proposal),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
users_get_resp = self.app.get(
|
|
||||||
"/api/v1/users/"
|
|
||||||
)
|
|
||||||
|
|
||||||
users_json = users_get_resp.json
|
|
||||||
self.assertEqual(users_json[0]["displayName"], test_team[0]["displayName"])
|
|
||||||
|
|
||||||
def test_get_user_associated_with_proposal(self):
|
|
||||||
self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(test_proposal),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'proposalId': test_proposal["crowdFundContractAddress"]
|
|
||||||
}
|
|
||||||
|
|
||||||
users_get_resp = self.app.get(
|
|
||||||
"/api/v1/users/",
|
|
||||||
query_string=data
|
|
||||||
)
|
|
||||||
|
|
||||||
users_json = users_get_resp.json
|
|
||||||
self.assertEqual(users_json[0]["avatar"]["imageUrl"], test_team[0]["avatar"]["link"])
|
|
||||||
self.assertEqual(users_json[0]["socialMedias"][0]["socialMediaLink"], test_team[0]["socialMedias"][0]["link"])
|
|
||||||
self.assertEqual(users_json[0]["displayName"], test_user["displayName"])
|
|
||||||
|
|
||||||
def test_get_single_user(self):
|
|
||||||
self.app.post(
|
|
||||||
"/api/v1/proposals/",
|
|
||||||
data=json.dumps(test_proposal),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
users_get_resp = self.app.get(
|
|
||||||
"/api/v1/users/{}".format(test_proposal["team"][0]["emailAddress"])
|
|
||||||
)
|
|
||||||
|
|
||||||
users_json = users_get_resp.json
|
|
||||||
self.assertEqual(users_json["avatar"]["imageUrl"], test_team[0]["avatar"]["link"])
|
|
||||||
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], test_team[0]["socialMedias"][0]["link"])
|
|
||||||
self.assertEqual(users_json["displayName"], test_team[0]["displayName"])
|
|
||||||
|
|
||||||
@patch('grant.email.send.send_email')
|
@patch('grant.email.send.send_email')
|
||||||
def test_create_user(self, mock_send_email):
|
def test_create_user(self, mock_send_email):
|
||||||
mock_send_email.return_value.ok = True
|
mock_send_email.return_value.ok = True
|
||||||
|
# Delete the user config user
|
||||||
|
db.session.delete(self.user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
self.app.post(
|
self.app.post(
|
||||||
"/api/v1/users/",
|
"/api/v1/users/",
|
||||||
data=json.dumps(test_team[0]),
|
data=json.dumps(test_user),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
|
|
||||||
# User
|
# User
|
||||||
user_db = User.get_by_identifier(account_address=test_team[0]["accountAddress"])
|
user_db = User.get_by_identifier(account_address=test_user["accountAddress"])
|
||||||
self.assertEqual(user_db.display_name, test_team[0]["displayName"])
|
self.assertEqual(user_db.display_name, test_user["displayName"])
|
||||||
self.assertEqual(user_db.title, test_team[0]["title"])
|
self.assertEqual(user_db.title, test_user["title"])
|
||||||
self.assertEqual(user_db.account_address, test_team[0]["accountAddress"])
|
self.assertEqual(user_db.account_address, test_user["accountAddress"])
|
||||||
|
|
||||||
@patch('grant.email.send.send_email')
|
def test_get_all_users(self):
|
||||||
def test_create_user_duplicate_400(self, mock_send_email):
|
users_get_resp = self.app.get(
|
||||||
mock_send_email.return_value.ok = True
|
"/api/v1/users/"
|
||||||
self.test_create_user()
|
)
|
||||||
|
users_json = users_get_resp.json
|
||||||
|
self.assertEqual(users_json[0]["displayName"], self.user.display_name)
|
||||||
|
|
||||||
|
def test_get_single_user_by_email(self):
|
||||||
|
users_get_resp = self.app.get(
|
||||||
|
"/api/v1/users/{}".format(self.user.email_address)
|
||||||
|
)
|
||||||
|
|
||||||
|
users_json = users_get_resp.json
|
||||||
|
self.assertEqual(users_json["avatar"]["imageUrl"], self.user.avatar.image_url)
|
||||||
|
self.assertEqual(users_json["socialMedias"][0]["service"], 'GITHUB')
|
||||||
|
self.assertEqual(users_json["socialMedias"][0]["username"], 'groot')
|
||||||
|
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
|
||||||
|
self.assertEqual(users_json["displayName"], self.user.display_name)
|
||||||
|
|
||||||
|
def test_get_single_user_by_account_address(self):
|
||||||
|
users_get_resp = self.app.get(
|
||||||
|
"/api/v1/users/{}".format(self.user.account_address)
|
||||||
|
)
|
||||||
|
|
||||||
|
users_json = users_get_resp.json
|
||||||
|
self.assertEqual(users_json["avatar"]["imageUrl"], self.user.avatar.image_url)
|
||||||
|
self.assertEqual(users_json["socialMedias"][0]["service"], 'GITHUB')
|
||||||
|
self.assertEqual(users_json["socialMedias"][0]["username"], 'groot')
|
||||||
|
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
|
||||||
|
self.assertEqual(users_json["displayName"], self.user.display_name)
|
||||||
|
|
||||||
|
def test_create_user_duplicate_400(self):
|
||||||
|
# self.user is identical to test_user, should throw
|
||||||
response = self.app.post(
|
response = self.app.post(
|
||||||
"/api/v1/users/",
|
"/api/v1/users/",
|
||||||
data=json.dumps(test_team[0]),
|
data=json.dumps(test_user),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -115,7 +115,7 @@ async-eventemitter@^0.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
async "^2.4.0"
|
async "^2.4.0"
|
||||||
|
|
||||||
"async-eventemitter@github:ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c":
|
async-eventemitter@ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c:
|
||||||
version "0.2.3"
|
version "0.2.3"
|
||||||
resolved "https://codeload.github.com/ahultgren/async-eventemitter/tar.gz/fa06e39e56786ba541c180061dbf2c0a5bbf951c"
|
resolved "https://codeload.github.com/ahultgren/async-eventemitter/tar.gz/fa06e39e56786ba541c180061dbf2c0a5bbf951c"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
@ -15,6 +15,7 @@ import Template, { TemplateProps } from 'components/Template';
|
||||||
// wrap components in loadable...import & they will be split
|
// wrap components in loadable...import & they will be split
|
||||||
const Home = loadable(() => import('pages/index'));
|
const Home = loadable(() => import('pages/index'));
|
||||||
const Create = loadable(() => import('pages/create'));
|
const Create = loadable(() => import('pages/create'));
|
||||||
|
const ProposalEdit = loadable(() => import('pages/proposal-edit'));
|
||||||
const Proposals = loadable(() => import('pages/proposals'));
|
const Proposals = loadable(() => import('pages/proposals'));
|
||||||
const Proposal = loadable(() => import('pages/proposal'));
|
const Proposal = loadable(() => import('pages/proposal'));
|
||||||
const Auth = loadable(() => import('pages/auth'));
|
const Auth = loadable(() => import('pages/auth'));
|
||||||
|
@ -60,10 +61,9 @@ const routeConfigs: RouteConfig[] = [
|
||||||
},
|
},
|
||||||
template: {
|
template: {
|
||||||
title: 'Create a Proposal',
|
title: 'Create a Proposal',
|
||||||
isFullScreen: true,
|
|
||||||
hideFooter: true,
|
|
||||||
requiresWeb3: true,
|
requiresWeb3: true,
|
||||||
},
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Browse proposals
|
// Browse proposals
|
||||||
|
@ -77,6 +77,20 @@ const routeConfigs: RouteConfig[] = [
|
||||||
requiresWeb3: false,
|
requiresWeb3: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Proposal edit page
|
||||||
|
route: {
|
||||||
|
path: '/proposals/:id/edit',
|
||||||
|
component: ProposalEdit,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Edit proposal',
|
||||||
|
isFullScreen: true,
|
||||||
|
hideFooter: true,
|
||||||
|
requiresWeb3: true,
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Proposal detail page
|
// Proposal detail page
|
||||||
route: {
|
route: {
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import axios from './axios';
|
import axios from './axios';
|
||||||
import { Proposal, TeamMember, Update, Contribution } from 'types';
|
|
||||||
import {
|
import {
|
||||||
formatProposalFromGet,
|
Proposal,
|
||||||
formatTeamMemberForPost,
|
ProposalDraft,
|
||||||
formatTeamMemberFromGet,
|
User,
|
||||||
} from 'utils/api';
|
Update,
|
||||||
import { PROPOSAL_CATEGORY } from './constants';
|
TeamInvite,
|
||||||
|
TeamInviteWithProposal,
|
||||||
|
Contribution,
|
||||||
|
} from 'types';
|
||||||
|
import { formatUserForPost, formatProposalFromGet } from 'utils/api';
|
||||||
|
|
||||||
export function getProposals(): Promise<{ data: Proposal[] }> {
|
export function getProposals(): Promise<{ data: Proposal[] }> {
|
||||||
return axios.get('/api/v1/proposals/').then(res => {
|
return axios.get('/api/v1/proposals/').then(res => {
|
||||||
|
@ -29,28 +32,16 @@ export function getProposalUpdates(proposalId: number | string) {
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postProposal(payload: {
|
export function postProposal(payload: ProposalDraft) {
|
||||||
// TODO type Milestone
|
|
||||||
accountAddress: string;
|
|
||||||
crowdFundContractAddress: string;
|
|
||||||
content: string;
|
|
||||||
title: string;
|
|
||||||
category: PROPOSAL_CATEGORY;
|
|
||||||
milestones: object[];
|
|
||||||
team: TeamMember[];
|
|
||||||
}) {
|
|
||||||
return axios.post(`/api/v1/proposals/`, {
|
return axios.post(`/api/v1/proposals/`, {
|
||||||
...payload,
|
...payload,
|
||||||
// Team has a different shape for POST
|
// Team has a different shape for POST
|
||||||
team: payload.team.map(formatTeamMemberForPost),
|
team: payload.team.map(formatUserForPost),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUser(address: string): Promise<{ data: TeamMember }> {
|
export function getUser(address: string): Promise<{ data: User }> {
|
||||||
return axios.get(`/api/v1/users/${address}`).then(res => {
|
return axios.get(`/api/v1/users/${address}`);
|
||||||
res.data = formatTeamMemberFromGet(res.data);
|
|
||||||
return res;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUser(payload: {
|
export function createUser(payload: {
|
||||||
|
@ -60,31 +51,20 @@ export function createUser(payload: {
|
||||||
title: string;
|
title: string;
|
||||||
signedMessage: string;
|
signedMessage: string;
|
||||||
rawTypedData: string;
|
rawTypedData: string;
|
||||||
}): Promise<{ data: TeamMember }> {
|
}): Promise<{ data: User }> {
|
||||||
return axios.post('/api/v1/users', payload).then(res => {
|
return axios.post('/api/v1/users', payload);
|
||||||
res.data = formatTeamMemberFromGet(res.data);
|
|
||||||
return res;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authUser(payload: {
|
export function authUser(payload: {
|
||||||
accountAddress: string;
|
accountAddress: string;
|
||||||
signedMessage: string;
|
signedMessage: string;
|
||||||
rawTypedData: string;
|
rawTypedData: string;
|
||||||
}): Promise<{ data: TeamMember }> {
|
}): Promise<{ data: User }> {
|
||||||
return axios.post('/api/v1/users/auth', payload).then(res => {
|
return axios.post('/api/v1/users/auth', payload);
|
||||||
res.data = formatTeamMemberFromGet(res.data);
|
|
||||||
return res;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
|
export function updateUser(user: User): Promise<{ data: User }> {
|
||||||
return axios
|
return axios.put(`/api/v1/users/${user.accountAddress}`, formatUserForPost(user));
|
||||||
.put(`/api/v1/users/${user.ethAddress}`, formatTeamMemberForPost(user))
|
|
||||||
.then(res => {
|
|
||||||
res.data = formatTeamMemberFromGet(res.data);
|
|
||||||
return res;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyEmail(code: string): Promise<any> {
|
export function verifyEmail(code: string): Promise<any> {
|
||||||
|
@ -112,6 +92,63 @@ export function postProposalUpdate(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProposalDrafts(): Promise<{ data: ProposalDraft[] }> {
|
||||||
|
return axios.get('/api/v1/proposals/drafts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postProposalDraft(): Promise<{ data: ProposalDraft }> {
|
||||||
|
return axios.post('/api/v1/proposals/drafts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProposalDraft(proposalId: number): Promise<any> {
|
||||||
|
return axios.delete(`/api/v1/proposals/${proposalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putProposal(proposal: ProposalDraft): Promise<{ data: ProposalDraft }> {
|
||||||
|
// Exclude some keys
|
||||||
|
const { proposalId, stage, dateCreated, team, ...rest } = proposal;
|
||||||
|
return axios.put(`/api/v1/proposals/${proposal.proposalId}`, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putProposalPublish(
|
||||||
|
proposal: ProposalDraft,
|
||||||
|
contractAddress: string,
|
||||||
|
): Promise<{ data: ProposalDraft }> {
|
||||||
|
return axios.put(`/api/v1/proposals/${proposal.proposalId}/publish`, {
|
||||||
|
contractAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postProposalInvite(
|
||||||
|
proposalId: number,
|
||||||
|
address: string,
|
||||||
|
): Promise<{ data: TeamInvite }> {
|
||||||
|
return axios.post(`/api/v1/proposals/${proposalId}/invite`, { address });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProposalInvite(
|
||||||
|
proposalId: number,
|
||||||
|
inviteIdOrAddress: number | string,
|
||||||
|
): Promise<{ data: TeamInvite }> {
|
||||||
|
return axios.delete(`/api/v1/proposals/${proposalId}/invite/${inviteIdOrAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUserInvites(
|
||||||
|
userid: string | number,
|
||||||
|
): Promise<{ data: TeamInviteWithProposal[] }> {
|
||||||
|
return axios.get(`/api/v1/users/${userid}/invites`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putInviteResponse(
|
||||||
|
userid: string | number,
|
||||||
|
inviteid: string | number,
|
||||||
|
response: boolean,
|
||||||
|
): Promise<{ data: void }> {
|
||||||
|
return axios.put(`/api/v1/users/${userid}/invites/${inviteid}/respond`, {
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function postProposalContribution(
|
export function postProposalContribution(
|
||||||
proposalId: number,
|
proposalId: number,
|
||||||
txId: string,
|
txId: string,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Button, Alert } from 'antd';
|
import { Button, Alert } from 'antd';
|
||||||
import { authActions } from 'modules/auth';
|
import { authActions } from 'modules/auth';
|
||||||
import { TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { AUTH_PROVIDER } from 'utils/auth';
|
import { AUTH_PROVIDER } from 'utils/auth';
|
||||||
import Identicon from 'components/Identicon';
|
import Identicon from 'components/Identicon';
|
||||||
|
@ -20,7 +20,7 @@ interface DispatchProps {
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
// TODO: Use common use User type instead
|
// TODO: Use common use User type instead
|
||||||
user: TeamMember;
|
user: User;
|
||||||
provider: AUTH_PROVIDER;
|
provider: AUTH_PROVIDER;
|
||||||
reset(): void;
|
reset(): void;
|
||||||
}
|
}
|
||||||
|
@ -34,11 +34,14 @@ class SignIn extends React.Component<Props> {
|
||||||
<div className="SignIn">
|
<div className="SignIn">
|
||||||
<div className="SignIn-container">
|
<div className="SignIn-container">
|
||||||
<div className="SignIn-identity">
|
<div className="SignIn-identity">
|
||||||
<Identicon address={user.ethAddress} className="SignIn-identity-identicon" />
|
<Identicon
|
||||||
|
address={user.accountAddress}
|
||||||
|
className="SignIn-identity-identicon"
|
||||||
|
/>
|
||||||
<div className="SignIn-identity-info">
|
<div className="SignIn-identity-info">
|
||||||
<div className="SignIn-identity-info-name">{user.name}</div>
|
<div className="SignIn-identity-info-name">{user.displayName}</div>
|
||||||
<code className="SignIn-identity-info-address">
|
<code className="SignIn-identity-info-address">
|
||||||
<ShortAddress address={user.ethAddress} />
|
<ShortAddress address={user.accountAddress} />
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,7 +72,7 @@ class SignIn extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private authUser = () => {
|
private authUser = () => {
|
||||||
this.props.authUser(this.props.user.ethAddress);
|
this.props.authUser(this.props.user.accountAddress);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -55,12 +55,12 @@ class Comment extends React.Component<Props> {
|
||||||
<Identicon address={comment.author.accountAddress} />
|
<Identicon address={comment.author.accountAddress} />
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="Comment-info-thumb" src={comment.author.avatar['120x120']} /> */}
|
{/* <div className="Comment-info-thumb" src={comment.author.avatar['120x120']} /> */}
|
||||||
<div className="Comment-info-name">{comment.author.username}</div>
|
<div className="Comment-info-name">{comment.author.displayName}</div>
|
||||||
<div className="Comment-info-time">{moment(comment.dateCreated).fromNow()}</div>
|
<div className="Comment-info-time">{moment(comment.dateCreated).fromNow()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="Comment-body">
|
<div className="Comment-body">
|
||||||
<Markdown source={comment.body} type={MARKDOWN_TYPE.REDUCED} />
|
<Markdown source={comment.content} type={MARKDOWN_TYPE.REDUCED} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="Comment-controls">
|
<div className="Comment-controls">
|
||||||
|
|
|
@ -2,20 +2,20 @@ import React from 'react';
|
||||||
import { Input, Form, Icon, Select } from 'antd';
|
import { Input, Form, Icon, Select } from 'antd';
|
||||||
import { SelectValue } from 'antd/lib/select';
|
import { SelectValue } from 'antd/lib/select';
|
||||||
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
|
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
|
||||||
import { CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
import { getCreateErrors } from 'modules/create/utils';
|
import { getCreateErrors } from 'modules/create/utils';
|
||||||
import { typedKeys } from 'utils/ts';
|
import { typedKeys } from 'utils/ts';
|
||||||
|
|
||||||
interface State {
|
interface State extends Partial<ProposalDraft> {
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
category: PROPOSAL_CATEGORY | null;
|
category?: PROPOSAL_CATEGORY;
|
||||||
amountToRaise: string;
|
target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialState?: Partial<State>;
|
initialState?: Partial<State>;
|
||||||
updateForm(form: Partial<CreateFormState>): void;
|
updateForm(form: Partial<ProposalDraft>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class CreateFlowBasics extends React.Component<Props, State> {
|
export default class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
|
@ -24,8 +24,8 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
this.state = {
|
this.state = {
|
||||||
title: '',
|
title: '',
|
||||||
brief: '',
|
brief: '',
|
||||||
category: null,
|
category: undefined,
|
||||||
amountToRaise: '',
|
target: '',
|
||||||
...(props.initialState || {}),
|
...(props.initialState || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { title, brief, category, amountToRaise } = this.state;
|
const { title, brief, category, target } = this.state;
|
||||||
const errors = getCreateErrors(this.state, true);
|
const errors = getCreateErrors(this.state, true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -101,17 +101,15 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Target amount"
|
label="Target amount"
|
||||||
validateStatus={errors.amountToRaise ? 'error' : undefined}
|
validateStatus={errors.target ? 'error' : undefined}
|
||||||
help={
|
help={errors.target || 'This cannot be changed once your proposal starts'}
|
||||||
errors.amountToRaise || 'This cannot be changed once your proposal starts'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
name="amountToRaise"
|
name="target"
|
||||||
placeholder="1.5"
|
placeholder="1.5"
|
||||||
type="number"
|
type="number"
|
||||||
value={amountToRaise}
|
value={target}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
addonAfter="ETH"
|
addonAfter="ETH"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,22 +1,22 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import MarkdownEditor from 'components/MarkdownEditor';
|
import MarkdownEditor from 'components/MarkdownEditor';
|
||||||
import { CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
details: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialState?: Partial<State>;
|
initialState?: Partial<State>;
|
||||||
updateForm(form: Partial<CreateFormState>): void;
|
updateForm(form: Partial<ProposalDraft>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class CreateFlowTeam extends React.Component<Props, State> {
|
export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
details: '',
|
content: '',
|
||||||
...(props.initialState || {}),
|
...(props.initialState || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -26,15 +26,17 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
|
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
initialMarkdown={this.state.details}
|
initialMarkdown={this.state.content}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChange = (markdown: string) => {
|
private handleChange = (markdown: string) => {
|
||||||
this.setState({ details: markdown }, () => {
|
if (markdown !== this.state.content) {
|
||||||
this.props.updateForm(this.state);
|
this.setState({ content: markdown }, () => {
|
||||||
});
|
this.props.updateForm(this.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,6 @@ interface StateProps {
|
||||||
|
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
createProposal: typeof createActions['createProposal'];
|
createProposal: typeof createActions['createProposal'];
|
||||||
resetForm: typeof createActions['resetForm'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = StateProps & DispatchProps;
|
type Props = StateProps & DispatchProps;
|
||||||
|
@ -27,12 +26,6 @@ class CreateFinal extends React.Component<Props> {
|
||||||
this.create();
|
this.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props) {
|
|
||||||
if (!prevProps.crowdFundCreatedAddress && this.props.crowdFundCreatedAddress) {
|
|
||||||
this.props.resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { crowdFundError, crowdFundCreatedAddress, createdProposal } = this.props;
|
const { crowdFundError, crowdFundCreatedAddress, createdProposal } = this.props;
|
||||||
let content;
|
let content;
|
||||||
|
@ -70,7 +63,9 @@ class CreateFinal extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private create = () => {
|
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,
|
createProposal: createActions.createProposal,
|
||||||
resetForm: createActions.resetForm,
|
|
||||||
},
|
},
|
||||||
)(CreateFinal);
|
)(CreateFinal);
|
||||||
|
|
|
@ -1,52 +1,52 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Input, Form, Icon, Button, Radio } from 'antd';
|
import { Input, Form, Icon, Button, Radio } from 'antd';
|
||||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||||
import { CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
import { getCreateErrors } from 'modules/create/utils';
|
import { getCreateErrors } from 'modules/create/utils';
|
||||||
import { ONE_DAY } from 'utils/time';
|
import { ONE_DAY } from 'utils/time';
|
||||||
import { DONATION } from 'utils/constants';
|
import { DONATION } from 'utils/constants';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
payOutAddress: string;
|
payoutAddress: string;
|
||||||
trustees: string[];
|
trustees: string[];
|
||||||
deadline: number;
|
deadlineDuration: number;
|
||||||
milestoneDeadline: number;
|
voteDuration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialState?: Partial<State>;
|
initialState?: Partial<State>;
|
||||||
updateForm(form: Partial<CreateFormState>): void;
|
updateForm(form: Partial<ProposalDraft>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class CreateFlowTeam extends React.Component<Props, State> {
|
export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
payOutAddress: '',
|
payoutAddress: '',
|
||||||
trustees: [],
|
trustees: [],
|
||||||
deadline: ONE_DAY * 60,
|
deadlineDuration: ONE_DAY * 60,
|
||||||
milestoneDeadline: ONE_DAY * 7,
|
voteDuration: ONE_DAY * 7,
|
||||||
...(props.initialState || {}),
|
...(props.initialState || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { payOutAddress, trustees, deadline, milestoneDeadline } = this.state;
|
const { payoutAddress, trustees, deadlineDuration, voteDuration } = this.state;
|
||||||
const errors = getCreateErrors(this.state, true);
|
const errors = getCreateErrors(this.state, true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
|
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Payout address"
|
label="Payout address"
|
||||||
validateStatus={errors.payOutAddress ? 'error' : undefined}
|
validateStatus={errors.payoutAddress ? 'error' : undefined}
|
||||||
help={errors.payOutAddress}
|
help={errors.payoutAddress}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
name="payOutAddress"
|
name="payoutAddress"
|
||||||
placeholder={DONATION.ETH}
|
placeholder={DONATION.ETH}
|
||||||
type="text"
|
type="text"
|
||||||
value={payOutAddress}
|
value={payoutAddress}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
@ -57,7 +57,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
size="large"
|
size="large"
|
||||||
type="text"
|
type="text"
|
||||||
disabled
|
disabled
|
||||||
value={payOutAddress}
|
value={payoutAddress}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{trustees.map((address, idx) => (
|
{trustees.map((address, idx) => (
|
||||||
|
@ -82,13 +82,13 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
|
|
||||||
<Form.Item label="Funding Deadline">
|
<Form.Item label="Funding Deadline">
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
name="deadline"
|
name="deadlineDuration"
|
||||||
value={deadline}
|
value={deadlineDuration}
|
||||||
onChange={this.handleRadioChange}
|
onChange={this.handleRadioChange}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ display: 'flex', textAlign: 'center' }}
|
style={{ display: 'flex', textAlign: 'center' }}
|
||||||
>
|
>
|
||||||
{deadline === 300 && (
|
{deadlineDuration === 300 && (
|
||||||
<Radio.Button style={{ flex: 1 }} value={300}>
|
<Radio.Button style={{ flex: 1 }} value={300}>
|
||||||
5 minutes
|
5 minutes
|
||||||
</Radio.Button>
|
</Radio.Button>
|
||||||
|
@ -107,13 +107,13 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
|
|
||||||
<Form.Item label="Milestone Voting Period">
|
<Form.Item label="Milestone Voting Period">
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
name="milestoneDeadline"
|
name="voteDuration"
|
||||||
value={milestoneDeadline}
|
value={voteDuration}
|
||||||
onChange={this.handleRadioChange}
|
onChange={this.handleRadioChange}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ display: 'flex', textAlign: 'center' }}
|
style={{ display: 'flex', textAlign: 'center' }}
|
||||||
>
|
>
|
||||||
{milestoneDeadline === 60 && (
|
{voteDuration === 60 && (
|
||||||
<Radio.Button style={{ flex: 1 }} value={60}>
|
<Radio.Button style={{ flex: 1 }} value={60}>
|
||||||
60 Seconds
|
60 Seconds
|
||||||
</Radio.Button>
|
</Radio.Button>
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd';
|
import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { CreateFormState, CreateMilestone } from 'types';
|
import { ProposalDraft, CreateMilestone } from 'types';
|
||||||
import { getCreateErrors } from 'modules/create/utils';
|
import { getCreateErrors } from 'modules/create/utils';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
milestones: CreateMilestone[];
|
milestones: ProposalDraft['milestones'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialState: Partial<State>;
|
initialState: Partial<State>;
|
||||||
updateForm(form: Partial<CreateFormState>): void;
|
updateForm(form: Partial<ProposalDraft>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STATE: State = {
|
const DEFAULT_STATE: State = {
|
||||||
milestones: [
|
milestones: [
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
content: '',
|
||||||
date: '',
|
dateEstimated: '',
|
||||||
payoutPercent: 100,
|
payoutPercent: '100',
|
||||||
immediatePayout: false,
|
immediatePayout: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -53,17 +53,17 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
|
||||||
addMilestone = () => {
|
addMilestone = () => {
|
||||||
const { milestones: oldMilestones } = this.state;
|
const { milestones: oldMilestones } = this.state;
|
||||||
const lastMilestone = oldMilestones[oldMilestones.length - 1];
|
const lastMilestone = oldMilestones[oldMilestones.length - 1];
|
||||||
const halfPayout = lastMilestone.payoutPercent / 2;
|
const halfPayout = parseInt(lastMilestone.payoutPercent, 10) / 2;
|
||||||
const milestones = [
|
const milestones = [
|
||||||
...oldMilestones,
|
...oldMilestones,
|
||||||
{
|
{
|
||||||
...DEFAULT_STATE.milestones[0],
|
...DEFAULT_STATE.milestones[0],
|
||||||
payoutPercent: halfPayout,
|
payoutPercent: halfPayout.toString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
milestones[milestones.length - 2] = {
|
milestones[milestones.length - 2] = {
|
||||||
...lastMilestone,
|
...lastMilestone,
|
||||||
payoutPercent: halfPayout,
|
payoutPercent: halfPayout.toString(),
|
||||||
};
|
};
|
||||||
this.setState({ milestones });
|
this.setState({ milestones });
|
||||||
};
|
};
|
||||||
|
@ -146,11 +146,11 @@ const MilestoneFields = ({
|
||||||
<div style={{ marginBottom: '0.5rem' }}>
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={3}
|
rows={3}
|
||||||
name="body"
|
name="content"
|
||||||
placeholder="Description of the deliverable"
|
placeholder="Description of the deliverable"
|
||||||
value={milestone.description}
|
value={milestone.content}
|
||||||
onChange={ev =>
|
onChange={ev =>
|
||||||
onChange(index, { ...milestone, description: ev.currentTarget.value })
|
onChange(index, { ...milestone, content: ev.currentTarget.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -159,10 +159,10 @@ const MilestoneFields = ({
|
||||||
<DatePicker.MonthPicker
|
<DatePicker.MonthPicker
|
||||||
style={{ flex: 1, marginRight: '0.5rem' }}
|
style={{ flex: 1, marginRight: '0.5rem' }}
|
||||||
placeholder="Expected completion date"
|
placeholder="Expected completion date"
|
||||||
value={milestone.date ? moment(milestone.date, 'MMMM YYYY') : undefined}
|
value={milestone.dateEstimated ? moment(milestone.dateEstimated) : undefined}
|
||||||
format="MMMM YYYY"
|
format="MMMM YYYY"
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
onChange={(_, date) => onChange(index, { ...milestone, date })}
|
onChange={(_, dateEstimated) => onChange(index, { ...milestone, dateEstimated })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
min={1}
|
min={1}
|
||||||
|
@ -172,7 +172,7 @@ const MilestoneFields = ({
|
||||||
onChange={ev =>
|
onChange={ev =>
|
||||||
onChange(index, {
|
onChange(index, {
|
||||||
...milestone,
|
...milestone,
|
||||||
payoutPercent: parseInt(ev.currentTarget.value, 10) || 0,
|
payoutPercent: ev.currentTarget.value || '0',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
addonAfter="%"
|
addonAfter="%"
|
||||||
|
|
|
@ -3,10 +3,11 @@ import { connect } from 'react-redux';
|
||||||
import { Alert } from 'antd';
|
import { Alert } from 'antd';
|
||||||
import { ProposalDetail } from 'components/Proposal';
|
import { ProposalDetail } from 'components/Proposal';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { makeProposalPreviewFromForm } from 'modules/create/utils';
|
import { makeProposalPreviewFromDraft } from 'modules/create/utils';
|
||||||
|
import { ProposalDraft } from 'types';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
form: AppState['create']['form'];
|
form: ProposalDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = StateProps;
|
type Props = StateProps;
|
||||||
|
@ -14,7 +15,7 @@ type Props = StateProps;
|
||||||
class CreateFlowPreview extends React.Component<Props> {
|
class CreateFlowPreview extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { form } = this.props;
|
const { form } = this.props;
|
||||||
const proposal = makeProposalPreviewFromForm(form);
|
const proposal = makeProposalPreviewFromDraft(form);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -37,5 +38,5 @@ class CreateFlowPreview extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, {}, {}, AppState>(state => ({
|
export default connect<StateProps, {}, {}, AppState>(state => ({
|
||||||
form: state.create.form,
|
form: state.create.form as ProposalDraft,
|
||||||
}))(CreateFlowPreview);
|
}))(CreateFlowPreview);
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal, Alert } from 'antd';
|
||||||
|
import { getCreateWarnings } from 'modules/create/utils';
|
||||||
|
import { ProposalDraft } from 'types';
|
||||||
|
import './PublishWarningModal.less';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
proposal: ProposalDraft | null;
|
||||||
|
isVisible: boolean;
|
||||||
|
handleClose(): void;
|
||||||
|
handlePublish(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PublishWarningModal extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { proposal, isVisible, handleClose, handlePublish } = this.props;
|
||||||
|
const warnings = proposal ? getCreateWarnings(proposal) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<>Confirm publish</>}
|
||||||
|
visible={isVisible}
|
||||||
|
okText="Confirm publish"
|
||||||
|
cancelText="Never mind"
|
||||||
|
onOk={handlePublish}
|
||||||
|
onCancel={handleClose}
|
||||||
|
>
|
||||||
|
<div className="PublishWarningModal">
|
||||||
|
{!!warnings.length && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="Some fields have warnings"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<ul>
|
||||||
|
{warnings.map(w => (
|
||||||
|
<li key={w}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>You can still publish, despite these warnings.</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
Are you sure you’re ready to publish your proposal? Once you’ve done so, you
|
||||||
|
won't be able to change certain fields such as: target amount, payout address,
|
||||||
|
team, trustees, deadline & vote durations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
.PublishWarningModal {
|
||||||
|
.ant-alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -109,4 +109,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-invites {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,18 +4,19 @@ import { Icon, Timeline } from 'antd';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
|
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { CREATE_STEP } from './index';
|
import { CREATE_STEP } from './index';
|
||||||
import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants';
|
import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants';
|
||||||
|
import { ProposalDraft } from 'types';
|
||||||
import './Review.less';
|
import './Review.less';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
setStep(step: CREATE_STEP): void;
|
setStep(step: CREATE_STEP): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
form: AppState['create']['form'];
|
form: ProposalDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps & StateProps;
|
type Props = OwnProps & StateProps;
|
||||||
|
@ -62,9 +63,9 @@ class CreateReview extends React.Component<Props> {
|
||||||
error: errors.category,
|
error: errors.category,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'amountToRaise',
|
key: 'target',
|
||||||
content: <div style={{ fontSize: '1.2rem' }}>{form.amountToRaise} ETH</div>,
|
content: <div style={{ fontSize: '1.2rem' }}>{form.target} ETH</div>,
|
||||||
error: errors.amountToRaise,
|
error: errors.target,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -74,7 +75,7 @@ class CreateReview extends React.Component<Props> {
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: 'team',
|
key: 'team',
|
||||||
content: <ReviewTeam team={form.team} />,
|
content: <ReviewTeam team={form.team} invites={form.invites} />,
|
||||||
error: errors.team && errors.team.join(' '),
|
error: errors.team && errors.team.join(' '),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -84,9 +85,9 @@ class CreateReview extends React.Component<Props> {
|
||||||
name: 'Details',
|
name: 'Details',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: 'details',
|
key: 'content',
|
||||||
content: <Markdown source={form.details} />,
|
content: <Markdown source={form.content} />,
|
||||||
error: errors.details,
|
error: errors.content,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -106,9 +107,9 @@ class CreateReview extends React.Component<Props> {
|
||||||
name: 'Governance',
|
name: 'Governance',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: 'payOutAddress',
|
key: 'payoutAddress',
|
||||||
content: <code>{form.payOutAddress}</code>,
|
content: <code>{form.payoutAddress}</code>,
|
||||||
error: errors.payOutAddress,
|
error: errors.payoutAddress,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'trustees',
|
key: 'trustees',
|
||||||
|
@ -120,18 +121,18 @@ class CreateReview extends React.Component<Props> {
|
||||||
error: errors.trustees && errors.trustees.join(' '),
|
error: errors.trustees && errors.trustees.join(' '),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'deadline',
|
key: 'deadlineDuration',
|
||||||
content: `${Math.floor(
|
content: `${Math.floor(
|
||||||
moment.duration((form.deadline || 0) * 1000).asDays(),
|
moment.duration((form.deadlineDuration || 0) * 1000).asDays(),
|
||||||
)} days`,
|
)} days`,
|
||||||
error: errors.deadline,
|
error: errors.deadlineDuration,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'milestoneDeadline',
|
key: 'voteDuration',
|
||||||
content: `${Math.floor(
|
content: `${Math.floor(
|
||||||
moment.duration((form.milestoneDeadline || 0) * 1000).asDays(),
|
moment.duration((form.voteDuration || 0) * 1000).asDays(),
|
||||||
)} days`,
|
)} days`,
|
||||||
error: errors.milestoneDeadline,
|
error: errors.voteDuration,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -183,13 +184,13 @@ class CreateReview extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
|
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
|
||||||
form: state.create.form,
|
form: state.create.form as ProposalDraft,
|
||||||
}))(CreateReview);
|
}))(CreateReview);
|
||||||
|
|
||||||
const ReviewMilestones = ({
|
const ReviewMilestones = ({
|
||||||
milestones,
|
milestones,
|
||||||
}: {
|
}: {
|
||||||
milestones: AppState['create']['form']['milestones'];
|
milestones: ProposalDraft['milestones'];
|
||||||
}) => (
|
}) => (
|
||||||
<Timeline>
|
<Timeline>
|
||||||
{milestones.map(m => (
|
{milestones.map(m => (
|
||||||
|
@ -197,27 +198,33 @@ const ReviewMilestones = ({
|
||||||
<div className="ReviewMilestone">
|
<div className="ReviewMilestone">
|
||||||
<div className="ReviewMilestone-title">{m.title}</div>
|
<div className="ReviewMilestone-title">{m.title}</div>
|
||||||
<div className="ReviewMilestone-info">
|
<div className="ReviewMilestone-info">
|
||||||
{moment(m.date, 'MMMM YYYY').format('MMMM YYYY')}
|
{moment(m.dateEstimated, 'MMMM YYYY').format('MMMM YYYY')}
|
||||||
{' – '}
|
{' – '}
|
||||||
{m.payoutPercent}% of funds
|
{m.payoutPercent}% of funds
|
||||||
</div>
|
</div>
|
||||||
<div className="ReviewMilestone-description">{m.description}</div>
|
<div className="ReviewMilestone-description">{m.content}</div>
|
||||||
</div>
|
</div>
|
||||||
</Timeline.Item>
|
</Timeline.Item>
|
||||||
))}
|
))}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => (
|
const ReviewTeam: React.SFC<{
|
||||||
|
team: ProposalDraft['team'];
|
||||||
|
invites: ProposalDraft['invites'];
|
||||||
|
}> = ({ team, invites }) => (
|
||||||
<div className="ReviewTeam">
|
<div className="ReviewTeam">
|
||||||
{team.map((u, idx) => (
|
{team.map((u, idx) => (
|
||||||
<div className="ReviewTeam-member" key={idx}>
|
<div className="ReviewTeam-member" key={idx}>
|
||||||
<UserAvatar className="ReviewTeam-member-avatar" user={u} />
|
<UserAvatar className="ReviewTeam-member-avatar" user={u} />
|
||||||
<div className="ReviewTeam-member-info">
|
<div className="ReviewTeam-member-info">
|
||||||
<div className="ReviewTeam-member-info-name">{u.name}</div>
|
<div className="ReviewTeam-member-info-name">{u.displayName}</div>
|
||||||
<div className="ReviewTeam-member-info-title">{u.title}</div>
|
<div className="ReviewTeam-member-info-title">{u.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{!!invites.filter(inv => inv.accepted === null).length && (
|
||||||
|
<div className="ReviewTeam-invites">+ {invites.length} invite(s) pending</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,49 +6,76 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&-pending,
|
||||||
&-add {
|
&-add {
|
||||||
display: flex;
|
margin-top: 2rem;
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 80ms ease, transform 80ms ease;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
&:hover,
|
&-title {
|
||||||
&:focus {
|
font-size: 1.2rem;
|
||||||
opacity: 1;
|
margin-bottom: 0.5rem;
|
||||||
}
|
padding-left: 0.25rem;
|
||||||
&:active {
|
|
||||||
transform: translateY(2px);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&-icon {
|
&-pending {
|
||||||
|
&-invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding: 1rem;
|
||||||
margin-right: 1.25rem;
|
font-size: 1rem;
|
||||||
width: 7.4rem;
|
background: #FFF;
|
||||||
height: 7.4rem;
|
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||||
border: 2px dashed @success-color;
|
border-bottom: 1px solid rgba(#000, 0.05);
|
||||||
color: @success-color;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
&:first-child {
|
||||||
text-align: left;
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
&-title {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: @success-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-subtitle {
|
&:last-child {
|
||||||
opacity: 0.7;
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-delete {
|
||||||
|
opacity: 0.3;
|
||||||
|
outline: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
transition: opacity 100ms ease, color 100ms ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
color: @error-color;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-add {
|
||||||
|
&-form {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem 1rem 0.3rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #FFF;
|
||||||
|
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||||
|
|
||||||
|
&-field {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.ant-form-explain {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-submit {
|
||||||
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Icon } from 'antd';
|
import { Icon, Form, Input, Button, Popconfirm, message } from 'antd';
|
||||||
import { CreateFormState, TeamMember } from 'types';
|
import { User, TeamInvite, ProposalDraft } from 'types';
|
||||||
import TeamMemberComponent from './TeamMember';
|
import TeamMemberComponent from './TeamMember';
|
||||||
import './Team.less';
|
import { postProposalInvite, deleteProposalInvite } from 'api/api';
|
||||||
|
import { isValidEthAddress, isValidEmail } from 'utils/validators';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
import './Team.less';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
team: TeamMember[];
|
team: User[];
|
||||||
|
invites: TeamInvite[];
|
||||||
|
address: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
|
@ -15,24 +19,18 @@ interface StateProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
|
proposalId: number;
|
||||||
initialState?: Partial<State>;
|
initialState?: Partial<State>;
|
||||||
updateForm(form: Partial<CreateFormState>): void;
|
updateForm(form: Partial<ProposalDraft>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps & StateProps;
|
type Props = OwnProps & StateProps;
|
||||||
|
|
||||||
const MAX_TEAM_SIZE = 6;
|
const MAX_TEAM_SIZE = 6;
|
||||||
const DEFAULT_STATE: State = {
|
const DEFAULT_STATE: State = {
|
||||||
team: [
|
team: [],
|
||||||
{
|
invites: [],
|
||||||
name: '',
|
address: '',
|
||||||
title: '',
|
|
||||||
avatarUrl: '',
|
|
||||||
ethAddress: '',
|
|
||||||
emailAddress: '',
|
|
||||||
socialAccounts: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class CreateFlowTeam extends React.Component<Props, State> {
|
class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
|
@ -43,16 +41,8 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
...(props.initialState || {}),
|
...(props.initialState || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't allow for empty team array
|
|
||||||
if (!this.state.team.length) {
|
|
||||||
this.state = {
|
|
||||||
...this.state,
|
|
||||||
team: [...DEFAULT_STATE.team],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth'd user is always first member of a team
|
// Auth'd user is always first member of a team
|
||||||
if (props.authUser) {
|
if (props.authUser && !this.state.team.length) {
|
||||||
this.state.team[0] = {
|
this.state.team[0] = {
|
||||||
...props.authUser,
|
...props.authUser,
|
||||||
};
|
};
|
||||||
|
@ -60,56 +50,106 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { team } = this.state;
|
const { team, invites, address } = this.state;
|
||||||
|
const inviteError =
|
||||||
|
address && !isValidEmail(address) && !isValidEthAddress(address)
|
||||||
|
? 'That doesn’t look like an email address or ETH address'
|
||||||
|
: undefined;
|
||||||
|
const inviteDisabled = !!inviteError || !address;
|
||||||
|
const pendingInvites = invites.filter(inv => inv.accepted === null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="TeamForm">
|
<div className="TeamForm">
|
||||||
{team.map((user, idx) => (
|
{team.map(user => (
|
||||||
<TeamMemberComponent
|
<TeamMemberComponent key={user.userid} user={user} />
|
||||||
key={idx}
|
|
||||||
index={idx}
|
|
||||||
user={user}
|
|
||||||
initialEditingState={!user.name}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
onRemove={this.removeMember}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{team.length < MAX_TEAM_SIZE && (
|
{!!pendingInvites.length && (
|
||||||
<button className="TeamForm-add" onClick={this.addMember}>
|
<div className="TeamForm-pending">
|
||||||
<div className="TeamForm-add-icon">
|
<h3 className="TeamForm-pending-title">Pending invitations</h3>
|
||||||
<Icon type="plus" />
|
{pendingInvites.map(inv => (
|
||||||
</div>
|
<div key={inv.id} className="TeamForm-pending-invite">
|
||||||
<div className="TeamForm-add-text">
|
<div className="TeamForm-pending-invite-name">{inv.address}</div>
|
||||||
<div className="TeamForm-add-text-title">Add a team member</div>
|
<Popconfirm
|
||||||
<div className="TeamForm-add-text-subtitle">
|
title="Are you sure?"
|
||||||
Find an existing user, or fill out their info yourself
|
onConfirm={() => this.removeInvitation(inv.id)}
|
||||||
|
>
|
||||||
|
<button className="TeamForm-pending-invite-delete">
|
||||||
|
<Icon type="delete" />
|
||||||
|
</button>
|
||||||
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
|
{team.length < MAX_TEAM_SIZE && (
|
||||||
|
<div className="TeamForm-add">
|
||||||
|
<h3 className="TeamForm-add-title">Add a team member</h3>
|
||||||
|
<Form className="TeamForm-add-form" onSubmit={this.handleAddSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
className="TeamForm-add-form-field"
|
||||||
|
validateStatus={inviteError ? 'error' : undefined}
|
||||||
|
help={
|
||||||
|
inviteError ||
|
||||||
|
'They will be notified and will have to accept the invitation before being added'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="TeamForm-add-form-field-input"
|
||||||
|
placeholder="Email address or ETH address"
|
||||||
|
size="large"
|
||||||
|
value={address}
|
||||||
|
onChange={this.handleChangeInviteAddress}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
className="TeamForm-add-form-submit"
|
||||||
|
type="primary"
|
||||||
|
disabled={inviteDisabled}
|
||||||
|
htmlType="submit"
|
||||||
|
icon="user-add"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChange = (user: TeamMember, idx: number) => {
|
private handleChangeInviteAddress = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const team = [...this.state.team];
|
this.setState({ address: ev.currentTarget.value });
|
||||||
team[idx] = user;
|
|
||||||
this.setState({ team });
|
|
||||||
this.props.updateForm({ team });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private addMember = () => {
|
private handleAddSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||||
const team = [...this.state.team, { ...DEFAULT_STATE.team[0] }];
|
ev.preventDefault();
|
||||||
this.setState({ team });
|
postProposalInvite(this.props.proposalId, this.state.address)
|
||||||
this.props.updateForm({ team });
|
.then(res => {
|
||||||
|
const invites = [...this.state.invites, res.data];
|
||||||
|
this.setState({
|
||||||
|
invites,
|
||||||
|
address: '',
|
||||||
|
});
|
||||||
|
this.props.updateForm({ invites });
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
console.error('Failed to send invite', err);
|
||||||
|
message.error('Failed to send invite', 3);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private removeMember = (index: number) => {
|
private removeInvitation = (invId: number) => {
|
||||||
const team = [
|
deleteProposalInvite(this.props.proposalId, invId)
|
||||||
...this.state.team.slice(0, index),
|
.then(() => {
|
||||||
...this.state.team.slice(index + 1),
|
const invites = this.state.invites.filter(inv => inv.id !== invId);
|
||||||
];
|
this.setState({ invites });
|
||||||
this.setState({ team });
|
this.props.updateForm({ invites });
|
||||||
this.props.updateForm({ team });
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
console.error('Failed to remove invite', err);
|
||||||
|
message.error('Failed to remove invite', 3);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
|
border-radius: 2px;
|
||||||
background: #FFF;
|
background: #FFF;
|
||||||
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||||
|
|
||||||
|
|
|
@ -1,241 +1,52 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
|
import { Icon } from 'antd';
|
||||||
import { SOCIAL_INFO } from 'utils/social';
|
import { SOCIAL_INFO } from 'utils/social';
|
||||||
import { SOCIAL_TYPE, TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
import { getCreateTeamMemberError } from 'modules/create/utils';
|
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import './TeamMember.less';
|
import './TeamMember.less';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
index: number;
|
user: User;
|
||||||
user: TeamMember;
|
|
||||||
initialEditingState?: boolean;
|
|
||||||
onChange(user: TeamMember, index: number): void;
|
|
||||||
onRemove(index: number): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
export default class CreateFlowTeamMember extends React.PureComponent<Props> {
|
||||||
fields: TeamMember;
|
|
||||||
isEditing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class CreateFlowTeamMember extends React.PureComponent<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
fields: { ...this.props.user },
|
|
||||||
isEditing: this.props.initialEditingState || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { user, index } = this.props;
|
const { user } = this.props;
|
||||||
const { fields, isEditing } = this.state;
|
|
||||||
const error = getCreateTeamMemberError(fields);
|
|
||||||
const isMissingField =
|
|
||||||
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
|
|
||||||
const isDisabled = !!error || isMissingField;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames('TeamMember', isEditing && 'is-editing')}>
|
<div className="TeamMember">
|
||||||
<div className="TeamMember-avatar">
|
<div className="TeamMember-avatar">
|
||||||
<UserAvatar className="TeamMember-avatar-img" user={fields} />
|
<UserAvatar className="TeamMember-avatar-img" user={user} />
|
||||||
{isEditing && (
|
|
||||||
<Button className="TeamMember-avatar-change" onClick={this.handleChangePhoto}>
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="TeamMember-info">
|
<div className="TeamMember-info">
|
||||||
{isEditing ? (
|
<div className="TeamMember-info-name">
|
||||||
<Form
|
{user.displayName || <em>No name</em>}
|
||||||
className="TeamMember-info-form"
|
</div>
|
||||||
layout="vertical"
|
<div className="TeamMember-info-title">{user.title || <em>No title</em>}</div>
|
||||||
onSubmit={this.toggleEditing}
|
<div className="TeamMember-info-social">
|
||||||
>
|
{Object.values(SOCIAL_INFO).map(s => {
|
||||||
<Form.Item>
|
const account = user.socialMedias.find(sm => s.service === sm.service);
|
||||||
<Input
|
const cn = classnames(
|
||||||
name="name"
|
'TeamMember-info-social-icon',
|
||||||
autoComplete="off"
|
account && 'is-active',
|
||||||
placeholder="Display name (Required)"
|
);
|
||||||
value={fields.name}
|
return (
|
||||||
onChange={this.handleChangeField}
|
<div key={s.name} className={cn}>
|
||||||
/>
|
{s.icon}
|
||||||
</Form.Item>
|
{account && (
|
||||||
|
<Icon
|
||||||
<Form.Item>
|
className="TeamMember-info-social-icon-check"
|
||||||
<Input
|
type="check-circle"
|
||||||
name="title"
|
theme="filled"
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Title (Required)"
|
|
||||||
value={fields.title}
|
|
||||||
onChange={this.handleChangeField}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Row gutter={12}>
|
|
||||||
<Col xs={24} sm={12}>
|
|
||||||
<Form.Item>
|
|
||||||
<Input
|
|
||||||
name="ethAddress"
|
|
||||||
autoComplete="ethAddress"
|
|
||||||
placeholder="Ethereum address (Required)"
|
|
||||||
value={fields.ethAddress}
|
|
||||||
onChange={this.handleChangeField}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
)}
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12}>
|
);
|
||||||
<Form.Item>
|
})}
|
||||||
<Input
|
</div>
|
||||||
name="emailAddress"
|
|
||||||
placeholder="Email address (Required)"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
value={fields.emailAddress}
|
|
||||||
onChange={this.handleChangeField}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={12}>
|
|
||||||
{Object.values(SOCIAL_INFO).map(s => (
|
|
||||||
<Col xs={24} sm={12} key={s.type}>
|
|
||||||
<Form.Item>
|
|
||||||
<Input
|
|
||||||
placeholder={`${s.name} account`}
|
|
||||||
autoComplete="off"
|
|
||||||
value={fields.socialAccounts[s.type]}
|
|
||||||
onChange={ev => this.handleSocialChange(ev, s.type)}
|
|
||||||
addonBefore={s.icon}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{!isMissingField &&
|
|
||||||
error && (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
message={error}
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: '0.75rem' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Row>
|
|
||||||
<Button type="primary" htmlType="submit" disabled={isDisabled}>
|
|
||||||
Save changes
|
|
||||||
</Button>
|
|
||||||
<Button type="ghost" htmlType="button" onClick={this.cancelEditing}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="TeamMember-info-name">{user.name || <em>No name</em>}</div>
|
|
||||||
<div className="TeamMember-info-title">
|
|
||||||
{user.title || <em>No title</em>}
|
|
||||||
</div>
|
|
||||||
<div className="TeamMember-info-social">
|
|
||||||
{Object.values(SOCIAL_INFO).map(s => {
|
|
||||||
const account = user.socialAccounts[s.type];
|
|
||||||
const cn = classnames(
|
|
||||||
'TeamMember-info-social-icon',
|
|
||||||
account && 'is-active',
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div key={s.name} className={cn}>
|
|
||||||
{s.icon}
|
|
||||||
{account && (
|
|
||||||
<Icon
|
|
||||||
className="TeamMember-info-social-icon-check"
|
|
||||||
type="check-circle"
|
|
||||||
theme="filled"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{index !== 0 && (
|
|
||||||
<>
|
|
||||||
<button className="TeamMember-info-edit" onClick={this.toggleEditing}>
|
|
||||||
<Icon type="form" /> Edit
|
|
||||||
</button>
|
|
||||||
<button className="TeamMember-info-remove" onClick={this.removeMember}>
|
|
||||||
<Icon type="close-circle" theme="filled" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleEditing = (ev?: React.SyntheticEvent<any>) => {
|
|
||||||
if (ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isEditing, fields } = this.state;
|
|
||||||
if (isEditing) {
|
|
||||||
// TODO: Check if valid first
|
|
||||||
this.props.onChange(fields, this.props.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ isEditing: !isEditing });
|
|
||||||
};
|
|
||||||
|
|
||||||
private cancelEditing = () => {
|
|
||||||
this.setState({
|
|
||||||
isEditing: false,
|
|
||||||
fields: { ...this.props.user },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = ev.currentTarget;
|
|
||||||
this.setState({
|
|
||||||
fields: {
|
|
||||||
...this.state.fields,
|
|
||||||
[name as any]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSocialChange = (
|
|
||||||
ev: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
type: SOCIAL_TYPE,
|
|
||||||
) => {
|
|
||||||
const { value } = ev.currentTarget;
|
|
||||||
this.setState({
|
|
||||||
fields: {
|
|
||||||
...this.state.fields,
|
|
||||||
socialAccounts: {
|
|
||||||
...this.state.fields.socialAccounts,
|
|
||||||
[type]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleChangePhoto = () => {
|
|
||||||
// TODO: Actual file uploading
|
|
||||||
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
|
|
||||||
const num = Math.floor(Math.random() * 80);
|
|
||||||
this.setState({
|
|
||||||
fields: {
|
|
||||||
...this.state.fields,
|
|
||||||
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private removeMember = () => {
|
|
||||||
this.props.onRemove(this.props.index);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,93 +1,47 @@
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||||
import { SOCIAL_TYPE, CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
|
|
||||||
function generateRandomAddress() {
|
|
||||||
return (
|
|
||||||
'0x' +
|
|
||||||
Math.random()
|
|
||||||
.toString(16)
|
|
||||||
.substring(2, 12) +
|
|
||||||
Math.random()
|
|
||||||
.toString(16)
|
|
||||||
.substring(2, 12) +
|
|
||||||
Math.random()
|
|
||||||
.toString(16)
|
|
||||||
.substring(2, 12) +
|
|
||||||
Math.random()
|
|
||||||
.toString(16)
|
|
||||||
.substring(2, 12)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createExampleProposal = (
|
const createExampleProposal = (
|
||||||
payOutAddress: string,
|
payoutAddress: string,
|
||||||
trustees: string[],
|
trustees: string[],
|
||||||
): CreateFormState => {
|
): Partial<ProposalDraft> => {
|
||||||
return {
|
return {
|
||||||
title: 'Grant.io T-Shirts',
|
title: 'Grant.io T-Shirts',
|
||||||
brief: "The most stylish wear, sporting your favorite brand's logo",
|
brief: "The most stylish wear, sporting your favorite brand's logo",
|
||||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||||
team: [
|
content:
|
||||||
{
|
|
||||||
name: 'John Smith',
|
|
||||||
title: 'CEO of Grant.io',
|
|
||||||
avatarUrl: `https://randomuser.me/api/portraits/men/${Math.floor(
|
|
||||||
Math.random() * 80,
|
|
||||||
)}.jpg`,
|
|
||||||
ethAddress: payOutAddress,
|
|
||||||
emailAddress: 'test@grant.io',
|
|
||||||
socialAccounts: {
|
|
||||||
[SOCIAL_TYPE.GITHUB]: 'dternyak',
|
|
||||||
[SOCIAL_TYPE.LINKEDIN]: 'dternyak',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Jane Smith',
|
|
||||||
title: 'T-Shirt Designer',
|
|
||||||
avatarUrl: `https://randomuser.me/api/portraits/women/${Math.floor(
|
|
||||||
Math.random() * 80,
|
|
||||||
)}.jpg`,
|
|
||||||
ethAddress: generateRandomAddress(),
|
|
||||||
emailAddress: 'designer@tshirt.com',
|
|
||||||
socialAccounts: {
|
|
||||||
[SOCIAL_TYPE.KEYBASE]: 'willo',
|
|
||||||
[SOCIAL_TYPE.TWITTER]: 'wbobeirne',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
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',
|
'![](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',
|
target: '5',
|
||||||
payOutAddress,
|
payoutAddress,
|
||||||
trustees,
|
trustees,
|
||||||
milestones: [
|
milestones: [
|
||||||
{
|
{
|
||||||
title: 'Initial Funding',
|
title: 'Initial Funding',
|
||||||
description:
|
content:
|
||||||
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
|
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
|
||||||
date: 'October 2018',
|
dateEstimated: 'October 2018',
|
||||||
payoutPercent: 30,
|
payoutPercent: '30',
|
||||||
immediatePayout: true,
|
immediatePayout: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Test Prints',
|
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.",
|
"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',
|
dateEstimated: 'November 2018',
|
||||||
payoutPercent: 20,
|
payoutPercent: '20',
|
||||||
immediatePayout: false,
|
immediatePayout: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'All Shirts Printed',
|
title: 'All Shirts Printed',
|
||||||
description:
|
content:
|
||||||
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
|
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
|
||||||
date: 'December 2018',
|
dateEstimated: 'December 2018',
|
||||||
payoutPercent: 50,
|
payoutPercent: '50',
|
||||||
immediatePayout: false,
|
immediatePayout: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
deadline: 300,
|
deadlineDuration: 300,
|
||||||
milestoneDeadline: 60,
|
voteDuration: 60,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { compose } from 'recompose';
|
import { compose } from 'recompose';
|
||||||
import { Steps, Icon, Spin, Alert } from 'antd';
|
import { Steps, Icon } from 'antd';
|
||||||
import qs from 'query-string';
|
import qs from 'query-string';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router';
|
import { withRouter, RouteComponentProps } from 'react-router';
|
||||||
import { History } from 'history';
|
import { History } from 'history';
|
||||||
|
@ -14,9 +14,10 @@ import Governance from './Governance';
|
||||||
import Review from './Review';
|
import Review from './Review';
|
||||||
import Preview from './Preview';
|
import Preview from './Preview';
|
||||||
import Final from './Final';
|
import Final from './Final';
|
||||||
|
import PublishWarningModal from './PubishWarningModal';
|
||||||
import createExampleProposal from './example';
|
import createExampleProposal from './example';
|
||||||
import { createActions } from 'modules/create';
|
import { createActions } from 'modules/create';
|
||||||
import { CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
import { getCreateErrors } from 'modules/create/utils';
|
import { getCreateErrors } from 'modules/create/utils';
|
||||||
import { web3Actions } from 'modules/web3';
|
import { web3Actions } from 'modules/web3';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
@ -108,14 +109,10 @@ interface StateProps {
|
||||||
form: AppState['create']['form'];
|
form: AppState['create']['form'];
|
||||||
isSavingDraft: AppState['create']['isSavingDraft'];
|
isSavingDraft: AppState['create']['isSavingDraft'];
|
||||||
hasSavedDraft: AppState['create']['hasSavedDraft'];
|
hasSavedDraft: AppState['create']['hasSavedDraft'];
|
||||||
isFetchingDraft: AppState['create']['isFetchingDraft'];
|
|
||||||
hasFetchedDraft: AppState['create']['hasFetchedDraft'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
updateForm: typeof createActions['updateForm'];
|
updateForm: typeof createActions['updateForm'];
|
||||||
resetForm: typeof createActions['resetForm'];
|
|
||||||
fetchDraft: typeof createActions['fetchDraft'];
|
|
||||||
resetCreateCrowdFund: typeof web3Actions['resetCreateCrowdFund'];
|
resetCreateCrowdFund: typeof web3Actions['resetCreateCrowdFund'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,13 +121,14 @@ type Props = OwnProps & StateProps & DispatchProps & RouteComponentProps<any>;
|
||||||
interface State {
|
interface State {
|
||||||
step: CREATE_STEP;
|
step: CREATE_STEP;
|
||||||
isPreviewing: boolean;
|
isPreviewing: boolean;
|
||||||
|
isShowingPublishWarning: boolean;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
isExample: boolean;
|
isExample: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreateFlow extends React.Component<Props, State> {
|
class CreateFlow extends React.Component<Props, State> {
|
||||||
private historyUnlisten: () => void;
|
private historyUnlisten: () => void;
|
||||||
private debouncedUpdateForm: (form: Partial<CreateFormState>) => void;
|
private debouncedUpdateForm: (form: Partial<ProposalDraft>) => void;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -144,6 +142,7 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
isPreviewing: false,
|
isPreviewing: false,
|
||||||
isPublishing: false,
|
isPublishing: false,
|
||||||
isExample: false,
|
isExample: false,
|
||||||
|
isShowingPublishWarning: false,
|
||||||
};
|
};
|
||||||
this.debouncedUpdateForm = debounce(this.updateForm, 800);
|
this.debouncedUpdateForm = debounce(this.updateForm, 800);
|
||||||
this.historyUnlisten = this.props.history.listen(this.handlePop);
|
this.historyUnlisten = this.props.history.listen(this.handlePop);
|
||||||
|
@ -151,7 +150,6 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.resetCreateCrowdFund();
|
this.props.resetCreateCrowdFund();
|
||||||
this.props.fetchDraft();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
@ -161,16 +159,8 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isFetchingDraft, isSavingDraft, hasFetchedDraft } = this.props;
|
const { isSavingDraft } = this.props;
|
||||||
const { step, isPreviewing, isPublishing } = this.state;
|
const { step, isPreviewing, isPublishing, isShowingPublishWarning } = this.state;
|
||||||
|
|
||||||
if (isFetchingDraft && !isPublishing) {
|
|
||||||
return (
|
|
||||||
<div className="CreateFlow-loading">
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = STEP_INFO[step];
|
const info = STEP_INFO[step];
|
||||||
const currentIndex = STEP_ORDER.indexOf(step);
|
const currentIndex = STEP_ORDER.indexOf(step);
|
||||||
|
@ -198,25 +188,12 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Steps>
|
</Steps>
|
||||||
{hasFetchedDraft && (
|
|
||||||
<Alert
|
|
||||||
style={{ margin: '2rem auto -2rem', maxWidth: '520px' }}
|
|
||||||
type="success"
|
|
||||||
closable
|
|
||||||
message="Welcome back"
|
|
||||||
description={
|
|
||||||
<span>
|
|
||||||
We've restored your state from before. If you want to start over,{' '}
|
|
||||||
<a onClick={this.props.resetForm}>click here</a>.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h1 className="CreateFlow-header-title">{info.title}</h1>
|
<h1 className="CreateFlow-header-title">{info.title}</h1>
|
||||||
<div className="CreateFlow-header-subtitle">{info.subtitle}</div>
|
<div className="CreateFlow-header-subtitle">{info.subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="CreateFlow-content">
|
<div className="CreateFlow-content">
|
||||||
<StepComponent
|
<StepComponent
|
||||||
|
proposalId={this.props.form && this.props.form.proposalId}
|
||||||
initialState={this.props.form}
|
initialState={this.props.form}
|
||||||
updateForm={this.debouncedUpdateForm}
|
updateForm={this.debouncedUpdateForm}
|
||||||
setStep={this.setStep}
|
setStep={this.setStep}
|
||||||
|
@ -243,7 +220,7 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
<button
|
<button
|
||||||
className="CreateFlow-footer-button is-primary"
|
className="CreateFlow-footer-button is-primary"
|
||||||
key="publish"
|
key="publish"
|
||||||
onClick={this.startPublish}
|
onClick={this.openPublishWarning}
|
||||||
disabled={this.checkFormErrors()}
|
disabled={this.checkFormErrors()}
|
||||||
>
|
>
|
||||||
Publish
|
Publish
|
||||||
|
@ -270,11 +247,17 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
{isSavingDraft && (
|
{isSavingDraft && (
|
||||||
<div className="CreateFlow-draftNotification">Saving draft...</div>
|
<div className="CreateFlow-draftNotification">Saving draft...</div>
|
||||||
)}
|
)}
|
||||||
|
<PublishWarningModal
|
||||||
|
proposal={this.props.form}
|
||||||
|
isVisible={isShowingPublishWarning}
|
||||||
|
handleClose={this.closePublishWarning}
|
||||||
|
handlePublish={this.startPublish}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateForm = (form: Partial<CreateFormState>) => {
|
private updateForm = (form: Partial<ProposalDraft>) => {
|
||||||
this.props.updateForm(form);
|
this.props.updateForm(form);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -298,10 +281,16 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private startPublish = () => {
|
private startPublish = () => {
|
||||||
this.setState({ isPublishing: true });
|
this.setState({
|
||||||
|
isPublishing: true,
|
||||||
|
isShowingPublishWarning: false,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private checkFormErrors = () => {
|
private checkFormErrors = () => {
|
||||||
|
if (!this.props.form) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const errors = getCreateErrors(this.props.form);
|
const errors = getCreateErrors(this.props.form);
|
||||||
return !!Object.keys(errors).length;
|
return !!Object.keys(errors).length;
|
||||||
};
|
};
|
||||||
|
@ -318,6 +307,14 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private openPublishWarning = () => {
|
||||||
|
this.setState({ isShowingPublishWarning: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private closePublishWarning = () => {
|
||||||
|
this.setState({ isShowingPublishWarning: false });
|
||||||
|
};
|
||||||
|
|
||||||
private fillInExample = () => {
|
private fillInExample = () => {
|
||||||
const { accounts } = this.props;
|
const { accounts } = this.props;
|
||||||
const [payoutAddress, ...trustees] = accounts;
|
const [payoutAddress, ...trustees] = accounts;
|
||||||
|
@ -337,16 +334,12 @@ const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
form: state.create.form,
|
form: state.create.form,
|
||||||
isSavingDraft: state.create.isSavingDraft,
|
isSavingDraft: state.create.isSavingDraft,
|
||||||
hasSavedDraft: state.create.hasSavedDraft,
|
hasSavedDraft: state.create.hasSavedDraft,
|
||||||
isFetchingDraft: state.create.isFetchingDraft,
|
|
||||||
hasFetchedDraft: state.create.hasFetchedDraft,
|
|
||||||
crowdFundLoading: state.web3.crowdFundLoading,
|
crowdFundLoading: state.web3.crowdFundLoading,
|
||||||
crowdFundError: state.web3.crowdFundError,
|
crowdFundError: state.web3.crowdFundError,
|
||||||
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
|
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
updateForm: createActions.updateForm,
|
updateForm: createActions.updateForm,
|
||||||
resetForm: createActions.resetForm,
|
|
||||||
fetchDraft: createActions.fetchDraft,
|
|
||||||
resetCreateCrowdFund: web3Actions.resetCreateCrowdFund,
|
resetCreateCrowdFund: web3Actions.resetCreateCrowdFund,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { List, Button, Divider, Spin, Popconfirm, message } from 'antd';
|
||||||
|
import Placeholder from 'components/Placeholder';
|
||||||
|
import { ProposalDraft } from 'types';
|
||||||
|
import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import './style.less';
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
drafts: AppState['create']['drafts'];
|
||||||
|
isFetchingDrafts: AppState['create']['isFetchingDrafts'];
|
||||||
|
fetchDraftsError: AppState['create']['fetchDraftsError'];
|
||||||
|
isCreatingDraft: AppState['create']['isCreatingDraft'];
|
||||||
|
createDraftError: AppState['create']['createDraftError'];
|
||||||
|
isDeletingDraft: AppState['create']['isDeletingDraft'];
|
||||||
|
deleteDraftError: AppState['create']['deleteDraftError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
fetchDrafts: typeof fetchDrafts;
|
||||||
|
createDraft: typeof createDraft;
|
||||||
|
deleteDraft: typeof deleteDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
createIfNone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & DispatchProps & OwnProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
deletingId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DraftList extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
deletingId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
this.props.fetchDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
const {
|
||||||
|
drafts,
|
||||||
|
createIfNone,
|
||||||
|
isDeletingDraft,
|
||||||
|
deleteDraftError,
|
||||||
|
createDraftError,
|
||||||
|
} = this.props;
|
||||||
|
if (createIfNone && drafts && !prevProps.drafts && !drafts.length) {
|
||||||
|
this.createDraft();
|
||||||
|
}
|
||||||
|
if (prevProps.isDeletingDraft && !isDeletingDraft) {
|
||||||
|
this.setState({ deletingId: null });
|
||||||
|
}
|
||||||
|
if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) {
|
||||||
|
message.error('Failed to delete draft', 3);
|
||||||
|
}
|
||||||
|
if (createDraftError && prevProps.createDraftError !== createDraftError) {
|
||||||
|
message.error('Failed to create draft', 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { drafts, isCreatingDraft } = this.props;
|
||||||
|
const { deletingId } = this.state;
|
||||||
|
|
||||||
|
if (!drafts || isCreatingDraft) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftsEl;
|
||||||
|
if (drafts.length) {
|
||||||
|
draftsEl = (
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={drafts}
|
||||||
|
renderItem={(d: ProposalDraft) => {
|
||||||
|
const actions = [
|
||||||
|
<Link key="edit" to={`/proposals/${d.proposalId}/edit`}>
|
||||||
|
Edit
|
||||||
|
</Link>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="Are you sure?"
|
||||||
|
onConfirm={() => this.deleteDraft(d.proposalId)}
|
||||||
|
>
|
||||||
|
<a>Delete</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Spin tip="deleting..." spinning={deletingId === d.proposalId}>
|
||||||
|
<List.Item actions={actions}>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={d.title || <em>Untitled proposal</em>}
|
||||||
|
description={d.brief || <em>No description</em>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
draftsEl = (
|
||||||
|
<Placeholder
|
||||||
|
title="You have no drafts"
|
||||||
|
subtitle="Why not make one now? Click below to start."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="DraftList">
|
||||||
|
<h2 className="DraftList-title">Your drafts</h2>
|
||||||
|
{draftsEl}
|
||||||
|
<Divider>or</Divider>
|
||||||
|
<Button
|
||||||
|
className="DraftList-create"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={this.createDraft}
|
||||||
|
loading={isCreatingDraft}
|
||||||
|
>
|
||||||
|
Create a new Proposal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDraft = () => {
|
||||||
|
this.props.createDraft({ redirect: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private deleteDraft = (proposalId: number) => {
|
||||||
|
this.props.deleteDraft(proposalId);
|
||||||
|
this.setState({ deletingId: proposalId });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
state => ({
|
||||||
|
drafts: state.create.drafts,
|
||||||
|
isFetchingDrafts: state.create.isFetchingDrafts,
|
||||||
|
fetchDraftsError: state.create.fetchDraftsError,
|
||||||
|
isCreatingDraft: state.create.isCreatingDraft,
|
||||||
|
createDraftError: state.create.createDraftError,
|
||||||
|
isDeletingDraft: state.create.isDeletingDraft,
|
||||||
|
deleteDraftError: state.create.deleteDraftError,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
fetchDrafts,
|
||||||
|
createDraft,
|
||||||
|
deleteDraft,
|
||||||
|
},
|
||||||
|
)(DraftList);
|
|
@ -0,0 +1,26 @@
|
||||||
|
.DraftList {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-create {
|
||||||
|
display: block;
|
||||||
|
max-width: 280px;
|
||||||
|
height: 3.2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-divider {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ import { Upload, Icon, Modal, Button, Alert } from 'antd';
|
||||||
import Cropper from 'react-cropper';
|
import Cropper from 'react-cropper';
|
||||||
import 'cropperjs/dist/cropper.css';
|
import 'cropperjs/dist/cropper.css';
|
||||||
import { UploadFile } from 'antd/lib/upload/interface';
|
import { UploadFile } from 'antd/lib/upload/interface';
|
||||||
import { TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
import { getBase64 } from 'utils/blob';
|
import { getBase64 } from 'utils/blob';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import './AvatarEdit.less';
|
import './AvatarEdit.less';
|
||||||
|
@ -13,7 +13,7 @@ const FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||||
const FILE_MAX_LOAD_MB = 10;
|
const FILE_MAX_LOAD_MB = 10;
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
user: TeamMember;
|
user: User;
|
||||||
onDelete(): void;
|
onDelete(): void;
|
||||||
onDone(url: string): void;
|
onDone(url: string): void;
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,7 @@ export default class AvatarEdit extends React.PureComponent<Props, State> {
|
||||||
const { newAvatarUrl, showModal, loadError, uploadError, isUploading } = this.state;
|
const { newAvatarUrl, showModal, loadError, uploadError, isUploading } = this.state;
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
user: { avatarUrl },
|
user: { avatar },
|
||||||
} = this.props;
|
} = this.props;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -58,12 +58,12 @@ export default class AvatarEdit extends React.PureComponent<Props, State> {
|
||||||
<Button className="AvatarEdit-avatar-change">
|
<Button className="AvatarEdit-avatar-change">
|
||||||
<Icon
|
<Icon
|
||||||
className="AvatarEdit-avatar-change-icon"
|
className="AvatarEdit-avatar-change-icon"
|
||||||
type={avatarUrl ? 'picture' : 'plus-circle'}
|
type={avatar ? 'picture' : 'plus-circle'}
|
||||||
/>
|
/>
|
||||||
<div>{avatarUrl ? 'Change photo' : 'Add photo'}</div>
|
<div>{avatar ? 'Change photo' : 'Add photo'}</div>
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
{avatarUrl && (
|
{avatar && (
|
||||||
<Button
|
<Button
|
||||||
className="AvatarEdit-avatar-delete"
|
className="AvatarEdit-avatar-delete"
|
||||||
icon="delete"
|
icon="delete"
|
||||||
|
|
|
@ -13,7 +13,7 @@ export default class Profile extends React.Component<OwnProps> {
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
userName,
|
userName,
|
||||||
comment: { body, proposal, dateCreated },
|
comment: { content, proposal, dateCreated },
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -28,7 +28,7 @@ export default class Profile extends React.Component<OwnProps> {
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
{moment(dateCreated).from(Date.now())}
|
{moment(dateCreated).from(Date.now())}
|
||||||
</div>
|
</div>
|
||||||
<div className="ProfileComment-body">{body}</div>
|
<div className="ProfileComment-body">{content}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import axios from 'api/axios';
|
import axios from 'api/axios';
|
||||||
import { Input, Form, Col, Row, Button, Alert } from 'antd';
|
import { Input, Form, Col, Row, Button, Alert } from 'antd';
|
||||||
import { SOCIAL_INFO } from 'utils/social';
|
import { SOCIAL_INFO, socialMediaToUrl } from 'utils/social';
|
||||||
import { SOCIAL_TYPE, TeamMember } from 'types';
|
import { SOCIAL_SERVICE, User } from 'types';
|
||||||
import { UserState } from 'modules/users/reducers';
|
import { UserState } from 'modules/users/reducers';
|
||||||
import { getCreateTeamMemberError } from 'modules/create/utils';
|
import { getCreateTeamMemberError } from 'modules/create/utils';
|
||||||
import AvatarEdit from './AvatarEdit';
|
import AvatarEdit from './AvatarEdit';
|
||||||
|
@ -12,18 +12,18 @@ import './ProfileEdit.less';
|
||||||
interface Props {
|
interface Props {
|
||||||
user: UserState;
|
user: UserState;
|
||||||
onDone(): void;
|
onDone(): void;
|
||||||
onEdit(user: TeamMember): void;
|
onEdit(user: User): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
fields: TeamMember;
|
fields: User;
|
||||||
isChanged: boolean;
|
isChanged: boolean;
|
||||||
showError: boolean;
|
showError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ProfileEdit extends React.PureComponent<Props, State> {
|
export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
state: State = {
|
state: State = {
|
||||||
fields: { ...this.props.user } as TeamMember,
|
fields: { ...this.props.user } as User,
|
||||||
isChanged: false,
|
isChanged: false,
|
||||||
showError: false,
|
showError: false,
|
||||||
};
|
};
|
||||||
|
@ -49,7 +49,10 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
const { fields } = this.state;
|
const { fields } = this.state;
|
||||||
const error = getCreateTeamMemberError(fields);
|
const error = getCreateTeamMemberError(fields);
|
||||||
const isMissingField =
|
const isMissingField =
|
||||||
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
|
!fields.displayName ||
|
||||||
|
!fields.title ||
|
||||||
|
!fields.emailAddress ||
|
||||||
|
!fields.accountAddress;
|
||||||
const isDisabled = !!error || isMissingField || !this.state.isChanged;
|
const isDisabled = !!error || isMissingField || !this.state.isChanged;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -72,7 +75,7 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
name="name"
|
name="name"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Display name (Required)"
|
placeholder="Display name (Required)"
|
||||||
value={fields.name}
|
value={fields.displayName}
|
||||||
onChange={this.handleChangeField}
|
onChange={this.handleChangeField}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
@ -101,29 +104,32 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Input
|
<Input
|
||||||
name="ethAddress"
|
name="accountAddress"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
autoComplete="ethAddress"
|
autoComplete="accountAddress"
|
||||||
placeholder="Ethereum address (Required)"
|
placeholder="Ethereum address (Required)"
|
||||||
value={fields.ethAddress}
|
value={fields.accountAddress}
|
||||||
onChange={this.handleChangeField}
|
onChange={this.handleChangeField}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Row gutter={12}>
|
<Row gutter={12}>
|
||||||
{Object.values(SOCIAL_INFO).map(s => (
|
{Object.values(SOCIAL_INFO).map(s => {
|
||||||
<Col xs={24} sm={12} key={s.type}>
|
const field = fields.socialMedias.find(sm => sm.service === s.service);
|
||||||
<Form.Item>
|
return (
|
||||||
<Input
|
<Col xs={24} sm={12} key={s.service}>
|
||||||
placeholder={`${s.name} account`}
|
<Form.Item>
|
||||||
autoComplete="off"
|
<Input
|
||||||
value={fields.socialAccounts[s.type]}
|
placeholder={`${s.name} account`}
|
||||||
onChange={ev => this.handleSocialChange(ev, s.type)}
|
autoComplete="off"
|
||||||
addonBefore={s.icon}
|
value={field ? field.username : ''}
|
||||||
/>
|
onChange={ev => this.handleSocialChange(ev, s.service)}
|
||||||
</Form.Item>
|
addonBefore={s.icon}
|
||||||
</Col>
|
/>
|
||||||
))}
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{!isMissingField &&
|
{!isMissingField &&
|
||||||
|
@ -173,11 +179,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleCancel = () => {
|
private handleCancel = () => {
|
||||||
const { avatarUrl } = this.state.fields;
|
const propsAvatar = this.props.user.avatar;
|
||||||
|
const stateAvatar = this.state.fields.avatar;
|
||||||
// cleanup uploaded file if we cancel
|
// cleanup uploaded file if we cancel
|
||||||
if (this.props.user.avatarUrl !== avatarUrl && avatarUrl) {
|
if (propsAvatar && stateAvatar && propsAvatar.imageUrl !== stateAvatar.imageUrl) {
|
||||||
axios.delete('/api/v1/users/avatar', {
|
axios.delete('/api/v1/users/avatar', {
|
||||||
params: { url: avatarUrl },
|
params: { url: stateAvatar.imageUrl },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.props.onDone();
|
this.props.onDone();
|
||||||
|
@ -198,20 +205,27 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
private handleSocialChange = (
|
private handleSocialChange = (
|
||||||
ev: React.ChangeEvent<HTMLInputElement>,
|
ev: React.ChangeEvent<HTMLInputElement>,
|
||||||
type: SOCIAL_TYPE,
|
service: SOCIAL_SERVICE,
|
||||||
) => {
|
) => {
|
||||||
const { value } = ev.currentTarget;
|
const { value } = ev.currentTarget;
|
||||||
|
|
||||||
|
// First remove...
|
||||||
|
const socialMedias = this.state.fields.socialMedias.filter(
|
||||||
|
sm => sm.service !== service,
|
||||||
|
);
|
||||||
|
if (value) {
|
||||||
|
// Then re-add if there as a value
|
||||||
|
socialMedias.push({
|
||||||
|
service,
|
||||||
|
username: value,
|
||||||
|
url: socialMediaToUrl(service, value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fields = {
|
const fields = {
|
||||||
...this.state.fields,
|
...this.state.fields,
|
||||||
socialAccounts: {
|
socialMedias,
|
||||||
...this.state.fields.socialAccounts,
|
|
||||||
[type]: value,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
// delete key for empty string
|
|
||||||
if (!value) {
|
|
||||||
delete fields.socialAccounts[type];
|
|
||||||
}
|
|
||||||
const isChanged = this.isChangedCheck(fields);
|
const isChanged = this.isChangedCheck(fields);
|
||||||
this.setState({
|
this.setState({
|
||||||
isChanged,
|
isChanged,
|
||||||
|
@ -222,7 +236,9 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
private handleChangePhoto = (url: string) => {
|
private handleChangePhoto = (url: string) => {
|
||||||
const fields = {
|
const fields = {
|
||||||
...this.state.fields,
|
...this.state.fields,
|
||||||
avatarUrl: url,
|
avatar: {
|
||||||
|
imageUrl: url,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const isChanged = this.isChangedCheck(fields);
|
const isChanged = this.isChangedCheck(fields);
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -232,13 +248,15 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleDeletePhoto = () => {
|
private handleDeletePhoto = () => {
|
||||||
const fields = lodash.clone(this.state.fields);
|
const fields = {
|
||||||
delete fields.avatarUrl;
|
...this.state.fields,
|
||||||
|
avatar: null,
|
||||||
|
};
|
||||||
const isChanged = this.isChangedCheck(fields);
|
const isChanged = this.isChangedCheck(fields);
|
||||||
this.setState({ isChanged, fields });
|
this.setState({ isChanged, fields });
|
||||||
};
|
};
|
||||||
|
|
||||||
private isChangedCheck = (a: TeamMember) => {
|
private isChangedCheck = (a: User) => {
|
||||||
return !lodash.isEqual(a, this.props.user);
|
return !lodash.isEqual(a, this.props.user);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
.ProfileInvite {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 1.2rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&-info {
|
||||||
|
&-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-brief {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-inviter {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
padding: 0 0.8rem !important;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Button, Popconfirm, message } from 'antd';
|
||||||
|
import { respondToInvite } from 'modules/users/actions';
|
||||||
|
import { TeamInviteWithResponse } from 'modules/users/reducers';
|
||||||
|
import './ProfileInvite.less';
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
respondToInvite: typeof respondToInvite;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
userId: string | number;
|
||||||
|
invite: TeamInviteWithResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = DispatchProps & OwnProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
isAccepting: boolean;
|
||||||
|
isRejecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileInvite extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
isAccepting: false,
|
||||||
|
isRejecting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
const { invite } = this.props;
|
||||||
|
if (prevProps.invite !== invite && invite.respondError) {
|
||||||
|
this.setState({
|
||||||
|
isAccepting: false,
|
||||||
|
isRejecting: false,
|
||||||
|
});
|
||||||
|
message.error('Failed to respond to invitation', 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { invite } = this.props;
|
||||||
|
const { isAccepting, isRejecting } = this.state;
|
||||||
|
const { proposal } = invite;
|
||||||
|
const inviter = proposal.team[0] || { displayName: 'Unknown user' };
|
||||||
|
return (
|
||||||
|
<div className="ProfileInvite">
|
||||||
|
<div className="ProfileInvite-info">
|
||||||
|
<div className="ProfileInvite-info-title">
|
||||||
|
{proposal.title || <em>No title</em>}
|
||||||
|
</div>
|
||||||
|
<div className="ProfileInvite-info-brief">
|
||||||
|
{proposal.brief || <em>No description</em>}
|
||||||
|
</div>
|
||||||
|
<div className="ProfileInvite-info-inviter">
|
||||||
|
created by {inviter.displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ProfileInvite-actions">
|
||||||
|
<Button
|
||||||
|
icon="check"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
ghost
|
||||||
|
onClick={this.accept}
|
||||||
|
disabled={isRejecting}
|
||||||
|
loading={isAccepting}
|
||||||
|
/>
|
||||||
|
<Popconfirm title="Are you sure?" onConfirm={this.reject}>
|
||||||
|
<Button
|
||||||
|
icon="close"
|
||||||
|
type="danger"
|
||||||
|
size="large"
|
||||||
|
ghost
|
||||||
|
disabled={isAccepting}
|
||||||
|
loading={isRejecting}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private accept = () => {
|
||||||
|
const { userId, invite } = this.props;
|
||||||
|
this.setState({ isAccepting: true });
|
||||||
|
this.props.respondToInvite(userId, invite.id, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
private reject = () => {
|
||||||
|
const { userId, invite } = this.props;
|
||||||
|
this.setState({ isRejecting: true });
|
||||||
|
this.props.respondToInvite(userId, invite.id, false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<{}, DispatchProps, OwnProps, {}>(
|
||||||
|
undefined,
|
||||||
|
{ respondToInvite },
|
||||||
|
)(ProfileInvite);
|
|
@ -30,7 +30,7 @@ export default class Profile extends React.Component<OwnProps> {
|
||||||
<h3>Team</h3>
|
<h3>Team</h3>
|
||||||
<div className="ProfileProposal-block-team">
|
<div className="ProfileProposal-block-team">
|
||||||
{team.map(user => (
|
{team.map(user => (
|
||||||
<UserRow key={user.ethAddress || user.emailAddress} user={user} />
|
<UserRow key={user.accountAddress || user.emailAddress} user={user} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { SocialInfo } from 'types';
|
import { SocialMedia } from 'types';
|
||||||
import { usersActions } from 'modules/users';
|
import { usersActions } from 'modules/users';
|
||||||
import { UserState } from 'modules/users/reducers';
|
import { UserState } from 'modules/users/reducers';
|
||||||
import { typedKeys } from 'utils/ts';
|
|
||||||
import ProfileEdit from './ProfileEdit';
|
import ProfileEdit from './ProfileEdit';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social';
|
import { SOCIAL_INFO } from 'utils/social';
|
||||||
import ShortAddress from 'components/ShortAddress';
|
import ShortAddress from 'components/ShortAddress';
|
||||||
import './ProfileUser.less';
|
import './ProfileUser.less';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
@ -39,10 +38,10 @@ class ProfileUser extends React.Component<Props> {
|
||||||
const {
|
const {
|
||||||
authUser,
|
authUser,
|
||||||
user,
|
user,
|
||||||
user: { socialAccounts },
|
user: { socialMedias },
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const isSelf = !!authUser && authUser.ethAddress === user.ethAddress;
|
const isSelf = !!authUser && authUser.accountAddress === user.accountAddress;
|
||||||
|
|
||||||
if (this.state.isEditing) {
|
if (this.state.isEditing) {
|
||||||
return (
|
return (
|
||||||
|
@ -60,7 +59,7 @@ class ProfileUser extends React.Component<Props> {
|
||||||
<UserAvatar className="ProfileUser-avatar-img" user={user} />
|
<UserAvatar className="ProfileUser-avatar-img" user={user} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ProfileUser-info">
|
<div className="ProfileUser-info">
|
||||||
<div className="ProfileUser-info-name">{user.name}</div>
|
<div className="ProfileUser-info-name">{user.displayName}</div>
|
||||||
<div className="ProfileUser-info-title">{user.title}</div>
|
<div className="ProfileUser-info-title">{user.title}</div>
|
||||||
<div>
|
<div>
|
||||||
{user.emailAddress && (
|
{user.emailAddress && (
|
||||||
|
@ -69,26 +68,18 @@ class ProfileUser extends React.Component<Props> {
|
||||||
{user.emailAddress}
|
{user.emailAddress}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user.ethAddress && (
|
{user.accountAddress && (
|
||||||
<div className="ProfileUser-info-address">
|
<div className="ProfileUser-info-address">
|
||||||
<span>ethereum address</span>
|
<span>ethereum address</span>
|
||||||
<ShortAddress address={user.ethAddress} />
|
<ShortAddress address={user.accountAddress} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{Object.keys(socialAccounts).length > 0 && (
|
{socialMedias.length > 0 && (
|
||||||
<div className="ProfileUser-info-social">
|
<div className="ProfileUser-info-social">
|
||||||
{typedKeys(SOCIAL_INFO).map(
|
{socialMedias.map(sm => (
|
||||||
s =>
|
<Social key={sm.service} socialMedia={sm} />
|
||||||
(socialAccounts[s] && (
|
))}
|
||||||
<Social
|
|
||||||
key={s}
|
|
||||||
account={socialAccounts[s] as string}
|
|
||||||
info={SOCIAL_INFO[s]}
|
|
||||||
/>
|
|
||||||
)) ||
|
|
||||||
null,
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
|
@ -104,10 +95,12 @@ class ProfileUser extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Social = ({ account, info }: { account: string; info: SocialInfo }) => {
|
const Social = ({ socialMedia }: { socialMedia: SocialMedia }) => {
|
||||||
return (
|
return (
|
||||||
<a href={socialAccountToUrl(account, info.type)}>
|
<a href={socialMedia.url} target="_blank" rel="noopener nofollow">
|
||||||
<div className="ProfileUser-info-social-icon">{info.icon}</div>
|
<div className="ProfileUser-info-social-icon">
|
||||||
|
{SOCIAL_INFO[socialMedia.service].icon}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,7 +10,8 @@ import HeaderDetails from 'components/HeaderDetails';
|
||||||
import ProfileUser from './ProfileUser';
|
import ProfileUser from './ProfileUser';
|
||||||
import ProfileProposal from './ProfileProposal';
|
import ProfileProposal from './ProfileProposal';
|
||||||
import ProfileComment from './ProfileComment';
|
import ProfileComment from './ProfileComment';
|
||||||
import PlaceHolder from 'components/Placeholder';
|
import ProfileInvite from './ProfileInvite';
|
||||||
|
import Placeholder from 'components/Placeholder';
|
||||||
import Exception from 'pages/exception';
|
import Exception from 'pages/exception';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ interface DispatchProps {
|
||||||
fetchUserCreated: typeof usersActions['fetchUserCreated'];
|
fetchUserCreated: typeof usersActions['fetchUserCreated'];
|
||||||
fetchUserFunded: typeof usersActions['fetchUserFunded'];
|
fetchUserFunded: typeof usersActions['fetchUserFunded'];
|
||||||
fetchUserComments: typeof usersActions['fetchUserComments'];
|
fetchUserComments: typeof usersActions['fetchUserComments'];
|
||||||
|
fetchUserInvites: typeof usersActions['fetchUserInvites'];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
|
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
|
||||||
|
@ -44,8 +46,8 @@ class Profile extends React.Component<Props> {
|
||||||
const userLookupParam = this.props.match.params.id;
|
const userLookupParam = this.props.match.params.id;
|
||||||
const { authUser } = this.props;
|
const { authUser } = this.props;
|
||||||
if (!userLookupParam) {
|
if (!userLookupParam) {
|
||||||
if (authUser && authUser.ethAddress) {
|
if (authUser && authUser.accountAddress) {
|
||||||
return <Redirect to={`/profile/${authUser.ethAddress}`} />;
|
return <Redirect to={`/profile/${authUser.accountAddress}`} />;
|
||||||
} else {
|
} else {
|
||||||
return <Redirect to="auth" />;
|
return <Redirect to="auth" />;
|
||||||
}
|
}
|
||||||
|
@ -53,6 +55,9 @@ class Profile extends React.Component<Props> {
|
||||||
|
|
||||||
const user = this.props.usersMap[userLookupParam];
|
const user = this.props.usersMap[userLookupParam];
|
||||||
const waiting = !user || !user.hasFetched;
|
const waiting = !user || !user.hasFetched;
|
||||||
|
// TODO: Replace with userid checks
|
||||||
|
const isAuthedUser =
|
||||||
|
user && authUser && user.accountAddress === authUser.accountAddress;
|
||||||
|
|
||||||
if (waiting) {
|
if (waiting) {
|
||||||
return <Spin />;
|
return <Spin />;
|
||||||
|
@ -62,19 +67,20 @@ class Profile extends React.Component<Props> {
|
||||||
return <Exception code="404" />;
|
return <Exception code="404" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createdProposals, fundedProposals, comments } = user;
|
const { createdProposals, fundedProposals, comments, invites } = user;
|
||||||
const noneCreated = user.hasFetchedCreated && createdProposals.length === 0;
|
const noneCreated = user.hasFetchedCreated && createdProposals.length === 0;
|
||||||
const noneFunded = user.hasFetchedFunded && fundedProposals.length === 0;
|
const noneFunded = user.hasFetchedFunded && fundedProposals.length === 0;
|
||||||
const noneCommented = user.hasFetchedComments && comments.length === 0;
|
const noneCommented = user.hasFetchedComments && comments.length === 0;
|
||||||
|
const noneInvites = user.hasFetchedInvites && invites.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Profile">
|
<div className="Profile">
|
||||||
{/* TODO: SSR fetch user details */}
|
{/* TODO: SSR fetch user details */}
|
||||||
{/* TODO: customize details for funders/creators */}
|
{/* TODO: customize details for funders/creators */}
|
||||||
<HeaderDetails
|
<HeaderDetails
|
||||||
title={`${user.name} is funding projects on Grant.io`}
|
title={`${user.displayName} is funding projects on Grant.io`}
|
||||||
description={`Join ${user.name} in funding the future!`}
|
description={`Join ${user.displayName} in funding the future!`}
|
||||||
image={user.avatarUrl}
|
image={user.avatar ? user.avatar.imageUrl : undefined}
|
||||||
/>
|
/>
|
||||||
<ProfileUser user={user} />
|
<ProfileUser user={user} />
|
||||||
<Tabs>
|
<Tabs>
|
||||||
|
@ -85,7 +91,7 @@ class Profile extends React.Component<Props> {
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{noneCreated && (
|
{noneCreated && (
|
||||||
<PlaceHolder subtitle="Has not created any proposals yet" />
|
<Placeholder subtitle="Has not created any proposals yet" />
|
||||||
)}
|
)}
|
||||||
{createdProposals.map(p => (
|
{createdProposals.map(p => (
|
||||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||||
|
@ -98,7 +104,7 @@ class Profile extends React.Component<Props> {
|
||||||
disabled={!user.hasFetchedFunded}
|
disabled={!user.hasFetchedFunded}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{noneFunded && <PlaceHolder subtitle="Has not funded any proposals yet" />}
|
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
|
||||||
{createdProposals.map(p => (
|
{createdProposals.map(p => (
|
||||||
<ProfileProposal key={p.proposalId} proposal={p} />
|
<ProfileProposal key={p.proposalId} proposal={p} />
|
||||||
))}
|
))}
|
||||||
|
@ -110,23 +116,52 @@ class Profile extends React.Component<Props> {
|
||||||
disabled={!user.hasFetchedComments}
|
disabled={!user.hasFetchedComments}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{noneCommented && <PlaceHolder subtitle="Has not made any comments yet" />}
|
{noneCommented && <Placeholder subtitle="Has not made any comments yet" />}
|
||||||
{comments.map(c => (
|
{comments.map(c => (
|
||||||
<ProfileComment key={c.commentId} userName={user.name} comment={c} />
|
<ProfileComment
|
||||||
|
key={c.commentId}
|
||||||
|
userName={user.displayName}
|
||||||
|
comment={c}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
{isAuthedUser && (
|
||||||
|
<Tabs.TabPane
|
||||||
|
tab={TabTitle('Invites', invites.length)}
|
||||||
|
key="invites"
|
||||||
|
disabled={!user.hasFetchedInvites}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{noneInvites && (
|
||||||
|
<Placeholder
|
||||||
|
title="No invites here!"
|
||||||
|
subtitle="You’ll be notified when you’ve been invited to join a proposal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{invites.map(invite => (
|
||||||
|
<ProfileInvite
|
||||||
|
key={invite.id}
|
||||||
|
userId={user.accountAddress}
|
||||||
|
invite={invite}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
private fetchData() {
|
private fetchData() {
|
||||||
const userLookupId = this.props.match.params.id;
|
const { match } = this.props;
|
||||||
|
const userLookupId = match.params.id;
|
||||||
if (userLookupId) {
|
if (userLookupId) {
|
||||||
this.props.fetchUser(userLookupId);
|
this.props.fetchUser(userLookupId);
|
||||||
this.props.fetchUserCreated(userLookupId);
|
this.props.fetchUserCreated(userLookupId);
|
||||||
this.props.fetchUserFunded(userLookupId);
|
this.props.fetchUserFunded(userLookupId);
|
||||||
this.props.fetchUserComments(userLookupId);
|
this.props.fetchUserComments(userLookupId);
|
||||||
|
this.props.fetchUserInvites(userLookupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -152,6 +187,7 @@ const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
fetchUserCreated: usersActions.fetchUserCreated,
|
fetchUserCreated: usersActions.fetchUserCreated,
|
||||||
fetchUserFunded: usersActions.fetchUserFunded,
|
fetchUserFunded: usersActions.fetchUserFunded,
|
||||||
fetchUserComments: usersActions.fetchUserComments,
|
fetchUserComments: usersActions.fetchUserComments,
|
||||||
|
fetchUserInvites: usersActions.fetchUserInvites,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -208,7 +208,7 @@ class ProposalMilestones extends React.Component<Props, State> {
|
||||||
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
|
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
|
||||||
{statuses}
|
{statuses}
|
||||||
{notification}
|
{notification}
|
||||||
{milestone.body}
|
{milestone.content}
|
||||||
</div>
|
</div>
|
||||||
{this.state.activeMilestoneIdx === i &&
|
{this.state.activeMilestoneIdx === i &&
|
||||||
!wasRefunded && (
|
!wasRefunded && (
|
||||||
|
|
|
@ -10,7 +10,7 @@ interface Props {
|
||||||
const TeamBlock = ({ proposal }: Props) => {
|
const TeamBlock = ({ proposal }: Props) => {
|
||||||
let content;
|
let content;
|
||||||
if (proposal) {
|
if (proposal) {
|
||||||
content = proposal.team.map(user => <UserRow key={user.name} user={user} />);
|
content = proposal.team.map(user => <UserRow key={user.displayName} user={user} />);
|
||||||
} else {
|
} else {
|
||||||
content = <Spin />;
|
content = <Spin />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -144,7 +144,11 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
['is-expanded']: isBodyExpanded,
|
['is-expanded']: isBodyExpanded,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{proposal ? <Markdown source={proposal.body} /> : <Spin size="large" />}
|
{proposal ? (
|
||||||
|
<Markdown source={proposal.content} />
|
||||||
|
) : (
|
||||||
|
<Spin size="large" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showExpand && (
|
{showExpand && (
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -53,7 +53,8 @@ export class ProposalCard extends React.Component<ProposalWithCrowdFund> {
|
||||||
|
|
||||||
<div className="ProposalCard-team">
|
<div className="ProposalCard-team">
|
||||||
<div className="ProposalCard-team-name">
|
<div className="ProposalCard-team-name">
|
||||||
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
|
{team[0].displayName}{' '}
|
||||||
|
{team.length > 1 && <small>+{team.length - 1} other</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="ProposalCard-team-avatars">
|
<div className="ProposalCard-team-avatars">
|
||||||
{[...team].reverse().map((u, idx) => (
|
{[...team].reverse().map((u, idx) => (
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Identicon from 'components/Identicon';
|
import Identicon from 'components/Identicon';
|
||||||
import { TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
import defaultUserImg from 'static/images/default-user.jpg';
|
import defaultUserImg from 'static/images/default-user.jpg';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: TeamMember;
|
user: User;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserAvatar: React.SFC<Props> = ({ user, className }) => {
|
const UserAvatar: React.SFC<Props> = ({ user, className }) => {
|
||||||
if (user.avatarUrl) {
|
if (user.avatar && user.avatar.imageUrl) {
|
||||||
return <img className={className} src={user.avatarUrl} />;
|
return <img className={className} src={user.avatar.imageUrl} />;
|
||||||
} else if (user.ethAddress) {
|
} else if (user.accountAddress) {
|
||||||
return <Identicon className={className} address={user.ethAddress} />;
|
return <Identicon className={className} address={user.accountAddress} />;
|
||||||
} else {
|
} else {
|
||||||
return <img className={className} src={defaultUserImg} />;
|
return <img className={className} src={defaultUserImg} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import { TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: TeamMember;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserRow = ({ user }: Props) => (
|
const UserRow = ({ user }: Props) => (
|
||||||
<Link to={`/profile/${user.ethAddress || user.emailAddress}`} className="UserRow">
|
<Link to={`/profile/${user.accountAddress || user.emailAddress}`} className="UserRow">
|
||||||
<div className="UserRow-avatar">
|
<div className="UserRow-avatar">
|
||||||
<UserAvatar user={user} className="UserRow-avatar-img" />
|
<UserAvatar user={user} className="UserRow-avatar-img" />
|
||||||
</div>
|
</div>
|
||||||
<div className="UserRow-info">
|
<div className="UserRow-info">
|
||||||
<div className="UserRow-info-main">{user.name}</div>
|
<div className="UserRow-info-main">{user.displayName}</div>
|
||||||
<p className="UserRow-info-secondary">{user.title}</p>
|
<p className="UserRow-info-secondary">{user.title}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -4,16 +4,16 @@ import { hot } from 'react-hot-loader';
|
||||||
import { hydrate } from 'react-dom';
|
import { hydrate } from 'react-dom';
|
||||||
import { loadComponents } from 'loadable-components';
|
import { loadComponents } from 'loadable-components';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { PersistGate } from 'redux-persist/integration/react';
|
import { PersistGate } from 'redux-persist/integration/react';
|
||||||
import * as Sentry from '@sentry/browser';
|
import * as Sentry from '@sentry/browser';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { configureStore } from 'store/configure';
|
import { configureStore } from 'store/configure';
|
||||||
|
import history from 'store/history';
|
||||||
import { massageSerializedState } from 'utils/api';
|
import { massageSerializedState } from 'utils/api';
|
||||||
import Routes from './Routes';
|
import Routes from './Routes';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: process.env.SENTRY_DSN,
|
dsn: process.env.SENTRY_DSN,
|
||||||
release: process.env.SENTRY_RELEASE,
|
release: process.env.SENTRY_RELEASE,
|
||||||
|
@ -30,7 +30,7 @@ const App = hot(module)(() => (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate persistor={persistor}>
|
<PersistGate persistor={persistor}>
|
||||||
<Router>
|
<Router history={history}>
|
||||||
<Routes />
|
<Routes />
|
||||||
</Router>
|
</Router>
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
|
|
|
@ -42,7 +42,7 @@ export function authUser(address: string, authSignature?: Falsy | AuthSignatureD
|
||||||
Sentry.configureScope(scope => {
|
Sentry.configureScope(scope => {
|
||||||
scope.setUser({
|
scope.setUser({
|
||||||
email: res.data.emailAddress,
|
email: res.data.emailAddress,
|
||||||
accountAddress: res.data.ethAddress,
|
accountAddress: res.data.accountAddress,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import usersTypes from 'modules/users/types';
|
import usersTypes from 'modules/users/types';
|
||||||
// TODO: Use a common User type instead of this
|
// TODO: Use a common User type instead of this
|
||||||
import { TeamMember, AuthSignatureData } from 'types';
|
import { User, AuthSignatureData } from 'types';
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
user: TeamMember | null;
|
user: User | null;
|
||||||
isAuthingUser: boolean;
|
isAuthingUser: boolean;
|
||||||
authUserError: string | null;
|
authUserError: string | null;
|
||||||
|
|
||||||
checkedUsers: { [address: string]: TeamMember | false };
|
checkedUsers: { [address: string]: User | false };
|
||||||
isCheckingUser: boolean;
|
isCheckingUser: boolean;
|
||||||
|
|
||||||
isCreatingUser: boolean;
|
isCreatingUser: boolean;
|
||||||
|
@ -54,14 +54,14 @@ export default function createReducer(
|
||||||
...state,
|
...state,
|
||||||
user: action.payload.user,
|
user: action.payload.user,
|
||||||
authSignature: action.payload.authSignature, // TODO: Make this the real token
|
authSignature: action.payload.authSignature, // TODO: Make this the real token
|
||||||
authSignatureAddress: action.payload.user.ethAddress,
|
authSignatureAddress: action.payload.user.accountAddress,
|
||||||
isAuthingUser: false,
|
isAuthingUser: false,
|
||||||
};
|
};
|
||||||
case usersTypes.UPDATE_USER_FULFILLED:
|
case usersTypes.UPDATE_USER_FULFILLED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
user:
|
user:
|
||||||
state.user && state.user.ethAddress === action.payload.user.ethAddress
|
state.user && state.user.accountAddress === action.payload.user.accountAddress
|
||||||
? action.payload.user
|
? action.payload.user
|
||||||
: state.user,
|
: state.user,
|
||||||
};
|
};
|
||||||
|
@ -83,7 +83,7 @@ export default function createReducer(
|
||||||
...state,
|
...state,
|
||||||
user: action.payload.user,
|
user: action.payload.user,
|
||||||
authSignature: action.payload.authSignature,
|
authSignature: action.payload.authSignature,
|
||||||
authSignatureAddress: action.payload.user.ethAddress,
|
authSignatureAddress: action.payload.user.accountAddress,
|
||||||
isCreatingUser: false,
|
isCreatingUser: false,
|
||||||
checkedUsers: {
|
checkedUsers: {
|
||||||
...state.checkedUsers,
|
...state.checkedUsers,
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
import types from './types';
|
|
||||||
import { sleep } from 'utils/helpers';
|
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { createCrowdFund } from 'modules/web3/actions';
|
import { createCrowdFund } from 'modules/web3/actions';
|
||||||
import { formToBackendData, formToContractData } from './utils';
|
import types, { CreateDraftOptions } from './types';
|
||||||
|
|
||||||
type GetState = () => AppState;
|
type GetState = () => AppState;
|
||||||
|
|
||||||
// TODO: Replace with server side storage
|
export function initializeForm(proposalId: number) {
|
||||||
const LS_DRAFT_KEY = 'CREATE_PROPOSAL_DRAFT';
|
return {
|
||||||
|
type: types.INITIALIZE_FORM_PENDING,
|
||||||
|
payload: proposalId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function updateForm(form: Partial<CreateFormState>) {
|
export function updateForm(form: Partial<ProposalDraft>) {
|
||||||
return (dispatch: Dispatch<any>) => {
|
return (dispatch: Dispatch<any>) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.UPDATE_FORM,
|
type: types.UPDATE_FORM,
|
||||||
|
@ -21,63 +23,35 @@ export function updateForm(form: Partial<CreateFormState>) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetForm() {
|
|
||||||
return async (dispatch: Dispatch<any>) => {
|
|
||||||
// TODO: Replace with server side reset
|
|
||||||
localStorage.removeItem(LS_DRAFT_KEY);
|
|
||||||
await sleep(100);
|
|
||||||
|
|
||||||
// Re-run fetch draft to ensure we've reset state
|
|
||||||
dispatch({ type: types.RESET_FORM });
|
|
||||||
dispatch(fetchDraft());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveDraft() {
|
export function saveDraft() {
|
||||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
return { type: types.SAVE_DRAFT_PENDING };
|
||||||
const { form } = getState().create;
|
}
|
||||||
dispatch({ type: types.SAVE_DRAFT_PENDING });
|
|
||||||
await sleep(100);
|
|
||||||
|
|
||||||
// TODO: Replace with server side save
|
export function fetchDrafts() {
|
||||||
localStorage.setItem(LS_DRAFT_KEY, JSON.stringify(form));
|
return { type: types.FETCH_DRAFTS_PENDING };
|
||||||
dispatch({ type: types.SAVE_DRAFT_FULFILLED });
|
}
|
||||||
|
|
||||||
|
export function createDraft(opts: CreateDraftOptions = {}) {
|
||||||
|
return {
|
||||||
|
type: types.CREATE_DRAFT_PENDING,
|
||||||
|
payload: opts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDraft() {
|
export function deleteDraft(proposalId: number) {
|
||||||
return async (dispatch: Dispatch<any>) => {
|
return {
|
||||||
dispatch({ type: types.FETCH_DRAFT_PENDING });
|
type: types.DELETE_DRAFT_PENDING,
|
||||||
await sleep(200);
|
payload: proposalId,
|
||||||
|
|
||||||
// TODO: Replace with server side fetch
|
|
||||||
const formJson = localStorage.getItem(LS_DRAFT_KEY);
|
|
||||||
try {
|
|
||||||
const form = formJson ? JSON.parse(formJson) : null;
|
|
||||||
dispatch({
|
|
||||||
type: types.FETCH_DRAFT_FULFILLED,
|
|
||||||
payload: form,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
localStorage.removeItem(LS_DRAFT_KEY);
|
|
||||||
dispatch({
|
|
||||||
type: types.FETCH_DRAFT_REJECTED,
|
|
||||||
payload: 'Malformed form JSON',
|
|
||||||
error: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProposal(form: CreateFormState) {
|
export function createProposal(form: ProposalDraft) {
|
||||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
// TODO: Handle if contract is unavailable
|
// TODO: Handle if contract is unavailable
|
||||||
const contract = state.web3.contracts[0];
|
const contract = state.web3.contracts[0];
|
||||||
// TODO: Move more of the backend handling into this action.
|
// TODO: Move more of the backend handling into this action.
|
||||||
dispatch(
|
dispatch(createCrowdFund(contract, form));
|
||||||
createCrowdFund(contract, formToContractData(form), formToBackendData(form)),
|
|
||||||
);
|
|
||||||
// TODO: dispatch reset conditionally, if crowd fund is success
|
// TODO: dispatch reset conditionally, if crowd fund is success
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import reducers, { CreateState, INITIAL_STATE } from './reducers';
|
import reducers, { CreateState, INITIAL_STATE } from './reducers';
|
||||||
import * as createActions from './actions';
|
import * as createActions from './actions';
|
||||||
import * as createTypes from './types';
|
import * as createTypes from './types';
|
||||||
|
import createSagas from './sagas';
|
||||||
|
|
||||||
export { createActions, createTypes, CreateState, INITIAL_STATE };
|
export { createActions, createTypes, createSagas, CreateState, INITIAL_STATE };
|
||||||
|
|
||||||
export default reducers;
|
export default reducers;
|
||||||
|
|
|
@ -1,45 +1,55 @@
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import { CreateFormState } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
import { ONE_DAY } from 'utils/time';
|
|
||||||
|
|
||||||
export interface CreateState {
|
export interface CreateState {
|
||||||
form: CreateFormState;
|
drafts: ProposalDraft[] | null;
|
||||||
|
form: ProposalDraft | null;
|
||||||
|
|
||||||
|
isInitializingForm: boolean;
|
||||||
|
initializeFormError: string | null;
|
||||||
|
|
||||||
isSavingDraft: boolean;
|
isSavingDraft: boolean;
|
||||||
hasSavedDraft: boolean;
|
hasSavedDraft: boolean;
|
||||||
saveDraftError: string | null;
|
saveDraftError: string | null;
|
||||||
|
|
||||||
isFetchingDraft: boolean;
|
isFetchingDrafts: boolean;
|
||||||
hasFetchedDraft: boolean;
|
fetchDraftsError: string | null;
|
||||||
fetchDraftError: string | null;
|
|
||||||
|
isCreatingDraft: boolean;
|
||||||
|
createDraftError: string | null;
|
||||||
|
|
||||||
|
isDeletingDraft: boolean;
|
||||||
|
deleteDraftError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_STATE: CreateState = {
|
export const INITIAL_STATE: CreateState = {
|
||||||
form: {
|
drafts: null,
|
||||||
title: '',
|
form: null,
|
||||||
brief: '',
|
|
||||||
details: '',
|
isInitializingForm: false,
|
||||||
category: null,
|
initializeFormError: null,
|
||||||
amountToRaise: '',
|
|
||||||
payOutAddress: '',
|
|
||||||
trustees: [],
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
deadline: ONE_DAY * 60,
|
|
||||||
milestoneDeadline: ONE_DAY * 7,
|
|
||||||
},
|
|
||||||
|
|
||||||
isSavingDraft: false,
|
isSavingDraft: false,
|
||||||
hasSavedDraft: true,
|
hasSavedDraft: true,
|
||||||
saveDraftError: null,
|
saveDraftError: null,
|
||||||
|
|
||||||
isFetchingDraft: false,
|
isFetchingDrafts: false,
|
||||||
hasFetchedDraft: false,
|
fetchDraftsError: null,
|
||||||
fetchDraftError: null,
|
|
||||||
|
isCreatingDraft: false,
|
||||||
|
createDraftError: null,
|
||||||
|
|
||||||
|
isDeletingDraft: false,
|
||||||
|
deleteDraftError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function createReducer(state: CreateState = INITIAL_STATE, action: any) {
|
export default function createReducer(
|
||||||
|
state: CreateState = INITIAL_STATE,
|
||||||
|
action: any,
|
||||||
|
): CreateState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
case types.CREATE_DRAFT_PENDING:
|
||||||
|
|
||||||
case types.UPDATE_FORM:
|
case types.UPDATE_FORM:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
@ -50,12 +60,24 @@ export default function createReducer(state: CreateState = INITIAL_STATE, action
|
||||||
hasSavedDraft: false,
|
hasSavedDraft: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
case types.RESET_FORM:
|
case types.INITIALIZE_FORM_PENDING:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
form: { ...INITIAL_STATE.form },
|
form: null,
|
||||||
hasSavedDraft: true,
|
isInitializingForm: true,
|
||||||
hasFetchedDraft: false,
|
initializeFormError: null,
|
||||||
|
};
|
||||||
|
case types.INITIALIZE_FORM_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
form: { ...action.payload },
|
||||||
|
isInitializingForm: false,
|
||||||
|
};
|
||||||
|
case types.INITIALIZE_FORM_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializingForm: false,
|
||||||
|
initializeFormError: action.payload,
|
||||||
};
|
};
|
||||||
|
|
||||||
case types.SAVE_DRAFT_PENDING:
|
case types.SAVE_DRAFT_PENDING:
|
||||||
|
@ -79,29 +101,60 @@ export default function createReducer(state: CreateState = INITIAL_STATE, action
|
||||||
saveDraftError: action.payload,
|
saveDraftError: action.payload,
|
||||||
};
|
};
|
||||||
|
|
||||||
case types.FETCH_DRAFT_PENDING:
|
case types.FETCH_DRAFTS_PENDING:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isFetchingDraft: true,
|
isFetchingDrafts: true,
|
||||||
fetchDraftError: null,
|
fetchDraftsError: null,
|
||||||
};
|
};
|
||||||
case types.FETCH_DRAFT_FULFILLED:
|
case types.FETCH_DRAFTS_FULFILLED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isFetchingDraft: false,
|
isFetchingDrafts: false,
|
||||||
hasFetchedDraft: !!action.payload,
|
drafts: action.payload,
|
||||||
form: action.payload
|
|
||||||
? {
|
|
||||||
...state.form,
|
|
||||||
...action.payload,
|
|
||||||
}
|
|
||||||
: state.form,
|
|
||||||
};
|
};
|
||||||
case types.FETCH_DRAFT_REJECTED:
|
case types.FETCH_DRAFTS_REJECTED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isFetchingDraft: false,
|
isFetchingDrafts: false,
|
||||||
fetchDraftError: action.payload,
|
fetchDraftsError: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case types.CREATE_DRAFT_PENDING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isCreatingDraft: true,
|
||||||
|
createDraftError: null,
|
||||||
|
};
|
||||||
|
case types.CREATE_DRAFT_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
drafts: [...(state.drafts || []), action.payload],
|
||||||
|
isCreatingDraft: false,
|
||||||
|
};
|
||||||
|
case types.CREATE_DRAFT_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
createDraftError: action.payload,
|
||||||
|
isCreatingDraft: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case types.DELETE_DRAFT_PENDING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDeletingDraft: true,
|
||||||
|
deleteDraftError: null,
|
||||||
|
};
|
||||||
|
case types.DELETE_DRAFT_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDeletingDraft: false,
|
||||||
|
};
|
||||||
|
case types.DELETE_DRAFT_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDeletingDraft: false,
|
||||||
|
deleteDraftError: action.payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { SagaIterator } from 'redux-saga';
|
||||||
|
import { takeEvery, takeLatest, put, call, select } from 'redux-saga/effects';
|
||||||
|
import { push } from 'connected-react-router';
|
||||||
|
import {
|
||||||
|
postProposalDraft,
|
||||||
|
getProposalDrafts,
|
||||||
|
putProposal,
|
||||||
|
deleteProposalDraft,
|
||||||
|
} from 'api/api';
|
||||||
|
import { getDraftById, getFormState } from './selectors';
|
||||||
|
import { createDraft, initializeForm, deleteDraft } from './actions';
|
||||||
|
import types from './types';
|
||||||
|
|
||||||
|
export function* handleCreateDraft(action: ReturnType<typeof createDraft>): SagaIterator {
|
||||||
|
try {
|
||||||
|
const res: Yielded<typeof postProposalDraft> = yield call(postProposalDraft);
|
||||||
|
yield put({
|
||||||
|
type: types.CREATE_DRAFT_FULFILLED,
|
||||||
|
payload: res.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action.payload.redirect) {
|
||||||
|
yield put(push(`/proposals/${res.data.proposalId}/edit`));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
yield put({
|
||||||
|
type: types.CREATE_DRAFT_REJECTED,
|
||||||
|
payload: err.message || err.toString(),
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* handleFetchDrafts(): SagaIterator {
|
||||||
|
try {
|
||||||
|
const res: Yielded<typeof getProposalDrafts> = yield call(getProposalDrafts);
|
||||||
|
yield put({
|
||||||
|
type: types.FETCH_DRAFTS_FULFILLED,
|
||||||
|
payload: res.data,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
yield put({
|
||||||
|
type: types.FETCH_DRAFTS_REJECTED,
|
||||||
|
payload: err.message || err.toString(),
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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* handleDeleteDraft(action: ReturnType<typeof deleteDraft>): SagaIterator {
|
||||||
|
try {
|
||||||
|
yield call(deleteProposalDraft, action.payload);
|
||||||
|
put({ type: types.DELETE_DRAFT_FULFILLED });
|
||||||
|
} catch (err) {
|
||||||
|
yield put({
|
||||||
|
type: types.DELETE_DRAFT_REJECTED,
|
||||||
|
payload: err.message || err.toString(),
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield call(handleFetchDrafts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* handleInitializeForm(
|
||||||
|
action: ReturnType<typeof initializeForm>,
|
||||||
|
): SagaIterator {
|
||||||
|
try {
|
||||||
|
let draft: Yielded<typeof getDraftById> = yield select(getDraftById, action.payload);
|
||||||
|
if (!draft) {
|
||||||
|
yield call(handleFetchDrafts);
|
||||||
|
draft = yield select(getDraftById, action.payload);
|
||||||
|
if (!draft) {
|
||||||
|
throw new Error('Proposal not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield put({
|
||||||
|
type: types.INITIALIZE_FORM_FULFILLED,
|
||||||
|
payload: draft,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
yield put({
|
||||||
|
type: types.INITIALIZE_FORM_REJECTED,
|
||||||
|
payload: err.message || err.toString(),
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.DELETE_DRAFT_PENDING, handleDeleteDraft);
|
||||||
|
yield takeEvery(types.INITIALIZE_FORM_PENDING, handleInitializeForm);
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { AppState as S } from 'store/reducers';
|
||||||
|
|
||||||
|
export const getDraftById = (s: S, id: number) => {
|
||||||
|
if (!s.create.drafts) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return s.create.drafts.find(d => d.proposalId === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFormState = (s: S) => s.create.form;
|
|
@ -1,17 +1,30 @@
|
||||||
enum CreateTypes {
|
enum CreateTypes {
|
||||||
UPDATE_FORM = 'UPDATE_FORM',
|
UPDATE_FORM = 'UPDATE_FORM',
|
||||||
|
|
||||||
RESET_FORM = 'RESET_FORM',
|
INITIALIZE_FORM = 'INITIALIZE_FORM',
|
||||||
|
INITIALIZE_FORM_PENDING = 'INITIALIZE_FORM_PENDING',
|
||||||
|
INITIALIZE_FORM_FULFILLED = 'INITIALIZE_FORM_FULFILLED',
|
||||||
|
INITIALIZE_FORM_REJECTED = 'INITIALIZE_FORM_REJECTED',
|
||||||
|
|
||||||
SAVE_DRAFT = 'SAVE_DRAFT',
|
SAVE_DRAFT = 'SAVE_DRAFT',
|
||||||
SAVE_DRAFT_PENDING = 'SAVE_DRAFT_PENDING',
|
SAVE_DRAFT_PENDING = 'SAVE_DRAFT_PENDING',
|
||||||
SAVE_DRAFT_FULFILLED = 'SAVE_DRAFT_FULFILLED',
|
SAVE_DRAFT_FULFILLED = 'SAVE_DRAFT_FULFILLED',
|
||||||
SAVE_DRAFT_REJECTED = 'SAVE_DRAFT_REJECTED',
|
SAVE_DRAFT_REJECTED = 'SAVE_DRAFT_REJECTED',
|
||||||
|
|
||||||
FETCH_DRAFT = 'FETCH_DRAFT',
|
FETCH_DRAFTS = 'FETCH_DRAFTS',
|
||||||
FETCH_DRAFT_PENDING = 'FETCH_DRAFT_PENDING',
|
FETCH_DRAFTS_PENDING = 'FETCH_DRAFTS_PENDING',
|
||||||
FETCH_DRAFT_FULFILLED = 'FETCH_DRAFT_FULFILLED',
|
FETCH_DRAFTS_FULFILLED = 'FETCH_DRAFTS_FULFILLED',
|
||||||
FETCH_DRAFT_REJECTED = 'FETCH_DRAFT_REJECTED',
|
FETCH_DRAFTS_REJECTED = 'FETCH_DRAFTS_REJECTED',
|
||||||
|
|
||||||
|
CREATE_DRAFT = 'CREATE_DRAFT',
|
||||||
|
CREATE_DRAFT_PENDING = 'CREATE_DRAFT_PENDING',
|
||||||
|
CREATE_DRAFT_FULFILLED = 'CREATE_DRAFT_FULFILLED',
|
||||||
|
CREATE_DRAFT_REJECTED = 'CREATE_DRAFT_REJECTED',
|
||||||
|
|
||||||
|
DELETE_DRAFT = 'DELETE_DRAFT',
|
||||||
|
DELETE_DRAFT_PENDING = 'DELETE_DRAFT_PENDING',
|
||||||
|
DELETE_DRAFT_FULFILLED = 'DELETE_DRAFT_FULFILLED',
|
||||||
|
DELETE_DRAFT_REJECTED = 'DELETE_DRAFT_REJECTED',
|
||||||
|
|
||||||
SUBMIT = 'CREATE_PROPOSAL',
|
SUBMIT = 'CREATE_PROPOSAL',
|
||||||
SUBMIT_PENDING = 'CREATE_PROPOSAL_PENDING',
|
SUBMIT_PENDING = 'CREATE_PROPOSAL_PENDING',
|
||||||
|
@ -19,4 +32,8 @@ enum CreateTypes {
|
||||||
SUBMIT_REJECTED = 'CREATE_PROPOSAL_REJECTED',
|
SUBMIT_REJECTED = 'CREATE_PROPOSAL_REJECTED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateDraftOptions {
|
||||||
|
redirect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default CreateTypes;
|
export default CreateTypes;
|
||||||
|
|
|
@ -1,54 +1,66 @@
|
||||||
import { CreateFormState, CreateMilestone } from 'types';
|
import { ProposalDraft, CreateMilestone } from 'types';
|
||||||
import { TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
import { isValidEthAddress, getAmountError } from 'utils/validators';
|
import { isValidEthAddress, getAmountError } from 'utils/validators';
|
||||||
import { MILESTONE_STATE, ProposalWithCrowdFund } from 'types';
|
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 { Wei, toWei } from 'utils/units';
|
||||||
import { ONE_DAY } from 'utils/time';
|
import { ONE_DAY } from 'utils/time';
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||||
|
|
||||||
// TODO: Raise this limit
|
// TODO: Raise this limit
|
||||||
export const TARGET_ETH_LIMIT = 10;
|
export const TARGET_ETH_LIMIT = 1000;
|
||||||
|
|
||||||
interface CreateFormErrors {
|
interface CreateFormErrors {
|
||||||
title?: string;
|
title?: string;
|
||||||
brief?: string;
|
brief?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
amountToRaise?: string;
|
target?: string;
|
||||||
team?: string[];
|
team?: string[];
|
||||||
details?: string;
|
content?: string;
|
||||||
payOutAddress?: string;
|
payoutAddress?: string;
|
||||||
trustees?: string[];
|
trustees?: string[];
|
||||||
milestones?: string[];
|
milestones?: string[];
|
||||||
deadline?: string;
|
deadlineDuration?: string;
|
||||||
milestoneDeadline?: string;
|
voteDuration?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KeyOfForm = keyof CreateFormState;
|
export type KeyOfForm = keyof CreateFormErrors;
|
||||||
export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
|
export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
brief: 'Brief',
|
brief: 'Brief',
|
||||||
category: 'Category',
|
category: 'Category',
|
||||||
amountToRaise: 'Target amount',
|
target: 'Target amount',
|
||||||
team: 'Team',
|
team: 'Team',
|
||||||
details: 'Details',
|
content: 'Details',
|
||||||
payOutAddress: 'Payout address',
|
payoutAddress: 'Payout address',
|
||||||
trustees: 'Trustees',
|
trustees: 'Trustees',
|
||||||
milestones: 'Milestones',
|
milestones: 'Milestones',
|
||||||
deadline: 'Funding deadline',
|
deadlineDuration: 'Funding deadline',
|
||||||
milestoneDeadline: 'Milestone deadline',
|
voteDuration: 'Milestone deadline',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requiredFields = [
|
||||||
|
'title',
|
||||||
|
'brief',
|
||||||
|
'category',
|
||||||
|
'target',
|
||||||
|
'content',
|
||||||
|
'payoutAddress',
|
||||||
|
'trustees',
|
||||||
|
'deadlineDuration',
|
||||||
|
'voteDuration',
|
||||||
|
];
|
||||||
|
|
||||||
export function getCreateErrors(
|
export function getCreateErrors(
|
||||||
form: Partial<CreateFormState>,
|
form: Partial<ProposalDraft>,
|
||||||
skipRequired?: boolean,
|
skipRequired?: boolean,
|
||||||
): CreateFormErrors {
|
): CreateFormErrors {
|
||||||
const errors: 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
|
// Required fields with no extra validation
|
||||||
if (!skipRequired) {
|
if (!skipRequired) {
|
||||||
for (const key in form) {
|
for (const key of requiredFields) {
|
||||||
if (!form[key as KeyOfForm]) {
|
if (!form[key as KeyOfForm]) {
|
||||||
errors[key as KeyOfForm] = `${FIELD_NAME_MAP[key as KeyOfForm]} is required`;
|
errors[key as KeyOfForm] = `${FIELD_NAME_MAP[key as KeyOfForm]} is required`;
|
||||||
}
|
}
|
||||||
|
@ -68,17 +80,17 @@ export function getCreateErrors(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Amount to raise
|
// Amount to raise
|
||||||
const amountFloat = amountToRaise ? parseFloat(amountToRaise) : 0;
|
const targetFloat = target ? parseFloat(target) : 0;
|
||||||
if (amountToRaise && !Number.isNaN(amountFloat)) {
|
if (target && !Number.isNaN(targetFloat)) {
|
||||||
const amountError = getAmountError(amountFloat, TARGET_ETH_LIMIT);
|
const targetErr = getAmountError(targetFloat, TARGET_ETH_LIMIT);
|
||||||
if (amountError) {
|
if (targetErr) {
|
||||||
errors.amountToRaise = amountError;
|
errors.target = targetErr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payout address
|
// Payout address
|
||||||
if (payOutAddress && !isValidEthAddress(payOutAddress)) {
|
if (payoutAddress && !isValidEthAddress(payoutAddress)) {
|
||||||
errors.payOutAddress = 'That doesn’t look like a valid address';
|
errors.payoutAddress = 'That doesn’t look like a valid address';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trustees
|
// Trustees
|
||||||
|
@ -94,7 +106,7 @@ export function getCreateErrors(
|
||||||
err = 'That doesn’t look like a valid address';
|
err = 'That doesn’t look like a valid address';
|
||||||
} else if (trustees.indexOf(address) !== idx) {
|
} else if (trustees.indexOf(address) !== idx) {
|
||||||
err = 'That address is already a trustee';
|
err = 'That address is already a trustee';
|
||||||
} else if (payOutAddress === address) {
|
} else if (payoutAddress === address) {
|
||||||
err = 'That address is already a trustee';
|
err = 'That address is already a trustee';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +123,7 @@ export function getCreateErrors(
|
||||||
let didMilestoneError = false;
|
let didMilestoneError = false;
|
||||||
let cumulativeMilestonePct = 0;
|
let cumulativeMilestonePct = 0;
|
||||||
const milestoneErrors = milestones.map((ms, idx) => {
|
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;
|
didMilestoneError = true;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
@ -119,12 +131,12 @@ export function getCreateErrors(
|
||||||
let err = '';
|
let err = '';
|
||||||
if (ms.title.length > 40) {
|
if (ms.title.length > 40) {
|
||||||
err = 'Title length can only be 40 characters maximum';
|
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';
|
err = 'Description can only be 200 characters maximum';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last one shows percentage errors
|
// Last one shows percentage errors
|
||||||
cumulativeMilestonePct += ms.payoutPercent;
|
cumulativeMilestonePct += parseInt(ms.payoutPercent, 10);
|
||||||
if (idx === milestones.length - 1 && cumulativeMilestonePct !== 100) {
|
if (idx === milestones.length - 1 && cumulativeMilestonePct !== 100) {
|
||||||
err = `Payout percentages doesn’t add up to 100% (currently ${cumulativeMilestonePct}%)`;
|
err = `Payout percentages doesn’t add up to 100% (currently ${cumulativeMilestonePct}%)`;
|
||||||
}
|
}
|
||||||
|
@ -141,7 +153,7 @@ export function getCreateErrors(
|
||||||
if (team) {
|
if (team) {
|
||||||
let didTeamError = false;
|
let didTeamError = false;
|
||||||
const teamErrors = team.map(u => {
|
const teamErrors = team.map(u => {
|
||||||
if (!u.name || !u.title || !u.emailAddress || !u.ethAddress) {
|
if (!u.displayName || !u.title || !u.emailAddress || !u.accountAddress) {
|
||||||
didTeamError = true;
|
didTeamError = true;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
@ -158,26 +170,43 @@ export function getCreateErrors(
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCreateTeamMemberError(user: TeamMember) {
|
export function getCreateTeamMemberError(user: User) {
|
||||||
if (user.name.length > 30) {
|
if (user.displayName.length > 30) {
|
||||||
return 'Display name can only be 30 characters maximum';
|
return 'Display name can only be 30 characters maximum';
|
||||||
} else if (user.title.length > 30) {
|
} else if (user.title.length > 30) {
|
||||||
return 'Title can only be 30 characters maximum';
|
return 'Title can only be 30 characters maximum';
|
||||||
} else if (!/.+\@.+\..+/.test(user.emailAddress)) {
|
} else if (!/.+\@.+\..+/.test(user.emailAddress)) {
|
||||||
return 'That doesn’t look like a valid email address';
|
return 'That doesn’t look like a valid email address';
|
||||||
} else if (!isValidEthAddress(user.ethAddress)) {
|
} else if (!isValidEthAddress(user.accountAddress)) {
|
||||||
return 'That doesn’t look like a valid ETH address';
|
return 'That doesn’t look like a valid ETH address';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function milestoneToMilestoneAmount(milestone: CreateMilestone, raiseGoal: Wei) {
|
export function getCreateWarnings(form: Partial<ProposalDraft>): string[] {
|
||||||
return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent.toString()));
|
const warnings = [];
|
||||||
|
|
||||||
|
// Warn about pending invites
|
||||||
|
const hasPending =
|
||||||
|
(form.invites || []).filter(inv => inv.accepted === null).length !== 0;
|
||||||
|
if (hasPending) {
|
||||||
|
warnings.push(`
|
||||||
|
You still have pending team invitations. If you publish before they
|
||||||
|
are accepted, your team will be locked in and they won’t be able to
|
||||||
|
accept join.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formToContractData(form: CreateFormState): ProposalContractData {
|
function milestoneToMilestoneAmount(milestone: CreateMilestone, raiseGoal: Wei) {
|
||||||
const targetInWei = toWei(form.amountToRaise, 'ether');
|
return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proposalToContractData(form: ProposalDraft): ProposalContractData {
|
||||||
|
const targetInWei = toWei(form.target, 'ether');
|
||||||
const milestoneAmounts = form.milestones.map(m =>
|
const milestoneAmounts = form.milestones.map(m =>
|
||||||
milestoneToMilestoneAmount(m, targetInWei),
|
milestoneToMilestoneAmount(m, targetInWei),
|
||||||
);
|
);
|
||||||
|
@ -185,51 +214,41 @@ export function formToContractData(form: CreateFormState): ProposalContractData
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ethAmount: targetInWei,
|
ethAmount: targetInWei,
|
||||||
payOutAddress: form.payOutAddress,
|
payoutAddress: form.payoutAddress,
|
||||||
trusteesAddresses: form.trustees,
|
trusteesAddresses: form.trustees,
|
||||||
milestoneAmounts,
|
milestoneAmounts,
|
||||||
milestones: form.milestones,
|
durationInMinutes: form.deadlineDuration || ONE_DAY * 60,
|
||||||
durationInMinutes: form.deadline || ONE_DAY * 60,
|
milestoneVotingPeriodInMinutes: form.voteDuration || ONE_DAY * 7,
|
||||||
milestoneVotingPeriodInMinutes: form.milestoneDeadline || ONE_DAY * 7,
|
|
||||||
immediateFirstMilestonePayout,
|
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.
|
// This is kind of a disgusting function, sorry.
|
||||||
export function makeProposalPreviewFromForm(
|
export function makeProposalPreviewFromDraft(
|
||||||
form: CreateFormState,
|
draft: ProposalDraft,
|
||||||
): ProposalWithCrowdFund {
|
): ProposalWithCrowdFund {
|
||||||
const target = parseFloat(form.amountToRaise);
|
const target = parseFloat(draft.target);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
proposalId: 0,
|
proposalId: 0,
|
||||||
proposalUrlId: '0-title',
|
proposalUrlId: '0-title',
|
||||||
proposalAddress: '0x0',
|
proposalAddress: '0x0',
|
||||||
dateCreated: Date.now(),
|
dateCreated: Date.now(),
|
||||||
title: form.title,
|
title: draft.title,
|
||||||
body: form.details,
|
brief: draft.brief,
|
||||||
|
content: draft.content,
|
||||||
stage: 'preview',
|
stage: 'preview',
|
||||||
category: form.category || PROPOSAL_CATEGORY.DAPP,
|
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
||||||
team: form.team,
|
team: draft.team,
|
||||||
milestones: form.milestones.map((m, idx) => ({
|
milestones: draft.milestones.map((m, idx) => ({
|
||||||
index: idx,
|
index: idx,
|
||||||
title: m.title,
|
title: m.title,
|
||||||
body: m.description,
|
content: m.content,
|
||||||
content: m.description,
|
amount: toWei(target * (parseInt(m.payoutPercent, 10) / 100), 'ether'),
|
||||||
amount: toWei(target * (m.payoutPercent / 100), 'ether'),
|
|
||||||
amountAgainstPayout: Wei('0'),
|
amountAgainstPayout: Wei('0'),
|
||||||
percentAgainstPayout: 0,
|
percentAgainstPayout: 0,
|
||||||
payoutRequestVoteDeadline: Date.now(),
|
payoutRequestVoteDeadline: Date.now(),
|
||||||
dateEstimated: m.date,
|
dateEstimated: m.dateEstimated,
|
||||||
immediatePayout: m.immediatePayout,
|
immediatePayout: m.immediatePayout,
|
||||||
isImmediatePayout: m.immediatePayout,
|
isImmediatePayout: m.immediatePayout,
|
||||||
isPaid: false,
|
isPaid: false,
|
||||||
|
@ -238,15 +257,15 @@ export function makeProposalPreviewFromForm(
|
||||||
stage: MILESTONE_STATE.WAITING,
|
stage: MILESTONE_STATE.WAITING,
|
||||||
})),
|
})),
|
||||||
crowdFund: {
|
crowdFund: {
|
||||||
immediateFirstMilestonePayout: form.milestones[0].immediatePayout,
|
immediateFirstMilestonePayout: draft.milestones[0].immediatePayout,
|
||||||
balance: Wei('0'),
|
balance: Wei('0'),
|
||||||
funded: Wei('0'),
|
funded: Wei('0'),
|
||||||
percentFunded: 0,
|
percentFunded: 0,
|
||||||
target: toWei(target, 'ether'),
|
target: toWei(target, 'ether'),
|
||||||
amountVotingForRefund: Wei('0'),
|
amountVotingForRefund: Wei('0'),
|
||||||
percentVotingForRefund: 0,
|
percentVotingForRefund: 0,
|
||||||
beneficiary: form.payOutAddress,
|
beneficiary: draft.payoutAddress,
|
||||||
trustees: form.trustees,
|
trustees: draft.trustees,
|
||||||
deadline: Date.now() + 100000,
|
deadline: Date.now() + 100000,
|
||||||
contributors: [],
|
contributors: [],
|
||||||
milestones: [],
|
milestones: [],
|
||||||
|
|
|
@ -87,7 +87,7 @@ export function postProposalComment(
|
||||||
parentCommentId,
|
parentCommentId,
|
||||||
comment: {
|
comment: {
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body: comment,
|
content: comment,
|
||||||
dateCreated: Date.now(),
|
dateCreated: Date.now(),
|
||||||
replies: [],
|
replies: [],
|
||||||
author: {
|
author: {
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { UserProposal, UserComment, TeamMember } from 'types';
|
import { UserProposal, UserComment, User } from 'types';
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import { getUser, updateUser as apiUpdateUser, getProposals } from 'api/api';
|
import {
|
||||||
|
getUser,
|
||||||
|
updateUser as apiUpdateUser,
|
||||||
|
getProposals,
|
||||||
|
fetchUserInvites as apiFetchUserInvites,
|
||||||
|
putInviteResponse,
|
||||||
|
} from 'api/api';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { Proposal } from 'types';
|
import { Proposal } from 'types';
|
||||||
import BN from 'bn.js';
|
import BN from 'bn.js';
|
||||||
|
@ -22,7 +28,7 @@ export function fetchUser(userFetchId: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUser(user: TeamMember) {
|
export function updateUser(user: User) {
|
||||||
const userClone = cleanClone(INITIAL_TEAM_MEMBER_STATE, user);
|
const userClone = cleanClone(INITIAL_TEAM_MEMBER_STATE, user);
|
||||||
return async (dispatch: Dispatch<any>) => {
|
return async (dispatch: Dispatch<any>) => {
|
||||||
dispatch({ type: types.UPDATE_USER_PENDING, payload: { user } });
|
dispatch({ type: types.UPDATE_USER_PENDING, payload: { user } });
|
||||||
|
@ -100,6 +106,55 @@ export function fetchUserComments(userFetchId: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchUserInvites(userFetchId: string) {
|
||||||
|
return async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch({
|
||||||
|
type: types.FETCH_USER_INVITES_PENDING,
|
||||||
|
payload: { userFetchId },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetchUserInvites(userFetchId);
|
||||||
|
const invites = res.data.sort((a, b) => (a.dateCreated > b.dateCreated ? -1 : 1));
|
||||||
|
dispatch({
|
||||||
|
type: types.FETCH_USER_INVITES_FULFILLED,
|
||||||
|
payload: { userFetchId, invites },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: types.FETCH_USER_INVITES_REJECTED,
|
||||||
|
payload: { userFetchId, error },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondToInvite(
|
||||||
|
userId: string | number,
|
||||||
|
inviteId: string | number,
|
||||||
|
response: boolean,
|
||||||
|
) {
|
||||||
|
return async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch({
|
||||||
|
type: types.RESPOND_TO_INVITE_PENDING,
|
||||||
|
payload: { userId, inviteId, response },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putInviteResponse(userId, inviteId, response);
|
||||||
|
dispatch({
|
||||||
|
type: types.RESPOND_TO_INVITE_FULFILLED,
|
||||||
|
payload: { userId, inviteId, response },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: types.RESPOND_TO_INVITE_REJECTED,
|
||||||
|
payload: { userId, inviteId, error },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const mockModifyProposals = (p: Proposal): UserProposal => {
|
const mockModifyProposals = (p: Proposal): UserProposal => {
|
||||||
const { proposalId, title, team } = p;
|
const { proposalId, title, team } = p;
|
||||||
return {
|
return {
|
||||||
|
@ -127,13 +182,13 @@ const mockComment = (p: UserProposal): UserComment[] => {
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body: "I can't WAIT to get my t-shirt!",
|
content: "I can't WAIT to get my t-shirt!",
|
||||||
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
||||||
proposal: p,
|
proposal: p,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body: 'I love the new design. Will they still be available next month?',
|
content: 'I love the new design. Will they still be available next month?',
|
||||||
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
||||||
proposal: p,
|
proposal: p,
|
||||||
},
|
},
|
||||||
|
@ -141,27 +196,27 @@ const mockComment = (p: UserProposal): UserComment[] => {
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body: 'Ut labore et dolore magna aliqua.',
|
content: 'Ut labore et dolore magna aliqua.',
|
||||||
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
||||||
proposal: p,
|
proposal: p,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body:
|
content:
|
||||||
'Adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
'Adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
||||||
proposal: p,
|
proposal: p,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body:
|
content:
|
||||||
'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||||
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
||||||
proposal: p,
|
proposal: p,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commentId: Math.random(),
|
commentId: Math.random(),
|
||||||
body:
|
content:
|
||||||
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.',
|
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.',
|
||||||
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
|
||||||
proposal: p,
|
proposal: p,
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { UserProposal, UserComment } from 'types';
|
import { UserProposal, UserComment, TeamInviteWithProposal } from 'types';
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import { TeamMember } from 'types';
|
import { User } from 'types';
|
||||||
|
|
||||||
export interface UserState extends TeamMember {
|
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
|
||||||
|
isResponding: boolean;
|
||||||
|
respondError: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserState extends User {
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
hasFetched: boolean;
|
hasFetched: boolean;
|
||||||
fetchError: number | null;
|
fetchError: number | null;
|
||||||
|
@ -17,22 +22,27 @@ export interface UserState extends TeamMember {
|
||||||
hasFetchedFunded: boolean;
|
hasFetchedFunded: boolean;
|
||||||
fetchErrorFunded: number | null;
|
fetchErrorFunded: number | null;
|
||||||
fundedProposals: UserProposal[];
|
fundedProposals: UserProposal[];
|
||||||
isFetchingCommments: boolean;
|
isFetchingComments: boolean;
|
||||||
hasFetchedComments: boolean;
|
hasFetchedComments: boolean;
|
||||||
fetchErrorComments: number | null;
|
fetchErrorComments: number | null;
|
||||||
comments: UserComment[];
|
comments: UserComment[];
|
||||||
|
isFetchingInvites: boolean;
|
||||||
|
hasFetchedInvites: boolean;
|
||||||
|
fetchErrorInvites: number | null;
|
||||||
|
invites: TeamInviteWithResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersState {
|
export interface UsersState {
|
||||||
map: { [index: string]: UserState };
|
map: { [index: string]: UserState };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_TEAM_MEMBER_STATE: TeamMember = {
|
export const INITIAL_TEAM_MEMBER_STATE: User = {
|
||||||
ethAddress: '',
|
userid: 0,
|
||||||
avatarUrl: '',
|
accountAddress: '',
|
||||||
name: '',
|
avatar: null,
|
||||||
|
displayName: '',
|
||||||
emailAddress: '',
|
emailAddress: '',
|
||||||
socialAccounts: {},
|
socialMedias: [],
|
||||||
title: '',
|
title: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -51,10 +61,14 @@ export const INITIAL_USER_STATE: UserState = {
|
||||||
hasFetchedFunded: false,
|
hasFetchedFunded: false,
|
||||||
fetchErrorFunded: null,
|
fetchErrorFunded: null,
|
||||||
fundedProposals: [],
|
fundedProposals: [],
|
||||||
isFetchingCommments: false,
|
isFetchingComments: false,
|
||||||
hasFetchedComments: false,
|
hasFetchedComments: false,
|
||||||
fetchErrorComments: null,
|
fetchErrorComments: null,
|
||||||
comments: [],
|
comments: [],
|
||||||
|
isFetchingInvites: false,
|
||||||
|
hasFetchedInvites: false,
|
||||||
|
fetchErrorInvites: null,
|
||||||
|
invites: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const INITIAL_STATE: UsersState = {
|
export const INITIAL_STATE: UsersState = {
|
||||||
|
@ -66,6 +80,7 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
const userFetchId = payload && payload.userFetchId;
|
const userFetchId = payload && payload.userFetchId;
|
||||||
const proposals = payload && payload.proposals;
|
const proposals = payload && payload.proposals;
|
||||||
const comments = payload && payload.comments;
|
const comments = payload && payload.comments;
|
||||||
|
const invites = payload && payload.invites;
|
||||||
const errorStatus =
|
const errorStatus =
|
||||||
(payload &&
|
(payload &&
|
||||||
payload.error &&
|
payload.error &&
|
||||||
|
@ -75,101 +90,151 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
// fetch
|
// fetch
|
||||||
case types.FETCH_USER_PENDING:
|
case types.FETCH_USER_PENDING:
|
||||||
return updateStateFetch(state, userFetchId, { isFetching: true, fetchError: null });
|
return updateUserState(state, userFetchId, { isFetching: true, fetchError: null });
|
||||||
case types.FETCH_USER_FULFILLED:
|
case types.FETCH_USER_FULFILLED:
|
||||||
return updateStateFetch(
|
return updateUserState(
|
||||||
state,
|
state,
|
||||||
userFetchId,
|
userFetchId,
|
||||||
{ isFetching: false, hasFetched: true },
|
{ isFetching: false, hasFetched: true },
|
||||||
payload.user,
|
payload.user,
|
||||||
);
|
);
|
||||||
case types.FETCH_USER_REJECTED:
|
case types.FETCH_USER_REJECTED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
hasFetched: true,
|
hasFetched: true,
|
||||||
fetchError: errorStatus,
|
fetchError: errorStatus,
|
||||||
});
|
});
|
||||||
// update
|
// update
|
||||||
case types.UPDATE_USER_PENDING:
|
case types.UPDATE_USER_PENDING:
|
||||||
return updateStateFetch(state, payload.user.ethAddress, {
|
return updateUserState(state, payload.user.accountAddress, {
|
||||||
isUpdating: true,
|
isUpdating: true,
|
||||||
updateError: null,
|
updateError: null,
|
||||||
});
|
});
|
||||||
case types.UPDATE_USER_FULFILLED:
|
case types.UPDATE_USER_FULFILLED:
|
||||||
return updateStateFetch(
|
return updateUserState(
|
||||||
state,
|
state,
|
||||||
payload.user.ethAddress,
|
payload.user.accountAddress,
|
||||||
{ isUpdating: false },
|
{ isUpdating: false },
|
||||||
payload.user,
|
payload.user,
|
||||||
);
|
);
|
||||||
case types.UPDATE_USER_REJECTED:
|
case types.UPDATE_USER_REJECTED:
|
||||||
return updateStateFetch(state, payload.user.ethAddress, {
|
return updateUserState(state, payload.user.accountAddress, {
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
updateError: errorStatus,
|
updateError: errorStatus,
|
||||||
});
|
});
|
||||||
// created proposals
|
// created proposals
|
||||||
case types.FETCH_USER_CREATED_PENDING:
|
case types.FETCH_USER_CREATED_PENDING:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingCreated: true,
|
isFetchingCreated: true,
|
||||||
fetchErrorCreated: null,
|
fetchErrorCreated: null,
|
||||||
});
|
});
|
||||||
case types.FETCH_USER_CREATED_FULFILLED:
|
case types.FETCH_USER_CREATED_FULFILLED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingCreated: false,
|
isFetchingCreated: false,
|
||||||
hasFetchedCreated: true,
|
hasFetchedCreated: true,
|
||||||
createdProposals: proposals,
|
createdProposals: proposals,
|
||||||
});
|
});
|
||||||
case types.FETCH_USER_CREATED_REJECTED:
|
case types.FETCH_USER_CREATED_REJECTED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingCreated: false,
|
isFetchingCreated: false,
|
||||||
hasFetchedCreated: true,
|
hasFetchedCreated: true,
|
||||||
fetchErrorCreated: errorStatus,
|
fetchErrorCreated: errorStatus,
|
||||||
});
|
});
|
||||||
// funded proposals
|
// funded proposals
|
||||||
case types.FETCH_USER_FUNDED_PENDING:
|
case types.FETCH_USER_FUNDED_PENDING:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingFunded: true,
|
isFetchingFunded: true,
|
||||||
fetchErrorFunded: null,
|
fetchErrorFunded: null,
|
||||||
});
|
});
|
||||||
case types.FETCH_USER_FUNDED_FULFILLED:
|
case types.FETCH_USER_FUNDED_FULFILLED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingFunded: false,
|
isFetchingFunded: false,
|
||||||
hasFetchedFunded: true,
|
hasFetchedFunded: true,
|
||||||
fundedProposals: proposals,
|
fundedProposals: proposals,
|
||||||
});
|
});
|
||||||
case types.FETCH_USER_FUNDED_REJECTED:
|
case types.FETCH_USER_FUNDED_REJECTED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingFunded: false,
|
isFetchingFunded: false,
|
||||||
hasFetchedFunded: true,
|
hasFetchedFunded: true,
|
||||||
fetchErrorFunded: errorStatus,
|
fetchErrorFunded: errorStatus,
|
||||||
});
|
});
|
||||||
// comments
|
// comments
|
||||||
case types.FETCH_USER_COMMENTS_PENDING:
|
case types.FETCH_USER_COMMENTS_PENDING:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingComments: true,
|
isFetchingComments: true,
|
||||||
fetchErrorComments: null,
|
fetchErrorComments: null,
|
||||||
});
|
});
|
||||||
case types.FETCH_USER_COMMENTS_FULFILLED:
|
case types.FETCH_USER_COMMENTS_FULFILLED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingComments: false,
|
isFetchingComments: false,
|
||||||
hasFetchedComments: true,
|
hasFetchedComments: true,
|
||||||
comments,
|
comments,
|
||||||
});
|
});
|
||||||
case types.FETCH_USER_COMMENTS_REJECTED:
|
case types.FETCH_USER_COMMENTS_REJECTED:
|
||||||
return updateStateFetch(state, userFetchId, {
|
return updateUserState(state, userFetchId, {
|
||||||
isFetchingComments: false,
|
isFetchingComments: false,
|
||||||
hasFetchedComments: true,
|
hasFetchedComments: true,
|
||||||
fetchErrorComments: errorStatus,
|
fetchErrorComments: errorStatus,
|
||||||
});
|
});
|
||||||
|
// invites
|
||||||
|
case types.FETCH_USER_INVITES_PENDING:
|
||||||
|
return updateUserState(state, userFetchId, {
|
||||||
|
isFetchingInvites: true,
|
||||||
|
fetchErrorInvites: null,
|
||||||
|
});
|
||||||
|
case types.FETCH_USER_INVITES_FULFILLED:
|
||||||
|
return updateUserState(state, userFetchId, {
|
||||||
|
isFetchingInvites: false,
|
||||||
|
hasFetchedInvites: true,
|
||||||
|
invites,
|
||||||
|
});
|
||||||
|
case types.FETCH_USER_INVITES_REJECTED:
|
||||||
|
return updateUserState(state, userFetchId, {
|
||||||
|
isFetchingInvites: false,
|
||||||
|
hasFetchedInvites: true,
|
||||||
|
fetchErrorInvites: errorStatus,
|
||||||
|
});
|
||||||
|
// invites
|
||||||
|
case types.FETCH_USER_INVITES_PENDING:
|
||||||
|
return updateUserState(state, userFetchId, {
|
||||||
|
isFetchingInvites: true,
|
||||||
|
fetchErrorInvites: null,
|
||||||
|
});
|
||||||
|
case types.FETCH_USER_INVITES_FULFILLED:
|
||||||
|
return updateUserState(state, userFetchId, {
|
||||||
|
isFetchingInvites: false,
|
||||||
|
hasFetchedInvites: true,
|
||||||
|
invites,
|
||||||
|
});
|
||||||
|
case types.FETCH_USER_INVITES_REJECTED:
|
||||||
|
return updateUserState(state, userFetchId, {
|
||||||
|
isFetchingInvites: false,
|
||||||
|
hasFetchedInvites: true,
|
||||||
|
fetchErrorInvites: errorStatus,
|
||||||
|
});
|
||||||
|
// invite response
|
||||||
|
case types.RESPOND_TO_INVITE_PENDING:
|
||||||
|
return updateTeamInvite(state, payload.userId, payload.inviteId, {
|
||||||
|
isResponding: true,
|
||||||
|
respondError: null,
|
||||||
|
});
|
||||||
|
case types.RESPOND_TO_INVITE_FULFILLED:
|
||||||
|
return removeTeamInvite(state, payload.userId, payload.inviteId);
|
||||||
|
case types.RESPOND_TO_INVITE_REJECTED:
|
||||||
|
return updateTeamInvite(state, payload.userId, payload.inviteId, {
|
||||||
|
isResponding: false,
|
||||||
|
respondError: errorStatus,
|
||||||
|
});
|
||||||
|
// default
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateStateFetch(
|
function updateUserState(
|
||||||
state: UsersState,
|
state: UsersState,
|
||||||
id: string,
|
id: string | number,
|
||||||
updates: object,
|
updates: Partial<UserState>,
|
||||||
loaded?: UserState,
|
loaded?: UserState,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
@ -180,3 +245,34 @@ function updateStateFetch(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTeamInvite(
|
||||||
|
state: UsersState,
|
||||||
|
userid: string | number,
|
||||||
|
inviteid: string | number,
|
||||||
|
updates: Partial<TeamInviteWithResponse>,
|
||||||
|
) {
|
||||||
|
const userUpdates = {
|
||||||
|
invites: state.map[userid].invites.map(inv => {
|
||||||
|
if (inv.id === inviteid) {
|
||||||
|
return {
|
||||||
|
...inv,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return inv;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return updateUserState(state, userid, userUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTeamInvite(
|
||||||
|
state: UsersState,
|
||||||
|
userid: string | number,
|
||||||
|
inviteid: string | number,
|
||||||
|
) {
|
||||||
|
const userUpdates = {
|
||||||
|
invites: state.map[userid].invites.filter(inv => inv.id !== inviteid),
|
||||||
|
};
|
||||||
|
return updateUserState(state, userid, userUpdates);
|
||||||
|
}
|
||||||
|
|
|
@ -23,6 +23,16 @@ enum UsersActions {
|
||||||
FETCH_USER_COMMENTS_PENDING = 'FETCH_USER_COMMENTS_PENDING',
|
FETCH_USER_COMMENTS_PENDING = 'FETCH_USER_COMMENTS_PENDING',
|
||||||
FETCH_USER_COMMENTS_FULFILLED = 'FETCH_USER_COMMENTS_FULFILLED',
|
FETCH_USER_COMMENTS_FULFILLED = 'FETCH_USER_COMMENTS_FULFILLED',
|
||||||
FETCH_USER_COMMENTS_REJECTED = 'FETCH_USER_COMMENTS_REJECTED',
|
FETCH_USER_COMMENTS_REJECTED = 'FETCH_USER_COMMENTS_REJECTED',
|
||||||
|
|
||||||
|
FETCH_USER_INVITES = 'FETCH_USER_INVITES',
|
||||||
|
FETCH_USER_INVITES_PENDING = 'FETCH_USER_INVITES_PENDING',
|
||||||
|
FETCH_USER_INVITES_FULFILLED = 'FETCH_USER_INVITES_FULFILLED',
|
||||||
|
FETCH_USER_INVITES_REJECTED = 'FETCH_USER_INVITES_REJECTED',
|
||||||
|
|
||||||
|
RESPOND_TO_INVITE = 'RESPOND_TO_INVITE',
|
||||||
|
RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING',
|
||||||
|
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
|
||||||
|
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UsersActions;
|
export default UsersActions;
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import types from './types';
|
import types from './types';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import getWeb3 from 'lib/getWeb3';
|
import getWeb3 from 'lib/getWeb3';
|
||||||
import { postProposal } from 'api/api';
|
|
||||||
import getContract, { WrongNetworkError } from 'lib/getContract';
|
import getContract, { WrongNetworkError } from 'lib/getContract';
|
||||||
import { sleep } from 'utils/helpers';
|
import { sleep } from 'utils/helpers';
|
||||||
import { web3ErrorToString } from 'utils/web3';
|
import { web3ErrorToString } from 'utils/web3';
|
||||||
|
import { putProposalPublish } from 'api/api';
|
||||||
|
import { proposalToContractData } from 'modules/create/utils';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { Wei } from 'utils/units';
|
||||||
|
import { AuthSignatureData, ProposalDraft, ProposalWithCrowdFund } from 'types';
|
||||||
import {
|
import {
|
||||||
fetchProposal,
|
fetchProposal,
|
||||||
fetchProposals,
|
fetchProposals,
|
||||||
postProposalContribution,
|
postProposalContribution,
|
||||||
} from 'modules/proposals/actions';
|
} from 'modules/proposals/actions';
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
|
||||||
import { AppState } from 'store/reducers';
|
|
||||||
import { Wei } from 'utils/units';
|
|
||||||
import { getCrowdFundContract } from 'lib/crowdFundContracts';
|
import { getCrowdFundContract } from 'lib/crowdFundContracts';
|
||||||
import { TeamMember, AuthSignatureData, ProposalWithCrowdFund } from 'types';
|
|
||||||
|
|
||||||
type GetState = () => AppState;
|
type GetState = () => AppState;
|
||||||
|
|
||||||
|
@ -100,38 +100,18 @@ export function setAccounts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move these to a better place?
|
// TODO: Move these to a better place?
|
||||||
interface MilestoneData {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
date: string;
|
|
||||||
payoutPercent: number;
|
|
||||||
immediatePayout: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProposalContractData {
|
export interface ProposalContractData {
|
||||||
ethAmount: Wei;
|
ethAmount: Wei;
|
||||||
payOutAddress: string;
|
payoutAddress: string;
|
||||||
trusteesAddresses: string[];
|
trusteesAddresses: string[];
|
||||||
milestoneAmounts: Wei[];
|
milestoneAmounts: Wei[];
|
||||||
milestones: MilestoneData[];
|
|
||||||
durationInMinutes: number;
|
durationInMinutes: number;
|
||||||
milestoneVotingPeriodInMinutes: number;
|
milestoneVotingPeriodInMinutes: number;
|
||||||
immediateFirstMilestonePayout: boolean;
|
immediateFirstMilestonePayout: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProposalBackendData {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
category: PROPOSAL_CATEGORY;
|
|
||||||
team: TeamMember[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TCreateCrowdFund = typeof createCrowdFund;
|
export type TCreateCrowdFund = typeof createCrowdFund;
|
||||||
export function createCrowdFund(
|
export function createCrowdFund(CrowdFundFactoryContract: any, proposal: ProposalDraft) {
|
||||||
CrowdFundFactoryContract: any,
|
|
||||||
contractData: ProposalContractData,
|
|
||||||
backendData: ProposalBackendData,
|
|
||||||
) {
|
|
||||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.CROWD_FUND_PENDING,
|
type: types.CROWD_FUND_PENDING,
|
||||||
|
@ -139,16 +119,13 @@ export function createCrowdFund(
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ethAmount,
|
ethAmount,
|
||||||
payOutAddress,
|
payoutAddress,
|
||||||
trusteesAddresses,
|
trusteesAddresses,
|
||||||
milestoneAmounts,
|
milestoneAmounts,
|
||||||
milestones,
|
|
||||||
durationInMinutes,
|
durationInMinutes,
|
||||||
milestoneVotingPeriodInMinutes,
|
milestoneVotingPeriodInMinutes,
|
||||||
immediateFirstMilestonePayout,
|
immediateFirstMilestonePayout,
|
||||||
} = contractData;
|
} = proposalToContractData(proposal);
|
||||||
|
|
||||||
const { content, title, category, team } = backendData;
|
|
||||||
|
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const accounts = state.web3.accounts;
|
const accounts = state.web3.accounts;
|
||||||
|
@ -157,8 +134,8 @@ export function createCrowdFund(
|
||||||
await CrowdFundFactoryContract.methods
|
await CrowdFundFactoryContract.methods
|
||||||
.createCrowdFund(
|
.createCrowdFund(
|
||||||
ethAmount,
|
ethAmount,
|
||||||
payOutAddress,
|
payoutAddress,
|
||||||
[payOutAddress, ...trusteesAddresses],
|
[payoutAddress, ...trusteesAddresses],
|
||||||
milestoneAmounts,
|
milestoneAmounts,
|
||||||
durationInMinutes,
|
durationInMinutes,
|
||||||
milestoneVotingPeriodInMinutes,
|
milestoneVotingPeriodInMinutes,
|
||||||
|
@ -168,15 +145,7 @@ export function createCrowdFund(
|
||||||
.once('confirmation', async (_: any, receipt: any) => {
|
.once('confirmation', async (_: any, receipt: any) => {
|
||||||
const crowdFundContractAddress =
|
const crowdFundContractAddress =
|
||||||
receipt.events.ContractCreated.returnValues.newAddress;
|
receipt.events.ContractCreated.returnValues.newAddress;
|
||||||
await postProposal({
|
await putProposalPublish(proposal, crowdFundContractAddress);
|
||||||
accountAddress: accounts[0],
|
|
||||||
crowdFundContractAddress,
|
|
||||||
content,
|
|
||||||
title,
|
|
||||||
milestones,
|
|
||||||
category,
|
|
||||||
team,
|
|
||||||
});
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.CROWD_FUND_CREATED,
|
type: types.CROWD_FUND_CREATED,
|
||||||
payload: crowdFundContractAddress,
|
payload: crowdFundContractAddress,
|
||||||
|
|
|
@ -1,17 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Spin } from 'antd';
|
import DraftList from 'components/DraftList';
|
||||||
import Web3Container from 'lib/Web3Container';
|
|
||||||
import CreateFlow from 'components/CreateFlow';
|
|
||||||
|
|
||||||
const Create = () => (
|
const CreatePage = () => <DraftList createIfNone />;
|
||||||
<Web3Container
|
|
||||||
renderLoading={() => <Spin />}
|
|
||||||
render={({ accounts }) => (
|
|
||||||
<div style={{ paddingTop: '3rem', paddingBottom: '8rem' }}>
|
|
||||||
<CreateFlow accounts={accounts} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Create;
|
export default CreatePage;
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { withRouter, RouteComponentProps } from 'react-router';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import Web3Container from 'lib/Web3Container';
|
||||||
|
import CreateFlow from 'components/CreateFlow';
|
||||||
|
import { initializeForm } from 'modules/create/actions';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
form: AppState['create']['form'];
|
||||||
|
isInitializingForm: AppState['create']['isInitializingForm'];
|
||||||
|
initializeFormError: AppState['create']['initializeFormError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
initializeForm: typeof initializeForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & DispatchProps & RouteComponentProps<{ id: string }>;
|
||||||
|
|
||||||
|
class ProposalEdit extends React.Component<Props> {
|
||||||
|
componentWillMount() {
|
||||||
|
const proposalId = parseInt(this.props.match.params.id, 10);
|
||||||
|
this.props.initializeForm(proposalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { form, initializeFormError } = this.props;
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
return (
|
||||||
|
<Web3Container
|
||||||
|
renderLoading={() => <Spin />}
|
||||||
|
render={({ accounts }) => (
|
||||||
|
<div style={{ paddingTop: '3rem', paddingBottom: '8rem' }}>
|
||||||
|
<CreateFlow accounts={accounts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (initializeFormError) {
|
||||||
|
return <h1>{initializeFormError}</h1>;
|
||||||
|
} else {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectedProposalEdit = connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
|
state => ({
|
||||||
|
form: state.create.form,
|
||||||
|
isInitializingForm: state.create.isInitializingForm,
|
||||||
|
initializeFormError: state.create.initializeFormError,
|
||||||
|
}),
|
||||||
|
{ initializeForm },
|
||||||
|
)(ProposalEdit);
|
||||||
|
|
||||||
|
export default withRouter(ConnectedProposalEdit);
|
|
@ -9,7 +9,7 @@ interface Props {
|
||||||
class ProfilePage extends React.Component<Props> {
|
class ProfilePage extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { user } = this.props;
|
const { user } = this.props;
|
||||||
return <h1>Settings for {user && user.name}</h1>;
|
return <h1>Settings for {user && user.displayName}</h1>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,10 @@ import thunkMiddleware, { ThunkMiddleware } from 'redux-thunk';
|
||||||
import promiseMiddleware from 'redux-promise-middleware';
|
import promiseMiddleware from 'redux-promise-middleware';
|
||||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||||
import { persistStore, Persistor } from 'redux-persist';
|
import { persistStore, Persistor } from 'redux-persist';
|
||||||
|
import { routerMiddleware } from 'connected-react-router';
|
||||||
import rootReducer, { AppState, combineInitialState } from './reducers';
|
import rootReducer, { AppState, combineInitialState } from './reducers';
|
||||||
import rootSaga from './sagas';
|
import rootSaga from './sagas';
|
||||||
|
import history from './history';
|
||||||
import axios from 'api/axios';
|
import axios from 'api/axios';
|
||||||
|
|
||||||
const sagaMiddleware = createSagaMiddleware();
|
const sagaMiddleware = createSagaMiddleware();
|
||||||
|
@ -27,7 +29,12 @@ export function configureStore(initialState: Partial<AppState> = combineInitialS
|
||||||
const store: Store<AppState> = createStore(
|
const store: Store<AppState> = createStore(
|
||||||
rootReducer,
|
rootReducer,
|
||||||
initialState,
|
initialState,
|
||||||
bindMiddleware([sagaMiddleware, thunkMiddleware, promiseMiddleware()]),
|
bindMiddleware([
|
||||||
|
sagaMiddleware,
|
||||||
|
thunkMiddleware,
|
||||||
|
promiseMiddleware(),
|
||||||
|
routerMiddleware(history),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
// Don't persist server side, but don't mess up types for client side
|
// Don't persist server side, but don't mess up types for client side
|
||||||
const persistor: Persistor = process.env.SERVER_SIDE_RENDER
|
const persistor: Persistor = process.env.SERVER_SIDE_RENDER
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createBrowserHistory, createMemoryHistory } from 'history';
|
||||||
|
|
||||||
|
const history = (() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return createMemoryHistory();
|
||||||
|
} else {
|
||||||
|
return createBrowserHistory();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export default history;
|
|
@ -1,4 +1,5 @@
|
||||||
import { combineReducers, Reducer } from 'redux';
|
import { combineReducers, Reducer } from 'redux';
|
||||||
|
import { connectRouter, RouterState } from 'connected-react-router';
|
||||||
import { persistReducer } from 'redux-persist';
|
import { persistReducer } from 'redux-persist';
|
||||||
import web3, { Web3State, INITIAL_STATE as web3InitialState } from 'modules/web3';
|
import web3, { Web3State, INITIAL_STATE as web3InitialState } from 'modules/web3';
|
||||||
import proposal, {
|
import proposal, {
|
||||||
|
@ -12,6 +13,7 @@ import authReducer, {
|
||||||
authPersistConfig,
|
authPersistConfig,
|
||||||
} from 'modules/auth';
|
} from 'modules/auth';
|
||||||
import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users';
|
import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users';
|
||||||
|
import history from './history';
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
proposal: ProposalState;
|
proposal: ProposalState;
|
||||||
|
@ -19,9 +21,10 @@ export interface AppState {
|
||||||
create: CreateState;
|
create: CreateState;
|
||||||
users: UsersState;
|
users: UsersState;
|
||||||
auth: AuthState;
|
auth: AuthState;
|
||||||
|
router: RouterState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const combineInitialState: AppState = {
|
export const combineInitialState: Partial<AppState> = {
|
||||||
proposal: proposalInitialState,
|
proposal: proposalInitialState,
|
||||||
web3: web3InitialState,
|
web3: web3InitialState,
|
||||||
create: createInitialState,
|
create: createInitialState,
|
||||||
|
@ -36,4 +39,5 @@ export default combineReducers<AppState>({
|
||||||
users,
|
users,
|
||||||
// Don't allow for redux-persist's _persist key to be touched in our code
|
// Don't allow for redux-persist's _persist key to be touched in our code
|
||||||
auth: (persistReducer(authPersistConfig, authReducer) as any) as Reducer<AuthState>,
|
auth: (persistReducer(authPersistConfig, authReducer) as any) as Reducer<AuthState>,
|
||||||
|
router: connectRouter(history),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { fork } from 'redux-saga/effects';
|
import { fork } from 'redux-saga/effects';
|
||||||
import { authSagas } from 'modules/auth';
|
import { authSagas } from 'modules/auth';
|
||||||
import { web3Sagas } from 'modules/web3';
|
import { web3Sagas } from 'modules/web3';
|
||||||
|
import { createSagas } from 'modules/create';
|
||||||
|
|
||||||
export default function* rootSaga() {
|
export default function* rootSaga() {
|
||||||
yield fork(authSagas);
|
yield fork(authSagas);
|
||||||
yield fork(web3Sagas);
|
yield fork(web3Sagas);
|
||||||
|
yield fork(createSagas);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Effect } from 'redux-saga/effects';
|
||||||
|
|
||||||
|
type ExtPromise<T> = T extends Promise<infer U> ? U : T;
|
||||||
|
|
||||||
|
type ExtSaga<T> = T extends IterableIterator<infer U>
|
||||||
|
? Exclude<U, Effect | Effect[]>
|
||||||
|
: ExtPromise<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this helper to unwrap return types from effects like Call / Apply
|
||||||
|
* In the case of calling a function that returns a promise, this helper will unwrap the
|
||||||
|
* promise and return the type inside it. In the case of calling another saga / generator,
|
||||||
|
* this helper will return the actual return value of the saga / generator, otherwise,
|
||||||
|
* it'll return the original type.
|
||||||
|
*
|
||||||
|
* NOTE 1: When using this to extract the type of a Saga, make sure to remove the `SagaIterator`
|
||||||
|
* return type of the saga if it contains one, since that masks the actual return type of the saga.
|
||||||
|
*
|
||||||
|
* NOTE 2: You will most likely need to use the `typeof` operator to use this helper.
|
||||||
|
* E.g type X = Yielded<typeof MyFunc/MySaga>
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
export type Yielded<T> = T extends (...args: any[]) => any
|
||||||
|
? ExtSaga<ReturnType<T>>
|
||||||
|
: ExtPromise<T>;
|
||||||
|
}
|
|
@ -1,31 +1,13 @@
|
||||||
import BN from 'bn.js';
|
import BN from 'bn.js';
|
||||||
import { TeamMember, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
|
import { socialMediaToUrl } from 'utils/social';
|
||||||
import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social';
|
import { User, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
|
||||||
export function formatTeamMemberForPost(user: TeamMember) {
|
export function formatUserForPost(user: User) {
|
||||||
return {
|
return {
|
||||||
displayName: user.name,
|
...user,
|
||||||
title: user.title,
|
avatar: user.avatar ? user.avatar.imageUrl : null,
|
||||||
accountAddress: user.ethAddress,
|
socialMedias: user.socialMedias.map(sm => socialMediaToUrl(sm.service, sm.username)),
|
||||||
emailAddress: user.emailAddress,
|
|
||||||
avatar: user.avatarUrl ? { link: user.avatarUrl } : {},
|
|
||||||
socialMedias: socialAccountsToUrls(user.socialAccounts).map(url => ({
|
|
||||||
link: url,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTeamMemberFromGet(user: any): TeamMember {
|
|
||||||
return {
|
|
||||||
name: user.displayName,
|
|
||||||
title: user.title,
|
|
||||||
ethAddress: user.accountAddress,
|
|
||||||
emailAddress: user.emailAddress,
|
|
||||||
avatarUrl: user.avatar && user.avatar.imageUrl,
|
|
||||||
socialAccounts: socialUrlsToAccounts(
|
|
||||||
user.socialMedias.map((sm: any) => sm.socialMediaLink),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +31,6 @@ export function formatCrowdFundFromGet(crowdFund: CrowdFund, base = 10): CrowdFu
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
|
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
|
||||||
proposal.team = proposal.team.map(formatTeamMemberFromGet);
|
|
||||||
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
||||||
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
|
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
|
||||||
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {
|
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {
|
||||||
|
|
|
@ -1,60 +1,36 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from 'antd';
|
import { Icon } from 'antd';
|
||||||
import keybaseIcon from 'static/images/keybase.svg';
|
import keybaseIcon from 'static/images/keybase.svg';
|
||||||
import { SOCIAL_TYPE, SocialAccountMap, SocialInfo } from 'types';
|
import { SOCIAL_SERVICE, SocialInfo } from 'types';
|
||||||
|
|
||||||
const accountNameRegex = '([a-zA-Z0-9-_]*)';
|
const accountNameRegex = '([a-zA-Z0-9-_]*)';
|
||||||
export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = {
|
export const SOCIAL_INFO: { [key in SOCIAL_SERVICE]: SocialInfo } = {
|
||||||
[SOCIAL_TYPE.GITHUB]: {
|
[SOCIAL_SERVICE.GITHUB]: {
|
||||||
type: SOCIAL_TYPE.GITHUB,
|
service: SOCIAL_SERVICE.GITHUB,
|
||||||
name: 'Github',
|
name: 'Github',
|
||||||
format: `https://github.com/${accountNameRegex}`,
|
format: `https://github.com/${accountNameRegex}`,
|
||||||
icon: <Icon type="github" />,
|
icon: <Icon type="github" />,
|
||||||
},
|
},
|
||||||
[SOCIAL_TYPE.TWITTER]: {
|
[SOCIAL_SERVICE.TWITTER]: {
|
||||||
type: SOCIAL_TYPE.TWITTER,
|
service: SOCIAL_SERVICE.TWITTER,
|
||||||
name: 'Twitter',
|
name: 'Twitter',
|
||||||
format: `https://twitter.com/${accountNameRegex}`,
|
format: `https://twitter.com/${accountNameRegex}`,
|
||||||
icon: <Icon type="twitter" />,
|
icon: <Icon type="twitter" />,
|
||||||
},
|
},
|
||||||
[SOCIAL_TYPE.LINKEDIN]: {
|
[SOCIAL_SERVICE.LINKEDIN]: {
|
||||||
type: SOCIAL_TYPE.LINKEDIN,
|
service: SOCIAL_SERVICE.LINKEDIN,
|
||||||
name: 'LinkedIn',
|
name: 'LinkedIn',
|
||||||
format: `https://linkedin.com/in/${accountNameRegex}`,
|
format: `https://linkedin.com/in/${accountNameRegex}`,
|
||||||
icon: <Icon type="linkedin" />,
|
icon: <Icon type="linkedin" />,
|
||||||
},
|
},
|
||||||
[SOCIAL_TYPE.KEYBASE]: {
|
[SOCIAL_SERVICE.KEYBASE]: {
|
||||||
type: SOCIAL_TYPE.KEYBASE,
|
service: SOCIAL_SERVICE.KEYBASE,
|
||||||
name: 'KeyBase',
|
name: 'KeyBase',
|
||||||
format: `https://keybase.io/${accountNameRegex}`,
|
format: `https://keybase.io/${accountNameRegex}`,
|
||||||
icon: <Icon component={keybaseIcon} />,
|
icon: <Icon component={keybaseIcon} />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function urlToAccount(format: string, url: string): string | false {
|
export function socialMediaToUrl(service: SOCIAL_SERVICE, username: string): string {
|
||||||
const matches = url.match(new RegExp(format));
|
return SOCIAL_INFO[service].format.replace(accountNameRegex, username);
|
||||||
return matches && matches[1] ? matches[1] : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function socialAccountToUrl(account: string, type: SOCIAL_TYPE): string {
|
|
||||||
return SOCIAL_INFO[type].format.replace(accountNameRegex, account);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function socialUrlsToAccounts(urls: string[]): SocialAccountMap {
|
|
||||||
const accounts: SocialAccountMap = {};
|
|
||||||
urls.forEach(url => {
|
|
||||||
Object.values(SOCIAL_INFO).forEach(s => {
|
|
||||||
const account = urlToAccount(s.format, url);
|
|
||||||
if (account) {
|
|
||||||
accounts[s.type] = account;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return accounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function socialAccountsToUrls(accounts: SocialAccountMap): string[] {
|
|
||||||
return Object.entries(accounts).map(([key, value]) => {
|
|
||||||
return socialAccountToUrl(value as string, key as SOCIAL_TYPE);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,3 +29,7 @@ export function isValidEthAddress(addr: string): boolean {
|
||||||
return addr === toChecksumAddress(addr);
|
return addr === toChecksumAddress(addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
return /\S+@\S+\.\S+/.test(email);
|
||||||
|
}
|
||||||
|
|
|
@ -85,6 +85,7 @@
|
||||||
"body-parser": "^1.18.3",
|
"body-parser": "^1.18.3",
|
||||||
"chalk": "^2.4.1",
|
"chalk": "^2.4.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
|
"connected-react-router": "5.0.1",
|
||||||
"cookie-parser": "^1.4.3",
|
"cookie-parser": "^1.4.3",
|
||||||
"copy-webpack-plugin": "^4.6.0",
|
"copy-webpack-plugin": "^4.6.0",
|
||||||
"core-js": "^2.5.7",
|
"core-js": "^2.5.7",
|
||||||
|
@ -103,6 +104,7 @@
|
||||||
"fs-extra": "^7.0.0",
|
"fs-extra": "^7.0.0",
|
||||||
"global": "4.3.2",
|
"global": "4.3.2",
|
||||||
"hdkey": "1.1.0",
|
"hdkey": "1.1.0",
|
||||||
|
"history": "4.7.2",
|
||||||
"http-proxy-middleware": "^0.18.0",
|
"http-proxy-middleware": "^0.18.0",
|
||||||
"https-proxy": "0.0.2",
|
"https-proxy": "0.0.2",
|
||||||
"husky": "^1.0.0-rc.8",
|
"husky": "^1.0.0-rc.8",
|
||||||
|
|
|
@ -2,20 +2,31 @@ import * as React from 'react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { DONATION } from 'utils/constants';
|
import { DONATION } from 'utils/constants';
|
||||||
|
import { User } from 'types';
|
||||||
|
|
||||||
import 'components/UserRow/style.less';
|
import 'components/UserRow/style.less';
|
||||||
import UserRow from 'components/UserRow';
|
import UserRow from 'components/UserRow';
|
||||||
|
|
||||||
const user = {
|
const user: User = {
|
||||||
name: 'Dana Hayes',
|
userid: 123,
|
||||||
|
displayName: 'Dana Hayes',
|
||||||
title: 'QA Engineer',
|
title: 'QA Engineer',
|
||||||
avatarUrl: 'https://randomuser.me/api/portraits/women/19.jpg',
|
avatar: {
|
||||||
ethAddress: DONATION.ETH,
|
imageUrl: 'https://randomuser.me/api/portraits/women/19.jpg',
|
||||||
|
},
|
||||||
|
accountAddress: DONATION.ETH,
|
||||||
emailAddress: 'test@test.test',
|
emailAddress: 'test@test.test',
|
||||||
socialAccounts: {},
|
socialMedias: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const cases = [
|
interface Case {
|
||||||
|
disp: string;
|
||||||
|
props: {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases: Case[] = [
|
||||||
{
|
{
|
||||||
disp: 'Full User',
|
disp: 'Full User',
|
||||||
props: {
|
props: {
|
||||||
|
@ -29,7 +40,7 @@ const cases = [
|
||||||
props: {
|
props: {
|
||||||
user: {
|
user: {
|
||||||
...user,
|
...user,
|
||||||
avatarUrl: '',
|
avatar: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -38,8 +49,8 @@ const cases = [
|
||||||
props: {
|
props: {
|
||||||
user: {
|
user: {
|
||||||
...user,
|
...user,
|
||||||
avatarUrl: '',
|
avatar: null,
|
||||||
ethAddress: '',
|
accountAddress: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -48,7 +59,7 @@ const cases = [
|
||||||
props: {
|
props: {
|
||||||
user: {
|
user: {
|
||||||
...user,
|
...user,
|
||||||
name: 'Dr. Baron Longnamivitch von Testeronomous III Esq.',
|
displayName: 'Dr. Baron Longnamivitch von Testeronomous III Esq.',
|
||||||
title: 'Amazing person, all around cool neat-o guy, 10/10 would order again',
|
title: 'Amazing person, all around cool neat-o guy, 10/10 would order again',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -161,33 +161,37 @@ export function getProposalWithCrowdFund({
|
||||||
proposalAddress: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710',
|
proposalAddress: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710',
|
||||||
dateCreated: created / 1000,
|
dateCreated: created / 1000,
|
||||||
title: 'Crowdfund Title',
|
title: 'Crowdfund Title',
|
||||||
body: 'body',
|
brief: 'A cool test crowdfund',
|
||||||
|
content: 'body',
|
||||||
stage: 'FUNDING_REQUIRED',
|
stage: 'FUNDING_REQUIRED',
|
||||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||||
team: [
|
team: [
|
||||||
{
|
{
|
||||||
name: 'Test Proposer',
|
userid: 123,
|
||||||
|
displayName: 'Test Proposer',
|
||||||
title: '',
|
title: '',
|
||||||
ethAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7',
|
accountAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7',
|
||||||
emailAddress: '',
|
emailAddress: '',
|
||||||
avatarUrl: '',
|
avatar: null,
|
||||||
socialAccounts: {},
|
socialMedias: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Test Proposer',
|
userid: 456,
|
||||||
|
displayName: 'Test Proposer',
|
||||||
title: '',
|
title: '',
|
||||||
ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
|
accountAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
|
||||||
emailAddress: '',
|
emailAddress: '',
|
||||||
avatarUrl: '',
|
avatar: null,
|
||||||
socialAccounts: {},
|
socialMedias: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Test Proposer',
|
userid: 789,
|
||||||
|
displayName: 'Test Proposer',
|
||||||
title: '',
|
title: '',
|
||||||
ethAddress: '0x529104532a9779ea9eae0c1e325b3368e0f8add4',
|
accountAddress: '0x529104532a9779ea9eae0c1e325b3368e0f8add4',
|
||||||
emailAddress: '',
|
emailAddress: '',
|
||||||
avatarUrl: '',
|
avatar: null,
|
||||||
socialAccounts: {},
|
socialMedias: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
milestones,
|
milestones,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { User, UserProposal } from 'types';
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
commentId: number | string;
|
commentId: number | string;
|
||||||
body: string;
|
content: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
author: User;
|
author: User;
|
||||||
replies: Comment[];
|
replies: Comment[];
|
||||||
|
@ -10,7 +10,7 @@ export interface Comment {
|
||||||
|
|
||||||
export interface UserComment {
|
export interface UserComment {
|
||||||
commentId: number | string;
|
commentId: number | string;
|
||||||
body: string;
|
content: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
proposal: UserProposal;
|
proposal: UserProposal;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,6 +1,5 @@
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './social';
|
export * from './social';
|
||||||
export * from './create';
|
|
||||||
export * from './comment';
|
export * from './comment';
|
||||||
export * from './contribution';
|
export * from './contribution';
|
||||||
export * from './milestone';
|
export * from './milestone';
|
||||||
|
|
|
@ -18,9 +18,7 @@ export interface Milestone {
|
||||||
isImmediatePayout: boolean;
|
isImmediatePayout: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - have backend camelCase keys before response
|
|
||||||
export interface ProposalMilestone extends Milestone {
|
export interface ProposalMilestone extends Milestone {
|
||||||
body: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
immediatePayout: boolean;
|
immediatePayout: boolean;
|
||||||
dateEstimated: string;
|
dateEstimated: string;
|
||||||
|
@ -31,8 +29,8 @@ export interface ProposalMilestone extends Milestone {
|
||||||
|
|
||||||
export interface CreateMilestone {
|
export interface CreateMilestone {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
content: string;
|
||||||
date: string;
|
dateEstimated: string;
|
||||||
payoutPercent: number;
|
payoutPercent: string;
|
||||||
immediatePayout: boolean;
|
immediatePayout: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,20 @@
|
||||||
import { TeamMember } from 'types';
|
|
||||||
import { Wei } from 'utils/units';
|
import { Wei } from 'utils/units';
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||||
import { Comment } from 'types';
|
import {
|
||||||
import { Milestone, ProposalMilestone, Update } from 'types';
|
CreateMilestone,
|
||||||
|
ProposalMilestone,
|
||||||
|
Update,
|
||||||
|
User,
|
||||||
|
Milestone,
|
||||||
|
Comment,
|
||||||
|
} from 'types';
|
||||||
|
|
||||||
|
export interface TeamInvite {
|
||||||
|
id: number;
|
||||||
|
dateCreated: number;
|
||||||
|
address: string;
|
||||||
|
accepted: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Contributor {
|
export interface Contributor {
|
||||||
address: string;
|
address: string;
|
||||||
|
@ -31,23 +43,46 @@ export interface CrowdFund {
|
||||||
isRaiseGoalReached: boolean;
|
isRaiseGoalReached: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProposalDraft {
|
||||||
|
proposalId: number;
|
||||||
|
dateCreated: number;
|
||||||
|
title: string;
|
||||||
|
brief: string;
|
||||||
|
category: PROPOSAL_CATEGORY;
|
||||||
|
content: string;
|
||||||
|
stage: string;
|
||||||
|
target: string;
|
||||||
|
payoutAddress: string;
|
||||||
|
trustees: string[];
|
||||||
|
deadlineDuration: number;
|
||||||
|
voteDuration: number;
|
||||||
|
milestones: CreateMilestone[];
|
||||||
|
team: User[];
|
||||||
|
invites: TeamInvite[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Proposal {
|
export interface Proposal {
|
||||||
proposalId: number;
|
proposalId: number;
|
||||||
proposalAddress: string;
|
proposalAddress: string;
|
||||||
proposalUrlId: string;
|
proposalUrlId: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
brief: string;
|
||||||
|
content: string;
|
||||||
stage: string;
|
stage: string;
|
||||||
category: PROPOSAL_CATEGORY;
|
category: PROPOSAL_CATEGORY;
|
||||||
milestones: ProposalMilestone[];
|
milestones: ProposalMilestone[];
|
||||||
team: TeamMember[];
|
team: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProposalWithCrowdFund extends Proposal {
|
export interface ProposalWithCrowdFund extends Proposal {
|
||||||
crowdFund: CrowdFund;
|
crowdFund: CrowdFund;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamInviteWithProposal extends TeamInvite {
|
||||||
|
proposal: Proposal;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProposalComments {
|
export interface ProposalComments {
|
||||||
proposalId: ProposalWithCrowdFund['proposalId'];
|
proposalId: ProposalWithCrowdFund['proposalId'];
|
||||||
totalComments: number;
|
totalComments: number;
|
||||||
|
@ -63,7 +98,7 @@ export interface UserProposal {
|
||||||
proposalId: number;
|
proposalId: number;
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
team: TeamMember[];
|
team: User[];
|
||||||
funded: Wei;
|
funded: Wei;
|
||||||
target: Wei;
|
target: Wei;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,21 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>;
|
export type SocialAccountMap = Partial<{ [key in SOCIAL_SERVICE]: string }>;
|
||||||
|
|
||||||
|
export interface SocialMedia {
|
||||||
|
url: string;
|
||||||
|
service: SOCIAL_SERVICE;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SocialInfo {
|
export interface SocialInfo {
|
||||||
type: SOCIAL_TYPE;
|
service: SOCIAL_SERVICE;
|
||||||
name: string;
|
name: string;
|
||||||
format: string;
|
format: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SOCIAL_TYPE {
|
export enum SOCIAL_SERVICE {
|
||||||
GITHUB = 'GITHUB',
|
GITHUB = 'GITHUB',
|
||||||
TWITTER = 'TWITTER',
|
TWITTER = 'TWITTER',
|
||||||
LINKEDIN = 'LINKEDIN',
|
LINKEDIN = 'LINKEDIN',
|
||||||
|
|
|
@ -1,21 +1,11 @@
|
||||||
import { SocialAccountMap } from 'types';
|
import { SocialMedia } from 'types';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
userid: number;
|
||||||
accountAddress: string;
|
accountAddress: string;
|
||||||
userid: number | string;
|
emailAddress: string; // TODO: Split into full user type
|
||||||
username: string;
|
displayName: string;
|
||||||
title: string;
|
title: string;
|
||||||
avatar: {
|
socialMedias: SocialMedia[];
|
||||||
'120x120': string;
|
avatar: { imageUrl: string } | null;
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Merge this or extend the `User` type in proposals/reducers.ts
|
|
||||||
export interface TeamMember {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
ethAddress: string;
|
|
||||||
emailAddress: string;
|
|
||||||
socialAccounts: SocialAccountMap;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4603,6 +4603,13 @@ connect-history-api-fallback@^1.3.0:
|
||||||
version "1.5.0"
|
version "1.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz#b06873934bc5e344fef611a196a6faae0aee015a"
|
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz#b06873934bc5e344fef611a196a6faae0aee015a"
|
||||||
|
|
||||||
|
connected-react-router@5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-5.0.1.tgz#8379854fad7e027b1e27652c00ad534f8ad244b3"
|
||||||
|
dependencies:
|
||||||
|
immutable "^3.8.1"
|
||||||
|
seamless-immutable "^7.1.3"
|
||||||
|
|
||||||
consola@^1.4.3:
|
consola@^1.4.3:
|
||||||
version "1.4.3"
|
version "1.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/consola/-/consola-1.4.3.tgz#945e967e05430ddabd3608b37f5fa37fcfacd9dd"
|
resolved "https://registry.yarnpkg.com/consola/-/consola-1.4.3.tgz#945e967e05430ddabd3608b37f5fa37fcfacd9dd"
|
||||||
|
@ -7551,7 +7558,7 @@ hex-color-regex@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
|
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
|
||||||
|
|
||||||
history@^4.7.2:
|
history@4.7.2, history@^4.7.2:
|
||||||
version "4.7.2"
|
version "4.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b"
|
resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -13693,6 +13700,10 @@ scss-tokenizer@^0.2.3:
|
||||||
js-base64 "^2.1.8"
|
js-base64 "^2.1.8"
|
||||||
source-map "^0.4.2"
|
source-map "^0.4.2"
|
||||||
|
|
||||||
|
seamless-immutable@^7.1.3:
|
||||||
|
version "7.1.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8"
|
||||||
|
|
||||||
secp256k1@^3.0.1:
|
secp256k1@^3.0.1:
|
||||||
version "3.5.0"
|
version "3.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-3.5.0.tgz#677d3b8a8e04e1a5fa381a1ae437c54207b738d0"
|
resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-3.5.0.tgz#677d3b8a8e04e1a5fa381a1ae437c54207b738d0"
|
||||||
|
|
Loading…
Reference in New Issue