Merge pull request #220 from grant-project/team-invites
Create overhaul (Pt 2. - Team invites, user type consolidation)
This commit is contained in:
commit
e6b2847929
|
@ -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,5 +1,6 @@
|
|||
import datetime
|
||||
from typing import List
|
||||
from sqlalchemy import func
|
||||
|
||||
from grant.comment.models import Comment
|
||||
from grant.extensions import ma, db
|
||||
|
@ -31,6 +32,30 @@ proposal_team = db.Table(
|
|||
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"
|
||||
|
@ -104,6 +129,7 @@ class Proposal(db.Model):
|
|||
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,
|
||||
|
@ -215,7 +241,8 @@ class ProposalSchema(ma.Schema):
|
|||
"trustees",
|
||||
"payout_address",
|
||||
"deadline_duration",
|
||||
"vote_duration"
|
||||
"vote_duration",
|
||||
"invites"
|
||||
)
|
||||
|
||||
date_created = ma.Method("get_date_created")
|
||||
|
@ -227,6 +254,7 @@ class ProposalSchema(ma.Schema):
|
|||
contributions = ma.Nested("ProposalContributionSchema", many=True)
|
||||
team = ma.Nested("UserSchema", many=True)
|
||||
milestones = ma.Nested("MilestoneSchema", many=True)
|
||||
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
||||
|
||||
def get_proposal_id(self, obj):
|
||||
return obj.id
|
||||
|
@ -272,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
|
||||
|
@ -294,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)
|
||||
|
|
|
@ -8,8 +8,10 @@ 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.utils.exceptions import ValidationException
|
||||
from grant.utils.misc import is_email
|
||||
from grant.web3.proposal import read_proposal
|
||||
from .models import(
|
||||
Proposal,
|
||||
|
@ -20,6 +22,8 @@ from .models import(
|
|||
ProposalContribution,
|
||||
proposal_contribution_schema,
|
||||
proposal_team,
|
||||
ProposalTeamInvite,
|
||||
proposal_team_invite_schema,
|
||||
db
|
||||
)
|
||||
import traceback
|
||||
|
@ -225,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)
|
||||
|
@ -242,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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -160,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
|
||||
|
@ -171,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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: e1e8573b7298
|
||||
Revises: a3b15766d9ab
|
||||
Create Date: 2018-11-15 13:47:06.051522
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e1e8573b7298'
|
||||
down_revision = 'a3b15766d9ab'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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('address', sa.String(length=255), nullable=False),
|
||||
sa.Column('accepted', sa.Boolean()),
|
||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('proposal_team_invite')
|
||||
# ### end Alembic commands ###
|
|
@ -44,7 +44,9 @@ class TestAPI(BaseUserConfig):
|
|||
|
||||
users_json = users_get_resp.json
|
||||
self.assertEqual(users_json["avatar"]["imageUrl"], self.user.avatar.image_url)
|
||||
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], self.user.social_medias[0].social_media_link)
|
||||
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):
|
||||
|
@ -54,7 +56,9 @@ class TestAPI(BaseUserConfig):
|
|||
|
||||
users_json = users_get_resp.json
|
||||
self.assertEqual(users_json["avatar"]["imageUrl"], self.user.avatar.image_url)
|
||||
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], self.user.social_medias[0].social_media_link)
|
||||
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):
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import axios from './axios';
|
||||
import { Proposal, ProposalDraft, TeamMember, Update, Contribution } from 'types';
|
||||
import {
|
||||
formatProposalFromGet,
|
||||
formatTeamMemberForPost,
|
||||
formatTeamMemberFromGet,
|
||||
} from 'utils/api';
|
||||
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 => {
|
||||
|
@ -32,15 +36,12 @@ 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: {
|
||||
|
@ -50,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> {
|
||||
|
@ -103,13 +93,7 @@ export function postProposalUpdate(
|
|||
}
|
||||
|
||||
export function getProposalDrafts(): Promise<{ data: ProposalDraft[] }> {
|
||||
return axios.get('/api/v1/proposals/drafts').then(res => {
|
||||
res.data = res.data.map((draft: any) => ({
|
||||
...draft,
|
||||
team: draft.team.map(formatTeamMemberFromGet),
|
||||
}));
|
||||
return res;
|
||||
});
|
||||
return axios.get('/api/v1/proposals/drafts');
|
||||
}
|
||||
|
||||
export function postProposalDraft(): Promise<{ data: ProposalDraft }> {
|
||||
|
@ -135,6 +119,36 @@ export function putProposalPublish(
|
|||
});
|
||||
}
|
||||
|
||||
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,7 +55,7 @@ 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>
|
||||
|
||||
|
|
|
@ -33,8 +33,10 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
private handleChange = (markdown: string) => {
|
||||
this.setState({ content: markdown }, () => {
|
||||
this.props.updateForm(this.state);
|
||||
});
|
||||
if (markdown !== this.state.content) {
|
||||
this.setState({ content: markdown }, () => {
|
||||
this.props.updateForm(this.state);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,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(' '),
|
||||
},
|
||||
],
|
||||
|
@ -209,16 +209,22 @@ const ReviewMilestones = ({
|
|||
</Timeline>
|
||||
);
|
||||
|
||||
const ReviewTeam = ({ team }: { team: ProposalDraft['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,12 +1,17 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { TeamMember, ProposalDraft } 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 {
|
||||
|
@ -14,24 +19,18 @@ interface StateProps {
|
|||
}
|
||||
|
||||
interface OwnProps {
|
||||
proposalId: number;
|
||||
initialState?: Partial<State>;
|
||||
updateForm(form: Partial<ProposalDraft>): void;
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps;
|
||||
|
||||
// const MAX_TEAM_SIZE = 6;
|
||||
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> {
|
||||
|
@ -42,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,
|
||||
};
|
||||
|
@ -59,37 +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} />
|
||||
))}
|
||||
{!!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>
|
||||
)}
|
||||
{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 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 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 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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ 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 { ProposalDraft } from 'types';
|
||||
|
@ -120,6 +121,7 @@ type Props = OwnProps & StateProps & DispatchProps & RouteComponentProps<any>;
|
|||
interface State {
|
||||
step: CREATE_STEP;
|
||||
isPreviewing: boolean;
|
||||
isShowingPublishWarning: boolean;
|
||||
isPublishing: boolean;
|
||||
isExample: boolean;
|
||||
}
|
||||
|
@ -140,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);
|
||||
|
@ -157,7 +160,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
|
||||
render() {
|
||||
const { isSavingDraft } = this.props;
|
||||
const { step, isPreviewing, isPublishing } = this.state;
|
||||
const { step, isPreviewing, isPublishing, isShowingPublishWarning } = this.state;
|
||||
|
||||
const info = STEP_INFO[step];
|
||||
const currentIndex = STEP_ORDER.indexOf(step);
|
||||
|
@ -190,6 +193,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
</div>
|
||||
<div className="CreateFlow-content">
|
||||
<StepComponent
|
||||
proposalId={this.props.form && this.props.form.proposalId}
|
||||
initialState={this.props.form}
|
||||
updateForm={this.debouncedUpdateForm}
|
||||
setStep={this.setStep}
|
||||
|
@ -216,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
|
||||
|
@ -243,6 +247,12 @@ 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>
|
||||
);
|
||||
}
|
||||
|
@ -271,7 +281,10 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
private startPublish = () => {
|
||||
this.setState({ isPublishing: true });
|
||||
this.setState({
|
||||
isPublishing: true,
|
||||
isShowingPublishWarning: false,
|
||||
});
|
||||
};
|
||||
|
||||
private checkFormErrors = () => {
|
||||
|
@ -279,7 +292,6 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
return true;
|
||||
}
|
||||
const errors = getCreateErrors(this.props.form);
|
||||
console.log(errors);
|
||||
return !!Object.keys(errors).length;
|
||||
};
|
||||
|
||||
|
@ -295,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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,5 +1,5 @@
|
|||
import { ProposalDraft, CreateMilestone } from 'types';
|
||||
import { TeamMember } from 'types';
|
||||
import { User } from 'types';
|
||||
import { isValidEthAddress, getAmountError } from 'utils/validators';
|
||||
import { MILESTONE_STATE, ProposalWithCrowdFund } from 'types';
|
||||
import { ProposalContractData } from 'modules/web3/actions';
|
||||
|
@ -153,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 '';
|
||||
}
|
||||
|
@ -170,20 +170,37 @@ 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 '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function milestoneToMilestoneAmount(milestone: CreateMilestone, raiseGoal: Wei) {
|
||||
return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent));
|
||||
}
|
||||
|
@ -218,6 +235,7 @@ export function makeProposalPreviewFromDraft(
|
|||
proposalAddress: '0x0',
|
||||
dateCreated: Date.now(),
|
||||
title: draft.title,
|
||||
brief: draft.brief,
|
||||
content: draft.content,
|
||||
stage: 'preview',
|
||||
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
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,
|
||||
|
|
|
@ -4,11 +4,18 @@ import {
|
|||
CreateMilestone,
|
||||
ProposalMilestone,
|
||||
Update,
|
||||
TeamMember,
|
||||
User,
|
||||
Milestone,
|
||||
Comment,
|
||||
} from 'types';
|
||||
|
||||
export interface TeamInvite {
|
||||
id: number;
|
||||
dateCreated: number;
|
||||
address: string;
|
||||
accepted: boolean | null;
|
||||
}
|
||||
|
||||
export interface Contributor {
|
||||
address: string;
|
||||
contributionAmount: Wei;
|
||||
|
@ -50,7 +57,8 @@ export interface ProposalDraft {
|
|||
deadlineDuration: number;
|
||||
voteDuration: number;
|
||||
milestones: CreateMilestone[];
|
||||
team: TeamMember[];
|
||||
team: User[];
|
||||
invites: TeamInvite[];
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
|
@ -59,17 +67,22 @@ export interface Proposal {
|
|||
proposalUrlId: string;
|
||||
dateCreated: number;
|
||||
title: 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;
|
||||
|
@ -85,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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue