Merge pull request #211 from grant-project/server-side-drafts
Overhaul create to provide server side drafts, team invites, and a bunch of refactors
This commit is contained in:
commit
ad6173b376
|
@ -85,7 +85,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
|||
};
|
||||
render() {
|
||||
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>
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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': 'You’ve been invited!',
|
||||
'preview': 'You’ve been invited to the "{}" project team'.format(email_args.proposal.title)
|
||||
}
|
||||
|
||||
get_info_lookup = {
|
||||
'signup': signup_info,
|
||||
'team_invite': team_invite_info
|
||||
}
|
||||
|
||||
|
||||
def send_email(to, type, email_args):
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<p style="margin: 0;">
|
||||
U invited
|
||||
</p>
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;">
|
||||
See invitation
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
|
@ -0,0 +1 @@
|
|||
U invited
|
|
@ -3,6 +3,7 @@ from grant.comment.models import Comment
|
|||
from grant.email.models import EmailVerification
|
||||
from grant.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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
class ValidationException(Exception):
|
||||
pass
|
|
@ -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))
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import re
|
||||
|
||||
username_regex = '([a-zA-Z0-9-_]*)'
|
||||
|
||||
social_patterns = {
|
||||
'GITHUB': 'https://github.com/{}'.format(username_regex),
|
||||
'TWITTER': 'https://twitter.com/{}'.format(username_regex),
|
||||
'LINKEDIN': 'https://linkedin.com/in/{}'.format(username_regex),
|
||||
'KEYBASE': 'https://keybase.io/{}'.format(username_regex),
|
||||
}
|
||||
|
||||
def get_social_info_from_url(url: str):
|
||||
for service, pattern in social_patterns.items():
|
||||
match = re.match(pattern, url)
|
||||
if match:
|
||||
return {
|
||||
'service': service,
|
||||
'username': match.group(1)
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 1d06a5e43324
|
||||
Revises: 312db8611967
|
||||
Create Date: 2018-11-17 11:07:40.413141
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1d06a5e43324'
|
||||
down_revision = '312db8611967'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('proposal_contribution',
|
||||
sa.Column('tx_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('date_created', sa.DateTime(), nullable=False),
|
||||
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('from_address', sa.String(length=255), nullable=False),
|
||||
sa.Column('amount', sa.String(length=255), nullable=False),
|
||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('tx_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('proposal_contribution')
|
||||
# ### end Alembic commands ###
|
|
@ -1,38 +0,0 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 312db8611967
|
||||
Revises: 95e93ff98cba
|
||||
Create Date: 2018-11-06 11:07:33.205401
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '312db8611967'
|
||||
down_revision = '95e93ff98cba'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# custom alter_column
|
||||
with op.batch_alter_table('proposal') as bop:
|
||||
bop.alter_column('proposal_id', new_column_name='proposal_address')
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# op.add_column('proposal', sa.Column('proposal_address', sa.String(length=255), nullable=False))
|
||||
# op.create_unique_constraint(None, 'proposal', ['proposal_address'])
|
||||
# op.drop_column('proposal', 'proposal_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# custom alter_column
|
||||
with op.batch_alter_table('proposal') as bop:
|
||||
bop.alter_column('proposal_address', new_column_name='proposal_id')
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# op.add_column('proposal', sa.Column('proposal_id', sa.VARCHAR(length=255), nullable=False))
|
||||
# op.drop_constraint(None, 'proposal', type_='unique')
|
||||
# op.drop_column('proposal', 'proposal_address')
|
||||
# ### end Alembic commands ###
|
|
@ -1,35 +0,0 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 6e02ee4b9ca3
|
||||
Revises: 5f38d8603897
|
||||
Create Date: 2018-11-01 16:29:11.190975
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6e02ee4b9ca3'
|
||||
down_revision = '5f38d8603897'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('email_verification',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(length=255), nullable=False),
|
||||
sa.Column('has_verified', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('email_verification')
|
||||
# ### end Alembic commands ###
|
|
@ -1,8 +1,8 @@
|
|||
"""empty message
|
||||
|
||||
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')
|
|
@ -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 ###
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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="%"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { Modal, Alert } from 'antd';
|
||||
import { getCreateWarnings } from 'modules/create/utils';
|
||||
import { ProposalDraft } from 'types';
|
||||
import './PublishWarningModal.less';
|
||||
|
||||
interface Props {
|
||||
proposal: ProposalDraft | null;
|
||||
isVisible: boolean;
|
||||
handleClose(): void;
|
||||
handlePublish(): void;
|
||||
}
|
||||
|
||||
export default class PublishWarningModal extends React.Component<Props> {
|
||||
render() {
|
||||
const { proposal, isVisible, handleClose, handlePublish } = this.props;
|
||||
const warnings = proposal ? getCreateWarnings(proposal) : [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<>Confirm publish</>}
|
||||
visible={isVisible}
|
||||
okText="Confirm publish"
|
||||
cancelText="Never mind"
|
||||
onOk={handlePublish}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<div className="PublishWarningModal">
|
||||
{!!warnings.length && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="Some fields have warnings"
|
||||
description={
|
||||
<>
|
||||
<ul>
|
||||
{warnings.map(w => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>You can still publish, despite these warnings.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
Are you sure you’re ready to publish your proposal? Once you’ve done so, you
|
||||
won't be able to change certain fields such as: target amount, payout address,
|
||||
team, trustees, deadline & vote durations.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.PublishWarningModal {
|
||||
.ant-alert {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
ul {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -109,4 +109,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-invites {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,18 +4,19 @@ import { Icon, Timeline } from 'antd';
|
|||
import moment from 'moment';
|
||||
import { 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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 doesn’t 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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { List, Button, Divider, Spin, Popconfirm, message } from 'antd';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import { ProposalDraft } from 'types';
|
||||
import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './style.less';
|
||||
|
||||
interface StateProps {
|
||||
drafts: AppState['create']['drafts'];
|
||||
isFetchingDrafts: AppState['create']['isFetchingDrafts'];
|
||||
fetchDraftsError: AppState['create']['fetchDraftsError'];
|
||||
isCreatingDraft: AppState['create']['isCreatingDraft'];
|
||||
createDraftError: AppState['create']['createDraftError'];
|
||||
isDeletingDraft: AppState['create']['isDeletingDraft'];
|
||||
deleteDraftError: AppState['create']['deleteDraftError'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
fetchDrafts: typeof fetchDrafts;
|
||||
createDraft: typeof createDraft;
|
||||
deleteDraft: typeof deleteDraft;
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
createIfNone?: boolean;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & OwnProps;
|
||||
|
||||
interface State {
|
||||
deletingId: number | null;
|
||||
}
|
||||
|
||||
class DraftList extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
deletingId: null,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
this.props.fetchDrafts();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const {
|
||||
drafts,
|
||||
createIfNone,
|
||||
isDeletingDraft,
|
||||
deleteDraftError,
|
||||
createDraftError,
|
||||
} = this.props;
|
||||
if (createIfNone && drafts && !prevProps.drafts && !drafts.length) {
|
||||
this.createDraft();
|
||||
}
|
||||
if (prevProps.isDeletingDraft && !isDeletingDraft) {
|
||||
this.setState({ deletingId: null });
|
||||
}
|
||||
if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) {
|
||||
message.error('Failed to delete draft', 3);
|
||||
}
|
||||
if (createDraftError && prevProps.createDraftError !== createDraftError) {
|
||||
message.error('Failed to create draft', 3);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { drafts, isCreatingDraft } = this.props;
|
||||
const { deletingId } = this.state;
|
||||
|
||||
if (!drafts || isCreatingDraft) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
let draftsEl;
|
||||
if (drafts.length) {
|
||||
draftsEl = (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={drafts}
|
||||
renderItem={(d: ProposalDraft) => {
|
||||
const actions = [
|
||||
<Link key="edit" to={`/proposals/${d.proposalId}/edit`}>
|
||||
Edit
|
||||
</Link>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.deleteDraft(d.proposalId)}
|
||||
>
|
||||
<a>Delete</a>
|
||||
</Popconfirm>,
|
||||
];
|
||||
return (
|
||||
<Spin tip="deleting..." spinning={deletingId === d.proposalId}>
|
||||
<List.Item actions={actions}>
|
||||
<List.Item.Meta
|
||||
title={d.title || <em>Untitled proposal</em>}
|
||||
description={d.brief || <em>No description</em>}
|
||||
/>
|
||||
</List.Item>
|
||||
</Spin>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
draftsEl = (
|
||||
<Placeholder
|
||||
title="You have no drafts"
|
||||
subtitle="Why not make one now? Click below to start."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DraftList">
|
||||
<h2 className="DraftList-title">Your drafts</h2>
|
||||
{draftsEl}
|
||||
<Divider>or</Divider>
|
||||
<Button
|
||||
className="DraftList-create"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={this.createDraft}
|
||||
loading={isCreatingDraft}
|
||||
>
|
||||
Create a new Proposal
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private createDraft = () => {
|
||||
this.props.createDraft({ redirect: true });
|
||||
};
|
||||
|
||||
private deleteDraft = (proposalId: number) => {
|
||||
this.props.deleteDraft(proposalId);
|
||||
this.setState({ deletingId: proposalId });
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
state => ({
|
||||
drafts: state.create.drafts,
|
||||
isFetchingDrafts: state.create.isFetchingDrafts,
|
||||
fetchDraftsError: state.create.fetchDraftsError,
|
||||
isCreatingDraft: state.create.isCreatingDraft,
|
||||
createDraftError: state.create.createDraftError,
|
||||
isDeletingDraft: state.create.isDeletingDraft,
|
||||
deleteDraftError: state.create.deleteDraftError,
|
||||
}),
|
||||
{
|
||||
fetchDrafts,
|
||||
createDraft,
|
||||
deleteDraft,
|
||||
},
|
||||
)(DraftList);
|
|
@ -0,0 +1,26 @@
|
|||
.DraftList {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
|
||||
&-title {
|
||||
font-size: 1.6rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&-create {
|
||||
display: block;
|
||||
max-width: 280px;
|
||||
height: 3.2rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ant-divider {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import { Upload, Icon, Modal, Button, Alert } from 'antd';
|
|||
import Cropper from 'react-cropper';
|
||||
import '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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
.ProfileInvite {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&-info {
|
||||
&-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&-brief {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.6rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&-inviter {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
|
||||
.ant-btn {
|
||||
padding: 0 0.8rem !important;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button, Popconfirm, message } from 'antd';
|
||||
import { respondToInvite } from 'modules/users/actions';
|
||||
import { TeamInviteWithResponse } from 'modules/users/reducers';
|
||||
import './ProfileInvite.less';
|
||||
|
||||
interface DispatchProps {
|
||||
respondToInvite: typeof respondToInvite;
|
||||
}
|
||||
|
||||
interface OwnProps {
|
||||
userId: string | number;
|
||||
invite: TeamInviteWithResponse;
|
||||
}
|
||||
|
||||
type Props = DispatchProps & OwnProps;
|
||||
|
||||
interface State {
|
||||
isAccepting: boolean;
|
||||
isRejecting: boolean;
|
||||
}
|
||||
|
||||
class ProfileInvite extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
isAccepting: false,
|
||||
isRejecting: false,
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const { invite } = this.props;
|
||||
if (prevProps.invite !== invite && invite.respondError) {
|
||||
this.setState({
|
||||
isAccepting: false,
|
||||
isRejecting: false,
|
||||
});
|
||||
message.error('Failed to respond to invitation', 3);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { invite } = this.props;
|
||||
const { isAccepting, isRejecting } = this.state;
|
||||
const { proposal } = invite;
|
||||
const inviter = proposal.team[0] || { displayName: 'Unknown user' };
|
||||
return (
|
||||
<div className="ProfileInvite">
|
||||
<div className="ProfileInvite-info">
|
||||
<div className="ProfileInvite-info-title">
|
||||
{proposal.title || <em>No title</em>}
|
||||
</div>
|
||||
<div className="ProfileInvite-info-brief">
|
||||
{proposal.brief || <em>No description</em>}
|
||||
</div>
|
||||
<div className="ProfileInvite-info-inviter">
|
||||
created by {inviter.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProfileInvite-actions">
|
||||
<Button
|
||||
icon="check"
|
||||
type="primary"
|
||||
size="large"
|
||||
ghost
|
||||
onClick={this.accept}
|
||||
disabled={isRejecting}
|
||||
loading={isAccepting}
|
||||
/>
|
||||
<Popconfirm title="Are you sure?" onConfirm={this.reject}>
|
||||
<Button
|
||||
icon="close"
|
||||
type="danger"
|
||||
size="large"
|
||||
ghost
|
||||
disabled={isAccepting}
|
||||
loading={isRejecting}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private accept = () => {
|
||||
const { userId, invite } = this.props;
|
||||
this.setState({ isAccepting: true });
|
||||
this.props.respondToInvite(userId, invite.id, true);
|
||||
};
|
||||
|
||||
private reject = () => {
|
||||
const { userId, invite } = this.props;
|
||||
this.setState({ isRejecting: true });
|
||||
this.props.respondToInvite(userId, invite.id, false);
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<{}, DispatchProps, OwnProps, {}>(
|
||||
undefined,
|
||||
{ respondToInvite },
|
||||
)(ProfileInvite);
|
|
@ -30,7 +30,7 @@ export default class Profile extends React.Component<OwnProps> {
|
|||
<h3>Team</h3>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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="You’ll be notified when you’ve been invited to join a proposal"
|
||||
/>
|
||||
)}
|
||||
{invites.map(invite => (
|
||||
<ProfileInvite
|
||||
key={invite.id}
|
||||
userId={user.accountAddress}
|
||||
invite={invite}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
</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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import { SagaIterator } from 'redux-saga';
|
||||
import { takeEvery, takeLatest, put, call, select } from 'redux-saga/effects';
|
||||
import { push } from 'connected-react-router';
|
||||
import {
|
||||
postProposalDraft,
|
||||
getProposalDrafts,
|
||||
putProposal,
|
||||
deleteProposalDraft,
|
||||
} from 'api/api';
|
||||
import { getDraftById, getFormState } from './selectors';
|
||||
import { createDraft, initializeForm, deleteDraft } from './actions';
|
||||
import types from './types';
|
||||
|
||||
export function* handleCreateDraft(action: ReturnType<typeof createDraft>): SagaIterator {
|
||||
try {
|
||||
const res: Yielded<typeof postProposalDraft> = yield call(postProposalDraft);
|
||||
yield put({
|
||||
type: types.CREATE_DRAFT_FULFILLED,
|
||||
payload: res.data,
|
||||
});
|
||||
|
||||
if (action.payload.redirect) {
|
||||
yield put(push(`/proposals/${res.data.proposalId}/edit`));
|
||||
}
|
||||
} catch (err) {
|
||||
yield put({
|
||||
type: types.CREATE_DRAFT_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleFetchDrafts(): SagaIterator {
|
||||
try {
|
||||
const res: Yielded<typeof getProposalDrafts> = yield call(getProposalDrafts);
|
||||
yield put({
|
||||
type: types.FETCH_DRAFTS_FULFILLED,
|
||||
payload: res.data,
|
||||
});
|
||||
} catch (err) {
|
||||
yield put({
|
||||
type: types.FETCH_DRAFTS_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleSaveDraft(): SagaIterator {
|
||||
try {
|
||||
const draft: Yielded<typeof getFormState> = yield select(getFormState);
|
||||
if (!draft) {
|
||||
throw new Error('No form state to save draft');
|
||||
}
|
||||
yield call(putProposal, draft);
|
||||
yield put({ type: types.SAVE_DRAFT_FULFILLED });
|
||||
} catch (err) {
|
||||
yield put({
|
||||
type: types.SAVE_DRAFT_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function* handleDeleteDraft(action: ReturnType<typeof deleteDraft>): SagaIterator {
|
||||
try {
|
||||
yield call(deleteProposalDraft, action.payload);
|
||||
put({ type: types.DELETE_DRAFT_FULFILLED });
|
||||
} catch (err) {
|
||||
yield put({
|
||||
type: types.DELETE_DRAFT_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
yield call(handleFetchDrafts);
|
||||
}
|
||||
|
||||
export function* handleInitializeForm(
|
||||
action: ReturnType<typeof initializeForm>,
|
||||
): SagaIterator {
|
||||
try {
|
||||
let draft: Yielded<typeof getDraftById> = yield select(getDraftById, action.payload);
|
||||
if (!draft) {
|
||||
yield call(handleFetchDrafts);
|
||||
draft = yield select(getDraftById, action.payload);
|
||||
if (!draft) {
|
||||
throw new Error('Proposal not found');
|
||||
}
|
||||
}
|
||||
yield put({
|
||||
type: types.INITIALIZE_FORM_FULFILLED,
|
||||
payload: draft,
|
||||
});
|
||||
} catch (err) {
|
||||
yield put({
|
||||
type: types.INITIALIZE_FORM_REJECTED,
|
||||
payload: err.message || err.toString(),
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function* createSagas(): SagaIterator {
|
||||
yield takeEvery(types.CREATE_DRAFT_PENDING, handleCreateDraft);
|
||||
yield takeLatest(types.FETCH_DRAFTS_PENDING, handleFetchDrafts);
|
||||
yield takeLatest(types.SAVE_DRAFT_PENDING, handleSaveDraft);
|
||||
yield takeEvery(types.DELETE_DRAFT_PENDING, handleDeleteDraft);
|
||||
yield takeEvery(types.INITIALIZE_FORM_PENDING, handleInitializeForm);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { AppState as S } from 'store/reducers';
|
||||
|
||||
export const getDraftById = (s: S, id: number) => {
|
||||
if (!s.create.drafts) {
|
||||
return undefined;
|
||||
}
|
||||
return s.create.drafts.find(d => d.proposalId === id);
|
||||
};
|
||||
|
||||
export const getFormState = (s: S) => s.create.form;
|
|
@ -1,17 +1,30 @@
|
|||
enum CreateTypes {
|
||||
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;
|
||||
|
|
|
@ -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 doesn’t look like a valid address';
|
||||
if (payoutAddress && !isValidEthAddress(payoutAddress)) {
|
||||
errors.payoutAddress = 'That doesn’t look like a valid address';
|
||||
}
|
||||
|
||||
// Trustees
|
||||
|
@ -94,7 +106,7 @@ export function getCreateErrors(
|
|||
err = 'That doesn’t 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 doesn’t 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 doesn’t look like a valid email address';
|
||||
} else if (!isValidEthAddress(user.ethAddress)) {
|
||||
} else if (!isValidEthAddress(user.accountAddress)) {
|
||||
return 'That doesn’t look like a valid ETH address';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
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 won’t 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: [],
|
||||
|
|
|
@ -87,7 +87,7 @@ export function postProposalComment(
|
|||
parentCommentId,
|
||||
comment: {
|
||||
commentId: Math.random(),
|
||||
body: comment,
|
||||
content: comment,
|
||||
dateCreated: Date.now(),
|
||||
replies: [],
|
||||
author: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter, RouteComponentProps } from 'react-router';
|
||||
import { Spin } from 'antd';
|
||||
import Web3Container from 'lib/Web3Container';
|
||||
import CreateFlow from 'components/CreateFlow';
|
||||
import { initializeForm } from 'modules/create/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
interface StateProps {
|
||||
form: AppState['create']['form'];
|
||||
isInitializingForm: AppState['create']['isInitializingForm'];
|
||||
initializeFormError: AppState['create']['initializeFormError'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
initializeForm: typeof initializeForm;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & RouteComponentProps<{ id: string }>;
|
||||
|
||||
class ProposalEdit extends React.Component<Props> {
|
||||
componentWillMount() {
|
||||
const proposalId = parseInt(this.props.match.params.id, 10);
|
||||
this.props.initializeForm(proposalId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { form, initializeFormError } = this.props;
|
||||
|
||||
if (form) {
|
||||
return (
|
||||
<Web3Container
|
||||
renderLoading={() => <Spin />}
|
||||
render={({ accounts }) => (
|
||||
<div style={{ paddingTop: '3rem', paddingBottom: '8rem' }}>
|
||||
<CreateFlow accounts={accounts} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} else if (initializeFormError) {
|
||||
return <h1>{initializeFormError}</h1>;
|
||||
} else {
|
||||
return <Spin />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedProposalEdit = connect<StateProps, DispatchProps, {}, AppState>(
|
||||
state => ({
|
||||
form: state.create.form,
|
||||
isInitializingForm: state.create.isInitializingForm,
|
||||
initializeFormError: state.create.initializeFormError,
|
||||
}),
|
||||
{ initializeForm },
|
||||
)(ProposalEdit);
|
||||
|
||||
export default withRouter(ConnectedProposalEdit);
|
|
@ -9,7 +9,7 @@ interface Props {
|
|||
class ProfilePage extends React.Component<Props> {
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
return <h1>Settings for {user && user.name}</h1>;
|
||||
return <h1>Settings for {user && user.displayName}</h1>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,10 @@ import thunkMiddleware, { ThunkMiddleware } from 'redux-thunk';
|
|||
import promiseMiddleware from 'redux-promise-middleware';
|
||||
import { 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
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { createBrowserHistory, createMemoryHistory } from 'history';
|
||||
|
||||
const history = (() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return createMemoryHistory();
|
||||
} else {
|
||||
return createBrowserHistory();
|
||||
}
|
||||
})();
|
||||
|
||||
export default history;
|
|
@ -1,4 +1,5 @@
|
|||
import { combineReducers, Reducer } from 'redux';
|
||||
import { 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),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { Effect } from 'redux-saga/effects';
|
||||
|
||||
type ExtPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
|
||||
type ExtSaga<T> = T extends IterableIterator<infer U>
|
||||
? Exclude<U, Effect | Effect[]>
|
||||
: ExtPromise<T>;
|
||||
|
||||
/**
|
||||
* Use this helper to unwrap return types from effects like Call / Apply
|
||||
* In the case of calling a function that returns a promise, this helper will unwrap the
|
||||
* promise and return the type inside it. In the case of calling another saga / generator,
|
||||
* this helper will return the actual return value of the saga / generator, otherwise,
|
||||
* it'll return the original type.
|
||||
*
|
||||
* NOTE 1: When using this to extract the type of a Saga, make sure to remove the `SagaIterator`
|
||||
* return type of the saga if it contains one, since that masks the actual return type of the saga.
|
||||
*
|
||||
* NOTE 2: You will most likely need to use the `typeof` operator to use this helper.
|
||||
* E.g type X = Yielded<typeof MyFunc/MySaga>
|
||||
*/
|
||||
declare global {
|
||||
export type Yielded<T> = T extends (...args: any[]) => any
|
||||
? ExtSaga<ReturnType<T>>
|
||||
: ExtPromise<T>;
|
||||
}
|
|
@ -1,31 +1,13 @@
|
|||
import BN from 'bn.js';
|
||||
import { 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++) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import { TeamMember, CreateMilestone } from 'types';
|
||||
|
||||
export interface CreateFormState {
|
||||
title: string;
|
||||
brief: string;
|
||||
category: PROPOSAL_CATEGORY | null;
|
||||
amountToRaise: string;
|
||||
details: string;
|
||||
payOutAddress: string;
|
||||
trustees: string[];
|
||||
milestones: CreateMilestone[];
|
||||
team: TeamMember[];
|
||||
deadline: number | null;
|
||||
milestoneDeadline: number | null;
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
export * from './user';
|
||||
export * from './social';
|
||||
export * from './create';
|
||||
export * from './comment';
|
||||
export * from './contribution';
|
||||
export * from './milestone';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue