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:
William O'Beirne 2018-11-27 12:53:53 -05:00 committed by GitHub
commit ad6173b376
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 2390 additions and 1568 deletions

View File

@ -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>

View File

@ -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[];

View File

@ -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)

View File

@ -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': 'Youve been invited!',
'preview': 'Youve 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,
) )

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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>

View File

@ -0,0 +1 @@
U invited

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,2 @@
class ValidationException(Exception):
pass

View File

@ -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))

View File

@ -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)
}

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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')

View File

@ -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 ###

View File

@ -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'

View File

@ -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'
) )

View File

@ -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:

View File

@ -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: {

View File

@ -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,

View File

@ -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);
}; };
} }

View File

@ -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">

View File

@ -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"
/> />

View File

@ -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);
});
}
}; };
} }

View File

@ -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);

View File

@ -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>

View File

@ -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="%"

View File

@ -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);

View File

@ -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 youre ready to publish your proposal? Once youve 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>
);
}
}

View File

@ -0,0 +1,13 @@
.PublishWarningModal {
.ant-alert {
margin-bottom: 1rem;
ul {
padding-top: 0.25rem;
}
p:last-child {
margin-bottom: 0;
}
}
}

View File

@ -109,4 +109,10 @@
} }
} }
} }
&-invites {
margin-top: 1rem;
font-size: 0.9rem;
opacity: 0.6;
}
} }

View File

@ -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>
); );

View File

@ -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;
} }
} }
} }

View File

@ -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 doesnt 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);
});
}; };
} }

View File

@ -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);

View File

@ -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);
};
} }

View File

@ -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,
}; };
}; };

View File

@ -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,
}, },
); );

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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"

View File

@ -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>
); );
} }

View File

@ -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);
}; };
} }

View File

@ -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;
}
}
}

View File

@ -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);

View File

@ -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>

View File

@ -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>
); );
}; };

View File

@ -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="Youll be notified when youve 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,
}, },
); );

View File

@ -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 && (

View File

@ -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 />;
} }

View File

@ -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

View File

@ -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) => (

View File

@ -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} />;
} }

View File

@ -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>

View File

@ -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>

View File

@ -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({

View File

@ -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,

View File

@ -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
}; };
} }

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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 doesnt look like a valid address'; errors.payoutAddress = 'That doesnt look like a valid address';
} }
// Trustees // Trustees
@ -94,7 +106,7 @@ export function getCreateErrors(
err = 'That doesnt look like a valid address'; err = 'That doesnt 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 doesnt add up to 100% (currently ${cumulativeMilestonePct}%)`; err = `Payout percentages doesnt 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 doesnt look like a valid email address'; return 'That doesnt look like a valid email address';
} else if (!isValidEthAddress(user.ethAddress)) { } else if (!isValidEthAddress(user.accountAddress)) {
return 'That doesnt look like a valid ETH address'; return 'That doesnt 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 wont 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: [],

View File

@ -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: {

View File

@ -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,

View File

@ -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);
}

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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);

View File

@ -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>;
} }
} }

View File

@ -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

View File

@ -0,0 +1,11 @@
import { createBrowserHistory, createMemoryHistory } from 'history';
const history = (() => {
if (typeof window === 'undefined') {
return createMemoryHistory();
} else {
return createBrowserHistory();
}
})();
export default history;

View File

@ -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),
}); });

View File

@ -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);
} }

View File

@ -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>;
}

View File

@ -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++) {

View File

@ -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);
});
} }

View File

@ -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);
}

View File

@ -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",

View File

@ -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',
}, },
}, },

View File

@ -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,

View File

@ -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;
} }

View File

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

View File

@ -1,6 +1,5 @@
export * from './user'; export * from './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';

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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',

View File

@ -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;
} }

View File

@ -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"