Merge pull request #220 from grant-project/team-invites

Create overhaul (Pt 2. - Team invites, user type consolidation)
This commit is contained in:
William O'Beirne 2018-11-27 12:46:06 -05:00 committed by GitHub
commit e6b2847929
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1247 additions and 625 deletions

View File

@ -9,34 +9,46 @@ default_template_args = {
'unsubscribe_url': 'https://grant.io/unsubscribe',
}
email_template_args = {
'signup': {
def signup_info(email_args):
return {
'subject': 'Confirm your email on Grant.io',
'title': 'Welcome to Grant.io!',
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
},
}
def team_invite_info(email_args):
return {
'subject': '{} has invited you to a project'.format(email_args.inviter.display_name),
'title': 'Youve been invited!',
'preview': 'Youve been invited to the "{}" project team'.format(email_args.proposal.title)
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info
}
def send_email(to, type, email_args):
try:
info = get_info_lookup[type](email_args)
body_text = render_template('emails/%s.txt' % (type), args=email_args)
body_html = render_template('emails/%s.html' % (type), args=email_args)
html = render_template('emails/template.html', args={
**default_template_args,
**email_template_args[type],
**info,
'body': Markup(body_html),
})
text = render_template('emails/template.txt', args={
**default_template_args,
**email_template_args[type],
**info,
'body': body_text,
})
res = mail.send_email(
to_email=to,
subject=email_template_args[type]['subject'],
subject=info['subject'],
text=text,
html=html,
)

View File

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

View File

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

View File

@ -0,0 +1,19 @@
<p style="margin: 0;">
U invited
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;">
See invitation
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1 @@
U invited

View File

@ -3,6 +3,7 @@ from grant.comment.models import Comment
from grant.email.models import EmailVerification
from grant.extensions import ma, db
from grant.utils.misc import make_url
from grant.utils.social import get_social_info_from_url
from grant.email.send import send_email
@ -122,7 +123,26 @@ class SocialMediaSchema(ma.Schema):
class Meta:
model = SocialMedia
# Fields to expose
fields = ("social_media_link",)
fields = (
"url",
"service",
"username",
)
url = ma.Method("get_url")
service = ma.Method("get_service")
username = ma.Method("get_username")
def get_url(self, obj):
return obj.social_media_link
def get_service(self, obj):
info = get_social_info_from_url(obj.social_media_link)
return info['service']
def get_username(self, obj):
info = get_social_info_from_url(obj.social_media_link)
return info['username']
social_media_schema = SocialMediaSchema()

View File

@ -1,7 +1,7 @@
from flask import Blueprint, g, request
from flask_yoloapi import endpoint, parameter
from grant.proposal.models import Proposal, proposal_team
from grant.proposal.models import Proposal, proposal_team, ProposalTeamInvite, invites_with_proposal_schema
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
from grant.utils.upload import save_avatar, send_upload, remove_avatar
from grant.settings import UPLOAD_URL
@ -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:
old_avatar_url = db_avatar and db_avatar.image_url
if old_avatar_url and old_avatar_url != new_avatar.image_url:
remove_avatar(old_avatar_url, user.id)
db.session.commit()
result = user_schema.dump(user)
return result
@blueprint.route("/<user_identity>/invites", methods=["GET"])
@requires_same_user_auth
@endpoint.api()
def get_user_invites(user_identity):
invites = ProposalTeamInvite.get_pending_for_user(g.current_user)
return invites_with_proposal_schema.dump(invites)
@blueprint.route("/<user_identity>/invites/<invite_id>/respond", methods=["PUT"])
@requires_same_user_auth
@endpoint.api(
parameter('response', type=bool, required=True)
)
def respond_to_invite(user_identity, invite_id, response):
invite = ProposalTeamInvite.query.filter_by(id=invite_id).first()
if not invite:
return {"message": "No invite found with id {}".format(invite_id)}, 404
invite.accepted = response
db.session.add(invite)
if invite.accepted:
invite.proposal.team.append(g.current_user)
db.session.add(invite)
db.session.commit()
return None, 200

View File

@ -2,6 +2,7 @@ import datetime
import time
import random
import string
import re
from grant.settings import SITE_URL
epoch = datetime.datetime.utcfromtimestamp(0)
@ -26,3 +27,6 @@ def gen_random_code(length=32):
def make_url(path: str):
return f'{SITE_URL}{path}'
def is_email(email: str):
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))

View File

@ -0,0 +1,19 @@
import re
username_regex = '([a-zA-Z0-9-_]*)'
social_patterns = {
'GITHUB': 'https://github.com/{}'.format(username_regex),
'TWITTER': 'https://twitter.com/{}'.format(username_regex),
'LINKEDIN': 'https://linkedin.com/in/{}'.format(username_regex),
'KEYBASE': 'https://keybase.io/{}'.format(username_regex),
}
def get_social_info_from_url(url: str):
for service, pattern in social_patterns.items():
match = re.match(pattern, url)
if match:
return {
'service': service,
'username': match.group(1)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,8 +33,10 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
}
private handleChange = (markdown: string) => {
if (markdown !== this.state.content) {
this.setState({ content: markdown }, () => {
this.props.updateForm(this.state);
});
}
};
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { Modal, Alert } from 'antd';
import { getCreateWarnings } from 'modules/create/utils';
import { ProposalDraft } from 'types';
import './PublishWarningModal.less';
interface Props {
proposal: ProposalDraft | null;
isVisible: boolean;
handleClose(): void;
handlePublish(): void;
}
export default class PublishWarningModal extends React.Component<Props> {
render() {
const { proposal, isVisible, handleClose, handlePublish } = this.props;
const warnings = proposal ? getCreateWarnings(proposal) : [];
return (
<Modal
title={<>Confirm publish</>}
visible={isVisible}
okText="Confirm publish"
cancelText="Never mind"
onOk={handlePublish}
onCancel={handleClose}
>
<div className="PublishWarningModal">
{!!warnings.length && (
<Alert
type="warning"
showIcon
message="Some fields have warnings"
description={
<>
<ul>
{warnings.map(w => (
<li key={w}>{w}</li>
))}
</ul>
<p>You can still publish, despite these warnings.</p>
</>
}
/>
)}
<p>
Are you sure youre ready to publish your proposal? Once youve done so, you
won't be able to change certain fields such as: target amount, payout address,
team, trustees, deadline & vote durations.
</p>
</div>
</Modal>
);
}
}

View File

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

View File

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

View File

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

View File

@ -6,49 +6,76 @@
width: 100%;
margin: 0 auto;
&-pending,
&-add {
display: flex;
width: 100%;
padding: 1rem;
align-items: center;
cursor: pointer;
opacity: 0.7;
transition: opacity 80ms ease, transform 80ms ease;
outline: none;
&:hover,
&:focus {
opacity: 1;
}
&:active {
transform: translateY(2px);
}
&-icon {
display: flex;
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;
}
&-text {
text-align: left;
margin-top: 2rem;
&-title {
font-size: 1.6rem;
font-weight: 300;
color: @success-color;
font-size: 1.2rem;
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
}
&-subtitle {
opacity: 0.7;
&-pending {
&-invite {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
font-size: 1rem;
background: #FFF;
box-shadow: 0 1px 2px rgba(#000, 0.2);
border-bottom: 1px solid rgba(#000, 0.05);
&:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
&:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom: 0;
}
&-delete {
opacity: 0.3;
outline: none;
font-size: 1rem;
padding: 0 0.25rem;
transition: opacity 100ms ease, color 100ms ease;
&:hover,
&:focus,
&:active {
color: @error-color;
opacity: 1;
}
}
}
}
&-add {
&-form {
display: flex;
padding: 1rem 1rem 0.3rem;
border-radius: 2px;
background: #FFF;
box-shadow: 0 1px 2px rgba(#000, 0.2);
&-field {
flex: 1;
.ant-form-explain {
margin-top: 0.3rem;
padding-left: 0.25rem;
font-size: 0.75rem;
}
}
&-submit {
margin-left: 0.5rem;
}
}
}

View File

@ -1,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 doesnt look like an email address or ETH address'
: undefined;
const inviteDisabled = !!inviteError || !address;
const pendingInvites = invites.filter(inv => inv.accepted === null);
return (
<div className="TeamForm">
{team.map((user, idx) => (
<TeamMemberComponent
key={idx}
index={idx}
user={user}
initialEditingState={!user.name}
onChange={this.handleChange}
onRemove={this.removeMember}
/>
{team.map(user => (
<TeamMemberComponent key={user.userid} user={user} />
))}
{!!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);
});
};
}

View File

@ -6,6 +6,7 @@
align-items: center;
padding: 1rem;
margin: 0 auto 1rem;
border-radius: 2px;
background: #FFF;
box-shadow: 0 1px 2px rgba(#000, 0.2);

View File

@ -1,146 +1,32 @@
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}
/>
</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 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.socialAccounts[s.type];
const account = user.socialMedias.find(sm => s.service === sm.service);
const cn = classnames(
'TeamMember-info-social-icon',
account && 'is-active',
@ -159,83 +45,8 @@ export default class CreateFlowTeamMember extends React.PureComponent<Props, Sta
);
})}
</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>
);
}
private toggleEditing = (ev?: React.SyntheticEvent<any>) => {
if (ev) {
ev.preventDefault();
}
const { isEditing, fields } = this.state;
if (isEditing) {
// TODO: Check if valid first
this.props.onChange(fields, this.props.index);
}
this.setState({ isEditing: !isEditing });
};
private cancelEditing = () => {
this.setState({
isEditing: false,
fields: { ...this.props.user },
});
};
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = ev.currentTarget;
this.setState({
fields: {
...this.state.fields,
[name as any]: value,
},
});
};
private handleSocialChange = (
ev: React.ChangeEvent<HTMLInputElement>,
type: SOCIAL_TYPE,
) => {
const { value } = ev.currentTarget;
this.setState({
fields: {
...this.state.fields,
socialAccounts: {
...this.state.fields.socialAccounts,
[type]: value,
},
},
});
};
private handleChangePhoto = () => {
// TODO: Actual file uploading
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
const num = Math.floor(Math.random() * 80);
this.setState({
fields: {
...this.state.fields,
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
},
});
};
private removeMember = () => {
this.props.onRemove(this.props.index);
};
}

View File

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

View File

@ -4,7 +4,7 @@ import { Upload, Icon, Modal, Button, Alert } from 'antd';
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import { UploadFile } from 'antd/lib/upload/interface';
import { TeamMember } from 'types';
import { User } from 'types';
import { getBase64 } from 'utils/blob';
import UserAvatar from 'components/UserAvatar';
import './AvatarEdit.less';
@ -13,7 +13,7 @@ const FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
const FILE_MAX_LOAD_MB = 10;
interface OwnProps {
user: TeamMember;
user: User;
onDelete(): void;
onDone(url: string): void;
}
@ -41,7 +41,7 @@ export default class AvatarEdit extends React.PureComponent<Props, State> {
const { newAvatarUrl, showModal, loadError, uploadError, isUploading } = this.state;
const {
user,
user: { avatarUrl },
user: { avatar },
} = this.props;
return (
<>
@ -58,12 +58,12 @@ export default class AvatarEdit extends React.PureComponent<Props, State> {
<Button className="AvatarEdit-avatar-change">
<Icon
className="AvatarEdit-avatar-change-icon"
type={avatarUrl ? 'picture' : 'plus-circle'}
type={avatar ? 'picture' : 'plus-circle'}
/>
<div>{avatarUrl ? 'Change photo' : 'Add photo'}</div>
<div>{avatar ? 'Change photo' : 'Add photo'}</div>
</Button>
</Upload>
{avatarUrl && (
{avatar && (
<Button
className="AvatarEdit-avatar-delete"
icon="delete"

View File

@ -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}>
{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={fields.socialAccounts[s.type]}
onChange={ev => this.handleSocialChange(ev, s.type)}
value={field ? field.username : ''}
onChange={ev => this.handleSocialChange(ev, s.service)}
addonBefore={s.icon}
/>
</Form.Item>
</Col>
))}
);
})}
</Row>
{!isMissingField &&
@ -173,11 +179,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
};
private handleCancel = () => {
const { avatarUrl } = this.state.fields;
const propsAvatar = this.props.user.avatar;
const stateAvatar = this.state.fields.avatar;
// cleanup uploaded file if we cancel
if (this.props.user.avatarUrl !== avatarUrl && avatarUrl) {
if (propsAvatar && stateAvatar && propsAvatar.imageUrl !== stateAvatar.imageUrl) {
axios.delete('/api/v1/users/avatar', {
params: { url: avatarUrl },
params: { url: stateAvatar.imageUrl },
});
}
this.props.onDone();
@ -198,20 +205,27 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
private handleSocialChange = (
ev: React.ChangeEvent<HTMLInputElement>,
type: SOCIAL_TYPE,
service: SOCIAL_SERVICE,
) => {
const { value } = ev.currentTarget;
// First remove...
const socialMedias = this.state.fields.socialMedias.filter(
sm => sm.service !== service,
);
if (value) {
// Then re-add if there as a value
socialMedias.push({
service,
username: value,
url: socialMediaToUrl(service, value),
});
}
const fields = {
...this.state.fields,
socialAccounts: {
...this.state.fields.socialAccounts,
[type]: value,
},
socialMedias,
};
// delete key for empty string
if (!value) {
delete fields.socialAccounts[type];
}
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
@ -222,7 +236,9 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
private handleChangePhoto = (url: string) => {
const fields = {
...this.state.fields,
avatarUrl: url,
avatar: {
imageUrl: url,
},
};
const isChanged = this.isChangedCheck(fields);
this.setState({
@ -232,13 +248,15 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
};
private handleDeletePhoto = () => {
const fields = lodash.clone(this.state.fields);
delete fields.avatarUrl;
const fields = {
...this.state.fields,
avatar: null,
};
const isChanged = this.isChangedCheck(fields);
this.setState({ isChanged, fields });
};
private isChangedCheck = (a: TeamMember) => {
private isChangedCheck = (a: User) => {
return !lodash.isEqual(a, this.props.user);
};
}

View File

@ -0,0 +1,38 @@
.ProfileInvite {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1.2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 1rem;
&-info {
&-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
&-brief {
font-size: 0.9rem;
margin-bottom: 0.6rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-inviter {
font-size: 0.8rem;
opacity: 0.6;
}
}
&-actions {
display: flex;
.ant-btn {
padding: 0 0.8rem !important;
margin-right: 0.5rem;
}
}
}

View File

@ -0,0 +1,100 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button, Popconfirm, message } from 'antd';
import { respondToInvite } from 'modules/users/actions';
import { TeamInviteWithResponse } from 'modules/users/reducers';
import './ProfileInvite.less';
interface DispatchProps {
respondToInvite: typeof respondToInvite;
}
interface OwnProps {
userId: string | number;
invite: TeamInviteWithResponse;
}
type Props = DispatchProps & OwnProps;
interface State {
isAccepting: boolean;
isRejecting: boolean;
}
class ProfileInvite extends React.Component<Props, State> {
state: State = {
isAccepting: false,
isRejecting: false,
};
componentDidUpdate(prevProps: Props) {
const { invite } = this.props;
if (prevProps.invite !== invite && invite.respondError) {
this.setState({
isAccepting: false,
isRejecting: false,
});
message.error('Failed to respond to invitation', 3);
}
}
render() {
const { invite } = this.props;
const { isAccepting, isRejecting } = this.state;
const { proposal } = invite;
const inviter = proposal.team[0] || { displayName: 'Unknown user' };
return (
<div className="ProfileInvite">
<div className="ProfileInvite-info">
<div className="ProfileInvite-info-title">
{proposal.title || <em>No title</em>}
</div>
<div className="ProfileInvite-info-brief">
{proposal.brief || <em>No description</em>}
</div>
<div className="ProfileInvite-info-inviter">
created by {inviter.displayName}
</div>
</div>
<div className="ProfileInvite-actions">
<Button
icon="check"
type="primary"
size="large"
ghost
onClick={this.accept}
disabled={isRejecting}
loading={isAccepting}
/>
<Popconfirm title="Are you sure?" onConfirm={this.reject}>
<Button
icon="close"
type="danger"
size="large"
ghost
disabled={isAccepting}
loading={isRejecting}
/>
</Popconfirm>
</div>
</div>
);
}
private accept = () => {
const { userId, invite } = this.props;
this.setState({ isAccepting: true });
this.props.respondToInvite(userId, invite.id, true);
};
private reject = () => {
const { userId, invite } = this.props;
this.setState({ isRejecting: true });
this.props.respondToInvite(userId, invite.id, false);
};
}
export default connect<{}, DispatchProps, OwnProps, {}>(
undefined,
{ respondToInvite },
)(ProfileInvite);

View File

@ -30,7 +30,7 @@ export default class Profile extends React.Component<OwnProps> {
<h3>Team</h3>
<div className="ProfileProposal-block-team">
{team.map(user => (
<UserRow key={user.ethAddress || user.emailAddress} user={user} />
<UserRow key={user.accountAddress || user.emailAddress} user={user} />
))}
</div>
</div>

View File

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

View File

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

View File

@ -10,7 +10,7 @@ interface Props {
const TeamBlock = ({ proposal }: Props) => {
let content;
if (proposal) {
content = proposal.team.map(user => <UserRow key={user.name} user={user} />);
content = proposal.team.map(user => <UserRow key={user.displayName} user={user} />);
} else {
content = <Spin />;
}

View File

@ -53,7 +53,8 @@ export class ProposalCard extends React.Component<ProposalWithCrowdFund> {
<div className="ProposalCard-team">
<div className="ProposalCard-team-name">
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
{team[0].displayName}{' '}
{team.length > 1 && <small>+{team.length - 1} other</small>}
</div>
<div className="ProposalCard-team-avatars">
{[...team].reverse().map((u, idx) => (

View File

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

View File

@ -1,20 +1,20 @@
import React from 'react';
import UserAvatar from 'components/UserAvatar';
import { TeamMember } from 'types';
import { User } from 'types';
import { Link } from 'react-router-dom';
import './style.less';
interface Props {
user: TeamMember;
user: User;
}
const UserRow = ({ user }: Props) => (
<Link to={`/profile/${user.ethAddress || user.emailAddress}`} className="UserRow">
<Link to={`/profile/${user.accountAddress || user.emailAddress}`} className="UserRow">
<div className="UserRow-avatar">
<UserAvatar user={user} className="UserRow-avatar-img" />
</div>
<div className="UserRow-info">
<div className="UserRow-info-main">{user.name}</div>
<div className="UserRow-info-main">{user.displayName}</div>
<p className="UserRow-info-secondary">{user.title}</p>
</div>
</Link>

View File

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

View File

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

View File

@ -1,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 doesnt look like a valid email address';
} else if (!isValidEthAddress(user.ethAddress)) {
} else if (!isValidEthAddress(user.accountAddress)) {
return 'That doesnt look like a valid ETH address';
}
return '';
}
export function getCreateWarnings(form: Partial<ProposalDraft>): string[] {
const warnings = [];
// Warn about pending invites
const hasPending =
(form.invites || []).filter(inv => inv.accepted === null).length !== 0;
if (hasPending) {
warnings.push(`
You still have pending team invitations. If you publish before they
are accepted, your team will be locked in and they wont be able to
accept join.
`);
}
return warnings;
}
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,

View File

@ -1,6 +1,12 @@
import { UserProposal, UserComment, TeamMember } from 'types';
import { UserProposal, UserComment, User } from 'types';
import types from './types';
import { getUser, updateUser as apiUpdateUser, getProposals } from 'api/api';
import {
getUser,
updateUser as apiUpdateUser,
getProposals,
fetchUserInvites as apiFetchUserInvites,
putInviteResponse,
} from 'api/api';
import { Dispatch } from 'redux';
import { Proposal } from 'types';
import BN from 'bn.js';
@ -22,7 +28,7 @@ export function fetchUser(userFetchId: string) {
};
}
export function updateUser(user: TeamMember) {
export function updateUser(user: User) {
const userClone = cleanClone(INITIAL_TEAM_MEMBER_STATE, user);
return async (dispatch: Dispatch<any>) => {
dispatch({ type: types.UPDATE_USER_PENDING, payload: { user } });
@ -100,6 +106,55 @@ export function fetchUserComments(userFetchId: string) {
};
}
export function fetchUserInvites(userFetchId: string) {
return async (dispatch: Dispatch<any>) => {
dispatch({
type: types.FETCH_USER_INVITES_PENDING,
payload: { userFetchId },
});
try {
const res = await apiFetchUserInvites(userFetchId);
const invites = res.data.sort((a, b) => (a.dateCreated > b.dateCreated ? -1 : 1));
dispatch({
type: types.FETCH_USER_INVITES_FULFILLED,
payload: { userFetchId, invites },
});
} catch (error) {
dispatch({
type: types.FETCH_USER_INVITES_REJECTED,
payload: { userFetchId, error },
});
}
};
}
export function respondToInvite(
userId: string | number,
inviteId: string | number,
response: boolean,
) {
return async (dispatch: Dispatch<any>) => {
dispatch({
type: types.RESPOND_TO_INVITE_PENDING,
payload: { userId, inviteId, response },
});
try {
await putInviteResponse(userId, inviteId, response);
dispatch({
type: types.RESPOND_TO_INVITE_FULFILLED,
payload: { userId, inviteId, response },
});
} catch (error) {
dispatch({
type: types.RESPOND_TO_INVITE_REJECTED,
payload: { userId, inviteId, error },
});
}
};
}
const mockModifyProposals = (p: Proposal): UserProposal => {
const { proposalId, title, team } = p;
return {

View File

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

View File

@ -23,6 +23,16 @@ enum UsersActions {
FETCH_USER_COMMENTS_PENDING = 'FETCH_USER_COMMENTS_PENDING',
FETCH_USER_COMMENTS_FULFILLED = 'FETCH_USER_COMMENTS_FULFILLED',
FETCH_USER_COMMENTS_REJECTED = 'FETCH_USER_COMMENTS_REJECTED',
FETCH_USER_INVITES = 'FETCH_USER_INVITES',
FETCH_USER_INVITES_PENDING = 'FETCH_USER_INVITES_PENDING',
FETCH_USER_INVITES_FULFILLED = 'FETCH_USER_INVITES_FULFILLED',
FETCH_USER_INVITES_REJECTED = 'FETCH_USER_INVITES_REJECTED',
RESPOND_TO_INVITE = 'RESPOND_TO_INVITE',
RESPOND_TO_INVITE_PENDING = 'RESPOND_TO_INVITE_PENDING',
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
}
export default UsersActions;

View File

@ -9,7 +9,7 @@ interface Props {
class ProfilePage extends React.Component<Props> {
render() {
const { user } = this.props;
return <h1>Settings for {user && user.name}</h1>;
return <h1>Settings for {user && user.displayName}</h1>;
}
}

View File

@ -1,31 +1,13 @@
import BN from 'bn.js';
import { TeamMember, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social';
import { socialMediaToUrl } from 'utils/social';
import { User, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
import { AppState } from 'store/reducers';
export function formatTeamMemberForPost(user: TeamMember) {
export function formatUserForPost(user: User) {
return {
displayName: user.name,
title: user.title,
accountAddress: user.ethAddress,
emailAddress: user.emailAddress,
avatar: user.avatarUrl ? { link: user.avatarUrl } : {},
socialMedias: socialAccountsToUrls(user.socialAccounts).map(url => ({
link: url,
})),
};
}
export function formatTeamMemberFromGet(user: any): TeamMember {
return {
name: user.displayName,
title: user.title,
ethAddress: user.accountAddress,
emailAddress: user.emailAddress,
avatarUrl: user.avatar && user.avatar.imageUrl,
socialAccounts: socialUrlsToAccounts(
user.socialMedias.map((sm: any) => sm.socialMediaLink),
),
...user,
avatar: user.avatar ? user.avatar.imageUrl : null,
socialMedias: user.socialMedias.map(sm => socialMediaToUrl(sm.service, sm.username)),
};
}
@ -49,7 +31,6 @@ export function formatCrowdFundFromGet(crowdFund: CrowdFund, base = 10): CrowdFu
}
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
proposal.team = proposal.team.map(formatTeamMemberFromGet);
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {

View File

@ -1,60 +1,36 @@
import React from 'react';
import { Icon } from 'antd';
import keybaseIcon from 'static/images/keybase.svg';
import { SOCIAL_TYPE, SocialAccountMap, SocialInfo } from 'types';
import { SOCIAL_SERVICE, SocialInfo } from 'types';
const accountNameRegex = '([a-zA-Z0-9-_]*)';
export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = {
[SOCIAL_TYPE.GITHUB]: {
type: SOCIAL_TYPE.GITHUB,
export const SOCIAL_INFO: { [key in SOCIAL_SERVICE]: SocialInfo } = {
[SOCIAL_SERVICE.GITHUB]: {
service: SOCIAL_SERVICE.GITHUB,
name: 'Github',
format: `https://github.com/${accountNameRegex}`,
icon: <Icon type="github" />,
},
[SOCIAL_TYPE.TWITTER]: {
type: SOCIAL_TYPE.TWITTER,
[SOCIAL_SERVICE.TWITTER]: {
service: SOCIAL_SERVICE.TWITTER,
name: 'Twitter',
format: `https://twitter.com/${accountNameRegex}`,
icon: <Icon type="twitter" />,
},
[SOCIAL_TYPE.LINKEDIN]: {
type: SOCIAL_TYPE.LINKEDIN,
[SOCIAL_SERVICE.LINKEDIN]: {
service: SOCIAL_SERVICE.LINKEDIN,
name: 'LinkedIn',
format: `https://linkedin.com/in/${accountNameRegex}`,
icon: <Icon type="linkedin" />,
},
[SOCIAL_TYPE.KEYBASE]: {
type: SOCIAL_TYPE.KEYBASE,
[SOCIAL_SERVICE.KEYBASE]: {
service: SOCIAL_SERVICE.KEYBASE,
name: 'KeyBase',
format: `https://keybase.io/${accountNameRegex}`,
icon: <Icon component={keybaseIcon} />,
},
};
function urlToAccount(format: string, url: string): string | false {
const matches = url.match(new RegExp(format));
return matches && matches[1] ? matches[1] : false;
}
export function socialAccountToUrl(account: string, type: SOCIAL_TYPE): string {
return SOCIAL_INFO[type].format.replace(accountNameRegex, account);
}
export function socialUrlsToAccounts(urls: string[]): SocialAccountMap {
const accounts: SocialAccountMap = {};
urls.forEach(url => {
Object.values(SOCIAL_INFO).forEach(s => {
const account = urlToAccount(s.format, url);
if (account) {
accounts[s.type] = account;
}
});
});
return accounts;
}
export function socialAccountsToUrls(accounts: SocialAccountMap): string[] {
return Object.entries(accounts).map(([key, value]) => {
return socialAccountToUrl(value as string, key as SOCIAL_TYPE);
});
export function socialMediaToUrl(service: SOCIAL_SERVICE, username: string): string {
return SOCIAL_INFO[service].format.replace(accountNameRegex, username);
}

View File

@ -29,3 +29,7 @@ export function isValidEthAddress(addr: string): boolean {
return addr === toChecksumAddress(addr);
}
}
export function isValidEmail(email: string): boolean {
return /\S+@\S+\.\S+/.test(email);
}

View File

@ -2,20 +2,31 @@ import * as React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { storiesOf } from '@storybook/react';
import { DONATION } from 'utils/constants';
import { User } from 'types';
import 'components/UserRow/style.less';
import UserRow from 'components/UserRow';
const user = {
name: 'Dana Hayes',
const user: User = {
userid: 123,
displayName: 'Dana Hayes',
title: 'QA Engineer',
avatarUrl: 'https://randomuser.me/api/portraits/women/19.jpg',
ethAddress: DONATION.ETH,
avatar: {
imageUrl: 'https://randomuser.me/api/portraits/women/19.jpg',
},
accountAddress: DONATION.ETH,
emailAddress: 'test@test.test',
socialAccounts: {},
socialMedias: [],
};
const cases = [
interface Case {
disp: string;
props: {
user: User;
};
}
const cases: Case[] = [
{
disp: 'Full User',
props: {
@ -29,7 +40,7 @@ const cases = [
props: {
user: {
...user,
avatarUrl: '',
avatar: null,
},
},
},
@ -38,8 +49,8 @@ const cases = [
props: {
user: {
...user,
avatarUrl: '',
ethAddress: '',
avatar: null,
accountAddress: '',
},
},
},
@ -48,7 +59,7 @@ const cases = [
props: {
user: {
...user,
name: 'Dr. Baron Longnamivitch von Testeronomous III Esq.',
displayName: 'Dr. Baron Longnamivitch von Testeronomous III Esq.',
title: 'Amazing person, all around cool neat-o guy, 10/10 would order again',
},
},

View File

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

View File

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

View File

@ -1,15 +1,21 @@
import React from 'react';
export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>;
export type SocialAccountMap = Partial<{ [key in SOCIAL_SERVICE]: string }>;
export interface SocialMedia {
url: string;
service: SOCIAL_SERVICE;
username: string;
}
export interface SocialInfo {
type: SOCIAL_TYPE;
service: SOCIAL_SERVICE;
name: string;
format: string;
icon: React.ReactNode;
}
export enum SOCIAL_TYPE {
export enum SOCIAL_SERVICE {
GITHUB = 'GITHUB',
TWITTER = 'TWITTER',
LINKEDIN = 'LINKEDIN',

View File

@ -1,21 +1,11 @@
import { SocialAccountMap } from 'types';
import { SocialMedia } from 'types';
export interface User {
userid: number;
accountAddress: string;
userid: number | string;
username: string;
emailAddress: string; // TODO: Split into full user type
displayName: string;
title: string;
avatar: {
'120x120': string;
};
}
// TODO: Merge this or extend the `User` type in proposals/reducers.ts
export interface TeamMember {
name: string;
title: string;
avatarUrl: string;
ethAddress: string;
emailAddress: string;
socialAccounts: SocialAccountMap;
socialMedias: SocialMedia[];
avatar: { imageUrl: string } | null;
}