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() {
const p = this.props;
const body = showdownConverter.makeHtml(p.body);
const body = showdownConverter.makeHtml(p.content);
return (
<div key={p.proposalId} className="Proposals-proposal">
<div>
@ -181,7 +181,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
<span>(payoutPercent)</span>
</div>
<div>
{ms.body}
{ms.content}
<span>(body)</span>
</div>
{/* <small>content</small>

View File

@ -3,7 +3,6 @@ export interface SocialMedia {
socialMediaLink: string;
}
export interface Milestone {
body: string;
content: string;
dateCreated: string;
dateEstimated: string;
@ -17,7 +16,7 @@ export interface Proposal {
proposalAddress: string;
dateCreated: number;
title: string;
body: string;
content: string;
stage: string;
category: string;
milestones: Milestone[];

View File

@ -30,16 +30,10 @@ class CommentSchema(ma.Schema):
"content",
"proposal_id",
"date_created",
"body",
)
body = ma.Method("get_body")
date_created = ma.Method("get_date_created")
def get_body(self, obj):
return obj.content
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)

View File

@ -9,34 +9,46 @@ default_template_args = {
'unsubscribe_url': 'https://grant.io/unsubscribe',
}
email_template_args = {
'signup': {
def signup_info(email_args):
return {
'subject': 'Confirm your email on Grant.io',
'title': 'Welcome to Grant.io!',
'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):
try:
info = get_info_lookup[type](email_args)
body_text = render_template('emails/%s.txt' % (type), args=email_args)
body_html = render_template('emails/%s.html' % (type), args=email_args)
html = render_template('emails/template.html', args={
**default_template_args,
**email_template_args[type],
**info,
'body': Markup(body_html),
})
text = render_template('emails/template.txt', args={
**default_template_args,
**email_template_args[type],
**info,
'body': body_text,
})
res = mail.send_email(
to_email=to,
subject=email_template_args[type]['subject'],
subject=info['subject'],
text=text,
html=html,
)

View File

@ -1,6 +1,7 @@
import datetime
from grant.extensions import ma, db
from grant.utils.exceptions import ValidationException
NOT_REQUESTED = 'NOT_REQUESTED'
ONGOING_VOTE = 'ONGOING_VOTE'
@ -42,6 +43,11 @@ class Milestone(db.Model):
self.immediate_payout = immediate_payout
self.proposal_id = proposal_id
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):
@ -50,7 +56,6 @@ class MilestoneSchema(ma.Schema):
# Fields to expose
fields = (
"title",
"body",
"content",
"stage",
"date_estimated",
@ -59,11 +64,6 @@ class MilestoneSchema(ma.Schema):
"date_created",
)
body = ma.Method("get_body")
def get_body(self, obj):
return obj.content
milestone_schema = MilestoneSchema()
milestones_schema = MilestoneSchema(many=True)

View File

@ -1,8 +1,17 @@
import datetime
from typing import List
from sqlalchemy import func
from grant.comment.models import Comment
from grant.extensions import ma, db
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'
COMPLETED = 'COMPLETED'
@ -17,16 +26,36 @@ ACCESSIBILITY = "ACCESSIBILITY"
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
class ValidationException(Exception):
pass
proposal_team = db.Table(
'proposal_team', db.Model.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('user.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):
__tablename__ = "proposal_update"
@ -78,51 +107,116 @@ class Proposal(db.Model):
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
# Database info
status = 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)
content = db.Column(db.Text, 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)
comments = db.relationship(Comment, backref="proposal", lazy=True)
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True)
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True)
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan")
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan")
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
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__(
self,
stage: str,
proposal_address: str,
title: str,
content: str,
category: str
status: str = 'DRAFT',
title: str = '',
brief: str = '',
content: 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.proposal_address = proposal_address
self.date_created = datetime.datetime.now()
self.status = status
self.title = title
self.brief = brief
self.content = content
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
def validate(
stage: str,
proposal_address: str,
title: str,
content: str,
category: str):
if stage not in PROPOSAL_STAGES:
raise ValidationException("{} not in {}".format(stage, PROPOSAL_STAGES))
if category not in CATEGORIES:
raise ValidationException("{} not in {}".format(category, CATEGORIES))
def validate(proposal):
title = proposal.get('title')
stage = proposal.get('stage')
category = proposal.get('category')
if title and len(title) > 60:
raise ValidationException("Proposal title cannot be longer than 60 characters")
if stage and stage not in PROPOSAL_STAGES:
raise ValidationException("Proposal stage {} not in {}".format(stage, PROPOSAL_STAGES))
if category and category not in CATEGORIES:
raise ValidationException("Category {} not in {}".format(category, CATEGORIES))
@staticmethod
def create(**kwargs):
Proposal.validate(**kwargs)
Proposal.validate(kwargs)
return Proposal(
**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):
@ -133,29 +227,34 @@ class ProposalSchema(ma.Schema):
"stage",
"date_created",
"title",
"brief",
"proposal_id",
"proposal_address",
"body",
"target",
"content",
"comments",
"updates",
"contributions",
"milestones",
"category",
"team"
"team",
"trustees",
"payout_address",
"deadline_duration",
"vote_duration",
"invites"
)
date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id")
body = ma.Method("get_body")
trustees = ma.Method("get_trustees")
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
contributions = ma.Nested("ProposalContributionSchema", many=True)
team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True)
def get_body(self, obj):
return obj.content
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
def get_proposal_id(self, obj):
return obj.id
@ -163,6 +262,9 @@ class ProposalSchema(ma.Schema):
def get_date_created(self, obj):
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()
proposals_schema = ProposalSchema(many=True)
@ -198,6 +300,46 @@ proposal_update_schema = ProposalUpdateSchema()
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 Meta:
model = ProposalContribution
@ -220,6 +362,5 @@ class ProposalContributionSchema(ma.Schema):
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
proposal_contribution_schema = ProposalContributionSchema()
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_yoloapi import endpoint, parameter
@ -7,8 +8,11 @@ from sqlalchemy.exc import IntegrityError
from grant.comment.models import Comment, comment_schema
from grant.milestone.models import Milestone
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.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(
Proposal,
proposals_schema,
@ -17,6 +21,9 @@ from .models import(
proposal_update_schema,
ProposalContribution,
proposal_contribution_schema,
proposal_team,
ProposalTeamInvite,
proposal_team_invite_schema,
db
)
import traceback
@ -82,13 +89,14 @@ def post_proposal_comments(proposal_id, user_id, content):
def get_proposals(stage):
if stage:
proposals = (
Proposal.query.filter_by(stage=stage)
.order_by(Proposal.date_created.desc())
.all()
Proposal.query.filter_by(status="LIVE", stage=stage)
.order_by(Proposal.date_created.desc())
.all()
)
else:
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
dumped_proposals = proposals_schema.dump(proposals)
try:
for p in dumped_proposals:
proposal_contract = read_proposal(p['proposal_address'])
@ -100,84 +108,98 @@ def get_proposals(stage):
print(traceback.format_exc())
return {"message": "Oops! Something went wrong."}, 500
@blueprint.route("/", methods=["POST"])
@blueprint.route("/drafts", methods=["POST"])
@requires_sm
@endpoint.api(
parameter('crowdFundContractAddress', type=str, required=True),
parameter('content', type=str, required=True),
parameter('title', type=str, required=True),
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
)
@endpoint.api()
def make_proposal_draft():
proposal = Proposal.create(status="DRAFT")
proposal.team.append(g.current_user)
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:
account_address = team_member.get("accountAddress")
display_name = team_member.get("displayName")
email_address = team_member.get("emailAddress")
title = team_member.get("title")
user = User.query.filter(
(User.account_address == account_address) | (User.email_address == email_address)).first()
if not user:
user = User(
account_address=account_address,
email_address=email_address,
display_name=display_name,
title=title
)
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("/drafts", methods=["GET"])
@requires_sm
@endpoint.api()
def get_proposal_drafts():
proposals = (
Proposal.query
.filter_by(status="DRAFT")
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
.all()
)
return proposals_schema.dump(proposals), 200
@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:
db.session.commit()
except IntegrityError as e:
print(e)
return {"message": "Oops! Something went wrong."}, 409
g.current_proposal.update(**kwargs)
except ValidationException as e:
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
db.session.add(g.current_proposal)
results = proposal_schema.dump(proposal)
return results, 201
# Delete & re-add milestones
[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"])
@ -207,7 +229,6 @@ def get_proposal_update(proposal_id, update_id):
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
@requires_team_member_auth
@requires_sm
@endpoint.api(
parameter('title', 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)
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"])
@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.extensions import ma, db
from grant.utils.misc import make_url
from grant.utils.social import get_social_info_from_url
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)
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)
avatar = db.relationship(Avatar, uselist=False, back_populates="user")
email_verification = db.relationship(EmailVerification, uselist=False, back_populates="user", lazy=True)
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, cascade="all, delete-orphan")
# TODO - add create and validate methods
@ -122,7 +123,26 @@ class SocialMediaSchema(ma.Schema):
class Meta:
model = SocialMedia
# 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()

View File

@ -1,7 +1,7 @@
from flask import Blueprint, g, request
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.upload import save_avatar, send_upload, remove_avatar
from grant.settings import UPLOAD_URL
@ -19,8 +19,13 @@ def get_users(proposal_id):
if not proposal:
users = User.query.all()
else:
users = User.query.join(proposal_team).join(Proposal) \
.filter(proposal_team.c.proposal_id == proposal.id).all()
users = (
User.query
.join(proposal_team)
.join(Proposal)
.filter(proposal_team.c.proposal_id == proposal.id)
.all()
)
result = users_schema.dump(users)
return result
@ -155,7 +160,7 @@ def delete_avatar(url):
parameter('displayName', type=str, required=True),
parameter('title', type=str, 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):
user = g.current_user
@ -166,29 +171,52 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
if title is not None:
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:
SocialMedia.query.filter_by(user_id=user.id).delete()
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)
else:
SocialMedia.query.filter_by(user_id=user.id).delete()
old_avatar = Avatar.query.filter_by(user_id=user.id).first()
if avatar is not None:
Avatar.query.filter_by(user_id=user.id).delete()
avatar_link = avatar.get('link')
if avatar_link:
avatar_obj = Avatar(image_url=avatar_link, user_id=user.id)
db.session.add(avatar_obj)
else:
Avatar.query.filter_by(user_id=user.id).delete()
db_avatar = Avatar.query.filter_by(user_id=user.id).first()
if db_avatar:
db.session.delete(db_avatar)
if avatar:
new_avatar = Avatar(image_url=avatar, user_id=user.id)
db.session.add(new_avatar)
old_avatar_url = old_avatar and old_avatar.image_url
new_avatar_url = avatar and avatar['link']
if old_avatar_url and old_avatar_url != new_avatar_url:
remove_avatar(old_avatar_url, user.id)
old_avatar_url = db_avatar and db_avatar.image_url
if old_avatar_url and old_avatar_url != new_avatar.image_url:
remove_avatar(old_avatar_url, user.id)
db.session.commit()
result = user_schema.dump(user)
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 ..proposal.models import Proposal
from ..user.models import User
from ..proposal.models import Proposal
TWO_WEEKS = 1209600
@ -80,7 +81,6 @@ def requires_sm(f):
return decorated
# Decorator that requires you to be the user you're interacting with
def requires_same_user_auth(f):
@wraps(f)

View File

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

View File

@ -2,6 +2,7 @@ import datetime
import time
import random
import string
import re
from grant.settings import SITE_URL
epoch = datetime.datetime.utcfromtimestamp(0)
@ -26,3 +27,6 @@ def gen_random_code(length=32):
def make_url(path: str):
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
Revision ID: 5f38d8603897
Revision ID: a3b15766d9ab
Revises:
Create Date: 2018-09-24 20:20:47.181807
Create Date: 2018-11-26 18:32:35.322687
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5f38d8603897'
revision = 'a3b15766d9ab'
down_revision = None
branch_labels = None
depends_on = None
@ -21,13 +21,20 @@ def upgrade():
op.create_table('proposal',
sa.Column('id', sa.Integer(), nullable=False),
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('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('content', sa.Text(), 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.UniqueConstraint('proposal_id')
sa.UniqueConstraint('proposal_address')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
@ -56,6 +63,14 @@ def upgrade():
sa.ForeignKeyConstraint(['user_id'], ['user.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',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
@ -69,12 +84,32 @@ def upgrade():
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.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',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.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',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('social_media_link', sa.String(length=255), nullable=True),
@ -88,8 +123,11 @@ def upgrade():
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('social_media')
op.drop_table('proposal_update')
op.drop_table('proposal_team')
op.drop_table('proposal_contribution')
op.drop_table('milestone')
op.drop_table('email_verification')
op.drop_table('comment')
op.drop_table('avatar')
op.drop_table('user')

View File

@ -1,8 +1,8 @@
"""empty message
Revision ID: 95e93ff98cba
Revises: 6e02ee4b9ca3
Create Date: 2018-11-04 19:37:09.027109
Revision ID: e1e8573b7298
Revises: a3b15766d9ab
Create Date: 2018-11-15 13:47:06.051522
"""
from alembic import op
@ -10,20 +10,20 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '95e93ff98cba'
down_revision = '6e02ee4b9ca3'
revision = 'e1e8573b7298'
down_revision = 'a3b15766d9ab'
branch_labels = None
depends_on = None
def upgrade():
# ### 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('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.Column('address', sa.String(length=255), nullable=False),
sa.Column('accepted', sa.Boolean()),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
@ -32,5 +32,5 @@ def upgrade():
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('proposal_update')
op.drop_table('proposal_team_invite')
# ### end Alembic commands ###

View File

@ -4,79 +4,40 @@ from mock import patch
from grant.proposal.models import Proposal
from grant.user.models import SocialMedia, Avatar
from ..config import BaseUserConfig
from ..test_data import test_proposal
from ..test_data import test_proposal, test_user
class TestAPI(BaseUserConfig):
def test_create_new_proposal(self):
self.assertIsNone(Proposal.query.filter_by(
proposal_address=test_proposal["crowdFundContractAddress"]
).first())
def test_create_new_draft(self):
resp = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
"/api/v1/proposals/drafts",
data=json.dumps({}),
headers=self.headers,
content_type='application/json'
)
self.assertEqual(resp.status_code, 201)
proposal_db = Proposal.query.filter_by(
proposal_address=test_proposal["crowdFundContractAddress"]
).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)
proposal_db = Proposal.query.filter_by(id=resp.json['proposalId'])
self.assertIsNotNone(proposal_db)
def test_create_new_proposal_comment(self):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
proposal = Proposal(
status="LIVE"
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
proposal_user_id = proposal_json["team"][0]["userid"]
comment_res = self.app.post(
"/api/v1/proposals/{}/comments".format(proposal_id),
data=json.dumps({
"userId": proposal_user_id,
"content": "What a comment"
}),
"/api/v1/proposals/{}/comments".format(proposal.id),
data=json.dumps({ "content": "What a comment" }),
headers=self.headers,
content_type='application/json'
)
self.assertTrue(comment_res.json)
def test_create_new_proposal_duplicate(self):
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)
@patch('grant.web3.proposal.validate_contribution_tx', return_value=True)
def test_create_proposal_contribution(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
@ -106,10 +67,10 @@ class TestAPI(BaseUserConfig):
eq("amount")
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):
proposal_res = self.app.post(
"/api/v1/proposals/",
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
@ -143,10 +104,10 @@ class TestAPI(BaseUserConfig):
eq("amount")
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):
proposal_res = self.app.post(
"/api/v1/proposals/",
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'

View File

@ -3,7 +3,7 @@ import json
from animal_case import animalify
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 ..config import BaseUserConfig
@ -11,182 +11,61 @@ from ..test_data import test_team, test_proposal, test_user
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')
def test_create_user(self, mock_send_email):
mock_send_email.return_value.ok = True
# Delete the user config user
db.session.delete(self.user)
db.session.commit()
self.app.post(
"/api/v1/users/",
data=json.dumps(test_team[0]),
data=json.dumps(test_user),
content_type='application/json'
)
# User
user_db = User.get_by_identifier(account_address=test_team[0]["accountAddress"])
self.assertEqual(user_db.display_name, test_team[0]["displayName"])
self.assertEqual(user_db.title, test_team[0]["title"])
self.assertEqual(user_db.account_address, test_team[0]["accountAddress"])
user_db = User.get_by_identifier(account_address=test_user["accountAddress"])
self.assertEqual(user_db.display_name, test_user["displayName"])
self.assertEqual(user_db.title, test_user["title"])
self.assertEqual(user_db.account_address, test_user["accountAddress"])
@patch('grant.email.send.send_email')
def test_create_user_duplicate_400(self, mock_send_email):
mock_send_email.return_value.ok = True
self.test_create_user()
def test_get_all_users(self):
users_get_resp = self.app.get(
"/api/v1/users/"
)
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(
"/api/v1/users/",
data=json.dumps(test_team[0]),
data=json.dumps(test_user),
content_type='application/json'
)

View File

@ -115,7 +115,7 @@ async-eventemitter@^0.2.2:
dependencies:
async "^2.4.0"
"async-eventemitter@github:ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c":
async-eventemitter@ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c:
version "0.2.3"
resolved "https://codeload.github.com/ahultgren/async-eventemitter/tar.gz/fa06e39e56786ba541c180061dbf2c0a5bbf951c"
dependencies:

View File

@ -15,6 +15,7 @@ import Template, { TemplateProps } from 'components/Template';
// wrap components in loadable...import & they will be split
const Home = loadable(() => import('pages/index'));
const Create = loadable(() => import('pages/create'));
const ProposalEdit = loadable(() => import('pages/proposal-edit'));
const Proposals = loadable(() => import('pages/proposals'));
const Proposal = loadable(() => import('pages/proposal'));
const Auth = loadable(() => import('pages/auth'));
@ -60,10 +61,9 @@ const routeConfigs: RouteConfig[] = [
},
template: {
title: 'Create a Proposal',
isFullScreen: true,
hideFooter: true,
requiresWeb3: true,
},
onlyLoggedIn: true,
},
{
// Browse proposals
@ -77,6 +77,20 @@ const routeConfigs: RouteConfig[] = [
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
route: {

View File

@ -1,11 +1,14 @@
import axios from './axios';
import { Proposal, TeamMember, Update, Contribution } from 'types';
import {
formatProposalFromGet,
formatTeamMemberForPost,
formatTeamMemberFromGet,
} from 'utils/api';
import { PROPOSAL_CATEGORY } from './constants';
Proposal,
ProposalDraft,
User,
Update,
TeamInvite,
TeamInviteWithProposal,
Contribution,
} from 'types';
import { formatUserForPost, formatProposalFromGet } from 'utils/api';
export function getProposals(): Promise<{ data: Proposal[] }> {
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`);
}
export function postProposal(payload: {
// TODO type Milestone
accountAddress: string;
crowdFundContractAddress: string;
content: string;
title: string;
category: PROPOSAL_CATEGORY;
milestones: object[];
team: TeamMember[];
}) {
export function postProposal(payload: ProposalDraft) {
return axios.post(`/api/v1/proposals/`, {
...payload,
// Team has a different shape for POST
team: payload.team.map(formatTeamMemberForPost),
team: payload.team.map(formatUserForPost),
});
}
export function getUser(address: string): Promise<{ data: TeamMember }> {
return axios.get(`/api/v1/users/${address}`).then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
export function getUser(address: string): Promise<{ data: User }> {
return axios.get(`/api/v1/users/${address}`);
}
export function createUser(payload: {
@ -60,31 +51,20 @@ export function createUser(payload: {
title: string;
signedMessage: string;
rawTypedData: string;
}): Promise<{ data: TeamMember }> {
return axios.post('/api/v1/users', payload).then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
}): Promise<{ data: User }> {
return axios.post('/api/v1/users', payload);
}
export function authUser(payload: {
accountAddress: string;
signedMessage: string;
rawTypedData: string;
}): Promise<{ data: TeamMember }> {
return axios.post('/api/v1/users/auth', payload).then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
}): Promise<{ data: User }> {
return axios.post('/api/v1/users/auth', payload);
}
export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
return axios
.put(`/api/v1/users/${user.ethAddress}`, formatTeamMemberForPost(user))
.then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
export function updateUser(user: User): Promise<{ data: User }> {
return axios.put(`/api/v1/users/${user.accountAddress}`, formatUserForPost(user));
}
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(
proposalId: number,
txId: string,

View File

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { Button, Alert } from 'antd';
import { authActions } from 'modules/auth';
import { TeamMember } from 'types';
import { User } from 'types';
import { AppState } from 'store/reducers';
import { AUTH_PROVIDER } from 'utils/auth';
import Identicon from 'components/Identicon';
@ -20,7 +20,7 @@ interface DispatchProps {
interface OwnProps {
// TODO: Use common use User type instead
user: TeamMember;
user: User;
provider: AUTH_PROVIDER;
reset(): void;
}
@ -34,11 +34,14 @@ class SignIn extends React.Component<Props> {
<div className="SignIn">
<div className="SignIn-container">
<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-name">{user.name}</div>
<div className="SignIn-identity-info-name">{user.displayName}</div>
<code className="SignIn-identity-info-address">
<ShortAddress address={user.ethAddress} />
<ShortAddress address={user.accountAddress} />
</code>
</div>
</div>
@ -69,7 +72,7 @@ class SignIn extends React.Component<Props> {
}
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} />
</div>
{/* <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>
<div className="Comment-body">
<Markdown source={comment.body} type={MARKDOWN_TYPE.REDUCED} />
<Markdown source={comment.content} type={MARKDOWN_TYPE.REDUCED} />
</div>
<div className="Comment-controls">

View File

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

View File

@ -1,22 +1,22 @@
import React from 'react';
import { Form } from 'antd';
import MarkdownEditor from 'components/MarkdownEditor';
import { CreateFormState } from 'types';
import { ProposalDraft } from 'types';
interface State {
details: string;
content: string;
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
details: '',
content: '',
...(props.initialState || {}),
};
}
@ -26,15 +26,17 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
<MarkdownEditor
onChange={this.handleChange}
initialMarkdown={this.state.details}
initialMarkdown={this.state.content}
/>
</Form>
);
}
private handleChange = (markdown: string) => {
this.setState({ details: markdown }, () => {
this.props.updateForm(this.state);
});
if (markdown !== this.state.content) {
this.setState({ content: markdown }, () => {
this.props.updateForm(this.state);
});
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,49 +6,76 @@
width: 100%;
margin: 0 auto;
&-pending,
&-add {
display: flex;
width: 100%;
padding: 1rem;
align-items: center;
cursor: pointer;
opacity: 0.7;
transition: opacity 80ms ease, transform 80ms ease;
outline: none;
margin-top: 2rem;
&:hover,
&:focus {
opacity: 1;
}
&:active {
transform: translateY(2px);
&-title {
font-size: 1.2rem;
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
}
&-icon {
&-pending {
&-invite {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
margin-right: 1.25rem;
width: 7.4rem;
height: 7.4rem;
border: 2px dashed @success-color;
color: @success-color;
border-radius: 8px;
font-size: 2rem;
}
padding: 1rem;
font-size: 1rem;
background: #FFF;
box-shadow: 0 1px 2px rgba(#000, 0.2);
border-bottom: 1px solid rgba(#000, 0.05);
&-text {
text-align: left;
&-title {
font-size: 1.6rem;
font-weight: 300;
color: @success-color;
&:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
&-subtitle {
opacity: 0.7;
&:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom: 0;
}
&-delete {
opacity: 0.3;
outline: none;
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 { connect } from 'react-redux';
import { Icon } from 'antd';
import { CreateFormState, TeamMember } from 'types';
import { Icon, Form, Input, Button, Popconfirm, message } from 'antd';
import { User, TeamInvite, ProposalDraft } from 'types';
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 './Team.less';
interface State {
team: TeamMember[];
team: User[];
invites: TeamInvite[];
address: string;
}
interface StateProps {
@ -15,24 +19,18 @@ interface StateProps {
}
interface OwnProps {
proposalId: number;
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
updateForm(form: Partial<ProposalDraft>): void;
}
type Props = OwnProps & StateProps;
const MAX_TEAM_SIZE = 6;
const DEFAULT_STATE: State = {
team: [
{
name: '',
title: '',
avatarUrl: '',
ethAddress: '',
emailAddress: '',
socialAccounts: {},
},
],
team: [],
invites: [],
address: '',
};
class CreateFlowTeam extends React.Component<Props, State> {
@ -43,16 +41,8 @@ class CreateFlowTeam extends React.Component<Props, State> {
...(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
if (props.authUser) {
if (props.authUser && !this.state.team.length) {
this.state.team[0] = {
...props.authUser,
};
@ -60,56 +50,106 @@ class CreateFlowTeam extends React.Component<Props, State> {
}
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 (
<div className="TeamForm">
{team.map((user, idx) => (
<TeamMemberComponent
key={idx}
index={idx}
user={user}
initialEditingState={!user.name}
onChange={this.handleChange}
onRemove={this.removeMember}
/>
{team.map(user => (
<TeamMemberComponent key={user.userid} user={user} />
))}
{team.length < MAX_TEAM_SIZE && (
<button className="TeamForm-add" onClick={this.addMember}>
<div className="TeamForm-add-icon">
<Icon type="plus" />
</div>
<div className="TeamForm-add-text">
<div className="TeamForm-add-text-title">Add a team member</div>
<div className="TeamForm-add-text-subtitle">
Find an existing user, or fill out their info yourself
{!!pendingInvites.length && (
<div className="TeamForm-pending">
<h3 className="TeamForm-pending-title">Pending invitations</h3>
{pendingInvites.map(inv => (
<div key={inv.id} className="TeamForm-pending-invite">
<div className="TeamForm-pending-invite-name">{inv.address}</div>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.removeInvitation(inv.id)}
>
<button className="TeamForm-pending-invite-delete">
<Icon type="delete" />
</button>
</Popconfirm>
</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>
);
}
private handleChange = (user: TeamMember, idx: number) => {
const team = [...this.state.team];
team[idx] = user;
this.setState({ team });
this.props.updateForm({ team });
private handleChangeInviteAddress = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ address: ev.currentTarget.value });
};
private addMember = () => {
const team = [...this.state.team, { ...DEFAULT_STATE.team[0] }];
this.setState({ team });
this.props.updateForm({ team });
private handleAddSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
postProposalInvite(this.props.proposalId, this.state.address)
.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) => {
const team = [
...this.state.team.slice(0, index),
...this.state.team.slice(index + 1),
];
this.setState({ team });
this.props.updateForm({ team });
private removeInvitation = (invId: number) => {
deleteProposalInvite(this.props.proposalId, invId)
.then(() => {
const invites = this.state.invites.filter(inv => inv.id !== invId);
this.setState({ invites });
this.props.updateForm({ invites });
})
.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;
padding: 1rem;
margin: 0 auto 1rem;
border-radius: 2px;
background: #FFF;
box-shadow: 0 1px 2px rgba(#000, 0.2);

View File

@ -1,241 +1,52 @@
import React from 'react';
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_TYPE, TeamMember } from 'types';
import { getCreateTeamMemberError } from 'modules/create/utils';
import { User } from 'types';
import UserAvatar from 'components/UserAvatar';
import './TeamMember.less';
interface Props {
index: number;
user: TeamMember;
initialEditingState?: boolean;
onChange(user: TeamMember, index: number): void;
onRemove(index: number): void;
user: User;
}
interface State {
fields: TeamMember;
isEditing: boolean;
}
export default class CreateFlowTeamMember extends React.PureComponent<Props, State> {
state: State = {
fields: { ...this.props.user },
isEditing: this.props.initialEditingState || false,
};
export default class CreateFlowTeamMember extends React.PureComponent<Props> {
render() {
const { user, index } = 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;
const { user } = this.props;
return (
<div className={classnames('TeamMember', isEditing && 'is-editing')}>
<div className="TeamMember">
<div className="TeamMember-avatar">
<UserAvatar className="TeamMember-avatar-img" user={fields} />
{isEditing && (
<Button className="TeamMember-avatar-change" onClick={this.handleChangePhoto}>
Change
</Button>
)}
<UserAvatar className="TeamMember-avatar-img" user={user} />
</div>
<div className="TeamMember-info">
{isEditing ? (
<Form
className="TeamMember-info-form"
layout="vertical"
onSubmit={this.toggleEditing}
>
<Form.Item>
<Input
name="name"
autoComplete="off"
placeholder="Display name (Required)"
value={fields.name}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="title"
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}
<div className="TeamMember-info-name">
{user.displayName || <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.socialMedias.find(sm => s.service === sm.service);
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"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item>
<Input
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 { SOCIAL_TYPE, CreateFormState } 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)
);
}
import { ProposalDraft } from 'types';
const createExampleProposal = (
payOutAddress: string,
payoutAddress: string,
trustees: string[],
): CreateFormState => {
): Partial<ProposalDraft> => {
return {
title: 'Grant.io T-Shirts',
brief: "The most stylish wear, sporting your favorite brand's logo",
category: PROPOSAL_CATEGORY.COMMUNITY,
team: [
{
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:
content:
'![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000',
amountToRaise: '5',
payOutAddress,
target: '5',
payoutAddress,
trustees,
milestones: [
{
title: 'Initial Funding',
description:
content:
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
date: 'October 2018',
payoutPercent: 30,
dateEstimated: 'October 2018',
payoutPercent: '30',
immediatePayout: true,
},
{
title: 'Test Prints',
description:
content:
"We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.",
date: 'November 2018',
payoutPercent: 20,
dateEstimated: 'November 2018',
payoutPercent: '20',
immediatePayout: false,
},
{
title: 'All Shirts Printed',
description:
content:
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
date: 'December 2018',
payoutPercent: 50,
dateEstimated: 'December 2018',
payoutPercent: '50',
immediatePayout: false,
},
],
deadline: 300,
milestoneDeadline: 60,
deadlineDuration: 300,
voteDuration: 60,
};
};

View File

@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { Steps, Icon, Spin, Alert } from 'antd';
import { Steps, Icon } from 'antd';
import qs from 'query-string';
import { withRouter, RouteComponentProps } from 'react-router';
import { History } from 'history';
@ -14,9 +14,10 @@ import Governance from './Governance';
import Review from './Review';
import Preview from './Preview';
import Final from './Final';
import PublishWarningModal from './PubishWarningModal';
import createExampleProposal from './example';
import { createActions } from 'modules/create';
import { CreateFormState } from 'types';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
@ -108,14 +109,10 @@ interface StateProps {
form: AppState['create']['form'];
isSavingDraft: AppState['create']['isSavingDraft'];
hasSavedDraft: AppState['create']['hasSavedDraft'];
isFetchingDraft: AppState['create']['isFetchingDraft'];
hasFetchedDraft: AppState['create']['hasFetchedDraft'];
}
interface DispatchProps {
updateForm: typeof createActions['updateForm'];
resetForm: typeof createActions['resetForm'];
fetchDraft: typeof createActions['fetchDraft'];
resetCreateCrowdFund: typeof web3Actions['resetCreateCrowdFund'];
}
@ -124,13 +121,14 @@ type Props = OwnProps & StateProps & DispatchProps & RouteComponentProps<any>;
interface State {
step: CREATE_STEP;
isPreviewing: boolean;
isShowingPublishWarning: boolean;
isPublishing: boolean;
isExample: boolean;
}
class CreateFlow extends React.Component<Props, State> {
private historyUnlisten: () => void;
private debouncedUpdateForm: (form: Partial<CreateFormState>) => void;
private debouncedUpdateForm: (form: Partial<ProposalDraft>) => void;
constructor(props: Props) {
super(props);
@ -144,6 +142,7 @@ class CreateFlow extends React.Component<Props, State> {
isPreviewing: false,
isPublishing: false,
isExample: false,
isShowingPublishWarning: false,
};
this.debouncedUpdateForm = debounce(this.updateForm, 800);
this.historyUnlisten = this.props.history.listen(this.handlePop);
@ -151,7 +150,6 @@ class CreateFlow extends React.Component<Props, State> {
componentDidMount() {
this.props.resetCreateCrowdFund();
this.props.fetchDraft();
}
componentWillUnmount() {
@ -161,16 +159,8 @@ class CreateFlow extends React.Component<Props, State> {
}
render() {
const { isFetchingDraft, isSavingDraft, hasFetchedDraft } = this.props;
const { step, isPreviewing, isPublishing } = this.state;
if (isFetchingDraft && !isPublishing) {
return (
<div className="CreateFlow-loading">
<Spin size="large" />
</div>
);
}
const { isSavingDraft } = this.props;
const { step, isPreviewing, isPublishing, isShowingPublishWarning } = this.state;
const info = STEP_INFO[step];
const currentIndex = STEP_ORDER.indexOf(step);
@ -198,25 +188,12 @@ class CreateFlow extends React.Component<Props, State> {
/>
))}
</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>
<div className="CreateFlow-header-subtitle">{info.subtitle}</div>
</div>
<div className="CreateFlow-content">
<StepComponent
proposalId={this.props.form && this.props.form.proposalId}
initialState={this.props.form}
updateForm={this.debouncedUpdateForm}
setStep={this.setStep}
@ -243,7 +220,7 @@ class CreateFlow extends React.Component<Props, State> {
<button
className="CreateFlow-footer-button is-primary"
key="publish"
onClick={this.startPublish}
onClick={this.openPublishWarning}
disabled={this.checkFormErrors()}
>
Publish
@ -270,11 +247,17 @@ class CreateFlow extends React.Component<Props, State> {
{isSavingDraft && (
<div className="CreateFlow-draftNotification">Saving draft...</div>
)}
<PublishWarningModal
proposal={this.props.form}
isVisible={isShowingPublishWarning}
handleClose={this.closePublishWarning}
handlePublish={this.startPublish}
/>
</div>
);
}
private updateForm = (form: Partial<CreateFormState>) => {
private updateForm = (form: Partial<ProposalDraft>) => {
this.props.updateForm(form);
};
@ -298,10 +281,16 @@ class CreateFlow extends React.Component<Props, State> {
};
private startPublish = () => {
this.setState({ isPublishing: true });
this.setState({
isPublishing: true,
isShowingPublishWarning: false,
});
};
private checkFormErrors = () => {
if (!this.props.form) {
return true;
}
const errors = getCreateErrors(this.props.form);
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 = () => {
const { accounts } = this.props;
const [payoutAddress, ...trustees] = accounts;
@ -337,16 +334,12 @@ const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
form: state.create.form,
isSavingDraft: state.create.isSavingDraft,
hasSavedDraft: state.create.hasSavedDraft,
isFetchingDraft: state.create.isFetchingDraft,
hasFetchedDraft: state.create.hasFetchedDraft,
crowdFundLoading: state.web3.crowdFundLoading,
crowdFundError: state.web3.crowdFundError,
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
}),
{
updateForm: createActions.updateForm,
resetForm: createActions.resetForm,
fetchDraft: createActions.fetchDraft,
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 'cropperjs/dist/cropper.css';
import { UploadFile } from 'antd/lib/upload/interface';
import { TeamMember } from 'types';
import { User } from 'types';
import { getBase64 } from 'utils/blob';
import UserAvatar from 'components/UserAvatar';
import './AvatarEdit.less';
@ -13,7 +13,7 @@ const FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
const FILE_MAX_LOAD_MB = 10;
interface OwnProps {
user: TeamMember;
user: User;
onDelete(): 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 {
user,
user: { avatarUrl },
user: { avatar },
} = this.props;
return (
<>
@ -58,12 +58,12 @@ export default class AvatarEdit extends React.PureComponent<Props, State> {
<Button 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>
</Upload>
{avatarUrl && (
{avatar && (
<Button
className="AvatarEdit-avatar-delete"
icon="delete"

View File

@ -13,7 +13,7 @@ export default class Profile extends React.Component<OwnProps> {
render() {
const {
userName,
comment: { body, proposal, dateCreated },
comment: { content, proposal, dateCreated },
} = this.props;
return (
@ -28,7 +28,7 @@ export default class Profile extends React.Component<OwnProps> {
</Link>{' '}
{moment(dateCreated).from(Date.now())}
</div>
<div className="ProfileComment-body">{body}</div>
<div className="ProfileComment-body">{content}</div>
</div>
);
}

View File

@ -2,8 +2,8 @@ import React from 'react';
import lodash from 'lodash';
import axios from 'api/axios';
import { Input, Form, Col, Row, Button, Alert } from 'antd';
import { SOCIAL_INFO } from 'utils/social';
import { SOCIAL_TYPE, TeamMember } from 'types';
import { SOCIAL_INFO, socialMediaToUrl } from 'utils/social';
import { SOCIAL_SERVICE, User } from 'types';
import { UserState } from 'modules/users/reducers';
import { getCreateTeamMemberError } from 'modules/create/utils';
import AvatarEdit from './AvatarEdit';
@ -12,18 +12,18 @@ import './ProfileEdit.less';
interface Props {
user: UserState;
onDone(): void;
onEdit(user: TeamMember): void;
onEdit(user: User): void;
}
interface State {
fields: TeamMember;
fields: User;
isChanged: boolean;
showError: boolean;
}
export default class ProfileEdit extends React.PureComponent<Props, State> {
state: State = {
fields: { ...this.props.user } as TeamMember,
fields: { ...this.props.user } as User,
isChanged: false,
showError: false,
};
@ -49,7 +49,10 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
const { fields } = this.state;
const error = getCreateTeamMemberError(fields);
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;
return (
@ -72,7 +75,7 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
name="name"
autoComplete="off"
placeholder="Display name (Required)"
value={fields.name}
value={fields.displayName}
onChange={this.handleChangeField}
/>
</Form.Item>
@ -101,29 +104,32 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
<Form.Item>
<Input
name="ethAddress"
name="accountAddress"
disabled={true}
autoComplete="ethAddress"
autoComplete="accountAddress"
placeholder="Ethereum address (Required)"
value={fields.ethAddress}
value={fields.accountAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
<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>
))}
{Object.values(SOCIAL_INFO).map(s => {
const field = fields.socialMedias.find(sm => sm.service === s.service);
return (
<Col xs={24} sm={12} key={s.service}>
<Form.Item>
<Input
placeholder={`${s.name} account`}
autoComplete="off"
value={field ? field.username : ''}
onChange={ev => this.handleSocialChange(ev, s.service)}
addonBefore={s.icon}
/>
</Form.Item>
</Col>
);
})}
</Row>
{!isMissingField &&
@ -173,11 +179,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
};
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
if (this.props.user.avatarUrl !== avatarUrl && avatarUrl) {
if (propsAvatar && stateAvatar && propsAvatar.imageUrl !== stateAvatar.imageUrl) {
axios.delete('/api/v1/users/avatar', {
params: { url: avatarUrl },
params: { url: stateAvatar.imageUrl },
});
}
this.props.onDone();
@ -198,20 +205,27 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
private handleSocialChange = (
ev: React.ChangeEvent<HTMLInputElement>,
type: SOCIAL_TYPE,
service: SOCIAL_SERVICE,
) => {
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 = {
...this.state.fields,
socialAccounts: {
...this.state.fields.socialAccounts,
[type]: value,
},
socialMedias,
};
// delete key for empty string
if (!value) {
delete fields.socialAccounts[type];
}
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
@ -222,7 +236,9 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
private handleChangePhoto = (url: string) => {
const fields = {
...this.state.fields,
avatarUrl: url,
avatar: {
imageUrl: url,
},
};
const isChanged = this.isChangedCheck(fields);
this.setState({
@ -232,13 +248,15 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
};
private handleDeletePhoto = () => {
const fields = lodash.clone(this.state.fields);
delete fields.avatarUrl;
const fields = {
...this.state.fields,
avatar: null,
};
const isChanged = this.isChangedCheck(fields);
this.setState({ isChanged, fields });
};
private isChangedCheck = (a: TeamMember) => {
private isChangedCheck = (a: 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>
<div className="ProfileProposal-block-team">
{team.map(user => (
<UserRow key={user.ethAddress || user.emailAddress} user={user} />
<UserRow key={user.accountAddress || user.emailAddress} user={user} />
))}
</div>
</div>

View File

@ -1,13 +1,12 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button } from 'antd';
import { SocialInfo } from 'types';
import { SocialMedia } from 'types';
import { usersActions } from 'modules/users';
import { UserState } from 'modules/users/reducers';
import { typedKeys } from 'utils/ts';
import ProfileEdit from './ProfileEdit';
import UserAvatar from 'components/UserAvatar';
import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social';
import { SOCIAL_INFO } from 'utils/social';
import ShortAddress from 'components/ShortAddress';
import './ProfileUser.less';
import { AppState } from 'store/reducers';
@ -39,10 +38,10 @@ class ProfileUser extends React.Component<Props> {
const {
authUser,
user,
user: { socialAccounts },
user: { socialMedias },
} = this.props;
const isSelf = !!authUser && authUser.ethAddress === user.ethAddress;
const isSelf = !!authUser && authUser.accountAddress === user.accountAddress;
if (this.state.isEditing) {
return (
@ -60,7 +59,7 @@ class ProfileUser extends React.Component<Props> {
<UserAvatar className="ProfileUser-avatar-img" user={user} />
</div>
<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>
{user.emailAddress && (
@ -69,26 +68,18 @@ class ProfileUser extends React.Component<Props> {
{user.emailAddress}
</div>
)}
{user.ethAddress && (
{user.accountAddress && (
<div className="ProfileUser-info-address">
<span>ethereum address</span>
<ShortAddress address={user.ethAddress} />
<ShortAddress address={user.accountAddress} />
</div>
)}
</div>
{Object.keys(socialAccounts).length > 0 && (
{socialMedias.length > 0 && (
<div className="ProfileUser-info-social">
{typedKeys(SOCIAL_INFO).map(
s =>
(socialAccounts[s] && (
<Social
key={s}
account={socialAccounts[s] as string}
info={SOCIAL_INFO[s]}
/>
)) ||
null,
)}
{socialMedias.map(sm => (
<Social key={sm.service} socialMedia={sm} />
))}
</div>
)}
{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 (
<a href={socialAccountToUrl(account, info.type)}>
<div className="ProfileUser-info-social-icon">{info.icon}</div>
<a href={socialMedia.url} target="_blank" rel="noopener nofollow">
<div className="ProfileUser-info-social-icon">
{SOCIAL_INFO[socialMedia.service].icon}
</div>
</a>
);
};

View File

@ -10,7 +10,8 @@ import HeaderDetails from 'components/HeaderDetails';
import ProfileUser from './ProfileUser';
import ProfileProposal from './ProfileProposal';
import ProfileComment from './ProfileComment';
import PlaceHolder from 'components/Placeholder';
import ProfileInvite from './ProfileInvite';
import Placeholder from 'components/Placeholder';
import Exception from 'pages/exception';
import './style.less';
@ -24,6 +25,7 @@ interface DispatchProps {
fetchUserCreated: typeof usersActions['fetchUserCreated'];
fetchUserFunded: typeof usersActions['fetchUserFunded'];
fetchUserComments: typeof usersActions['fetchUserComments'];
fetchUserInvites: typeof usersActions['fetchUserInvites'];
}
type Props = RouteComponentProps<any> & StateProps & DispatchProps;
@ -44,8 +46,8 @@ class Profile extends React.Component<Props> {
const userLookupParam = this.props.match.params.id;
const { authUser } = this.props;
if (!userLookupParam) {
if (authUser && authUser.ethAddress) {
return <Redirect to={`/profile/${authUser.ethAddress}`} />;
if (authUser && authUser.accountAddress) {
return <Redirect to={`/profile/${authUser.accountAddress}`} />;
} else {
return <Redirect to="auth" />;
}
@ -53,6 +55,9 @@ class Profile extends React.Component<Props> {
const user = this.props.usersMap[userLookupParam];
const waiting = !user || !user.hasFetched;
// TODO: Replace with userid checks
const isAuthedUser =
user && authUser && user.accountAddress === authUser.accountAddress;
if (waiting) {
return <Spin />;
@ -62,19 +67,20 @@ class Profile extends React.Component<Props> {
return <Exception code="404" />;
}
const { createdProposals, fundedProposals, comments } = user;
const { createdProposals, fundedProposals, comments, invites } = user;
const noneCreated = user.hasFetchedCreated && createdProposals.length === 0;
const noneFunded = user.hasFetchedFunded && fundedProposals.length === 0;
const noneCommented = user.hasFetchedComments && comments.length === 0;
const noneInvites = user.hasFetchedInvites && invites.length === 0;
return (
<div className="Profile">
{/* TODO: SSR fetch user details */}
{/* TODO: customize details for funders/creators */}
<HeaderDetails
title={`${user.name} is funding projects on Grant.io`}
description={`Join ${user.name} in funding the future!`}
image={user.avatarUrl}
title={`${user.displayName} is funding projects on Grant.io`}
description={`Join ${user.displayName} in funding the future!`}
image={user.avatar ? user.avatar.imageUrl : undefined}
/>
<ProfileUser user={user} />
<Tabs>
@ -85,7 +91,7 @@ class Profile extends React.Component<Props> {
>
<div>
{noneCreated && (
<PlaceHolder subtitle="Has not created any proposals yet" />
<Placeholder subtitle="Has not created any proposals yet" />
)}
{createdProposals.map(p => (
<ProfileProposal key={p.proposalId} proposal={p} />
@ -98,7 +104,7 @@ class Profile extends React.Component<Props> {
disabled={!user.hasFetchedFunded}
>
<div>
{noneFunded && <PlaceHolder subtitle="Has not funded any proposals yet" />}
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
{createdProposals.map(p => (
<ProfileProposal key={p.proposalId} proposal={p} />
))}
@ -110,23 +116,52 @@ class Profile extends React.Component<Props> {
disabled={!user.hasFetchedComments}
>
<div>
{noneCommented && <PlaceHolder subtitle="Has not made any comments yet" />}
{noneCommented && <Placeholder subtitle="Has not made any comments yet" />}
{comments.map(c => (
<ProfileComment key={c.commentId} userName={user.name} comment={c} />
<ProfileComment
key={c.commentId}
userName={user.displayName}
comment={c}
/>
))}
</div>
</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>
</div>
);
}
private fetchData() {
const userLookupId = this.props.match.params.id;
const { match } = this.props;
const userLookupId = match.params.id;
if (userLookupId) {
this.props.fetchUser(userLookupId);
this.props.fetchUserCreated(userLookupId);
this.props.fetchUserFunded(userLookupId);
this.props.fetchUserComments(userLookupId);
this.props.fetchUserInvites(userLookupId);
}
}
}
@ -152,6 +187,7 @@ const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
fetchUserCreated: usersActions.fetchUserCreated,
fetchUserFunded: usersActions.fetchUserFunded,
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>
{statuses}
{notification}
{milestone.body}
{milestone.content}
</div>
{this.state.activeMilestoneIdx === i &&
!wasRefunded && (

View File

@ -10,7 +10,7 @@ interface Props {
const TeamBlock = ({ proposal }: Props) => {
let content;
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 {
content = <Spin />;
}

View File

@ -144,7 +144,11 @@ export class ProposalDetail extends React.Component<Props, State> {
['is-expanded']: isBodyExpanded,
})}
>
{proposal ? <Markdown source={proposal.body} /> : <Spin size="large" />}
{proposal ? (
<Markdown source={proposal.content} />
) : (
<Spin size="large" />
)}
</div>
{showExpand && (
<button

View File

@ -53,7 +53,8 @@ export class ProposalCard extends React.Component<ProposalWithCrowdFund> {
<div className="ProposalCard-team">
<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 className="ProposalCard-team-avatars">
{[...team].reverse().map((u, idx) => (

View File

@ -1,18 +1,18 @@
import React from 'react';
import Identicon from 'components/Identicon';
import { TeamMember } from 'types';
import { User } from 'types';
import defaultUserImg from 'static/images/default-user.jpg';
interface Props {
user: TeamMember;
user: User;
className?: string;
}
const UserAvatar: React.SFC<Props> = ({ user, className }) => {
if (user.avatarUrl) {
return <img className={className} src={user.avatarUrl} />;
} else if (user.ethAddress) {
return <Identicon className={className} address={user.ethAddress} />;
if (user.avatar && user.avatar.imageUrl) {
return <img className={className} src={user.avatar.imageUrl} />;
} else if (user.accountAddress) {
return <Identicon className={className} address={user.accountAddress} />;
} else {
return <img className={className} src={defaultUserImg} />;
}

View File

@ -1,20 +1,20 @@
import React from 'react';
import UserAvatar from 'components/UserAvatar';
import { TeamMember } from 'types';
import { User } from 'types';
import { Link } from 'react-router-dom';
import './style.less';
interface Props {
user: TeamMember;
user: User;
}
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">
<UserAvatar user={user} className="UserRow-avatar-img" />
</div>
<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>
</div>
</Link>

View File

@ -4,16 +4,16 @@ import { hot } from 'react-hot-loader';
import { hydrate } from 'react-dom';
import { loadComponents } from 'loadable-components';
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 * as Sentry from '@sentry/browser';
import { I18nextProvider } from 'react-i18next';
import { configureStore } from 'store/configure';
import history from 'store/history';
import { massageSerializedState } from 'utils/api';
import Routes from './Routes';
import i18n from './i18n';
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: process.env.SENTRY_RELEASE,
@ -30,7 +30,7 @@ const App = hot(module)(() => (
<I18nextProvider i18n={i18n}>
<Provider store={store}>
<PersistGate persistor={persistor}>
<Router>
<Router history={history}>
<Routes />
</Router>
</PersistGate>

View File

@ -42,7 +42,7 @@ export function authUser(address: string, authSignature?: Falsy | AuthSignatureD
Sentry.configureScope(scope => {
scope.setUser({
email: res.data.emailAddress,
accountAddress: res.data.ethAddress,
accountAddress: res.data.accountAddress,
});
});
dispatch({

View File

@ -1,14 +1,14 @@
import types from './types';
import usersTypes from 'modules/users/types';
// TODO: Use a common User type instead of this
import { TeamMember, AuthSignatureData } from 'types';
import { User, AuthSignatureData } from 'types';
export interface AuthState {
user: TeamMember | null;
user: User | null;
isAuthingUser: boolean;
authUserError: string | null;
checkedUsers: { [address: string]: TeamMember | false };
checkedUsers: { [address: string]: User | false };
isCheckingUser: boolean;
isCreatingUser: boolean;
@ -54,14 +54,14 @@ export default function createReducer(
...state,
user: action.payload.user,
authSignature: action.payload.authSignature, // TODO: Make this the real token
authSignatureAddress: action.payload.user.ethAddress,
authSignatureAddress: action.payload.user.accountAddress,
isAuthingUser: false,
};
case usersTypes.UPDATE_USER_FULFILLED:
return {
...state,
user:
state.user && state.user.ethAddress === action.payload.user.ethAddress
state.user && state.user.accountAddress === action.payload.user.accountAddress
? action.payload.user
: state.user,
};
@ -83,7 +83,7 @@ export default function createReducer(
...state,
user: action.payload.user,
authSignature: action.payload.authSignature,
authSignatureAddress: action.payload.user.ethAddress,
authSignatureAddress: action.payload.user.accountAddress,
isCreatingUser: false,
checkedUsers: {
...state.checkedUsers,

View File

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

View File

@ -1,7 +1,8 @@
import reducers, { CreateState, INITIAL_STATE } from './reducers';
import * as createActions from './actions';
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;

View File

@ -1,45 +1,55 @@
import types from './types';
import { CreateFormState } from 'types';
import { ONE_DAY } from 'utils/time';
import { ProposalDraft } from 'types';
export interface CreateState {
form: CreateFormState;
drafts: ProposalDraft[] | null;
form: ProposalDraft | null;
isInitializingForm: boolean;
initializeFormError: string | null;
isSavingDraft: boolean;
hasSavedDraft: boolean;
saveDraftError: string | null;
isFetchingDraft: boolean;
hasFetchedDraft: boolean;
fetchDraftError: string | null;
isFetchingDrafts: boolean;
fetchDraftsError: string | null;
isCreatingDraft: boolean;
createDraftError: string | null;
isDeletingDraft: boolean;
deleteDraftError: string | null;
}
export const INITIAL_STATE: CreateState = {
form: {
title: '',
brief: '',
details: '',
category: null,
amountToRaise: '',
payOutAddress: '',
trustees: [],
milestones: [],
team: [],
deadline: ONE_DAY * 60,
milestoneDeadline: ONE_DAY * 7,
},
drafts: null,
form: null,
isInitializingForm: false,
initializeFormError: null,
isSavingDraft: false,
hasSavedDraft: true,
saveDraftError: null,
isFetchingDraft: false,
hasFetchedDraft: false,
fetchDraftError: null,
isFetchingDrafts: false,
fetchDraftsError: 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) {
case types.CREATE_DRAFT_PENDING:
case types.UPDATE_FORM:
return {
...state,
@ -50,12 +60,24 @@ export default function createReducer(state: CreateState = INITIAL_STATE, action
hasSavedDraft: false,
};
case types.RESET_FORM:
case types.INITIALIZE_FORM_PENDING:
return {
...state,
form: { ...INITIAL_STATE.form },
hasSavedDraft: true,
hasFetchedDraft: false,
form: null,
isInitializingForm: true,
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:
@ -79,29 +101,60 @@ export default function createReducer(state: CreateState = INITIAL_STATE, action
saveDraftError: action.payload,
};
case types.FETCH_DRAFT_PENDING:
case types.FETCH_DRAFTS_PENDING:
return {
...state,
isFetchingDraft: true,
fetchDraftError: null,
isFetchingDrafts: true,
fetchDraftsError: null,
};
case types.FETCH_DRAFT_FULFILLED:
case types.FETCH_DRAFTS_FULFILLED:
return {
...state,
isFetchingDraft: false,
hasFetchedDraft: !!action.payload,
form: action.payload
? {
...state.form,
...action.payload,
}
: state.form,
isFetchingDrafts: false,
drafts: action.payload,
};
case types.FETCH_DRAFT_REJECTED:
case types.FETCH_DRAFTS_REJECTED:
return {
...state,
isFetchingDraft: false,
fetchDraftError: action.payload,
isFetchingDrafts: false,
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;

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 {
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_PENDING = 'SAVE_DRAFT_PENDING',
SAVE_DRAFT_FULFILLED = 'SAVE_DRAFT_FULFILLED',
SAVE_DRAFT_REJECTED = 'SAVE_DRAFT_REJECTED',
FETCH_DRAFT = 'FETCH_DRAFT',
FETCH_DRAFT_PENDING = 'FETCH_DRAFT_PENDING',
FETCH_DRAFT_FULFILLED = 'FETCH_DRAFT_FULFILLED',
FETCH_DRAFT_REJECTED = 'FETCH_DRAFT_REJECTED',
FETCH_DRAFTS = 'FETCH_DRAFTS',
FETCH_DRAFTS_PENDING = 'FETCH_DRAFTS_PENDING',
FETCH_DRAFTS_FULFILLED = 'FETCH_DRAFTS_FULFILLED',
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_PENDING = 'CREATE_PROPOSAL_PENDING',
@ -19,4 +32,8 @@ enum CreateTypes {
SUBMIT_REJECTED = 'CREATE_PROPOSAL_REJECTED',
}
export interface CreateDraftOptions {
redirect?: boolean;
}
export default CreateTypes;

View File

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

View File

@ -87,7 +87,7 @@ export function postProposalComment(
parentCommentId,
comment: {
commentId: Math.random(),
body: comment,
content: comment,
dateCreated: Date.now(),
replies: [],
author: {

View File

@ -1,6 +1,12 @@
import { UserProposal, UserComment, TeamMember } from 'types';
import { UserProposal, UserComment, User } 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 { Proposal } from 'types';
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);
return async (dispatch: Dispatch<any>) => {
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 { proposalId, title, team } = p;
return {
@ -127,13 +182,13 @@ const mockComment = (p: UserProposal): UserComment[] => {
? [
{
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),
proposal: p,
},
{
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),
proposal: p,
},
@ -141,27 +196,27 @@ const mockComment = (p: UserProposal): UserComment[] => {
: [
{
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),
proposal: p,
},
{
commentId: Math.random(),
body:
content:
'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),
proposal: p,
},
{
commentId: Math.random(),
body:
content:
'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),
proposal: p,
},
{
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.',
dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30),
proposal: p,

View File

@ -1,9 +1,14 @@
import lodash from 'lodash';
import { UserProposal, UserComment } from 'types';
import { UserProposal, UserComment, TeamInviteWithProposal } 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;
hasFetched: boolean;
fetchError: number | null;
@ -17,22 +22,27 @@ export interface UserState extends TeamMember {
hasFetchedFunded: boolean;
fetchErrorFunded: number | null;
fundedProposals: UserProposal[];
isFetchingCommments: boolean;
isFetchingComments: boolean;
hasFetchedComments: boolean;
fetchErrorComments: number | null;
comments: UserComment[];
isFetchingInvites: boolean;
hasFetchedInvites: boolean;
fetchErrorInvites: number | null;
invites: TeamInviteWithResponse[];
}
export interface UsersState {
map: { [index: string]: UserState };
}
export const INITIAL_TEAM_MEMBER_STATE: TeamMember = {
ethAddress: '',
avatarUrl: '',
name: '',
export const INITIAL_TEAM_MEMBER_STATE: User = {
userid: 0,
accountAddress: '',
avatar: null,
displayName: '',
emailAddress: '',
socialAccounts: {},
socialMedias: [],
title: '',
};
@ -51,10 +61,14 @@ export const INITIAL_USER_STATE: UserState = {
hasFetchedFunded: false,
fetchErrorFunded: null,
fundedProposals: [],
isFetchingCommments: false,
isFetchingComments: false,
hasFetchedComments: false,
fetchErrorComments: null,
comments: [],
isFetchingInvites: false,
hasFetchedInvites: false,
fetchErrorInvites: null,
invites: [],
};
export const INITIAL_STATE: UsersState = {
@ -66,6 +80,7 @@ export default (state = INITIAL_STATE, action: any) => {
const userFetchId = payload && payload.userFetchId;
const proposals = payload && payload.proposals;
const comments = payload && payload.comments;
const invites = payload && payload.invites;
const errorStatus =
(payload &&
payload.error &&
@ -75,101 +90,151 @@ export default (state = INITIAL_STATE, action: any) => {
switch (action.type) {
// fetch
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:
return updateStateFetch(
return updateUserState(
state,
userFetchId,
{ isFetching: false, hasFetched: true },
payload.user,
);
case types.FETCH_USER_REJECTED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetching: false,
hasFetched: true,
fetchError: errorStatus,
});
// update
case types.UPDATE_USER_PENDING:
return updateStateFetch(state, payload.user.ethAddress, {
return updateUserState(state, payload.user.accountAddress, {
isUpdating: true,
updateError: null,
});
case types.UPDATE_USER_FULFILLED:
return updateStateFetch(
return updateUserState(
state,
payload.user.ethAddress,
payload.user.accountAddress,
{ isUpdating: false },
payload.user,
);
case types.UPDATE_USER_REJECTED:
return updateStateFetch(state, payload.user.ethAddress, {
return updateUserState(state, payload.user.accountAddress, {
isUpdating: false,
updateError: errorStatus,
});
// created proposals
case types.FETCH_USER_CREATED_PENDING:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingCreated: true,
fetchErrorCreated: null,
});
case types.FETCH_USER_CREATED_FULFILLED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingCreated: false,
hasFetchedCreated: true,
createdProposals: proposals,
});
case types.FETCH_USER_CREATED_REJECTED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingCreated: false,
hasFetchedCreated: true,
fetchErrorCreated: errorStatus,
});
// funded proposals
case types.FETCH_USER_FUNDED_PENDING:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingFunded: true,
fetchErrorFunded: null,
});
case types.FETCH_USER_FUNDED_FULFILLED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingFunded: false,
hasFetchedFunded: true,
fundedProposals: proposals,
});
case types.FETCH_USER_FUNDED_REJECTED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingFunded: false,
hasFetchedFunded: true,
fetchErrorFunded: errorStatus,
});
// comments
case types.FETCH_USER_COMMENTS_PENDING:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingComments: true,
fetchErrorComments: null,
});
case types.FETCH_USER_COMMENTS_FULFILLED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingComments: false,
hasFetchedComments: true,
comments,
});
case types.FETCH_USER_COMMENTS_REJECTED:
return updateStateFetch(state, userFetchId, {
return updateUserState(state, userFetchId, {
isFetchingComments: false,
hasFetchedComments: true,
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:
return state;
}
};
function updateStateFetch(
function updateUserState(
state: UsersState,
id: string,
updates: object,
id: string | number,
updates: Partial<UserState>,
loaded?: UserState,
) {
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_FULFILLED = 'FETCH_USER_COMMENTS_FULFILLED',
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;

View File

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

View File

@ -1,17 +1,6 @@
import React from 'react';
import { Spin } from 'antd';
import Web3Container from 'lib/Web3Container';
import CreateFlow from 'components/CreateFlow';
import DraftList from 'components/DraftList';
const Create = () => (
<Web3Container
renderLoading={() => <Spin />}
render={({ accounts }) => (
<div style={{ paddingTop: '3rem', paddingBottom: '8rem' }}>
<CreateFlow accounts={accounts} />
</div>
)}
/>
);
const CreatePage = () => <DraftList createIfNone />;
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> {
render() {
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 { composeWithDevTools } from 'redux-devtools-extension';
import { persistStore, Persistor } from 'redux-persist';
import { routerMiddleware } from 'connected-react-router';
import rootReducer, { AppState, combineInitialState } from './reducers';
import rootSaga from './sagas';
import history from './history';
import axios from 'api/axios';
const sagaMiddleware = createSagaMiddleware();
@ -27,7 +29,12 @@ export function configureStore(initialState: Partial<AppState> = combineInitialS
const store: Store<AppState> = createStore(
rootReducer,
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
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 { connectRouter, RouterState } from 'connected-react-router';
import { persistReducer } from 'redux-persist';
import web3, { Web3State, INITIAL_STATE as web3InitialState } from 'modules/web3';
import proposal, {
@ -12,6 +13,7 @@ import authReducer, {
authPersistConfig,
} from 'modules/auth';
import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users';
import history from './history';
export interface AppState {
proposal: ProposalState;
@ -19,9 +21,10 @@ export interface AppState {
create: CreateState;
users: UsersState;
auth: AuthState;
router: RouterState;
}
export const combineInitialState: AppState = {
export const combineInitialState: Partial<AppState> = {
proposal: proposalInitialState,
web3: web3InitialState,
create: createInitialState,
@ -36,4 +39,5 @@ export default combineReducers<AppState>({
users,
// Don't allow for redux-persist's _persist key to be touched in our code
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 { authSagas } from 'modules/auth';
import { web3Sagas } from 'modules/web3';
import { createSagas } from 'modules/create';
export default function* rootSaga() {
yield fork(authSagas);
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 { TeamMember, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social';
import { socialMediaToUrl } from 'utils/social';
import { User, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
import { AppState } from 'store/reducers';
export function formatTeamMemberForPost(user: TeamMember) {
export function formatUserForPost(user: User) {
return {
displayName: user.name,
title: user.title,
accountAddress: user.ethAddress,
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),
),
...user,
avatar: user.avatar ? user.avatar.imageUrl : null,
socialMedias: user.socialMedias.map(sm => socialMediaToUrl(sm.service, sm.username)),
};
}
@ -49,7 +31,6 @@ export function formatCrowdFundFromGet(crowdFund: CrowdFund, base = 10): CrowdFu
}
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
proposal.team = proposal.team.map(formatTeamMemberFromGet);
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {

View File

@ -1,60 +1,36 @@
import React from 'react';
import { Icon } from 'antd';
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-_]*)';
export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = {
[SOCIAL_TYPE.GITHUB]: {
type: SOCIAL_TYPE.GITHUB,
export const SOCIAL_INFO: { [key in SOCIAL_SERVICE]: SocialInfo } = {
[SOCIAL_SERVICE.GITHUB]: {
service: SOCIAL_SERVICE.GITHUB,
name: 'Github',
format: `https://github.com/${accountNameRegex}`,
icon: <Icon type="github" />,
},
[SOCIAL_TYPE.TWITTER]: {
type: SOCIAL_TYPE.TWITTER,
[SOCIAL_SERVICE.TWITTER]: {
service: SOCIAL_SERVICE.TWITTER,
name: 'Twitter',
format: `https://twitter.com/${accountNameRegex}`,
icon: <Icon type="twitter" />,
},
[SOCIAL_TYPE.LINKEDIN]: {
type: SOCIAL_TYPE.LINKEDIN,
[SOCIAL_SERVICE.LINKEDIN]: {
service: SOCIAL_SERVICE.LINKEDIN,
name: 'LinkedIn',
format: `https://linkedin.com/in/${accountNameRegex}`,
icon: <Icon type="linkedin" />,
},
[SOCIAL_TYPE.KEYBASE]: {
type: SOCIAL_TYPE.KEYBASE,
[SOCIAL_SERVICE.KEYBASE]: {
service: SOCIAL_SERVICE.KEYBASE,
name: 'KeyBase',
format: `https://keybase.io/${accountNameRegex}`,
icon: <Icon component={keybaseIcon} />,
},
};
function urlToAccount(format: string, url: string): string | false {
const matches = url.match(new RegExp(format));
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);
});
export function socialMediaToUrl(service: SOCIAL_SERVICE, username: string): string {
return SOCIAL_INFO[service].format.replace(accountNameRegex, username);
}

View File

@ -29,3 +29,7 @@ export function isValidEthAddress(addr: string): boolean {
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",
"chalk": "^2.4.1",
"classnames": "^2.2.6",
"connected-react-router": "5.0.1",
"cookie-parser": "^1.4.3",
"copy-webpack-plugin": "^4.6.0",
"core-js": "^2.5.7",
@ -103,6 +104,7 @@
"fs-extra": "^7.0.0",
"global": "4.3.2",
"hdkey": "1.1.0",
"history": "4.7.2",
"http-proxy-middleware": "^0.18.0",
"https-proxy": "0.0.2",
"husky": "^1.0.0-rc.8",

View File

@ -2,20 +2,31 @@ import * as React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { storiesOf } from '@storybook/react';
import { DONATION } from 'utils/constants';
import { User } from 'types';
import 'components/UserRow/style.less';
import UserRow from 'components/UserRow';
const user = {
name: 'Dana Hayes',
const user: User = {
userid: 123,
displayName: 'Dana Hayes',
title: 'QA Engineer',
avatarUrl: 'https://randomuser.me/api/portraits/women/19.jpg',
ethAddress: DONATION.ETH,
avatar: {
imageUrl: 'https://randomuser.me/api/portraits/women/19.jpg',
},
accountAddress: DONATION.ETH,
emailAddress: 'test@test.test',
socialAccounts: {},
socialMedias: [],
};
const cases = [
interface Case {
disp: string;
props: {
user: User;
};
}
const cases: Case[] = [
{
disp: 'Full User',
props: {
@ -29,7 +40,7 @@ const cases = [
props: {
user: {
...user,
avatarUrl: '',
avatar: null,
},
},
},
@ -38,8 +49,8 @@ const cases = [
props: {
user: {
...user,
avatarUrl: '',
ethAddress: '',
avatar: null,
accountAddress: '',
},
},
},
@ -48,7 +59,7 @@ const cases = [
props: {
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',
},
},

View File

@ -161,33 +161,37 @@ export function getProposalWithCrowdFund({
proposalAddress: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710',
dateCreated: created / 1000,
title: 'Crowdfund Title',
body: 'body',
brief: 'A cool test crowdfund',
content: 'body',
stage: 'FUNDING_REQUIRED',
category: PROPOSAL_CATEGORY.COMMUNITY,
team: [
{
name: 'Test Proposer',
userid: 123,
displayName: 'Test Proposer',
title: '',
ethAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7',
accountAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7',
emailAddress: '',
avatarUrl: '',
socialAccounts: {},
avatar: null,
socialMedias: [],
},
{
name: 'Test Proposer',
userid: 456,
displayName: 'Test Proposer',
title: '',
ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
accountAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
emailAddress: '',
avatarUrl: '',
socialAccounts: {},
avatar: null,
socialMedias: [],
},
{
name: 'Test Proposer',
userid: 789,
displayName: 'Test Proposer',
title: '',
ethAddress: '0x529104532a9779ea9eae0c1e325b3368e0f8add4',
accountAddress: '0x529104532a9779ea9eae0c1e325b3368e0f8add4',
emailAddress: '',
avatarUrl: '',
socialAccounts: {},
avatar: null,
socialMedias: [],
},
],
milestones,

View File

@ -2,7 +2,7 @@ import { User, UserProposal } from 'types';
export interface Comment {
commentId: number | string;
body: string;
content: string;
dateCreated: number;
author: User;
replies: Comment[];
@ -10,7 +10,7 @@ export interface Comment {
export interface UserComment {
commentId: number | string;
body: string;
content: string;
dateCreated: number;
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 './social';
export * from './create';
export * from './comment';
export * from './contribution';
export * from './milestone';

View File

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

View File

@ -1,8 +1,20 @@
import { TeamMember } from 'types';
import { Wei } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { Comment } from 'types';
import { Milestone, ProposalMilestone, Update } from 'types';
import {
CreateMilestone,
ProposalMilestone,
Update,
User,
Milestone,
Comment,
} from 'types';
export interface TeamInvite {
id: number;
dateCreated: number;
address: string;
accepted: boolean | null;
}
export interface Contributor {
address: string;
@ -31,23 +43,46 @@ export interface CrowdFund {
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 {
proposalId: number;
proposalAddress: string;
proposalUrlId: string;
dateCreated: number;
title: string;
body: string;
brief: string;
content: string;
stage: string;
category: PROPOSAL_CATEGORY;
milestones: ProposalMilestone[];
team: TeamMember[];
team: User[];
}
export interface ProposalWithCrowdFund extends Proposal {
crowdFund: CrowdFund;
}
export interface TeamInviteWithProposal extends TeamInvite {
proposal: Proposal;
}
export interface ProposalComments {
proposalId: ProposalWithCrowdFund['proposalId'];
totalComments: number;
@ -63,7 +98,7 @@ export interface UserProposal {
proposalId: number;
title: string;
brief: string;
team: TeamMember[];
team: User[];
funded: Wei;
target: Wei;
}

View File

@ -1,15 +1,21 @@
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 {
type: SOCIAL_TYPE;
service: SOCIAL_SERVICE;
name: string;
format: string;
icon: React.ReactNode;
}
export enum SOCIAL_TYPE {
export enum SOCIAL_SERVICE {
GITHUB = 'GITHUB',
TWITTER = 'TWITTER',
LINKEDIN = 'LINKEDIN',

View File

@ -1,21 +1,11 @@
import { SocialAccountMap } from 'types';
import { SocialMedia } from 'types';
export interface User {
userid: number;
accountAddress: string;
userid: number | string;
username: string;
emailAddress: string; // TODO: Split into full user type
displayName: string;
title: string;
avatar: {
'120x120': string;
};
}
// 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;
socialMedias: SocialMedia[];
avatar: { imageUrl: string } | null;
}

View File

@ -4603,6 +4603,13 @@ connect-history-api-fallback@^1.3.0:
version "1.5.0"
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:
version "1.4.3"
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"
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"
resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b"
dependencies:
@ -13693,6 +13700,10 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8"
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:
version "3.5.0"
resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-3.5.0.tgz#677d3b8a8e04e1a5fa381a1ae437c54207b738d0"