From 28d0ab76e626308b2baa322057d8b8a680185211 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 26 Sep 2018 14:35:22 -0500 Subject: [PATCH 01/18] Backend Regression Tests (#112) --- backend/grant/proposal/views.py | 4 +- backend/tests/user/test_user_api.py | 57 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 7e9c9501..aad1c471 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -108,7 +108,8 @@ def make_proposal(): display_name = team_member.get("displayName") email_address = team_member.get("emailAddress") title = team_member.get("title") - user = User.query.filter((User.account_address == account_address) | (User.email_address == email_address)).first() + user = User.query.filter( + (User.account_address == account_address) | (User.email_address == email_address)).first() if not user: user = User( account_address=account_address, @@ -130,7 +131,6 @@ def make_proposal(): sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id) db.session.add(sm) - proposal.team.append(user) for each_milestone in milestones: diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index 1c677713..ebae51a7 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -3,6 +3,7 @@ import json import random from grant.proposal.models import CATEGORIES +from grant.proposal.models import Proposal from grant.user.models import User from ..config import BaseTestConfig @@ -75,6 +76,62 @@ class TestAPI(BaseTestConfig): self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) + def test_associate_user_via_proposal_by_email(self): + proposal_by_email = copy.deepcopy(proposal) + del proposal_by_email["team"][0]["accountAddress"] + + self.app.post( + "/api/v1/proposals/", + data=json.dumps(proposal_by_email), + content_type='application/json' + ) + + # User + user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first() + self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) + self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) + proposal_db = Proposal.query.filter_by( + proposal_id=proposal["crowdFundContractAddress"] + ).first() + self.assertEqual(proposal_db.team[0].id, user_db.id) + + def test_associate_user_via_proposal_by_email_when_user_already_exists(self): + proposal_by_email = copy.deepcopy(proposal) + del proposal_by_email["team"][0]["accountAddress"] + + self.app.post( + "/api/v1/proposals/", + data=json.dumps(proposal_by_email), + content_type='application/json' + ) + + # User + user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first() + self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) + self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) + proposal_db = Proposal.query.filter_by( + proposal_id=proposal["crowdFundContractAddress"] + ).first() + self.assertEqual(proposal_db.team[0].id, user_db.id) + + new_proposal_by_email = copy.deepcopy(proposal) + new_proposal_by_email["crowdFundContractAddress"] = "0x2222" + del new_proposal_by_email["team"][0]["accountAddress"] + + self.app.post( + "/api/v1/proposals/", + data=json.dumps(new_proposal_by_email), + content_type='application/json' + ) + + user_db = User.query.filter_by(email_address=new_proposal_by_email["team"][0]["emailAddress"]).first() + self.assertEqual(user_db.display_name, new_proposal_by_email["team"][0]["displayName"]) + self.assertEqual(user_db.title, new_proposal_by_email["team"][0]["title"]) + proposal_db = Proposal.query.filter_by( + proposal_id=proposal["crowdFundContractAddress"] + ).first() + self.assertEqual(proposal_db.team[0].id, user_db.id) + def test_get_all_users(self): self.app.post( "/api/v1/proposals/", From 9e0ecaef021e8d460eea40d10f16db321143cab1 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 26 Sep 2018 14:42:40 -0500 Subject: [PATCH 02/18] User Resource Enhancements (#113) * add tests to ensure already existing users get associated with a new proposal when they are specified in team * serilaize avatar, social_medias on user; add tests * add user resource GET API; test * remove commented out serializer stuff --- backend/grant/user/models.py | 42 ++++++++++++++++++++++------- backend/grant/user/views.py | 19 ++++++++++--- backend/tests/user/test_user_api.py | 18 +++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index a55c9cc4..2701370c 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -57,21 +57,45 @@ class UserSchema(ma.Schema): class Meta: model = User # Fields to expose - fields = ("account_address", "userid", "title", "email_address", "display_name", "title") + fields = ( + "account_address", + "title", + "email_address", + "social_medias", + "avatar", + "display_name", + "userid" + ) + + social_medias = ma.Nested("SocialMediaSchema", many=True) + avatar = ma.Nested("AvatarSchema") userid = ma.Method("get_userid") - title = ma.Method("get_title") - avatar = ma.Method("get_avatar") def get_userid(self, obj): return obj.id - def get_title(self, obj): - return "" - - def get_avatar(self, obj): - return "https://forum.getmonero.org/uploads/profile/small_no_picture.jpg" - user_schema = UserSchema() users_schema = UserSchema(many=True) + + +class SocialMediaSchema(ma.Schema): + class Meta: + model = SocialMedia + # Fields to expose + fields = ("social_media_link",) + +social_media_schema = SocialMediaSchema() +social_media_schemas = SocialMediaSchema(many=True) + + +class AvatarSchema(ma.Schema): + class Meta: + model = SocialMedia + # Fields to expose + fields = ("image_url",) + + +avatar_schema = AvatarSchema() +avatar_schemas = AvatarSchema(many=True) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 96b6be9f..1a6b3f19 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -1,8 +1,8 @@ from flask import Blueprint, request -from .models import User, users_schema -from ..proposal.models import Proposal, proposal_team from grant import JSONResponse +from .models import User, users_schema, user_schema +from ..proposal.models import Proposal, proposal_team blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users') @@ -14,7 +14,20 @@ def get_users(): if not proposal: users = User.query.all() else: - users = User.query.join(proposal_team).join(Proposal)\ + users = User.query.join(proposal_team).join(Proposal) \ .filter(proposal_team.c.proposal_id == proposal.id).all() result = users_schema.dump(users) return JSONResponse(result) + + +@blueprint.route("/", methods=["GET"]) +def get_user(user_identity): + user = User.query.filter( + (User.account_address == user_identity) | (User.email_address == user_identity)).first() + if user: + result = user_schema.dump(user) + return JSONResponse(result) + else: + return JSONResponse( + message="User with account_address or user_identity matching {} not found".format(user_identity), + _statusCode=404) diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index ebae51a7..4df2fb1e 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -162,4 +162,22 @@ class TestAPI(BaseTestConfig): ) users_json = users_get_resp.json + self.assertEqual(users_json[0]["avatar"]["imageUrl"], team[0]["avatar"]["link"]) + self.assertEqual(users_json[0]["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"]) self.assertEqual(users_json[0]["displayName"], team[0]["displayName"]) + + def test_get_single_user(self): + self.app.post( + "/api/v1/proposals/", + data=json.dumps(proposal), + content_type='application/json' + ) + + users_get_resp = self.app.get( + "/api/v1/users/{}".format(proposal["team"][0]["emailAddress"]) + ) + + users_json = users_get_resp.json + self.assertEqual(users_json["avatar"]["imageUrl"], team[0]["avatar"]["link"]) + self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"]) + self.assertEqual(users_json["displayName"], team[0]["displayName"]) From 3b161f3476064e68a562b3b3df2199b7761f4400 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Thu, 27 Sep 2018 16:25:49 -0400 Subject: [PATCH 03/18] Functioning proposal teams (pt 1 - the form) (#115) * Team create flow step * Show team on review step. * Fix image types. * Get team into ideal format. Properly post it to backend. * Validate team forms and show errors. * Adjust team member buttons. --- frontend/client/api/api.ts | 15 +- .../client/components/CreateFlow/Review.less | 27 ++ .../client/components/CreateFlow/Review.tsx | 38 ++- .../client/components/CreateFlow/Team.less | 53 ++++ .../client/components/CreateFlow/Team.tsx | 90 ++++++- .../components/CreateFlow/TeamMember.less | 122 +++++++++ .../components/CreateFlow/TeamMember.tsx | 239 ++++++++++++++++++ .../client/components/CreateFlow/example.ts | 28 +- .../client/components/CreateFlow/index.tsx | 24 +- frontend/client/modules/create/reducers.ts | 3 +- frontend/client/modules/create/types.ts | 11 + frontend/client/modules/create/utils.ts | 40 ++- frontend/client/modules/web3/actions.ts | 5 +- frontend/client/static/images/keybase.svg | 3 + frontend/client/typings/images.d.ts | 3 +- frontend/client/utils/social.tsx | 75 ++++++ 16 files changed, 741 insertions(+), 35 deletions(-) create mode 100644 frontend/client/components/CreateFlow/Team.less create mode 100644 frontend/client/components/CreateFlow/TeamMember.less create mode 100644 frontend/client/components/CreateFlow/TeamMember.tsx create mode 100644 frontend/client/static/images/keybase.svg create mode 100644 frontend/client/utils/social.tsx diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 520fb3d5..de638bd9 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -1,5 +1,7 @@ import axios from './axios'; import { Proposal } from 'modules/proposals/reducers'; +import { TeamMember } from 'modules/create/types'; +import { socialAccountsToUrls } from 'utils/social'; import { PROPOSAL_CATEGORY } from './constants'; export function getProposals(): Promise<{ data: Proposal[] }> { @@ -26,9 +28,20 @@ export function postProposal(payload: { title: string; category: PROPOSAL_CATEGORY; milestones: object[]; + team: TeamMember[]; }) { return axios.post(`/api/v1/proposals/`, { ...payload, - team: [{ accountAddress: payload.accountAddress }], + // Team has a different shape for POST + team: payload.team.map(u => ({ + displayName: u.name, + title: u.title, + accountAddress: u.ethAddress, + emailAddress: u.emailAddress, + avatar: { link: u.avatarUrl }, + socialMedias: socialAccountsToUrls(u.socialAccounts).map(url => ({ + link: url, + })), + })), }); } diff --git a/frontend/client/components/CreateFlow/Review.less b/frontend/client/components/CreateFlow/Review.less index 3082ebec..90bace9b 100644 --- a/frontend/client/components/CreateFlow/Review.less +++ b/frontend/client/components/CreateFlow/Review.less @@ -81,3 +81,30 @@ font-size: 1rem; } } + +.ReviewTeam { + &-member { + display: flex; + align-items: center; + margin-bottom: 0.75rem; + + &-avatar { + height: 4.2rem; + width: 4.2rem; + margin-right: 1rem; + border-radius: 4px; + } + + &-info { + &-name { + font-size: 1.2rem; + margin-bottom: 0.2rem; + } + + &-title { + font-size: 1rem; + opacity: 0.5; + } + } + } +} diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 559f8545..262985f9 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { connect } from 'react-redux'; import { Icon, Timeline } from 'antd'; import moment from 'moment'; -import { Milestone } from 'modules/create/types'; import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils'; import Markdown from 'components/Markdown'; import { AppState } from 'store/reducers'; import { CREATE_STEP } from './index'; import { CATEGORY_UI } from 'api/constants'; +import defaultUserImg from 'static/images/default-user.jpg'; import './Review.less'; interface OwnProps { @@ -68,11 +68,17 @@ class CreateReview extends React.Component { }, ], }, - // { - // step: CREATE_STEP.TEAM, - // name: 'Team', - // fields: [], - // }, + { + step: CREATE_STEP.TEAM, + name: 'Team', + fields: [ + { + key: 'team', + content: , + error: errors.team && errors.team.join(' '), + }, + ], + }, { step: CREATE_STEP.DETAILS, name: 'Details', @@ -178,7 +184,11 @@ export default connect(state => ({ form: state.create.form, }))(CreateReview); -const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => ( +const ReviewMilestones = ({ + milestones, +}: { + milestones: AppState['create']['form']['milestones']; +}) => ( {milestones.map(m => ( @@ -195,3 +205,17 @@ const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => ( ))} ); + +const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => ( +
+ {team.map((u, idx) => ( +
+ +
+
{u.name}
+
{u.title}
+
+
+ ))} +
+); diff --git a/frontend/client/components/CreateFlow/Team.less b/frontend/client/components/CreateFlow/Team.less new file mode 100644 index 00000000..16dbf8e5 --- /dev/null +++ b/frontend/client/components/CreateFlow/Team.less @@ -0,0 +1,53 @@ +.TeamForm { + max-width: 660px; + padding: 0 1rem; + width: 100%; + margin: 0 auto; + + &-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 #2ecc71; + color: #2ecc71; + border-radius: 8px; + font-size: 2rem; + } + + &-text { + text-align: left; + + &-title { + font-size: 1.6rem; + font-weight: 300; + color: #2ecc71; + } + + &-subtitle { + opacity: 0.7; + font-size: 1rem; + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx index b8e5561a..386cb392 100644 --- a/frontend/client/components/CreateFlow/Team.tsx +++ b/frontend/client/components/CreateFlow/Team.tsx @@ -1,29 +1,101 @@ import React from 'react'; -import Placeholder from 'components/Placeholder'; -import { CreateFormState } from 'modules/create/types'; +import { Icon } from 'antd'; +import { CreateFormState, TeamMember } from 'modules/create/types'; +import TeamMemberComponent from './TeamMember'; +import './Team.less'; -type State = object; +interface State { + team: TeamMember[]; +} interface Props { initialState?: Partial; updateForm(form: Partial): void; } -export default class CreateFlowTeam extends React.Component { +const MAX_TEAM_SIZE = 6; +const DEFAULT_STATE: State = { + team: [ + { + name: '', + title: '', + avatarUrl: '', + ethAddress: '', + emailAddress: '', + socialAccounts: {}, + }, + ], +}; + +export default class CreateFlowTeam extends React.PureComponent { constructor(props: Props) { super(props); this.state = { + ...DEFAULT_STATE, ...(props.initialState || {}), }; + + // Don't allow for empty team array + // TODO: Default first user to auth'd user + if (!this.state.team.length) { + this.state = { + ...this.state, + team: [...DEFAULT_STATE.team], + }; + } } render() { + const { team } = this.state; + return ( - +
+ {team.map((user, idx) => ( + + ))} + {team.length < MAX_TEAM_SIZE && ( + + )} +
); } + + private handleChange = (user: TeamMember, idx: number) => { + const team = [...this.state.team]; + team[idx] = user; + this.setState({ team }); + this.props.updateForm({ team }); + }; + + private addMember = () => { + const team = [...this.state.team, { ...DEFAULT_STATE.team[0] }]; + this.setState({ team }); + this.props.updateForm({ team }); + }; + + 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 }); + }; } diff --git a/frontend/client/components/CreateFlow/TeamMember.less b/frontend/client/components/CreateFlow/TeamMember.less new file mode 100644 index 00000000..547e4acc --- /dev/null +++ b/frontend/client/components/CreateFlow/TeamMember.less @@ -0,0 +1,122 @@ +.TeamMember { + position: relative; + display: flex; + align-items: center; + padding: 1rem; + margin: 0 auto 1rem; + background: #FFF; + box-shadow: 0 1px 2px rgba(#000, 0.2); + + &.is-editing { + align-items: flex-start; + } + + &-avatar { + position: relative; + height: 7.5rem; + width: 7.5rem; + margin-right: 1.25rem; + + img { + height: 100%; + width: 100%; + border-radius: 8px; + } + + &-change { + position: absolute; + top: 100%; + left: 50%; + transform: translateY(1rem) translateX(-50%); + } + } + + &-info { + flex: 1; + + // Read only view + &-name { + font-size: 1.6rem; + font-weight: 300; + } + + &-title { + font-size: 1rem; + opacity: 0.7; + margin-bottom: 0.5rem; + } + + &-social { + display: flex; + + &-icon { + position: relative; + height: 1.3rem; + font-size: 1.3rem; + margin-right: 1rem; + opacity: 0.2; + + &.is-active { + opacity: 1; + } + + &:last-child { + margin: 0; + } + + &-check { + position: absolute; + font-size: 0.8rem; + bottom: 0; + right: 0; + transform: translate(50%, 50%); + color: #2ecc71; + background: #FFF; + border: 1px solid #FFF; + border-radius: 100%; + } + } + } + + &-edit { + position: absolute; + bottom: 1.5rem; + right: 1rem; + opacity: 0.5; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + + &-remove { + position: absolute; + top: 0.5rem; + right: 1rem; + font-size: 1rem; + opacity: 0.3; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + + // Edit form + .ant-form-item { + margin-bottom: 0.25rem; + } + + .ant-btn { + margin-right: 0.5rem; + + &:last-child { + margin: 0; + } + } + } + + + +} \ No newline at end of file diff --git a/frontend/client/components/CreateFlow/TeamMember.tsx b/frontend/client/components/CreateFlow/TeamMember.tsx new file mode 100644 index 00000000..1ade41e8 --- /dev/null +++ b/frontend/client/components/CreateFlow/TeamMember.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import classnames from 'classnames'; +import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd'; +import { SOCIAL_TYPE, SOCIAL_INFO } from 'utils/social'; +import { TeamMember } from 'modules/create/types'; +import { getCreateTeamMemberError } from 'modules/create/utils'; +import defaultUserImg from 'static/images/default-user.jpg'; +import './TeamMember.less'; + +interface Props { + index: number; + user: TeamMember; + initialEditingState?: boolean; + onChange(user: TeamMember, index: number): void; + onRemove(index: number): void; +} + +interface State { + fields: TeamMember; + isEditing: boolean; +} + +export default class CreateFlowTeamMember extends React.PureComponent { + state: State = { + fields: { ...this.props.user }, + isEditing: this.props.initialEditingState || false, + }; + + 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; + + return ( +
+
+ + {isEditing && ( + + )} +
+
+ {isEditing ? ( +
+ + + + + + + + + + + + + + + + + + + + + + + {Object.values(SOCIAL_INFO).map(s => ( + + + this.handleSocialChange(ev, s.type)} + addonBefore={s.icon} + /> + + + ))} + + + {!isMissingField && + error && ( + + )} + + + + + + + ) : ( + <> +
{user.name || No name}
+
+ {user.title || No title} +
+
+ {Object.values(SOCIAL_INFO).map(s => { + const account = user.socialAccounts[s.type]; + const cn = classnames( + 'TeamMember-info-social-icon', + account && 'is-active', + ); + return ( +
+ {s.icon} + {account && ( + + )} +
+ ); + })} +
+ + {index !== 0 && ( + + )} + + )} +
+
+ ); + } + + private toggleEditing = (ev?: React.SyntheticEvent) => { + 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) => { + const { name, value } = ev.currentTarget; + this.setState({ + fields: { + ...this.state.fields, + [name as any]: value, + }, + }); + }; + + private handleSocialChange = ( + ev: React.ChangeEvent, + 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); + }; +} diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index f29370a5..86e87540 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,10 +1,36 @@ import { PROPOSAL_CATEGORY } from 'api/constants'; +import { CreateFormState } from 'modules/create/types'; -const createExampleProposal = (payOutAddress: string, trustees: string[]) => { +const createExampleProposal = ( + payOutAddress: string, + trustees: string[], +): CreateFormState => { return { title: 'Grant.io T-Shirts', brief: "The most stylish wear, sporting your favorite brand's logo", category: PROPOSAL_CATEGORY.COMMUNITY, + team: [ + { + name: 'John Smith', + title: 'CEO of Grant.io', + avatarUrl: `https://randomuser.me/api/portraits/men/${Math.floor( + Math.random() * 80, + )}.jpg`, + ethAddress: payOutAddress, + emailAddress: 'test@grant.io', + socialAccounts: {}, + }, + { + name: 'Jane Smith', + title: 'T-Shirt Designer', + avatarUrl: `https://randomuser.me/api/portraits/women/${Math.floor( + Math.random() * 80, + )}.jpg`, + ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', + emailAddress: 'designer@tshirt.com', + socialAccounts: {}, + }, + ], details: '![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000', amountToRaise: '5', diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index c893b8d5..623a9db9 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -6,7 +6,7 @@ import qs from 'query-string'; import { withRouter, RouteComponentProps } from 'react-router'; import { debounce } from 'underscore'; import Basics from './Basics'; -// import Team from './Team'; +import Team from './Team'; import Details from './Details'; import Milestones from './Milestones'; import Governance from './Governance'; @@ -25,7 +25,7 @@ import './index.less'; export enum CREATE_STEP { BASICS = 'BASICS', - // TEAM = 'TEAM', + TEAM = 'TEAM', DETAILS = 'DETAILS', MILESTONES = 'MILESTONES', GOVERNANCE = 'GOVERNANCE', @@ -34,7 +34,7 @@ export enum CREATE_STEP { const STEP_ORDER = [ CREATE_STEP.BASICS, - // CREATE_STEP.TEAM, + CREATE_STEP.TEAM, CREATE_STEP.DETAILS, CREATE_STEP.MILESTONES, CREATE_STEP.GOVERNANCE, @@ -57,14 +57,14 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = { 'You don’t have to fill out everything at once right now, you can come back later.', component: Basics, }, - // [CREATE_STEP.TEAM]: { - // short: 'Team', - // title: 'Assemble your team', - // subtitle: 'Let everyone know if you’re flying solo, or who you’re working with', - // help: - // 'More team members, real names, and linked social accounts adds legitimacy to your proposal', - // component: Team, - // }, + [CREATE_STEP.TEAM]: { + short: 'Team', + title: 'Assemble your team', + subtitle: 'Let everyone know if you’re flying solo, or who you’re working with', + help: + 'More team members, real names, and linked social accounts adds legitimacy to your proposal', + component: Team, + }, [CREATE_STEP.DETAILS]: { short: 'Details', title: 'Dive into the details', @@ -198,7 +198,7 @@ class CreateFlow extends React.Component {
- {STEP_ORDER.slice(0, 4).map(s => ( + {STEP_ORDER.slice(0, 5).map(s => ( { + if (!u.name || !u.title || !u.emailAddress || !u.ethAddress) { + didTeamError = true; + return ''; + } + + const err = getCreateTeamMemberError(u); + didTeamError = didTeamError || !!err; + return err; + }); + if (didTeamError) { + errors.team = teamErrors; + } + return errors; } +export function getCreateTeamMemberError(user: TeamMember) { + if (user.name.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)) { + return 'That doesn’t look like a valid ETH address'; + } + + return ''; +} + function milestoneToMilestoneAmount(milestone: Milestone, raiseGoal: Wei) { return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent.toString())); } @@ -157,6 +192,7 @@ export function formToBackendData(form: CreateFormState): ProposalBackendData { title: form.title, category: form.category, content: form.details, + team: form.team, }; } diff --git a/frontend/client/modules/web3/actions.ts b/frontend/client/modules/web3/actions.ts index c92a653a..8ec0dc52 100644 --- a/frontend/client/modules/web3/actions.ts +++ b/frontend/client/modules/web3/actions.ts @@ -8,6 +8,7 @@ import { fetchProposal, fetchProposals } from 'modules/proposals/actions'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { AppState } from 'store/reducers'; import { Wei } from 'utils/units'; +import { TeamMember } from 'modules/create/types'; type GetState = () => AppState; @@ -112,6 +113,7 @@ export interface ProposalBackendData { title: string; content: string; category: PROPOSAL_CATEGORY; + team: TeamMember[]; } export type TCreateCrowdFund = typeof createCrowdFund; @@ -136,7 +138,7 @@ export function createCrowdFund( immediateFirstMilestonePayout, } = contractData; - const { content, title, category } = backendData; + const { content, title, category, team } = backendData; const state = getState(); const accounts = state.web3.accounts; @@ -163,6 +165,7 @@ export function createCrowdFund( title, milestones, category, + team, }); dispatch({ type: types.CROWD_FUND_CREATED, diff --git a/frontend/client/static/images/keybase.svg b/frontend/client/static/images/keybase.svg new file mode 100644 index 00000000..98649b70 --- /dev/null +++ b/frontend/client/static/images/keybase.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/client/typings/images.d.ts b/frontend/client/typings/images.d.ts index cf741001..359abcd1 100644 --- a/frontend/client/typings/images.d.ts +++ b/frontend/client/typings/images.d.ts @@ -1,5 +1,6 @@ declare module '*.svg' { - const content: string; + import React from 'react'; + const content: React.ReactComponent>; export default content; } diff --git a/frontend/client/utils/social.tsx b/frontend/client/utils/social.tsx new file mode 100644 index 00000000..34881a58 --- /dev/null +++ b/frontend/client/utils/social.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Icon } from 'antd'; +import keybaseIcon from 'static/images/keybase.svg'; + +export enum SOCIAL_TYPE { + GITHUB = 'GITHUB', + TWITTER = 'TWITTER', + LINKEDIN = 'LINKEDIN', + KEYBASE = 'KEYBASE', +} + +export interface SocialInfo { + type: SOCIAL_TYPE; + name: string; + format: string; + icon: React.ReactNode; +} + +const accountNameRegex = '([a-zA-Z0-9-_]*)'; +export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = { + [SOCIAL_TYPE.GITHUB]: { + type: SOCIAL_TYPE.GITHUB, + name: 'Github', + format: `https://github.com/${accountNameRegex}`, + icon: , + }, + [SOCIAL_TYPE.TWITTER]: { + type: SOCIAL_TYPE.TWITTER, + name: 'Twitter', + format: `https://twitter.com/${accountNameRegex}`, + icon: , + }, + [SOCIAL_TYPE.LINKEDIN]: { + type: SOCIAL_TYPE.LINKEDIN, + name: 'LinkedIn', + format: `https://linkedin.com/in/${accountNameRegex}`, + icon: , + }, + [SOCIAL_TYPE.KEYBASE]: { + type: SOCIAL_TYPE.KEYBASE, + name: 'KeyBase', + format: `https://keybase.io/${accountNameRegex}`, + icon: , + }, +}; + +export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>; + +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.keys(accounts).map((key: SOCIAL_TYPE) => { + return socialAccountToUrl(accounts[key], key); + }); +} From e47b5987392ce9b330fcf007b990bc82f77b06b4 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Thu, 27 Sep 2018 16:39:37 -0400 Subject: [PATCH 04/18] Functioning proposal teams (pt 2 - the views) (#116) * Team create flow step * Show team on review step. * Fix image types. * Get team into ideal format. Properly post it to backend. * Validate team forms and show errors. * Adjust team member buttons. * Add social medias to examples. * Transform post and get responses to align with the TeamMember type. * Split out user row and address row components. Use user row in the team block on proposal view. * Use team on proposal card. Make user avatar component, use in create flow. * Fix proposal preview. * Fix up stories, add new one for UserRow --- frontend/client/api/api.ts | 26 ++++---- .../client/components/AddressRow/index.tsx | 25 +++++++ .../client/components/AddressRow/style.less | 47 ++++++++++++++ .../components/CreateFlow/TeamMember.less | 2 +- .../components/CreateFlow/TeamMember.tsx | 4 +- .../client/components/CreateFlow/example.ts | 11 +++- frontend/client/components/Identicon.tsx | 6 +- .../Proposal/Contributors/index.tsx | 4 +- .../components/Proposal/TeamBlock/index.tsx | 12 ++-- frontend/client/components/Proposal/index.tsx | 2 +- .../Proposals/ProposalCard/index.tsx | 24 +++++-- .../Proposals/ProposalCard/style.less | 7 +- frontend/client/components/UserAvatar.tsx | 21 ++++++ frontend/client/components/UserRow/index.tsx | 17 ++--- frontend/client/components/UserRow/style.less | 2 +- frontend/client/modules/create/types.ts | 1 + frontend/client/modules/create/utils.ts | 2 +- frontend/client/modules/proposals/reducers.ts | 3 +- frontend/client/utils/api.ts | 28 ++++++++ frontend/stories/AddressRow.tsx | 0 frontend/stories/UserRow.story.tsx | 65 +++++++++++++++++++ frontend/stories/props.tsx | 37 +++++++---- 22 files changed, 279 insertions(+), 67 deletions(-) create mode 100644 frontend/client/components/AddressRow/index.tsx create mode 100644 frontend/client/components/AddressRow/style.less create mode 100644 frontend/client/components/UserAvatar.tsx create mode 100644 frontend/client/utils/api.ts create mode 100644 frontend/stories/AddressRow.tsx create mode 100644 frontend/stories/UserRow.story.tsx diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index de638bd9..d9c7b601 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -1,15 +1,24 @@ import axios from './axios'; import { Proposal } from 'modules/proposals/reducers'; import { TeamMember } from 'modules/create/types'; -import { socialAccountsToUrls } from 'utils/social'; +import { formatTeamMemberForPost, formatTeamMemberFromGet } from 'utils/api'; import { PROPOSAL_CATEGORY } from './constants'; export function getProposals(): Promise<{ data: Proposal[] }> { - return axios.get('/api/v1/proposals/'); + return axios.get('/api/v1/proposals/').then(res => { + res.data = res.data.map((proposal: any) => { + proposal.team = proposal.team.map(formatTeamMemberFromGet); + return proposal; + }); + return res; + }); } export function getProposal(proposalId: number | string): Promise<{ data: Proposal }> { - return axios.get(`/api/v1/proposals/${proposalId}`); + return axios.get(`/api/v1/proposals/${proposalId}`).then(res => { + res.data.team = res.data.team.map(formatTeamMemberFromGet); + return res; + }); } export function getProposalComments(proposalId: number | string) { @@ -33,15 +42,6 @@ export function postProposal(payload: { return axios.post(`/api/v1/proposals/`, { ...payload, // Team has a different shape for POST - team: payload.team.map(u => ({ - displayName: u.name, - title: u.title, - accountAddress: u.ethAddress, - emailAddress: u.emailAddress, - avatar: { link: u.avatarUrl }, - socialMedias: socialAccountsToUrls(u.socialAccounts).map(url => ({ - link: url, - })), - })), + team: payload.team.map(formatTeamMemberForPost), }); } diff --git a/frontend/client/components/AddressRow/index.tsx b/frontend/client/components/AddressRow/index.tsx new file mode 100644 index 00000000..22d9e1c7 --- /dev/null +++ b/frontend/client/components/AddressRow/index.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import ShortAddress from 'components/ShortAddress'; +import Identicon from 'components/Identicon'; +import './style.less'; + +interface Props { + address: string; + secondary?: React.ReactNode; +} + +const AddressRow = ({ address, secondary }: Props) => ( +
+
+ +
+
+
+ +
+ {secondary &&

{secondary}

} +
+
+); + +export default AddressRow; diff --git a/frontend/client/components/AddressRow/style.less b/frontend/client/components/AddressRow/style.less new file mode 100644 index 00000000..4a1080e4 --- /dev/null +++ b/frontend/client/components/AddressRow/style.less @@ -0,0 +1,47 @@ +@height: 3rem; + +.AddressRow { + position: relative; + display: flex; + height: @height; + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + + &-avatar { + display: block; + height: @height; + width: @height; + margin-right: 0.75rem; + + img { + width: 100%; + border-radius: 4px; + } + } + + &-info { + flex: 1; + min-width: 0; + + &-main { + font-size: 1.1rem; + margin-bottom: 0.1rem; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &-secondary { + font-size: 0.9rem; + opacity: 0.7; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} diff --git a/frontend/client/components/CreateFlow/TeamMember.less b/frontend/client/components/CreateFlow/TeamMember.less index 547e4acc..83ab32bb 100644 --- a/frontend/client/components/CreateFlow/TeamMember.less +++ b/frontend/client/components/CreateFlow/TeamMember.less @@ -17,7 +17,7 @@ width: 7.5rem; margin-right: 1.25rem; - img { + &-img { height: 100%; width: 100%; border-radius: 8px; diff --git a/frontend/client/components/CreateFlow/TeamMember.tsx b/frontend/client/components/CreateFlow/TeamMember.tsx index 1ade41e8..69daa021 100644 --- a/frontend/client/components/CreateFlow/TeamMember.tsx +++ b/frontend/client/components/CreateFlow/TeamMember.tsx @@ -4,7 +4,7 @@ import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd'; import { SOCIAL_TYPE, SOCIAL_INFO } from 'utils/social'; import { TeamMember } from 'modules/create/types'; import { getCreateTeamMemberError } from 'modules/create/utils'; -import defaultUserImg from 'static/images/default-user.jpg'; +import UserAvatar from 'components/UserAvatar'; import './TeamMember.less'; interface Props { @@ -37,7 +37,7 @@ export default class CreateFlowTeamMember extends React.PureComponent
- + {isEditing && (
- +
diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index c62ee723..96bcf541 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -11,7 +11,7 @@ import * as web3Actions from 'modules/web3/actions'; import { AppState } from 'store/reducers'; import { connect } from 'react-redux'; import { compose } from 'recompose'; -import Identicon from 'components/Identicon'; +import UserAvatar from 'components/UserAvatar'; import UnitDisplay from 'components/UnitDisplay'; interface Props extends ProposalWithCrowdFund { @@ -24,8 +24,15 @@ export class ProposalCard extends React.Component { if (this.state.redirect) { return ; } - const { title, proposalId, category, dateCreated, web3, crowdFund } = this.props; - const team = [...this.props.team].reverse(); + const { + title, + proposalId, + category, + dateCreated, + web3, + crowdFund, + team, + } = this.props; if (!web3) { return ; @@ -58,12 +65,15 @@ export class ProposalCard extends React.Component {
- {team[0].accountAddress}{' '} - {team.length > 1 && +{team.length - 1} other} + {team[0].name} {team.length > 1 && +{team.length - 1} other}
- {team.reverse().map(u => ( - + {[...team].reverse().map((u, idx) => ( + ))}
diff --git a/frontend/client/components/Proposals/ProposalCard/style.less b/frontend/client/components/Proposals/ProposalCard/style.less index f1aefe09..8034fb07 100644 --- a/frontend/client/components/Proposals/ProposalCard/style.less +++ b/frontend/client/components/Proposals/ProposalCard/style.less @@ -51,11 +51,12 @@ flex-direction: row-reverse; margin-left: 1.25rem; - img { - width: 1.5rem; - height: 1.5rem; + &-avatar { + width: 1.8rem; + height: 1.8rem; margin-left: -0.75rem; border-radius: 100%; + border: 2px solid #FFF; } } } diff --git a/frontend/client/components/UserAvatar.tsx b/frontend/client/components/UserAvatar.tsx new file mode 100644 index 00000000..31744146 --- /dev/null +++ b/frontend/client/components/UserAvatar.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Identicon from 'components/Identicon'; +import { TeamMember } from 'modules/create/types'; +import defaultUserImg from 'static/images/default-user.jpg'; + +interface Props { + user: TeamMember; + className?: string; +} + +const UserAvatar: React.SFC = ({ user, className }) => { + if (user.avatarUrl) { + return ; + } else if (user.ethAddress) { + return ; + } else { + return ; + } +}; + +export default UserAvatar; diff --git a/frontend/client/components/UserRow/index.tsx b/frontend/client/components/UserRow/index.tsx index 871094cf..7c5a93fa 100644 --- a/frontend/client/components/UserRow/index.tsx +++ b/frontend/client/components/UserRow/index.tsx @@ -1,23 +1,20 @@ import React from 'react'; -import ShortAddress from 'components/ShortAddress'; -import Identicon from 'components/Identicon'; +import UserAvatar from 'components/UserAvatar'; +import { TeamMember } from 'modules/create/types'; import './style.less'; interface Props { - address: string; - secondary?: React.ReactNode; + user: TeamMember; } -const UserRow = ({ address, secondary }: Props) => ( +const UserRow = ({ user }: Props) => (
- +
-
- -
- {secondary &&

{secondary}

} +
{user.name}
+

{user.title}

); diff --git a/frontend/client/components/UserRow/style.less b/frontend/client/components/UserRow/style.less index d6423494..3c479e9e 100644 --- a/frontend/client/components/UserRow/style.less +++ b/frontend/client/components/UserRow/style.less @@ -16,7 +16,7 @@ width: @height; margin-right: 0.75rem; - img { + &-img { width: 100%; border-radius: 4px; } diff --git a/frontend/client/modules/create/types.ts b/frontend/client/modules/create/types.ts index 8a33bcc1..692faf2a 100644 --- a/frontend/client/modules/create/types.ts +++ b/frontend/client/modules/create/types.ts @@ -32,6 +32,7 @@ export interface Milestone { immediatePayout: boolean; } +// TODO: Merge this or extend the `User` type in proposals/reducers.ts export interface TeamMember { name: string; title: string; diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index a1aeb58e..bc8f1ab6 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -209,7 +209,7 @@ export function makeProposalPreviewFromForm( body: form.details, stage: 'preview', category: form.category, - team: [], + team: form.team, milestones: form.milestones.map((m, idx) => ({ index: idx, title: m.title, diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 2e6cd324..a6c5aa68 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -2,6 +2,7 @@ import types from './types'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { Wei } from 'utils/units'; import { findComment } from 'utils/helpers'; +import { TeamMember } from 'modules/create/types'; export interface User { accountAddress: string; @@ -77,7 +78,7 @@ export interface Proposal { stage: string; category: PROPOSAL_CATEGORY; milestones: ProposalMilestone[]; - team: User[]; + team: TeamMember[]; } export interface ProposalWithCrowdFund extends Proposal { diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts new file mode 100644 index 00000000..b1df8457 --- /dev/null +++ b/frontend/client/utils/api.ts @@ -0,0 +1,28 @@ +import { TeamMember } from 'modules/create/types'; +import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social'; + +export function formatTeamMemberForPost(user: TeamMember) { + return { + displayName: user.name, + title: user.title, + accountAddress: user.ethAddress, + emailAddress: user.emailAddress, + avatar: { 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.imageUrl, + socialAccounts: socialUrlsToAccounts( + user.socialMedias.map((sm: any) => sm.socialMediaLink), + ), + }; +} diff --git a/frontend/stories/AddressRow.tsx b/frontend/stories/AddressRow.tsx new file mode 100644 index 00000000..e69de29b diff --git a/frontend/stories/UserRow.story.tsx b/frontend/stories/UserRow.story.tsx new file mode 100644 index 00000000..3329298e --- /dev/null +++ b/frontend/stories/UserRow.story.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; + +import 'components/UserRow/style.less'; +import UserRow from 'components/UserRow'; + +const user = { + name: 'Dana Hayes', + title: 'QA Engineer', + avatarUrl: 'https://randomuser.me/api/portraits/women/19.jpg', + ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', + emailAddress: 'test@test.test', + socialAccounts: {}, +}; + +const cases = [ + { + disp: 'Full User', + props: { + user: { + ...user, + }, + }, + }, + { + disp: 'ETH Address Only User', + props: { + user: { + ...user, + avatarUrl: '', + }, + }, + }, + { + disp: 'No Avatar, No ETH Address User', + props: { + user: { + ...user, + avatarUrl: '', + ethAddress: '', + }, + }, + }, + { + disp: 'Long text user', + props: { + user: { + ...user, + name: 'Dr. Baron Longnamivitch von Testeronomous III Esq.', + title: 'Amazing person, all around cool neat-o guy, 10/10 would order again', + }, + }, + }, +]; + +storiesOf('UserRow', module).add('all', () => ( +
+ {cases.map(c => ( +
+
{`${c.disp}`}
+ +
+ ))} +
+)); diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 316c26ef..cedd8817 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -1,4 +1,9 @@ -import { Contributor, Milestone, MILESTONE_STATE } from 'modules/proposals/reducers'; +import { + Contributor, + Milestone, + MILESTONE_STATE, + ProposalWithCrowdFund, +} from 'modules/proposals/reducers'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { fundCrowdFund, @@ -155,7 +160,7 @@ export function getProposalWithCrowdFund({ Object.assign(milestones[idx], mso); }); - const proposal = { + const proposal: ProposalWithCrowdFund = { proposalId: '0x033fDc6C01DC2385118C7bAAB88093e22B8F0710', dateCreated: created / 1000, title: 'Crowdfund Title', @@ -164,25 +169,28 @@ export function getProposalWithCrowdFund({ category: PROPOSAL_CATEGORY.COMMUNITY, team: [ { - accountAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7', + name: 'Test Proposer', title: '', - userid: 1, - username: '', - avatar: { '120x120': '' }, + ethAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7', + emailAddress: '', + avatarUrl: '', + socialAccounts: {}, }, { - accountAddress: '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7', + name: 'Test Proposer', title: '', - userid: 2, - username: '', - avatar: { '120x120': '' }, + ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', + emailAddress: '', + avatarUrl: '', + socialAccounts: {}, }, { - accountAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', + name: 'Test Proposer', title: '', - userid: 3, - username: '', - avatar: { '120x120': '' }, + ethAddress: '0x529104532a9779ea9eae0c1e325b3368e0f8add4', + emailAddress: '', + avatarUrl: '', + socialAccounts: {}, }, ], milestones, @@ -191,6 +199,7 @@ export function getProposalWithCrowdFund({ trustees: [ '0x0c7C6178AD0618Bf289eFd5E1Ff9Ada25fC3bDE7', '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', + '0x529104532a9779ea9eae0c1e325b3368e0f8add4', ], contributors, milestones, From 73bbb1e7cd1d0309a145c2d96ebd5eb6ce0bd611 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Thu, 27 Sep 2018 22:03:53 -0500 Subject: [PATCH 05/18] Add 404 Page (#118) --- frontend/client/Routes.tsx | 5 +++-- frontend/client/components/LinkButton.tsx | 26 ++++++++++++++++++++++ frontend/client/pages/exception.tsx | 27 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 frontend/client/components/LinkButton.tsx create mode 100644 frontend/client/pages/exception.tsx diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index 9ae1a8ed..c8d5a5d4 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { hot } from 'react-hot-loader'; -import { Switch, Route, Redirect } from 'react-router'; +import { Switch, Route } from 'react-router'; import loadable from 'loadable-components'; // wrap components in loadable...import & they will be split @@ -8,6 +8,7 @@ const Home = loadable(() => import('pages/index')); const Create = loadable(() => import('pages/create')); const Proposals = loadable(() => import('pages/proposals')); const Proposal = loadable(() => import('pages/proposal')); +const Exception = loadable(() => import('pages/exception')); import 'styles/style.less'; @@ -19,7 +20,7 @@ class Routes extends React.Component { - } /> + } /> ); } diff --git a/frontend/client/components/LinkButton.tsx b/frontend/client/components/LinkButton.tsx new file mode 100644 index 00000000..6d4276ad --- /dev/null +++ b/frontend/client/components/LinkButton.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router'; +import { Button } from 'antd'; +import { BaseButtonProps } from 'antd/lib/button/button'; + +interface OwnProps { + to: string; +} + +type Props = OwnProps & BaseButtonProps & RouteComponentProps; + +class LinkButton extends React.Component { + render() { + const { history, to, staticContext, ...rest } = this.props; + return ( + + ))} +
+ ); + } +} diff --git a/frontend/client/components/AuthFlow/SignIn.tsx b/frontend/client/components/AuthFlow/SignIn.tsx new file mode 100644 index 00000000..4cf2316a --- /dev/null +++ b/frontend/client/components/AuthFlow/SignIn.tsx @@ -0,0 +1,3 @@ +import React from 'react'; + +export default () =>

Hi

; diff --git a/frontend/client/components/AuthFlow/SignUp.less b/frontend/client/components/AuthFlow/SignUp.less new file mode 100644 index 00000000..7a278a34 --- /dev/null +++ b/frontend/client/components/AuthFlow/SignUp.less @@ -0,0 +1,47 @@ +.SignUp { + &-container { + width: 100%; + max-width: 460px; + margin: 0 auto; + padding: 1rem; + box-shadow: 0 1px 2px rgba(#000, 0.2); + } + + &-identity { + display: flex; + align-items: center; + margin-bottom: 1rem; + + &-identicon { + border-radius: 100%; + width: 3.6rem; + height: 3.6rem; + margin-right: 0.75rem; + box-shadow: 0 1px 2px rgba(#000, 0.3); + } + + &-address { + width: 0; + flex: 1 1 auto; + font-size: 1rem; + opacity: 0.8; + } + } + + &-form { + &-item { + margin-bottom: 0.4rem; + + .ant-form-item-label { + padding-bottom: 0.2rem; + } + } + } + + &-back { + margin-top: 2rem; + opacity: 0.7; + font-size: 0.8rem; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/SignUp.tsx b/frontend/client/components/AuthFlow/SignUp.tsx new file mode 100644 index 00000000..44dc230b --- /dev/null +++ b/frontend/client/components/AuthFlow/SignUp.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Form, Input, Button } from 'antd'; +import Identicon from 'components/Identicon'; +import ShortAddress from 'components/ShortAddress'; +import { AUTH_PROVIDER } from 'utils/auth'; +import { authActions } from 'modules/auth'; +import { AppState } from 'store/reducers'; +import './SignUp.less'; + +interface StateProps { + isCreatingUser: AppState['auth']['isCreatingUser']; + createUserError: AppState['auth']['createUserError']; +} + +interface DispatchProps { + createUser: typeof authActions['createUser']; +} + +interface OwnProps { + address: string; + provider: AUTH_PROVIDER; + reset(): void; +} + +type Props = StateProps & DispatchProps & OwnProps; + +interface State { + name: string; + email: string; +} + +class SignUp extends React.PureComponent { + state: State = { + name: '', + email: '', + }; + + render() { + const { address, isCreatingUser } = this.props; + const { name, email } = this.state; + + return ( +
+
+
+ + +
+ +
+ + + + + + + + + {} + +
+
+ +

+ Want to use a different identity? Click here. +

+
+ ); + } + + private handleChange = (ev: React.ChangeEvent) => { + const { name, value } = ev.currentTarget; + this.setState({ [name]: value } as any); + }; + + private handleSubmit = (ev: React.FormEvent) => { + const { address, createUser } = this.props; + const { name, email } = this.state; + ev.preventDefault(); + createUser(address, name, email); + }; +} + +export default connect( + state => ({ + isCreatingUser: state.auth.isCreatingUser, + createUserError: state.auth.createUserError, + }), + { + createUser: authActions.createUser, + }, +)(SignUp); diff --git a/frontend/client/components/AuthFlow/index.less b/frontend/client/components/AuthFlow/index.less new file mode 100644 index 00000000..eb7f615b --- /dev/null +++ b/frontend/client/components/AuthFlow/index.less @@ -0,0 +1,14 @@ +.AuthFlow { + &-title { + font-size: 1.8rem; + margin: 0 auto 0.25rem; + text-align: center; + } + + &-subtitle { + font-size: 1.2rem; + margin-bottom: 2rem; + opacity: 0.7; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/index.tsx b/frontend/client/components/AuthFlow/index.tsx new file mode 100644 index 00000000..2c2fbf65 --- /dev/null +++ b/frontend/client/components/AuthFlow/index.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { AUTH_PROVIDER } from 'utils/auth'; +import SignIn from './SignIn'; +import SignUp from './SignUp'; +import SelectProvider from './SelectProvider'; +import ProvideIdentity from './ProvideIdentity'; +import './index.less'; + +interface StateProps { + web3Accounts: AppState['web3']['accounts']; +} + +type Props = StateProps; + +interface State { + provider: AUTH_PROVIDER | null; + address: string | null; +} + +const DEFAULT_STATE: State = { + provider: null, + address: null, +}; + +class AuthFlow extends React.Component { + state: State = { ...DEFAULT_STATE }; + + private pages = { + SIGN_IN: { + title: () => 'Prove your Identity', + subtitle: () => 'Log into your Grant.io account by proving your identity', + render: () => , + }, + SIGN_UP: { + title: () => 'Claim your Identity', + subtitle: () => 'Create a Grant.io account by claiming your identity', + render: () => ( + + ), + }, + SELECT_PROVIDER: { + title: () => 'Provide an Identity', + subtitle: () => + 'Sign in or create a new account by selecting your identity provider', + render: () => , + }, + PROVIDE_IDENTITY: { + title: () => 'Provide an Identity', + subtitle: () => { + switch (this.state.provider) { + case AUTH_PROVIDER.ADDRESS: + return 'Enter your Ethereum Address'; + case AUTH_PROVIDER.LEDGER: + return 'Connect with your Ledger'; + case AUTH_PROVIDER.TREZOR: + return 'Connect with your TREZOR'; + case AUTH_PROVIDER.WEB3: + // TODO: Dynamically use web3 name + return 'Connect with MetaMask'; + } + }, + render: () => ( + + ), + }, + }; + + componentDidMount() { + // If web3 is available, default to it + const { web3Accounts } = this.props; + if (web3Accounts && web3Accounts[0]) { + this.setState({ + provider: AUTH_PROVIDER.WEB3, + address: web3Accounts[0], + }); + } + } + + render() { + const { provider, address } = this.state; + let page; + + if (provider) { + if (address) { + // TODO: If address results in user, show SIGN_IN. + page = this.pages.SIGN_UP; + } else { + page = this.pages.PROVIDE_IDENTITY; + } + } else { + page = this.pages.SELECT_PROVIDER; + } + + return ( +
+

{page.title()}

+

{page.subtitle()}

+
{page.render()}
+
+ ); + } + + private setProvider = (provider: AUTH_PROVIDER) => { + this.setState({ provider }); + }; + + private setAddress = (address: string) => { + this.setState({ address }); + }; + + private resetState = () => { + this.setState({ ...DEFAULT_STATE }); + }; +} + +export default connect(state => ({ + web3Accounts: state.web3.accounts, +}))(AuthFlow); diff --git a/frontend/client/components/AuthFlow/providers/Address.less b/frontend/client/components/AuthFlow/providers/Address.less new file mode 100644 index 00000000..ab2c9b9b --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Address.less @@ -0,0 +1,9 @@ +.AddressProvider { + width: 100%; + max-width: 360px; + margin: -0.5rem auto 0; + + &-address { + margin-bottom: 0.5rem; + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/providers/Address.tsx b/frontend/client/components/AuthFlow/providers/Address.tsx new file mode 100644 index 00000000..f64e7d39 --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Address.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Form, Input, Button } from 'antd'; +import { isValidEthAddress } from 'utils/validators'; +import './Address.less'; + +interface Props { + onSelectAddress(addr: string): void; +} + +interface State { + address: string; +} + +export default class AddressProvider extends React.Component { + state: State = { + address: '', + }; + + render() { + const { address } = this.state; + return ( +
+ + + + + +
+ ); + } + + private handleChange = (ev: React.ChangeEvent) => { + this.setState({ address: ev.currentTarget.value }); + }; + + private handleSubmit = (ev: React.FormEvent) => { + ev.preventDefault(); + this.props.onSelectAddress(this.state.address); + }; +} diff --git a/frontend/client/components/AuthFlow/providers/Ledger.tsx b/frontend/client/components/AuthFlow/providers/Ledger.tsx new file mode 100644 index 00000000..50ca6d5e --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Ledger.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +interface Props { + onSelectAddress(addr: string): void; +} + +export default (_: Props) =>
Not yet implemented
; diff --git a/frontend/client/components/AuthFlow/providers/Trezor.tsx b/frontend/client/components/AuthFlow/providers/Trezor.tsx new file mode 100644 index 00000000..50ca6d5e --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Trezor.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +interface Props { + onSelectAddress(addr: string): void; +} + +export default (_: Props) =>
Not yet implemented
; diff --git a/frontend/client/components/AuthFlow/providers/Web3.less b/frontend/client/components/AuthFlow/providers/Web3.less new file mode 100644 index 00000000..248ff8c1 --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Web3.less @@ -0,0 +1,16 @@ +.Web3Provider { + max-width: 360px; + margin: 0 auto; + text-align: center; + + &-logo { + display: block; + max-width: 120px; + margin: 0 auto 1.5rem; + } + + &-description { + font-size: 0.9rem; + margin-bottom: 1rem; + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/providers/Web3.tsx b/frontend/client/components/AuthFlow/providers/Web3.tsx new file mode 100644 index 00000000..a29d0c01 --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Web3.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Button, Alert } from 'antd'; +import { web3Actions } from 'modules/web3'; +import { AppState } from 'store/reducers'; +import MetamaskIcon from 'static/images/metamask.png'; +import './Web3.less'; + +interface StateProps { + accounts: AppState['web3']['accounts']; + isWeb3Locked: AppState['web3']['isWeb3Locked']; +} + +interface DispatchProps { + setWeb3: typeof web3Actions['setWeb3']; + setAccounts: typeof web3Actions['setAccounts']; +} + +interface OwnProps { + onSelectAddress(addr: string): void; +} + +type Props = StateProps & DispatchProps & OwnProps; + +class Web3Provider extends React.Component { + componentDidUpdate() { + const { accounts } = this.props; + if (accounts && accounts[0]) { + this.props.onSelectAddress(accounts[0]); + } + } + + render() { + const { isWeb3Locked } = this.props; + return ( +
+ +

+ Make sure you have MetaMask or another web3 provider installed and unlocked, + then click below. +

+ {isWeb3Locked && ( + + )} + +
+ ); + } + + private connect = () => { + this.props.setWeb3(); + this.props.setAccounts(); + }; +} + +export default connect( + state => ({ + accounts: state.web3.accounts, + isWeb3Locked: state.web3.isWeb3Locked, + }), + { + setWeb3: web3Actions.setWeb3, + setAccounts: web3Actions.setAccounts, + }, +)(Web3Provider); diff --git a/frontend/client/components/AuthRoute.tsx b/frontend/client/components/AuthRoute.tsx new file mode 100644 index 00000000..f51cb641 --- /dev/null +++ b/frontend/client/components/AuthRoute.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Spin } from 'antd'; +import { Route, Redirect, RouteProps } from 'react-router-dom'; +import { AppState } from 'store/reducers'; + +interface StateProps { + user: AppState['auth']['user']; + isAuthingUser: AppState['auth']['isAuthingUser']; +} + +interface OwnProps { + onlyLoggedOut?: boolean; +} + +type Props = RouteProps & StateProps & OwnProps; + +class AuthRoute extends React.Component { + public render() { + const { user, isAuthingUser, onlyLoggedOut, ...routeProps } = this.props; + + if (isAuthingUser) { + return ; + } else if ((user && !onlyLoggedOut) || (!user && onlyLoggedOut)) { + return ; + } else { + // TODO: redirect to desired destination after auth + // TODO: Show alert that claims they need to be logged in + return ; + } + } +} + +export default connect((state: AppState) => ({ + user: state.auth.user, + isAuthingUser: state.auth.isAuthingUser, +}))(AuthRoute); diff --git a/frontend/client/components/Header/index.tsx b/frontend/client/components/Header/index.tsx index fcad066a..06ee3aed 100644 --- a/frontend/client/components/Header/index.tsx +++ b/frontend/client/components/Header/index.tsx @@ -1,18 +1,59 @@ import React from 'react'; -import { Icon } from 'antd'; +import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; +import { Spin, Icon } from 'antd'; +import Identicon from 'components/Identicon'; +import { web3Actions } from 'modules/web3'; +import { AppState } from 'store/reducers'; import './style.less'; +interface StateProps { + user: AppState['auth']['user']; + isAuthingUser: AppState['auth']['isAuthingUser']; + web3: AppState['web3']['web3']; + accounts: AppState['web3']['accounts']; + accountsLoading: AppState['web3']['accountsLoading']; + accountsError: AppState['web3']['accountsError']; +} + +interface DispatchProps { + setWeb3: typeof web3Actions['setWeb3']; + setAccounts: typeof web3Actions['setAccounts']; +} + interface OwnProps { isTransparent?: boolean; } -type Props = OwnProps; +type Props = StateProps & DispatchProps & OwnProps; + +class Header extends React.Component { + componentDidMount() { + this.props.setWeb3(); + } + + componentDidUpdate() { + const { web3, accounts, accountsLoading, accountsError } = this.props; + if (web3 && !accounts.length && !accountsLoading && !accountsError) { + this.props.setAccounts(); + } + } -export default class Header extends React.Component { render() { - const { isTransparent } = this.props; + const { isTransparent, accounts, accountsLoading, user, isAuthingUser } = this.props; + const isAuthed = !!user; + + let avatar; + if (user) { + // TODO: Load user's avatar as well + avatar = ; + } else if (accounts && accounts[0]) { + avatar = ; + } else if (accountsLoading || isAuthingUser) { + avatar = ; + } + return (
{ ['is-transparent']: isTransparent, })} > - - - - - Explore - +
+ + Browse + + + Start a Proposal + +
Grant.io - - - - - Start a Proposal - +
+ + {isAuthed ? '' : 'Sign in'} + {avatar && ( +
+ {avatar} + {!isAuthed && ( +
+ +
+ )} +
+ )} + +
{!isTransparent &&
Alpha
}
); } } + +export default connect( + state => ({ + user: state.auth.user, + isAuthingUser: state.auth.isAuthingUser, + web3: state.web3.web3, + accounts: state.web3.accounts, + accountsLoading: state.web3.accountsLoading, + accountsError: state.web3.accountsError, + }), + { + setWeb3: web3Actions.setWeb3, + setAccounts: web3Actions.setAccounts, + }, +)(Header); diff --git a/frontend/client/components/Header/style.less b/frontend/client/components/Header/style.less index 2ac2d7f5..318464bd 100644 --- a/frontend/client/components/Header/style.less +++ b/frontend/client/components/Header/style.less @@ -1,4 +1,4 @@ -@header-height: 78px; +@header-height: 62px; @small-query: ~'(max-width: 520px)'; .Header { @@ -15,7 +15,7 @@ color: #333; background: #fff; text-shadow: none; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px rgba(0, 0, 0, 0.1); &.is-transparent { position: absolute; @@ -29,8 +29,8 @@ position: absolute; left: 50%; top: 50%; - transform: translate(-50%, -50%); - font-size: 2.2rem; + transform: translateY(-2px) translate(-50%, -50%); + font-size: 1.8rem; margin: 0; color: inherit; letter-spacing: 0.08rem; @@ -43,46 +43,42 @@ &:focus, &:active { color: inherit; - transform: translateY(-2px) translate(-50%, -50%); + transform: translateY(-4px) translate(-50%, -50%); } } - &-button { - display: block; - background: none; - padding: 0; - font-size: 1.2rem; - font-weight: 300; - color: inherit; - letter-spacing: 0.05rem; - cursor: pointer; - opacity: 0.8; - transition: transform 100ms ease, opacity 100ms ease; + &-links { + display: flex; - &:hover, - &:focus, - &:active { - opacity: 1; - transform: translateY(-1px); + &.is-left { + justify-self: flex-start; + margin-left: -0.75rem; + } + + &.is-right { + justify-self: flex-end; + margin-right: -0.75rem; + } + + &-link { + display: block; + background: none; + padding: 0 0.75rem; + font-size: 1rem; + font-weight: 300; color: inherit; - text-decoration-color: transparent; - } + letter-spacing: 0.05rem; + cursor: pointer; + opacity: 0.8; + transition: transform 100ms ease, opacity 100ms ease; - &-text { - font-size: 1.1rem; - - @media @small-query { - display: none; - } - } - - &-icon { - padding-right: 10px; - - @media @small-query { - padding: 0; - font-weight: 400; - font-size: 1.5rem; + &:hover, + &:focus, + &:active { + opacity: 1; + transform: translateY(-1px); + color: inherit; + text-decoration-color: transparent; } } } @@ -94,16 +90,63 @@ transform: translate(-50%, 50%); background: linear-gradient(to right, #8e2de2, #4a00e0); color: #fff; - width: 80px; - height: 22px; - border-radius: 11px; - line-height: 22px; + width: 70px; + height: 18px; + border-radius: 9px; + line-height: 18px; text-align: center; text-transform: uppercase; letter-spacing: 0.2rem; - font-size: 10px; + font-size: 9px; font-weight: bold; text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } } + +.AuthButton { + display: flex; + align-items: center; + margin-right: -0.2rem; + transform: none !important; + + &:hover { + .anticon { + opacity: 1; + } + } + + &-avatar { + position: relative; + height: 2.4rem; + width: 2.4rem; + margin-left: 0.6rem; + border-radius: 100%; + overflow: hidden; + box-shadow: 0 0.5px 2px rgba(#000, 0.3); + + img { + height: 100%; + width: 100%; + border-radius: 100%; + } + + &-locked { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(#000, 0.2); + + .anticon { + color: #FFF; + font-size: 1.4rem; + opacity: 0.8; + } + } + } +} diff --git a/frontend/client/components/NoAuthRoute.tsx b/frontend/client/components/NoAuthRoute.tsx new file mode 100644 index 00000000..e69de29b diff --git a/frontend/client/components/ShortAddress.tsx b/frontend/client/components/ShortAddress.tsx index 1ee12b40..5ca16606 100644 --- a/frontend/client/components/ShortAddress.tsx +++ b/frontend/client/components/ShortAddress.tsx @@ -1,12 +1,14 @@ import React from 'react'; +import classnames from 'classnames'; import './ShortAddress.less'; interface Props { address: string; + className?: string; } -const ShortAddress = ({ address }: Props) => ( -
+const ShortAddress = ({ address, className }: Props) => ( +
{address.substr(0, 7)}
{address.substr(7, address.length - 5)}
{address.substr(address.length - 5)}
diff --git a/frontend/client/modules/auth/actions.ts b/frontend/client/modules/auth/actions.ts new file mode 100644 index 00000000..aa160a32 --- /dev/null +++ b/frontend/client/modules/auth/actions.ts @@ -0,0 +1,73 @@ +import types from './types'; +import { Dispatch } from 'redux'; +import { sleep } from 'utils/helpers'; +import { AppState } from 'store/reducers'; + +type GetState = () => AppState; + +export function authUser() { + return async (dispatch: Dispatch, getState: GetState) => { + const token = getState().auth.token; + if (!token) { + return; + } + + // TODO: Implement authentication + dispatch({ type: types.AUTH_USER_PENDING }); + await sleep(500); + dispatch({ + type: types.AUTH_USER_REJECTED, + payload: 'Auth not implemented yet', + error: true, + }); + }; +} + +export function createUser(address: string, name: string, email: string) { + return async (dispatch: Dispatch) => { + // TODO: Implement user creation + dispatch({ type: types.CREATE_USER_PENDING }); + await sleep(500); + dispatch({ + type: types.CREATE_USER_FULFILLED, + payload: { + user: { + address, + name, + email, + }, + token: Math.random(), + }, + }); + }; +} + +export function signToken(address: string) { + return async (dispatch: Dispatch) => { + // TODO: Implement signing + dispatch({ type: types.SIGN_TOKEN_PENDING }); + await sleep(500); + dispatch({ + type: types.SIGN_TOKEN_FULFILLED, + payload: { + token: Math.random(), + address, + }, + }); + }; +} + +export function setToken(address: string, signedMessage: string) { + // TODO: Check token for errors + return { + type: types.SIGN_TOKEN_FULFILLED, + payload: { + token: signedMessage, + address, + }, + }; +} + +export function logout() { + return { type: types.LOGOUT }; +} diff --git a/frontend/client/modules/auth/index.ts b/frontend/client/modules/auth/index.ts new file mode 100644 index 00000000..957a4970 --- /dev/null +++ b/frontend/client/modules/auth/index.ts @@ -0,0 +1,7 @@ +import reducers, { AuthState, INITIAL_STATE } from './reducers'; +import * as authActions from './actions'; +import * as authTypes from './types'; + +export { authActions, authTypes, AuthState, INITIAL_STATE }; + +export default reducers; diff --git a/frontend/client/modules/auth/reducers.ts b/frontend/client/modules/auth/reducers.ts new file mode 100644 index 00000000..1bef75a2 --- /dev/null +++ b/frontend/client/modules/auth/reducers.ts @@ -0,0 +1,109 @@ +import types from './types'; + +// TODO: Replace this with full user object once auth is really done +interface AuthedUser { + name: string; + email: string; + address: string; +} + +export interface AuthState { + user: AuthedUser | null; + isAuthingUser: boolean; + authUserError: string | null; + + isCreatingUser: boolean; + createUserError: string | null; + + token: string | null; + tokenAddress: string | null; + isSigningToken: boolean; + signTokenError: string | null; +} + +export const INITIAL_STATE: AuthState = { + user: null, + isAuthingUser: false, + authUserError: null, + + isCreatingUser: false, + createUserError: null, + + token: null, + tokenAddress: null, + isSigningToken: false, + signTokenError: null, +}; + +export default function createReducer(state: AuthState = INITIAL_STATE, action: any) { + switch (action.type) { + case types.AUTH_USER_PENDING: + return { + ...state, + user: null, + isAuthingUser: true, + authUserError: null, + }; + case types.AUTH_USER_FULFILLED: + return { + ...state, + user: action.payload, + isAuthingUser: false, + }; + case types.AUTH_USER_REJECTED: + return { + ...state, + isAuthingUser: false, + authUserError: action.payload, + }; + + case types.CREATE_USER_PENDING: + return { + ...state, + isCreatingUser: true, + createUserError: null, + }; + case types.CREATE_USER_FULFILLED: + return { + ...state, + user: action.payload.user, + token: action.payload.token, + isCreatingUser: false, + }; + case types.CREATE_USER_REJECTED: + return { + ...state, + isCreatingUser: false, + createUserError: action.payload, + }; + + case types.SIGN_TOKEN_PENDING: + return { + ...state, + token: null, + isSigningToken: true, + signTokenError: null, + }; + case types.SIGN_TOKEN_FULFILLED: + return { + ...state, + token: action.payload.token, + tokenAddress: action.payload.address, + isSigningToken: false, + }; + case types.SIGN_TOKEN_REJECTED: + return { + ...state, + isSigningToken: false, + signTokenError: action.payload, + }; + + case types.LOGOUT: + return { + ...state, + user: null, + token: null, + }; + } + return state; +} diff --git a/frontend/client/modules/auth/types.ts b/frontend/client/modules/auth/types.ts new file mode 100644 index 00000000..7a6bc0e8 --- /dev/null +++ b/frontend/client/modules/auth/types.ts @@ -0,0 +1,20 @@ +enum AuthTypes { + AUTH_USER = 'AUTH_USER', + AUTH_USER_PENDING = 'AUTH_USER_PENDING', + AUTH_USER_FULFILLED = 'AUTH_USER_FULFILLED', + AUTH_USER_REJECTED = 'AUTH_USER_REJECTED', + + CREATE_USER = 'CREATE_USER', + CREATE_USER_PENDING = 'CREATE_USER_PENDING', + CREATE_USER_FULFILLED = 'CREATE_USER_FULFILLED', + CREATE_USER_REJECTED = 'CREATE_USER_REJECTED', + + SIGN_TOKEN = 'AUTH_USER', + SIGN_TOKEN_PENDING = 'SIGN_TOKEN_PENDING', + SIGN_TOKEN_FULFILLED = 'SIGN_TOKEN_FULFILLED', + SIGN_TOKEN_REJECTED = 'SIGN_TOKEN_REJECTED', + + LOGOUT = 'LOGOUT', +} + +export default AuthTypes; diff --git a/frontend/client/modules/web3/actions.ts b/frontend/client/modules/web3/actions.ts index 8ec0dc52..cc4139d9 100644 --- a/frontend/client/modules/web3/actions.ts +++ b/frontend/client/modules/web3/actions.ts @@ -25,7 +25,7 @@ function handleWrongNetworkError(dispatch: (action: any) => void) { export type TSetWeb3 = typeof setWeb3; export function setWeb3() { return (dispatch: Dispatch) => { - dispatch({ + return dispatch({ type: types.WEB3, payload: getWeb3(), }); diff --git a/frontend/client/pages/auth.tsx b/frontend/client/pages/auth.tsx new file mode 100644 index 00000000..9d145c4d --- /dev/null +++ b/frontend/client/pages/auth.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import AntWrap from 'components/AntWrap'; +import AuthFlow from 'components/AuthFlow'; + +const SignInPage = () => ( + + + +); + +export default SignInPage; diff --git a/frontend/client/pages/profile.tsx b/frontend/client/pages/profile.tsx new file mode 100644 index 00000000..765c7585 --- /dev/null +++ b/frontend/client/pages/profile.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import AntWrap from 'components/AntWrap'; +import Identicon from 'components/Identicon'; +import { AppState } from 'store/reducers'; + +interface Props { + user: AppState['auth']['user']; +} + +class ProfilePage extends React.Component { + render() { + const { user } = this.props; + return ( + +

Hello, {user && user.name}

+ +
+ ); + } +} + +export default connect((state: AppState) => ({ user: state.auth.user }))(ProfilePage); diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx index 29fb20d0..414f4d8e 100644 --- a/frontend/client/store/reducers.tsx +++ b/frontend/client/store/reducers.tsx @@ -5,21 +5,25 @@ import proposal, { INITIAL_STATE as proposalInitialState, } from 'modules/proposals'; import create, { CreateState, INITIAL_STATE as createInitialState } from 'modules/create'; +import auth, { AuthState, INITIAL_STATE as authInitialState } from 'modules/auth'; export interface AppState { proposal: ProposalState; web3: Web3State; create: CreateState; + auth: AuthState; } export const combineInitialState: AppState = { proposal: proposalInitialState, web3: web3InitialState, create: createInitialState, + auth: authInitialState, }; export default combineReducers({ proposal, web3, create, + auth, }); diff --git a/frontend/client/utils/auth.ts b/frontend/client/utils/auth.ts new file mode 100644 index 00000000..d0519041 --- /dev/null +++ b/frontend/client/utils/auth.ts @@ -0,0 +1,39 @@ +export enum AUTH_PROVIDER { + WEB3 = 'WEB3', + LEDGER = 'LEDGER', + TREZOR = 'TREZOR', + ADDRESS = 'ADDRESS', +} + +interface AuthProvider { + type: AUTH_PROVIDER; + name: string; + canSignMessage: boolean; +} + +export const AUTH_PROVIDERS: { [key in AUTH_PROVIDER]: AuthProvider } = { + [AUTH_PROVIDER.WEB3]: { + type: AUTH_PROVIDER.WEB3, + name: 'MetaMask', // TODO: Set dynamically based on provider + canSignMessage: true, + }, + [AUTH_PROVIDER.LEDGER]: { + type: AUTH_PROVIDER.LEDGER, + name: 'Ledger', + canSignMessage: true, + }, + [AUTH_PROVIDER.TREZOR]: { + type: AUTH_PROVIDER.TREZOR, + name: 'TREZOR', + canSignMessage: true, + }, + [AUTH_PROVIDER.ADDRESS]: { + type: AUTH_PROVIDER.ADDRESS, + name: 'Address', + canSignMessage: false, + }, +}; + +export function generateAuthMessage(address: string): string { + return `I am proving the identity of ${address} on Grant.io`; +} diff --git a/frontend/server/render.tsx b/frontend/server/render.tsx index edcf228f..e114a843 100644 --- a/frontend/server/render.tsx +++ b/frontend/server/render.tsx @@ -61,7 +61,7 @@ const chunkExtractFromLoadables = (loadableState: any) => .filter( (c: any) => c.origins.filter( - (o: any) => o.loc === origin.loc && o.moduleId === origin.moduleId, + (o: any) => origin && o.loc === origin.loc && o.moduleId === origin.moduleId, ).length > 0, ) .reduce((a: string[], c: any) => a.concat(c.files), []); From 7ca1fc8de41561e0d154ab69dd3091a6d2eaec1f Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Mon, 1 Oct 2018 19:22:56 -0400 Subject: [PATCH 08/18] User Authentication UI (Pt 2 - Real Users) (#125) * Check in auth flow work. * More work on auth steps. Check in before redux state. * Create auth reducer and actions * Stubbed out profile page to test auth aware routes. Minor style fixes. * Fill out provider components * Handle missing origin * Fix reducer mistake. Show user info in profile page. * Reflect auth state in header. * tslint * Actual user creation. * Implement sign in * Fix redux types. --- backend/grant/user/views.py | 32 ++++- frontend/client/api/api.ts | 20 ++++ .../client/components/AuthFlow/SignIn.less | 47 ++++++++ .../client/components/AuthFlow/SignIn.tsx | 70 ++++++++++- .../client/components/AuthFlow/SignUp.tsx | 37 ++++-- frontend/client/components/AuthFlow/index.tsx | 44 ++++++- frontend/client/components/Header/index.tsx | 4 +- frontend/client/modules/auth/actions.ts | 113 +++++++++++++----- frontend/client/modules/auth/reducers.ts | 46 +++++-- frontend/client/modules/auth/types.ts | 5 + frontend/client/pages/profile.tsx | 2 +- frontend/client/utils/api.ts | 4 +- 12 files changed, 367 insertions(+), 57 deletions(-) create mode 100644 frontend/client/components/AuthFlow/SignIn.less diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 1a6b3f19..5c432b4e 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -1,7 +1,7 @@ from flask import Blueprint, request from grant import JSONResponse -from .models import User, users_schema, user_schema +from .models import User, users_schema, user_schema, db from ..proposal.models import Proposal, proposal_team blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users') @@ -31,3 +31,33 @@ def get_user(user_identity): return JSONResponse( message="User with account_address or user_identity matching {} not found".format(user_identity), _statusCode=404) + +@blueprint.route("/", methods=["POST"]) +def create_user(): + incoming = request.get_json() + account_address = incoming["accountAddress"] + email_address = incoming["emailAddress"] + display_name = incoming["displayName"] + title = incoming["title"] + + # TODO: Move create and validation stuff into User model + existing_user = User.query.filter( + (User.account_address == account_address) | (User.email_address == email_address)).first() + if existing_user: + return JSONResponse( + message="User with that address or email already exists", + _statusCode=400) + + # TODO: Handle avatar & social stuff too + user = User( + account_address=account_address, + email_address=email_address, + display_name=display_name, + title=title + ) + db.session.add(user) + db.session.flush() + db.session.commit() + + result = user_schema.dump(user) + return JSONResponse(result) \ No newline at end of file diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index d9c7b601..6d26b7be 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -45,3 +45,23 @@ export function postProposal(payload: { team: payload.team.map(formatTeamMemberForPost), }); } + +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 createUser(payload: { + accountAddress: string; + emailAddress: string; + displayName: string; + title: string; + token: string; +}): Promise<{ data: TeamMember }> { + return axios.post(`/api/v1/users/`, payload).then(res => { + res.data = formatTeamMemberFromGet(res.data); + return res; + }); +} diff --git a/frontend/client/components/AuthFlow/SignIn.less b/frontend/client/components/AuthFlow/SignIn.less new file mode 100644 index 00000000..5e7f8525 --- /dev/null +++ b/frontend/client/components/AuthFlow/SignIn.less @@ -0,0 +1,47 @@ +.SignIn { + &-container { + width: 100%; + max-width: 460px; + margin: 0 auto; + padding: 1rem; + box-shadow: 0 1px 2px rgba(#000, 0.2); + } + + &-identity { + display: flex; + align-items: center; + margin-bottom: 1.25rem; + + &-identicon { + border-radius: 100%; + width: 3.6rem; + height: 3.6rem; + margin-right: 0.75rem; + box-shadow: 0 1px 2px rgba(#000, 0.3); + } + + &-info { + width: 0; + flex: 1 1 auto; + + &-name { + font-size: 1.4rem; + } + + &-address { + font-size: 0.8rem; + // Bug: doesn't seem to like opacity, so apply to children + > * { + opacity: 0.7; + } + } + } + } + + &-back { + margin-top: 2rem; + opacity: 0.7; + font-size: 0.8rem; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/SignIn.tsx b/frontend/client/components/AuthFlow/SignIn.tsx index 4cf2316a..6e7e4ddd 100644 --- a/frontend/client/components/AuthFlow/SignIn.tsx +++ b/frontend/client/components/AuthFlow/SignIn.tsx @@ -1,3 +1,71 @@ import React from 'react'; +import { connect } from 'react-redux'; +import { Button } from 'antd'; +import { authActions } from 'modules/auth'; +import { TeamMember } from 'modules/create/types'; +import { AppState } from 'store/reducers'; +import { AUTH_PROVIDER } from 'utils/auth'; +import Identicon from 'components/Identicon'; +import ShortAddress from 'components/ShortAddress'; +import './SignIn.less'; -export default () =>

Hi

; +interface StateProps { + isAuthingUser: AppState['auth']['isAuthingUser']; + authUserError: AppState['auth']['authUserError']; +} + +interface DispatchProps { + authUser: typeof authActions['authUser']; +} + +interface OwnProps { + // TODO: Use common use User type instead + user: TeamMember; + provider: AUTH_PROVIDER; + reset(): void; +} + +type Props = StateProps & DispatchProps & OwnProps; + +class SignIn extends React.Component { + render() { + const { user } = this.props; + return ( +
+
+
+ +
+
{user.name}
+ + + +
+
+ + +
+ +

+ Want to use a different identity? Click here. +

+
+ ); + } + + private authUser = () => { + this.props.authUser(this.props.user.ethAddress); + }; +} + +export default connect( + state => ({ + isAuthingUser: state.auth.isAuthingUser, + authUserError: state.auth.authUserError, + }), + { + authUser: authActions.authUser, + }, +)(SignIn); diff --git a/frontend/client/components/AuthFlow/SignUp.tsx b/frontend/client/components/AuthFlow/SignUp.tsx index 44dc230b..9b290aed 100644 --- a/frontend/client/components/AuthFlow/SignUp.tsx +++ b/frontend/client/components/AuthFlow/SignUp.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Form, Input, Button } from 'antd'; +import { Form, Input, Button, Alert } from 'antd'; import Identicon from 'components/Identicon'; import ShortAddress from 'components/ShortAddress'; import { AUTH_PROVIDER } from 'utils/auth'; @@ -27,18 +27,20 @@ type Props = StateProps & DispatchProps & OwnProps; interface State { name: string; + title: string; email: string; } -class SignUp extends React.PureComponent { +class SignUp extends React.Component { state: State = { name: '', + title: '', email: '', }; render() { - const { address, isCreatingUser } = this.props; - const { name, email } = this.state; + const { address, isCreatingUser, createUserError } = this.props; + const { name, title, email } = this.state; return (
@@ -59,17 +61,24 @@ class SignUp extends React.PureComponent { /> + + + + - {} + + {createUserError && ( + + )}
@@ -95,10 +114,10 @@ class SignUp extends React.PureComponent { }; private handleSubmit = (ev: React.FormEvent) => { - const { address, createUser } = this.props; - const { name, email } = this.state; ev.preventDefault(); - createUser(address, name, email); + const { address, createUser } = this.props; + const { name, title, email } = this.state; + createUser({ address, name, title, email }); }; } diff --git a/frontend/client/components/AuthFlow/index.tsx b/frontend/client/components/AuthFlow/index.tsx index 2c2fbf65..56b7739e 100644 --- a/frontend/client/components/AuthFlow/index.tsx +++ b/frontend/client/components/AuthFlow/index.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { connect } from 'react-redux'; +import { Spin } from 'antd'; import { AppState } from 'store/reducers'; import { AUTH_PROVIDER } from 'utils/auth'; +import { authActions } from 'modules/auth'; import SignIn from './SignIn'; import SignUp from './SignUp'; import SelectProvider from './SelectProvider'; @@ -10,9 +12,15 @@ import './index.less'; interface StateProps { web3Accounts: AppState['web3']['accounts']; + checkedUsers: AppState['auth']['checkedUsers']; + isCheckingUser: AppState['auth']['isCheckingUser']; } -type Props = StateProps; +interface DispatchProps { + checkUser: typeof authActions['checkUser']; +} + +type Props = StateProps & DispatchProps; interface State { provider: AUTH_PROVIDER | null; @@ -31,7 +39,14 @@ class AuthFlow extends React.Component { SIGN_IN: { title: () => 'Prove your Identity', subtitle: () => 'Log into your Grant.io account by proving your identity', - render: () => , + render: () => { + const user = this.props.checkedUsers[this.state.address]; + return ( + user && ( + + ) + ); + }, }, SIGN_UP: { title: () => 'Claim your Identity', @@ -83,17 +98,26 @@ class AuthFlow extends React.Component { provider: AUTH_PROVIDER.WEB3, address: web3Accounts[0], }); + this.props.checkUser(web3Accounts[0]); } } render() { + const { checkedUsers, isCheckingUser } = this.props; const { provider, address } = this.state; + const checkedUser = checkedUsers[address]; let page; if (provider) { if (address) { // TODO: If address results in user, show SIGN_IN. - page = this.pages.SIGN_UP; + if (isCheckingUser) { + return ; + } else if (checkedUser) { + page = this.pages.SIGN_IN; + } else { + page = this.pages.SIGN_UP; + } } else { page = this.pages.PROVIDE_IDENTITY; } @@ -116,6 +140,7 @@ class AuthFlow extends React.Component { private setAddress = (address: string) => { this.setState({ address }); + this.props.checkUser(address); }; private resetState = () => { @@ -123,6 +148,13 @@ class AuthFlow extends React.Component { }; } -export default connect(state => ({ - web3Accounts: state.web3.accounts, -}))(AuthFlow); +export default connect( + state => ({ + web3Accounts: state.web3.accounts, + checkedUsers: state.auth.checkedUsers, + isCheckingUser: state.auth.isCheckingUser, + }), + { + checkUser: authActions.checkUser, + }, +)(AuthFlow); diff --git a/frontend/client/components/Header/index.tsx b/frontend/client/components/Header/index.tsx index 06ee3aed..fbb6a83d 100644 --- a/frontend/client/components/Header/index.tsx +++ b/frontend/client/components/Header/index.tsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; import { Spin, Icon } from 'antd'; +import UserAvatar from 'components/UserAvatar'; import Identicon from 'components/Identicon'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; @@ -46,8 +47,7 @@ class Header extends React.Component { let avatar; if (user) { - // TODO: Load user's avatar as well - avatar = ; + avatar = ; } else if (accounts && accounts[0]) { avatar = ; } else if (accountsLoading || isAuthingUser) { diff --git a/frontend/client/modules/auth/actions.ts b/frontend/client/modules/auth/actions.ts index aa160a32..0f6a3c00 100644 --- a/frontend/client/modules/auth/actions.ts +++ b/frontend/client/modules/auth/actions.ts @@ -2,43 +2,102 @@ import types from './types'; import { Dispatch } from 'redux'; import { sleep } from 'utils/helpers'; import { AppState } from 'store/reducers'; +import { createUser as apiCreateUser, getUser as apiGetUser } from 'api/api'; type GetState = () => AppState; -export function authUser() { - return async (dispatch: Dispatch, getState: GetState) => { - const token = getState().auth.token; - if (!token) { - return; - } - - // TODO: Implement authentication +export function authUser(address: string) { + return async (dispatch: Dispatch) => { dispatch({ type: types.AUTH_USER_PENDING }); - await sleep(500); - dispatch({ - type: types.AUTH_USER_REJECTED, - payload: 'Auth not implemented yet', - error: true, - }); + + // TODO: Actually auth using a signed token + try { + const res = await apiGetUser(address); + dispatch({ + type: types.AUTH_USER_FULFILLED, + payload: res.data, + }); + } catch (err) { + dispatch({ + type: types.AUTH_USER_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } }; } -export function createUser(address: string, name: string, email: string) { +export function createUser(user: { + address: string; + email: string; + name: string; + title: string; +}) { return async (dispatch: Dispatch) => { - // TODO: Implement user creation dispatch({ type: types.CREATE_USER_PENDING }); - await sleep(500); - dispatch({ - type: types.CREATE_USER_FULFILLED, - payload: { - user: { - address, - name, - email, + + try { + // TODO: Pass real token + const token = Math.random().toString(); + const res = await apiCreateUser({ + accountAddress: user.address, + emailAddress: user.email, + displayName: user.name, + title: user.title, + token, + }); + dispatch({ + type: types.CREATE_USER_FULFILLED, + payload: { + user: res.data, + token, }, - token: Math.random(), - }, - }); + }); + } catch (err) { + dispatch({ + type: types.CREATE_USER_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + }; +} + +export function checkUser(address: string) { + return async (dispatch: Dispatch, getState: GetState) => { + const checkedUsers = getState().auth.checkedUsers; + if (checkedUsers[address] !== undefined) { + return; + } + + dispatch({ type: types.CHECK_USER_PENDING }); + + try { + const res = await apiGetUser(address); + dispatch({ + type: types.CHECK_USER_FULFILLED, + payload: { + address, + user: res.data, + }, + }); + } catch (err) { + if (err.response && err.response.status === 404) { + dispatch({ + type: types.CHECK_USER_FULFILLED, + payload: { + address, + user: false, + }, + }); + } else { + dispatch({ + type: types.CHECK_USER_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + } }; } diff --git a/frontend/client/modules/auth/reducers.ts b/frontend/client/modules/auth/reducers.ts index 1bef75a2..0e274bd6 100644 --- a/frontend/client/modules/auth/reducers.ts +++ b/frontend/client/modules/auth/reducers.ts @@ -1,17 +1,15 @@ import types from './types'; - -// TODO: Replace this with full user object once auth is really done -interface AuthedUser { - name: string; - email: string; - address: string; -} +// TODO: Use a common User type instead of this +import { TeamMember } from 'modules/create/types'; export interface AuthState { - user: AuthedUser | null; + user: TeamMember | null; isAuthingUser: boolean; authUserError: string | null; + checkedUsers: { [address: string]: TeamMember | false }; + isCheckingUser: boolean; + isCreatingUser: boolean; createUserError: string | null; @@ -29,6 +27,9 @@ export const INITIAL_STATE: AuthState = { isCreatingUser: false, createUserError: null, + checkedUsers: {}, + isCheckingUser: false, + token: null, tokenAddress: null, isSigningToken: false, @@ -69,6 +70,10 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action: user: action.payload.user, token: action.payload.token, isCreatingUser: false, + checkedUsers: { + ...state.checkedUsers, + [action.payload.user.address]: action.payload.user, + }, }; case types.CREATE_USER_REJECTED: return { @@ -77,6 +82,31 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action: createUserError: action.payload, }; + case types.CHECK_USER_PENDING: + return { + ...state, + isCheckingUser: true, + }; + case types.CHECK_USER_FULFILLED: + return { + ...state, + isCheckingUser: false, + checkedUsers: action.payload.user + ? { + ...state.checkedUsers, + [action.payload.address]: action.payload.user, + } + : { + ...state.checkedUsers, + [action.payload.address]: false, + }, + }; + case types.CHECK_USER_REJECTED: + return { + ...state, + isCheckingUser: false, + }; + case types.SIGN_TOKEN_PENDING: return { ...state, diff --git a/frontend/client/modules/auth/types.ts b/frontend/client/modules/auth/types.ts index 7a6bc0e8..90b2631e 100644 --- a/frontend/client/modules/auth/types.ts +++ b/frontend/client/modules/auth/types.ts @@ -9,6 +9,11 @@ enum AuthTypes { CREATE_USER_FULFILLED = 'CREATE_USER_FULFILLED', CREATE_USER_REJECTED = 'CREATE_USER_REJECTED', + CHECK_USER = 'CHECK_USER', + CHECK_USER_PENDING = 'CHECK_USER_PENDING', + CHECK_USER_FULFILLED = 'CHECK_USER_FULFILLED', + CHECK_USER_REJECTED = 'CHECK_USER_REJECTED', + SIGN_TOKEN = 'AUTH_USER', SIGN_TOKEN_PENDING = 'SIGN_TOKEN_PENDING', SIGN_TOKEN_FULFILLED = 'SIGN_TOKEN_FULFILLED', diff --git a/frontend/client/pages/profile.tsx b/frontend/client/pages/profile.tsx index 765c7585..ed0dc256 100644 --- a/frontend/client/pages/profile.tsx +++ b/frontend/client/pages/profile.tsx @@ -14,7 +14,7 @@ class ProfilePage extends React.Component { return (

Hello, {user && user.name}

- +
); } diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index b1df8457..9e39f7b7 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -7,7 +7,7 @@ export function formatTeamMemberForPost(user: TeamMember) { title: user.title, accountAddress: user.ethAddress, emailAddress: user.emailAddress, - avatar: { link: user.avatarUrl }, + avatar: user.avatarUrl ? { link: user.avatarUrl } : undefined, socialMedias: socialAccountsToUrls(user.socialAccounts).map(url => ({ link: url, })), @@ -20,7 +20,7 @@ export function formatTeamMemberFromGet(user: any): TeamMember { title: user.title, ethAddress: user.accountAddress, emailAddress: user.emailAddress, - avatarUrl: user.avatar.imageUrl, + avatarUrl: user.avatar && user.avatar.imageUrl, socialAccounts: socialUrlsToAccounts( user.socialMedias.map((sm: any) => sm.socialMediaLink), ), From 1db0cd2adb8c943eed2f7ae67216c8c54b6a7c27 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Wed, 3 Oct 2018 12:11:44 -0500 Subject: [PATCH 09/18] Profile UI (#128) * basic users redux + Profile page, route * UserRow links to Profile * Update UserRow story with BrowserRouter for Link * display basic profile info * render + style created and funded proposals * clean up unused vars * ProposalComment + misc. adjustments * auth user adjustments * user not found redirect to 404 + don't fetch if no user id param * use PlaceHolder for empty proposal & comments --- frontend/client/Routes.tsx | 1 + .../components/Profile/ProfileComment.less | 27 +++ .../components/Profile/ProfileComment.tsx | 35 ++++ .../components/Profile/ProfileProposal.less | 60 +++++++ .../components/Profile/ProfileProposal.tsx | 40 +++++ .../components/Profile/ProfileUser.less | 71 ++++++++ .../client/components/Profile/ProfileUser.tsx | 61 +++++++ frontend/client/components/Profile/index.tsx | 164 ++++++++++++++++++ frontend/client/components/Profile/style.less | 37 ++++ frontend/client/components/UserRow/index.tsx | 5 +- frontend/client/components/UserRow/style.less | 1 + frontend/client/modules/users/actions.ts | 151 ++++++++++++++++ frontend/client/modules/users/index.ts | 7 + frontend/client/modules/users/reducers.ts | 144 +++++++++++++++ frontend/client/modules/users/types.ts | 42 +++++ frontend/client/pages/profile.tsx | 25 +-- frontend/client/store/reducers.tsx | 4 + frontend/stories/UserRow.story.tsx | 19 +- 18 files changed, 861 insertions(+), 33 deletions(-) create mode 100644 frontend/client/components/Profile/ProfileComment.less create mode 100644 frontend/client/components/Profile/ProfileComment.tsx create mode 100644 frontend/client/components/Profile/ProfileProposal.less create mode 100644 frontend/client/components/Profile/ProfileProposal.tsx create mode 100644 frontend/client/components/Profile/ProfileUser.less create mode 100644 frontend/client/components/Profile/ProfileUser.tsx create mode 100644 frontend/client/components/Profile/index.tsx create mode 100644 frontend/client/components/Profile/style.less create mode 100644 frontend/client/modules/users/actions.ts create mode 100644 frontend/client/modules/users/index.ts create mode 100644 frontend/client/modules/users/reducers.ts create mode 100644 frontend/client/modules/users/types.ts diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index b533854d..778f1f78 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -24,6 +24,7 @@ class Routes extends React.Component { + } /> diff --git a/frontend/client/components/Profile/ProfileComment.less b/frontend/client/components/Profile/ProfileComment.less new file mode 100644 index 00000000..e3f0bd64 --- /dev/null +++ b/frontend/client/components/Profile/ProfileComment.less @@ -0,0 +1,27 @@ +.ProfileComment { + padding-bottom: 1.2rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 1rem; + + &:last-child { + border-bottom: none; + padding-bottom: none; + } + + &-head { + color: #989898; + margin-bottom: 0.2rem; + font-size: 0.8rem; + + &-name { + color: #4c4c4c; + font-size: 1rem; + } + + &-proposal { + color: #4c4c4c; + font-size: 1rem; + font-weight: 600; + } + } +} diff --git a/frontend/client/components/Profile/ProfileComment.tsx b/frontend/client/components/Profile/ProfileComment.tsx new file mode 100644 index 00000000..5f848edd --- /dev/null +++ b/frontend/client/components/Profile/ProfileComment.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import moment from 'moment'; +import { UserComment } from 'modules/users/types'; +import './ProfileComment.less'; + +interface OwnProps { + comment: UserComment; + userName: string; +} + +export default class Profile extends React.Component { + render() { + const { + userName, + comment: { body, proposal, dateCreated }, + } = this.props; + + return ( +
+
+ {userName} commented on{' '} + + {proposal.title} + {' '} + {moment(dateCreated).from(Date.now())} +
+
{body}
+
+ ); + } +} diff --git a/frontend/client/components/Profile/ProfileProposal.less b/frontend/client/components/Profile/ProfileProposal.less new file mode 100644 index 00000000..003e7f2a --- /dev/null +++ b/frontend/client/components/Profile/ProfileProposal.less @@ -0,0 +1,60 @@ +@small-query: ~'(max-width: 640px)'; + +.ProfileProposal { + display: flex; + padding-bottom: 1.2rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 1rem; + + &:last-child { + border-bottom: none; + padding-bottom: none; + } + + @media @small-query { + flex-direction: column; + padding-bottom: 0.6rem; + } + + &-title { + font-size: 1.2rem; + font-weight: 600; + color: inherit; + display: block; + margin-bottom: 0.5rem; + } + + &-block { + flex: 1 0 0%; + + &:last-child { + margin-left: 1.2rem; + flex: 0 0 0%; + min-width: 15rem; + + @media @small-query { + margin-left: 0; + margin-top: 0.6rem; + } + } + + &-team { + @media @small-query { + display: flex; + flex-flow: wrap; + } + + & .UserRow { + margin-right: 1rem; + } + } + } + + &-raised { + margin-top: 0.6rem; + + & small { + opacity: 0.6; + } + } +} diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx new file mode 100644 index 00000000..5333bfb1 --- /dev/null +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { UserProposal } from 'modules/users/types'; +import './ProfileProposal.less'; +import UserRow from 'components/UserRow'; +import UnitDisplay from 'components/UnitDisplay'; + +interface OwnProps { + proposal: UserProposal; +} + +export default class Profile extends React.Component { + render() { + const { title, brief, team, funded, target, proposalId } = this.props.proposal; + + return ( +
+
+ + {title} + +
{brief}
+
+ {' '} + raised of{' '} + goal +
+
+
+

Team

+
+ {team.map(user => ( + + ))} +
+
+
+ ); + } +} diff --git a/frontend/client/components/Profile/ProfileUser.less b/frontend/client/components/Profile/ProfileUser.less new file mode 100644 index 00000000..3cda5834 --- /dev/null +++ b/frontend/client/components/Profile/ProfileUser.less @@ -0,0 +1,71 @@ +.ProfileUser { + display: flex; + align-items: center; + margin-bottom: 1.5rem; + + &-avatar { + position: relative; + flex: 0 0 auto; + height: 10.5rem; + width: 10.5rem; + margin-right: 1.25rem; + + &-img { + height: 100%; + width: 100%; + border-radius: 1rem; + } + } + + &-info { + // no overflow of flexbox + min-width: 0; + + &-name { + font-size: 1.6rem; + font-weight: 300; + } + + &-title { + font-size: 1rem; + opacity: 0.7; + margin-bottom: 0.3rem; + } + + &-address { + position: relative; + font-size: 1rem; + margin-bottom: 0.7rem; + + &:last-child { + margin-bottom: 1rem; + } + + & > span { + position: absolute; + top: 1.2rem; + font-size: 0.7rem; + opacity: 0.7; + } + } + + &-social { + display: flex; + + & a { + display: block; + color: inherit; + } + + &-icon { + height: 1.3rem; + font-size: 1.3rem; + margin-right: 0.5rem; + transition: transform 100ms; + &:hover { + transform: scale(1.1); + } + } + } + } +} diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx new file mode 100644 index 00000000..1add7649 --- /dev/null +++ b/frontend/client/components/Profile/ProfileUser.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { TeamMember } from 'modules/create/types'; +import UserAvatar from 'components/UserAvatar'; +import './ProfileUser.less'; +import { SOCIAL_INFO, SocialInfo, socialAccountToUrl } from 'utils/social'; +import ShortAddress from 'components/ShortAddress'; + +interface OwnProps { + user: TeamMember; +} + +export default class Profile extends React.Component { + render() { + const { + user, + user: { socialAccounts }, + } = this.props; + return ( +
+
+ +
+
+
{user.name}
+
{user.title}
+
+ {user.emailAddress && ( +
+ email address + {user.emailAddress} +
+ )} + {user.ethAddress && ( +
+ ethereum address + +
+ )} +
+
+ {Object.values(SOCIAL_INFO).map( + s => + (socialAccounts[s.type] && ( + + )) || + null, + )} +
+
+
+ ); + } +} + +const Social = ({ account, info }: { account: string; info: SocialInfo }) => { + return ( + +
{info.icon}
+
+ ); +}; diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx new file mode 100644 index 00000000..817dc293 --- /dev/null +++ b/frontend/client/components/Profile/index.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import AntWrap from 'components/AntWrap'; +import { UsersState } from 'modules/users/reducers'; +import { withRouter, RouteComponentProps, Redirect } from 'react-router-dom'; +import { usersActions } from 'modules/users'; +import { AppState } from 'store/reducers'; +import { connect } from 'react-redux'; +import { compose } from 'recompose'; +import { Spin, Tabs, Badge } from 'antd'; +import ProfileUser from './ProfileUser'; +import ProfileProposal from './ProfileProposal'; +import ProfileComment from './ProfileComment'; +import PlaceHolder from 'components/Placeholder'; +import Exception from 'pages/exception'; +import './style.less'; + +interface StateProps { + usersMap: UsersState['map']; + authUser: AppState['auth']['user']; +} + +interface DispatchProps { + fetchUser: typeof usersActions['fetchUser']; + fetchUserCreated: typeof usersActions['fetchUserCreated']; + fetchUserFunded: typeof usersActions['fetchUserFunded']; + fetchUserComments: typeof usersActions['fetchUserComments']; +} + +type Props = RouteComponentProps & StateProps & DispatchProps; + +class Profile extends React.Component { + componentDidMount() { + this.fetchData(); + } + componentDidUpdate(prevProps: Props) { + const userLookupId = this.props.match.params.id; + const prevUserLookupId = prevProps.match.params.id; + if (userLookupId !== prevUserLookupId) { + window.scrollTo(0, 0); + this.fetchData(); + } + } + render() { + const userLookupParam = this.props.match.params.id; + const { authUser } = this.props; + if (!userLookupParam) { + if (authUser.ethAddress) { + return ; + } else { + return ; + } + } + + const user = this.props.usersMap[userLookupParam]; + const waiting = !user || !user.hasFetched; + + if (waiting) { + return ( + + + + ); + } + + if (user.fetchError) { + return ; + } + + const { createdProposals, fundedProposals, comments } = user; + const noneCreated = user.hasFetchedCreated && createdProposals.length === 0; + const noneFunded = user.hasFetchedFunded && fundedProposals.length === 0; + const noneCommented = user.hasFetchedComments && comments.length === 0; + + return ( + +
+ + + +
+ {noneCreated && ( + + )} + {createdProposals.map(p => ( + + ))} +
+
+ +
+ {noneFunded && ( + + )} + {createdProposals.map(p => ( + + ))} +
+
+ +
+ {noneCommented && ( + + )} + {comments.map(c => ( + + ))} +
+
+
+
+
+ ); + } + private fetchData() { + const userLookupId = this.props.match.params.id; + if (userLookupId) { + this.props.fetchUser(userLookupId); + this.props.fetchUserCreated(userLookupId); + this.props.fetchUserFunded(userLookupId); + this.props.fetchUserComments(userLookupId); + } + } +} + +const TabTitle = (disp: string, count: number) => ( +
+ {disp} + 0 ? 'is-not-zero' : 'is-zero'}`} + showZero={true} + count={count} + /> +
+); + +const withConnect = connect( + (state: AppState) => ({ + usersMap: state.users.map, + authUser: state.auth.user, + }), + { + fetchUser: usersActions.fetchUser, + fetchUserCreated: usersActions.fetchUserCreated, + fetchUserFunded: usersActions.fetchUserFunded, + fetchUserComments: usersActions.fetchUserComments, + }, +); + +export default compose( + withRouter, + withConnect, +)(Profile); diff --git a/frontend/client/components/Profile/style.less b/frontend/client/components/Profile/style.less new file mode 100644 index 00000000..d5add97a --- /dev/null +++ b/frontend/client/components/Profile/style.less @@ -0,0 +1,37 @@ +@small-query: ~'(max-width: 640px)'; + +.Profile { + max-width: 800px; + margin: 0 auto; + + & .ant-tabs-nav .ant-tabs-tab { + padding-right: 5px; + } + + @media @small-query { + & .ant-tabs-nav .ant-tabs-tab { + margin-right: 0; + } + } + + &-tabBadge { + transform: scale(0.8) translate(-0.4rem); + + & .ant-badge-count { + box-shadow: none; + background-color: transparent; + } + + &.is-zero { + & .ant-badge-count { + color: inherit; + } + } + + &.is-not-zero { + & .ant-badge-count { + color: inherit; + } + } + } +} diff --git a/frontend/client/components/UserRow/index.tsx b/frontend/client/components/UserRow/index.tsx index 7c5a93fa..a9c77584 100644 --- a/frontend/client/components/UserRow/index.tsx +++ b/frontend/client/components/UserRow/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import UserAvatar from 'components/UserAvatar'; import { TeamMember } from 'modules/create/types'; +import { Link } from 'react-router-dom'; import './style.less'; interface Props { @@ -8,7 +9,7 @@ interface Props { } const UserRow = ({ user }: Props) => ( -
+
@@ -16,7 +17,7 @@ const UserRow = ({ user }: Props) => (
{user.name}

{user.title}

-
+ ); export default UserRow; diff --git a/frontend/client/components/UserRow/style.less b/frontend/client/components/UserRow/style.less index 3c479e9e..c9047c93 100644 --- a/frontend/client/components/UserRow/style.less +++ b/frontend/client/components/UserRow/style.less @@ -5,6 +5,7 @@ display: flex; height: @height; margin-bottom: 1rem; + color: inherit; &:last-child { margin-bottom: 0; diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts new file mode 100644 index 00000000..166832e3 --- /dev/null +++ b/frontend/client/modules/users/actions.ts @@ -0,0 +1,151 @@ +import types, { UserProposal, UserComment } from './types'; +import { getUser, getProposals } from 'api/api'; +import { Dispatch } from 'redux'; +import { Proposal } from 'modules/proposals/reducers'; +import BN from 'bn.js'; + +export function fetchUser(userFetchId: string) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.FETCH_USER_PENDING, payload: { userFetchId } }); + try { + const { data: user } = await getUser(userFetchId); + dispatch({ + type: types.FETCH_USER_FULFILLED, + payload: { userFetchId, user }, + }); + } catch (error) { + dispatch({ type: types.FETCH_USER_REJECTED, payload: { userFetchId, error } }); + } + }; +} + +export function fetchUserCreated(userFetchId: string) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.FETCH_USER_CREATED_PENDING, payload: { userFetchId } }); + try { + // temporary, grab all proposals + const proposalsRes = await getProposals(); + const proposals = proposalsRes.data.map(mockModifyProposals); + dispatch({ + type: types.FETCH_USER_CREATED_FULFILLED, + payload: { userFetchId, proposals }, + }); + } catch (error) { + dispatch({ + type: types.FETCH_USER_CREATED_REJECTED, + payload: { userFetchId, error }, + }); + } + }; +} + +export function fetchUserFunded(userFetchId: string) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.FETCH_USER_FUNDED_PENDING, payload: { userFetchId } }); + try { + // temporary, grab all proposals + const proposalsRes = await getProposals(); + const proposals = proposalsRes.data.map(mockModifyProposals); + dispatch({ + type: types.FETCH_USER_FUNDED_FULFILLED, + payload: { userFetchId, proposals }, + }); + } catch (error) { + dispatch({ + type: types.FETCH_USER_FUNDED_REJECTED, + payload: { userFetchId, error }, + }); + } + }; +} + +export function fetchUserComments(userFetchId: string) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.FETCH_USER_COMMENTS_PENDING, payload: { userFetchId } }); + try { + // temporary, grab all proposals, mock comments + const proposalsRes = await getProposals(); + const proposals = proposalsRes.data.map(mockModifyProposals); + const comments = mockComments(proposals); + comments.sort((a, b) => (a.dateCreated > b.dateCreated ? -1 : 1)); + dispatch({ + type: types.FETCH_USER_COMMENTS_FULFILLED, + payload: { userFetchId, comments }, + }); + } catch (error) { + dispatch({ + type: types.FETCH_USER_COMMENTS_REJECTED, + payload: { userFetchId, error }, + }); + } + }; +} + +const mockModifyProposals = (p: Proposal): UserProposal => { + const { proposalId, title, team } = p; + return { + proposalId, + title, + team, + funded: new BN('5000000000000000000'), + target: new BN('10000000000000000000'), + brief: genBrief(title), + }; +}; + +const genBrief = (title: string) => { + return title.indexOf('T-Shirts') > -1 + ? 'Stylish, classy logo tees for Grant.io! Show everyone your love for the future of crowdfunding!' + : 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; +}; + +const mockComments = (ps: UserProposal[]): UserComment[] => { + return ps.reduce((a: UserComment[], p) => a.concat(mockComment(p)), []); +}; + +const mockComment = (p: UserProposal): UserComment[] => { + return p.title.indexOf('T-Shirts') > -1 + ? [ + { + commentId: Math.random(), + body: "I can't WAIT to get my t-shirt!", + dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30), + proposal: p, + }, + { + commentId: Math.random(), + body: 'I love the new design. Will they still be available next month?', + dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30), + proposal: p, + }, + ] + : [ + { + commentId: Math.random(), + body: 'Ut labore et dolore magna aliqua.', + dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30), + proposal: p, + }, + { + commentId: Math.random(), + body: + 'Adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30), + proposal: p, + }, + { + commentId: Math.random(), + body: + 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30), + proposal: p, + }, + { + commentId: Math.random(), + body: + 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.', + dateCreated: Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30), + proposal: p, + }, + ]; +}; diff --git a/frontend/client/modules/users/index.ts b/frontend/client/modules/users/index.ts new file mode 100644 index 00000000..b63c8ae2 --- /dev/null +++ b/frontend/client/modules/users/index.ts @@ -0,0 +1,7 @@ +import reducers, { UsersState, INITIAL_STATE } from './reducers'; +import * as usersActions from './actions'; +import * as usersTypes from './types'; + +export { usersActions, usersTypes, UsersState, INITIAL_STATE }; + +export default reducers; diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts new file mode 100644 index 00000000..79bd21b1 --- /dev/null +++ b/frontend/client/modules/users/reducers.ts @@ -0,0 +1,144 @@ +import lodash from 'lodash'; +import types, { UserProposal, UserComment } from './types'; +import { TeamMember } from 'modules/create/types'; + +export interface UserState extends TeamMember { + isFetching: boolean; + hasFetched: boolean; + fetchError: number | null; + isFetchingCreated: boolean; + hasFetchedCreated: boolean; + fetchErrorCreated: number | null; + createdProposals: UserProposal[]; + isFetchingFunded: boolean; + hasFetchedFunded: boolean; + fetchErrorFunded: number | null; + fundedProposals: UserProposal[]; + isFetchingCommments: boolean; + hasFetchedComments: boolean; + fetchErrorComments: number | null; + comments: UserComment[]; +} + +export interface UsersState { + map: { [index: string]: UserState }; +} + +export const INITIAL_USER_STATE: UserState = { + ethAddress: '', + avatarUrl: '', + name: '', + emailAddress: '', + socialAccounts: {}, + title: '', + isFetching: false, + hasFetched: false, + fetchError: null, + isFetchingCreated: false, + hasFetchedCreated: false, + fetchErrorCreated: null, + createdProposals: [], + isFetchingFunded: false, + hasFetchedFunded: false, + fetchErrorFunded: null, + fundedProposals: [], + isFetchingCommments: false, + hasFetchedComments: false, + fetchErrorComments: null, + comments: [], +}; + +export const INITIAL_STATE: UsersState = { + map: {}, +}; + +export default (state = INITIAL_STATE, action: any) => { + const { payload } = action; + const userFetchId = payload && payload.userFetchId; + const proposals = payload && payload.proposals; + const comments = payload && payload.comments; + const errorStatus = payload && payload.error && payload.error.response.status; + switch (action.type) { + case types.FETCH_USER_PENDING: + return updateState(state, userFetchId, { isFetching: true, fetchError: null }); + case types.FETCH_USER_FULFILLED: + return updateState( + state, + userFetchId, + { isFetching: false, hasFetched: true }, + payload.user, + ); + case types.FETCH_USER_REJECTED: + return updateState(state, userFetchId, { + isFetching: false, + hasFetched: true, + fetchError: errorStatus, + }); + // created proposals + case types.FETCH_USER_CREATED_PENDING: + return updateState(state, userFetchId, { + isFetchingCreated: true, + fetchErrorCreated: null, + }); + case types.FETCH_USER_CREATED_FULFILLED: + return updateState(state, userFetchId, { + isFetchingCreated: false, + hasFetchedCreated: true, + createdProposals: proposals, + }); + case types.FETCH_USER_CREATED_REJECTED: + return updateState(state, userFetchId, { + isFetchingCreated: false, + hasFetchedCreated: true, + fetchErrorCreated: errorStatus, + }); + // funded proposals + case types.FETCH_USER_FUNDED_PENDING: + return updateState(state, userFetchId, { + isFetchingFunded: true, + fetchErrorFunded: null, + }); + case types.FETCH_USER_FUNDED_FULFILLED: + return updateState(state, userFetchId, { + isFetchingFunded: false, + hasFetchedFunded: true, + fundedProposals: proposals, + }); + case types.FETCH_USER_FUNDED_REJECTED: + return updateState(state, userFetchId, { + isFetchingFunded: false, + hasFetchedFunded: true, + fetchErrorFunded: errorStatus, + }); + // comments + case types.FETCH_USER_COMMENTS_PENDING: + return updateState(state, userFetchId, { + isFetchingComments: true, + fetchErrorComments: null, + }); + case types.FETCH_USER_COMMENTS_FULFILLED: + return updateState(state, userFetchId, { + isFetchingComments: false, + hasFetchedComments: true, + comments, + }); + case types.FETCH_USER_COMMENTS_REJECTED: + return updateState(state, userFetchId, { + isFetchingComments: false, + hasFetchedComments: true, + fetchErrorComments: errorStatus, + }); + default: + return state; + } +}; + +function updateState(state: UsersState, id: string, updates: object, loaded?: UserState) { + return { + ...state, + map: { + ...state.map, + [id]: lodash.defaultsDeep(updates, loaded, state.map[id] || INITIAL_USER_STATE), + }, + }; +} diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts new file mode 100644 index 00000000..cdfcd2fa --- /dev/null +++ b/frontend/client/modules/users/types.ts @@ -0,0 +1,42 @@ +import { TeamMember } from 'modules/create/types'; +import { Wei } from 'utils/units'; + +export interface UserProposal { + proposalId: string; + title: string; + brief: string; + team: TeamMember[]; + funded: Wei; + target: Wei; +} + +export interface UserComment { + commentId: number | string; + body: string; + dateCreated: number; + proposal: UserProposal; +} + +enum UsersActions { + FETCH_USER = 'FETCH_USER', + FETCH_USER_PENDING = 'FETCH_USER_PENDING', + FETCH_USER_FULFILLED = 'FETCH_USER_FULFILLED', + FETCH_USER_REJECTED = 'FETCH_USER_REJECTED', + + FETCH_USER_CREATED = 'FETCH_USER_CREATED', + FETCH_USER_CREATED_PENDING = 'FETCH_USER_CREATED_PENDING', + FETCH_USER_CREATED_FULFILLED = 'FETCH_USER_CREATED_FULFILLED', + FETCH_USER_CREATED_REJECTED = 'FETCH_USER_CREATED_REJECTED', + + FETCH_USER_FUNDED = 'FETCH_USER_FUNDED', + FETCH_USER_FUNDED_PENDING = 'FETCH_USER_FUNDED_PENDING', + FETCH_USER_FUNDED_FULFILLED = 'FETCH_USER_FUNDED_FULFILLED', + FETCH_USER_FUNDED_REJECTED = 'FETCH_USER_FUNDED_REJECTED', + + FETCH_USER_COMMENTS = 'FETCH_USER_COMMENTS', + FETCH_USER_COMMENTS_PENDING = 'FETCH_USER_COMMENTS_PENDING', + FETCH_USER_COMMENTS_FULFILLED = 'FETCH_USER_COMMENTS_FULFILLED', + FETCH_USER_COMMENTS_REJECTED = 'FETCH_USER_COMMENTS_REJECTED', +} + +export default UsersActions; diff --git a/frontend/client/pages/profile.tsx b/frontend/client/pages/profile.tsx index ed0dc256..accd81b3 100644 --- a/frontend/client/pages/profile.tsx +++ b/frontend/client/pages/profile.tsx @@ -1,23 +1,2 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import AntWrap from 'components/AntWrap'; -import Identicon from 'components/Identicon'; -import { AppState } from 'store/reducers'; - -interface Props { - user: AppState['auth']['user']; -} - -class ProfilePage extends React.Component { - render() { - const { user } = this.props; - return ( - -

Hello, {user && user.name}

- -
- ); - } -} - -export default connect((state: AppState) => ({ user: state.auth.user }))(ProfilePage); +import Profile from 'components/Profile'; +export default Profile; diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx index 414f4d8e..2defeb8b 100644 --- a/frontend/client/store/reducers.tsx +++ b/frontend/client/store/reducers.tsx @@ -5,12 +5,14 @@ import proposal, { INITIAL_STATE as proposalInitialState, } from 'modules/proposals'; import create, { CreateState, INITIAL_STATE as createInitialState } from 'modules/create'; +import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users'; import auth, { AuthState, INITIAL_STATE as authInitialState } from 'modules/auth'; export interface AppState { proposal: ProposalState; web3: Web3State; create: CreateState; + users: UsersState; auth: AuthState; } @@ -18,6 +20,7 @@ export const combineInitialState: AppState = { proposal: proposalInitialState, web3: web3InitialState, create: createInitialState, + users: usersInitialState, auth: authInitialState, }; @@ -25,5 +28,6 @@ export default combineReducers({ proposal, web3, create, + users, auth, }); diff --git a/frontend/stories/UserRow.story.tsx b/frontend/stories/UserRow.story.tsx index 3329298e..86e608c9 100644 --- a/frontend/stories/UserRow.story.tsx +++ b/frontend/stories/UserRow.story.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { BrowserRouter } from 'react-router-dom'; import { storiesOf } from '@storybook/react'; import 'components/UserRow/style.less'; @@ -54,12 +55,14 @@ const cases = [ ]; storiesOf('UserRow', module).add('all', () => ( -
- {cases.map(c => ( -
-
{`${c.disp}`}
- -
- ))} -
+ +
+ {cases.map(c => ( +
+
{`${c.disp}`}
+ +
+ ))} +
+
)); From 94aec1fdd3b614f41f5b9831fa009e56c10ed0d2 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Wed, 3 Oct 2018 14:04:08 -0500 Subject: [PATCH 10/18] Milestone Enhancements (#117) * use antd Steps for Proposal/Milestones * MilestoneAction component. * Proposal/Milestones modifications. * Proposal/Governance modifications. * rename Governance tab to Refunds + hide if not contributor * Story modifications for ProposalMilestones. * Remove old Governance/Milestones + update story * Make sure active step updates after mount via componentDidUpdate. * have ProposalMilestones fill horizontal space * allow outsiders to view state of MilestoneAction * refactor + add is-count-n style * count styles * dynamic num milestones + rando titles * geometryCases of 1 - 10 milestones * better selected milestone visual hint * dynamic step title overflow check + styles * nowrap milestone title --- .../components/Proposal/Governance/index.tsx | 9 +- .../components/Proposal/Governance/style.less | 30 +- .../Proposal/Milestones/MilestoneAction.less | 49 +++ .../MilestoneAction.tsx} | 66 ++-- .../components/Proposal/Milestones/index.tsx | 343 ++++++++++++++---- .../components/Proposal/Milestones/style.less | 159 +++++++- frontend/client/components/Proposal/index.tsx | 9 +- frontend/stories/Proposal.story.tsx | 203 +---------- frontend/stories/ProposalMilestones.story.tsx | 189 ++++++++++ frontend/stories/props.tsx | 117 +++--- 10 files changed, 740 insertions(+), 434 deletions(-) create mode 100644 frontend/client/components/Proposal/Milestones/MilestoneAction.less rename frontend/client/components/Proposal/{Governance/Milestones.tsx => Milestones/MilestoneAction.tsx} (82%) create mode 100644 frontend/stories/ProposalMilestones.story.tsx diff --git a/frontend/client/components/Proposal/Governance/index.tsx b/frontend/client/components/Proposal/Governance/index.tsx index 2036efa6..e4e4cef4 100644 --- a/frontend/client/components/Proposal/Governance/index.tsx +++ b/frontend/client/components/Proposal/Governance/index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import GovernanceMilestones from './Milestones'; import GovernanceRefunds from './Refunds'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; import './style.less'; @@ -13,13 +12,7 @@ export default class ProposalGovernance extends React.Component { const { proposal } = this.props; return (
-
-

Milestone Voting

- -
-
-
-

Refunds

+
diff --git a/frontend/client/components/Proposal/Governance/style.less b/frontend/client/components/Proposal/Governance/style.less index 5901ddc4..082a7742 100644 --- a/frontend/client/components/Proposal/Governance/style.less +++ b/frontend/client/components/Proposal/Governance/style.less @@ -2,35 +2,11 @@ .ProposalGovernance { display: flex; + justify-content: center; padding-top: 1rem; - @media (max-width: @small-screen) { - flex-direction: column; - } - - &-section { - flex: 1; - - &-title { - font-weight: bold; - margin-bottom: 1rem; - } - } - - &-divider { - width: 1px; - background: rgba(0, 0, 0, 0.05); - margin: 0 2rem; - - @media (max-width: @small-screen) { - height: 1px; - width: 100%; - margin: 2rem 0; - } - } - - &-milestoneActionText { - font-size: 1rem; + &-content { + max-width: 800px; } &-progress { diff --git a/frontend/client/components/Proposal/Milestones/MilestoneAction.less b/frontend/client/components/Proposal/Milestones/MilestoneAction.less new file mode 100644 index 00000000..aa6792fe --- /dev/null +++ b/frontend/client/components/Proposal/Milestones/MilestoneAction.less @@ -0,0 +1,49 @@ +@small-screen: 1080px; + +.MilestoneAction { + &-top { + display: flex; + align-items: center; + } + + &-text { + font-size: 1rem; + } + + &-progress { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin: 0 2rem 0.5rem 0; + + &-text { + white-space: nowrap; + opacity: 0.6; + font-size: 0.75rem; + } + + // Ant progress overrides + .ant-progress-text { + color: inherit !important; + } + + &.is-starting { + .ant-progress-circle-path { + stroke: #1890ff; + } + } + + &.is-started { + .ant-progress-circle-path { + stroke: #faad14; + } + } + + &.is-finishing { + .ant-progress-circle-path { + stroke: #f5222d; + } + } + } +} diff --git a/frontend/client/components/Proposal/Governance/Milestones.tsx b/frontend/client/components/Proposal/Milestones/MilestoneAction.tsx similarity index 82% rename from frontend/client/components/Proposal/Governance/Milestones.tsx rename to frontend/client/components/Proposal/Milestones/MilestoneAction.tsx index 0831f537..0a8c5f5e 100644 --- a/frontend/client/components/Proposal/Governance/Milestones.tsx +++ b/frontend/client/components/Proposal/Milestones/MilestoneAction.tsx @@ -1,25 +1,23 @@ import React from 'react'; import moment from 'moment'; import { connect } from 'react-redux'; -import { Button, Progress, Spin, Alert } from 'antd'; +import { Button, Progress, Alert } from 'antd'; import { ProposalWithCrowdFund, MILESTONE_STATE } from 'modules/proposals/reducers'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; -import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; import UnitDisplay from 'components/UnitDisplay'; import Placeholder from 'components/Placeholder'; +import './MilestoneAction.less'; +import { REJECTED } from 'redux-promise-middleware'; interface OwnProps { proposal: ProposalWithCrowdFund; } -interface Web3Props { - accounts: Web3RenderProps['accounts']; -} - interface StateProps { isMilestoneActionPending: AppState['web3']['isMilestoneActionPending']; milestoneActionError: AppState['web3']['milestoneActionError']; + accounts: AppState['web3']['accounts']; } interface ActionProps { @@ -28,7 +26,7 @@ interface ActionProps { voteMilestonePayout: typeof web3Actions['voteMilestonePayout']; } -type Props = OwnProps & Web3Props & StateProps & ActionProps; +type Props = OwnProps & StateProps & ActionProps; export class Milestones extends React.Component { render() { @@ -39,7 +37,6 @@ export class Milestones extends React.Component { milestoneActionError, } = this.props; const { crowdFund } = proposal; - if (!crowdFund.isRaiseGoalReached) { return ( { /> ); } - const contributor = crowdFund.contributors.find(c => c.address === accounts[0]); const isTrustee = crowdFund.trustees.includes(accounts[0]); - const firstMilestone = crowdFund.milestones[0]; + const firstMilestone = proposal.milestones[0]; const isImmediatePayout = crowdFund.immediateFirstMilestonePayout; // TODO: Should this information be abstracted to a lib or redux? const hasImmediatePayoutStarted = isImmediatePayout && firstMilestone.payoutRequestVoteDeadline; const hasImmediatePayoutBeenPaid = isImmediatePayout && firstMilestone.isPaid; - const activeVoteMilestone = crowdFund.milestones.find( + const activeVoteMilestone = proposal.milestones.find( m => m.state === MILESTONE_STATE.ACTIVE, ); - const uncollectedMilestone = crowdFund.milestones.find( + const uncollectedMilestone = proposal.milestones.find( m => m.state === MILESTONE_STATE.PAID && !m.isPaid, ); - const nextUnpaidMilestone = crowdFund.milestones.find( + const nextUnpaidMilestone = proposal.milestones.find( m => m.state !== MILESTONE_STATE.PAID, ); @@ -78,7 +74,7 @@ export class Milestones extends React.Component { if (isImmediatePayout && !hasImmediatePayoutBeenPaid) { if (!hasImmediatePayoutStarted) { content = ( -

+

Congratulations on getting funded! You can now begin the process of receiving your initial payment. Click below to begin a milestone payout request. It will instantly be approved, and you’ll be able to request the @@ -92,7 +88,7 @@ export class Milestones extends React.Component { }; } else { content = ( -

+

Your initial payout is ready! Click below to claim it.

); @@ -104,7 +100,7 @@ export class Milestones extends React.Component { } } else if (activeVoteMilestone) { content = ( -

+

The vote for your payout is in progress. If payout rejection votes don’t exceed 50% before{' '} {moment(activeVoteMilestone.payoutRequestVoteDeadline).format( @@ -116,7 +112,7 @@ export class Milestones extends React.Component { showVoteProgress = true; } else if (uncollectedMilestone) { content = ( -

+

Congratulations! Your milestone payout request was succesful. Click below to receive your payment of{' '} @@ -132,9 +128,11 @@ export class Milestones extends React.Component { }; } else if (nextUnpaidMilestone) { content = ( -

- You can request a payout for your next milestone, "Milestone Title". If fewer - than 50% of funders vote against it before{' '} +

+ {nextUnpaidMilestone.state === REJECTED + ? 'You can make another request for this milestone payout. ' + : 'You can request a payout for this milestone. '} + If fewer than 50% of funders vote against it before{' '} {moment(Date.now() + crowdFund.milestoneVotingPeriod).format('MMM Do h:mm a')} , you will be able to collect your payout here.

@@ -169,7 +167,7 @@ export class Milestones extends React.Component { } content = ( -

+

A milestone vote is currently in progress. If funders vote against paying out the milestone by over 50% before{' '} {moment(activeVoteMilestone.payoutRequestVoteDeadline).format( @@ -182,31 +180,29 @@ export class Milestones extends React.Component { showVoteProgress = true; } else if (nextUnpaidMilestone) { content = ( -

+

There is no milestone vote currently active.

); } else { content = ( -

- All milestones have been paid out. -

+

All milestones have been paid out.

); } } return ( - <> -
+
+
{showVoteProgress && ( -
+
`${p}%`} status="exception" /> -
voted against payout
+
voted against payout
)}
@@ -232,7 +228,7 @@ export class Milestones extends React.Component { showIcon /> )} - +
); } @@ -254,6 +250,7 @@ export class Milestones extends React.Component { const ConnectedMilestones = connect( (state: AppState) => ({ + accounts: state.web3.accounts, isMilestoneActionPending: state.web3.isMilestoneActionPending, milestoneActionError: state.web3.milestoneActionError, }), @@ -264,11 +261,4 @@ const ConnectedMilestones = connect( }, )(Milestones); -export default (props: OwnProps) => ( - } - render={({ accounts }: Web3RenderProps) => ( - - )} - /> -); +export default (props: OwnProps) => ; diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 6e1802d3..05f43d4a 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -1,107 +1,290 @@ +import lodash from 'lodash'; import React from 'react'; import moment from 'moment'; -import { Timeline, Spin, Icon } from 'antd'; +import { Alert, Steps, Spin } from 'antd'; import { ProposalWithCrowdFund, MILESTONE_STATE } from 'modules/proposals/reducers'; import UnitDisplay from 'components/UnitDisplay'; +import MilestoneAction from './MilestoneAction'; +import { AppState } from 'store/reducers'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; import './style.less'; +const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; + +enum STEP_STATUS { + WAIT = 'wait', + PROCESS = 'process', + FINISH = 'finish', + ERROR = 'error', +} + +const milestoneStateToStepState = { + [WAITING]: STEP_STATUS.WAIT, + [ACTIVE]: STEP_STATUS.PROCESS, + [PAID]: STEP_STATUS.FINISH, + [REJECTED]: STEP_STATUS.ERROR, +}; + interface OwnProps { proposal: ProposalWithCrowdFund; } -type Props = OwnProps; +interface StateProps { + accounts: AppState['web3']['accounts']; +} + +type Props = OwnProps & StateProps; + +interface State { + step: number; + activeMilestoneIdx: number; + doTitlesOverflow: boolean; +} + +class ProposalMilestones extends React.Component { + stepTitleRefs: Array>; + ref: React.RefObject; + throttledUpdateDoTitlesOverflow: () => void; + constructor(props: Props) { + super(props); + this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef()); + this.ref = React.createRef(); + this.throttledUpdateDoTitlesOverflow = lodash.throttle( + this.updateDoTitlesOverflow, + 500, + ); + this.state = { + step: 0, + activeMilestoneIdx: 0, + doTitlesOverflow: true, + }; + } + + componentDidMount() { + if (this.props.proposal) { + const activeMilestoneIdx = this.getActiveMilestoneIdx(); + this.setState({ step: activeMilestoneIdx, activeMilestoneIdx }); + } + this.updateDoTitlesOverflow(); + window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow); + } + componentWillUnmount() { + window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow); + } + + componentDidUpdate(_: Props, prevState: State) { + const activeMilestoneIdx = this.getActiveMilestoneIdx(); + if (prevState.activeMilestoneIdx !== activeMilestoneIdx) { + this.setState({ step: activeMilestoneIdx, activeMilestoneIdx }); + } + } -export default class ProposalMilestones extends React.Component { render() { const { proposal } = this.props; - if (!proposal) { return ; } + const { + milestones, + crowdFund, + crowdFund: { milestoneVotingPeriod, percentVotingForRefund }, + } = proposal; + const { accounts } = this.props; - const { milestones } = proposal; - return ( - - {milestones.map((milestone, i) => { - let paymentInfo; - let icon; - let color = 'blue'; - switch (milestone.state) { - case MILESTONE_STATE.PAID: - color = 'green'; - paymentInfo = ( -
- The team was awarded{' '} - - - {' '} + const wasRefunded = percentVotingForRefund > 50; + const isTrustee = crowdFund.trustees.includes(accounts[0]); + const milestoneCount = milestones.length; + + const milestoneSteps = milestones.map((milestone, i) => { + const status = + this.state.activeMilestoneIdx === i && milestone.state === WAITING + ? STEP_STATUS.PROCESS + : milestoneStateToStepState[milestone.state]; + + const className = this.state.step === i ? 'is-active' : 'is-inactive'; + const estimatedDate = moment(milestone.dateEstimated).format('MMMM YYYY'); + const reward = ( + + ); + const approvalPeriod = milestone.isImmediatePayout + ? 'Immediate' + : moment.duration(milestoneVotingPeriod).humanize(); + const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' }; + + const stepProps = { + title:
{milestone.title}
, + status, + className, + onClick: () => this.setState({ step: i }), + }; + + let notification; + + switch (milestone.state) { + case PAID: + notification = ( + + The team was awarded {reward}{' '} {milestone.isImmediatePayout ? 'as an initial payout' : `on ${moment(milestone.payoutRequestVoteDeadline).format( 'MMM Do, YYYY', )}`} -
- ); - break; - case MILESTONE_STATE.ACTIVE: - icon = ; - paymentInfo = ( -
- Payout vote is in progress! Go to the Governance tab to see more. -
- ); - break; - case MILESTONE_STATE.REJECTED: - color = 'red'; - paymentInfo = ( - <> -
- Payout was voted against on{' '} - {moment(milestone.payoutRequestVoteDeadline).format('MMM Do, YYYY')} -
-
- They can request another payout vote at any time -
- - ); - break; - default: - paymentInfo = ( - <> -
- Rewards team with{' '} - - - -
-
- {milestone.isImmediatePayout - ? 'Paid immediately upon funding completion' - : 'Paid only on approval after 7 day voting period'} -
- - ); - } - - return ( - -
- {/* TODO: Real data from backend */} -

{milestone.title}

- {!milestone.isImmediatePayout && ( -
- Estimate: {moment(milestone.dateEstimated).format('MMMM YYYY')} -
- )} -

- {milestone.body} -

- {paymentInfo} -
-
+ . + + } + style={alertStyle} + /> ); + break; + case ACTIVE: + notification = ( + + Payout vote is in progress! The approval period ends{' '} + {moment(milestone.payoutRequestVoteDeadline).from(new Date())}. + + } + style={alertStyle} + /> + ); + break; + case REJECTED: + notification = ( + + Payout was voted against on{' '} + {moment(milestone.payoutRequestVoteDeadline).format('MMM Do, YYYY')}. + {isTrustee ? ' You ' : ' The team '} can request another payout vote at + any time. + + } + style={alertStyle} + /> + ); + break; + } + + if (wasRefunded) { + notification = ( + A majority of the funders of this project voted for a refund. + } + style={alertStyle} + /> + ); + } + + const statuses = ( +
+ {!milestone.isImmediatePayout && ( +
+ Estimate: {estimatedDate} +
+ )} +
+ Reward: {reward} +
+
+ Approval period: {approvalPeriod} +
+
+ ); + + const Content = ( +
+
+
+

{milestone.title}

+ {statuses} + {notification} + {milestone.body} +
+ {this.state.activeMilestoneIdx === i && + !wasRefunded && ( + <> +
+
+ +
+ + )} +
+
+ ); + return { key: i, stepProps, Content }; + }); + + const stepSize = milestoneCount > 5 ? 'small' : 'default'; + + return ( +
+ > + + {milestoneSteps.map(mss => ( + + ))} + + {milestoneSteps[this.state.step].Content} +
); } + + private getActiveMilestoneIdx = () => { + const { milestones } = this.props.proposal; + const activeMilestone = + milestones.find( + m => + m.state === WAITING || + m.state === ACTIVE || + (m.state === PAID && !m.isPaid) || + m.state === REJECTED, + ) || milestones[0]; + return activeMilestone.index; + }; + + private updateDoTitlesOverflow = () => { + // hmr can sometimes muck up refs, let's make sure they all exist + if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a)) return; + let doTitlesOverflow = false; + const stepCount = this.stepTitleRefs.length; + if (stepCount > 1) { + // avoiding style calculation here by hardcoding antd icon width + padding + margin + const iconWidths = stepCount * 56; + const totalWidth = this.ref.current.clientWidth; + const last = this.stepTitleRefs.slice(stepCount - 1).pop().current; + // last title gets full space + const lastWidth = last.clientWidth; + const remainingWidth = totalWidth - (lastWidth + iconWidths); + const remainingWidthSingle = remainingWidth / (stepCount - 1); + // first titles have to share remaining space + this.stepTitleRefs.slice(0, stepCount - 1).forEach(r => { + doTitlesOverflow = + doTitlesOverflow || r.current.clientWidth > remainingWidthSingle; + }); + } + this.setState({ doTitlesOverflow }); + }; } + +const ConnectedProposalMilestones = connect((state: AppState) => ({ + accounts: state.web3.accounts, +}))(ProposalMilestones); + +export default ConnectedProposalMilestones; diff --git a/frontend/client/components/Proposal/Milestones/style.less b/frontend/client/components/Proposal/Milestones/style.less index 1e1c10a1..98e45ef3 100644 --- a/frontend/client/components/Proposal/Milestones/style.less +++ b/frontend/client/components/Proposal/Milestones/style.less @@ -1,35 +1,164 @@ +@medium-query: ~'(max-width: 920px)'; +@small-query: ~'(max-width: 480px)'; + .ProposalMilestones { + width: 100%; + + .ant-steps-customization(); + + .ant-steps-item-title > div { + position: relative; + } + &.do-titles-overflow { + .ant-steps-item, + .ant-steps-item-icon { + margin-right: 0.4rem; + } + .ant-steps-item-title { + width: 0; + padding-right: 0; + color: rgba(0, 0, 0, 0) !important; + + & > div { + position: absolute; + } + } + + .ProposalMilestones-milestone-title { + display: block; + } + } + &-milestone { - margin-left: 0.5rem; - margin-bottom: 2rem; + min-height: 15rem; + margin-top: 2rem; + + @media @small-query { + margin-top: 0; + margin-left: 0; + } &-title { + display: none; + white-space: nowrap; font-size: 1.5rem; margin-bottom: 0; transform: translateY(-0.5rem); + @media @small-query { + display: block !important; + } } - &-estimate { - margin-top: -0.5rem; - margin-bottom: 1rem; - font-size: 0.9rem; - opacity: 0.5; - font-style: italic; + &-status { + white-space: nowrap; + @media @small-query { + margin-bottom: 0.6rem; + } + + & > div { + margin-bottom: 1rem; + font-size: 0.9rem; + opacity: 0.8; + display: inline-block; + @media @small-query { + margin-bottom: 0; + display: block; + } + } + + & > div + div { + padding-left: 0.5em; + margin-left: 0.5em; + border-left: 1px solid rgba(0, 0, 0, 0.35); + @media @small-query { + padding-left: 0; + margin-left: 0; + border-left: none; + } + } + } + + &-body { + display: flex; + + @media @medium-query { + flex-direction: column; + margin-left: 0.4rem; + } } &-description { font-size: 1.1rem; } - &-payoutAmount { - margin-bottom: 0.2rem; - font-size: 0.9rem; - opacity: 0.8; + &-description, + &-action { + flex: 1; } - &-payoutInfo { - opacity: 0.5; - font-size: 0.7rem; + &-divider { + width: 1px; + background: rgba(0, 0, 0, 0.05); + margin: 0 2rem; + + @media @medium-query { + height: 1px; + width: 100%; + margin: 1rem 0; + } + } + } +} + +.ant-steps-customization() { + @media @small-query { + display: flex; + .ant-steps { + width: 50px; + margin-left: -2rem; + .ant-steps-item { + margin-right: 0; + } + .ant-steps-item-title { + display: none; + } + } + } + + .ant-steps-item { + cursor: pointer; + } + + .ant-steps-item-override(@status, @color, @title-color) { + .ant-steps-item-@{status} { + &.is-active { + .ant-steps-item-icon { + background: @color; + border-color: @color; + & > .ant-steps-icon { + color: #fff; + font-weight: 600; + } + } + .ant-steps-item-title { + color: @title-color; + } + } + } + } + .ant-steps-item-override(wait, #949191, #949191); + .ant-steps-item-override(finish, #1890ff, #4c4c4c); + .ant-steps-item-override(error, #f5222d, #f5222d); + + .ant-steps-item-process { + &.is-inactive { + .ant-steps-item-icon { + background: #fff; + border-color: #1890ff; + & > .ant-steps-icon { + color: #1890ff; + } + } } } } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 204771da..b397efbd 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -87,6 +87,7 @@ export class ProposalDetail extends React.Component { } else { const { crowdFund } = proposal; const isTrustee = crowdFund.trustees.includes(account); + const isContributor = !!crowdFund.contributors.find(c => c.address === account); const hasBeenFunded = crowdFund.isRaiseGoalReached; const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now(); const canRefund = (hasBeenFunded || isProposalActive) && !crowdFund.isFrozen; @@ -179,9 +180,11 @@ export class ProposalDetail extends React.Component {
- - - + {isContributor && ( + + + + )} diff --git a/frontend/stories/Proposal.story.tsx b/frontend/stories/Proposal.story.tsx index e34a7e92..1cde79de 100644 --- a/frontend/stories/Proposal.story.tsx +++ b/frontend/stories/Proposal.story.tsx @@ -3,15 +3,11 @@ import { storiesOf } from '@storybook/react'; import { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock'; import Contributors from 'components/Proposal/Contributors'; -import { Milestones as GovernanceMilestones } from 'components/Proposal/Governance/Milestones'; -import Milestones from 'components/Proposal/Milestones'; -import { MILESTONE_STATE } from 'modules/proposals/reducers'; -const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; import 'styles/style.less'; import 'components/Proposal/style.less'; import 'components/Proposal/Governance/style.less'; -import { getProposalWithCrowdFund, getGovernanceMilestonesProps } from './props'; +import { getProposalWithCrowdFund } from './props'; const propsNoFunding = getProposalWithCrowdFund({ amount: 5, @@ -30,82 +26,6 @@ const propsNotFundedExpired = getProposalWithCrowdFund({ deadline: Date.now() - 1, }); -const msWaiting = { state: WAITING, isPaid: false }; -const msPaid = { state: PAID, isPaid: true }; -const msActive = { state: ACTIVE, isPaid: false }; -const msRejected = { state: REJECTED, isPaid: false }; - -const propsMilestoneActive = getProposalWithCrowdFund({ - milestoneOverrides: [msPaid, msActive, msWaiting], -}); -const propsMilestoneActiveOneVote = getProposalWithCrowdFund({ - milestoneOverrides: [ - msPaid, - { state: ACTIVE, isPaid: false, percentAgainstPayout: 33 }, - msWaiting, - ], - contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], -}); -const propsMilestoneRejected = getProposalWithCrowdFund({ - milestoneOverrides: [msPaid, msPaid, msRejected], -}); -const propsMilestoneFirstPaid = getProposalWithCrowdFund({ - milestoneOverrides: [msPaid, msWaiting, msWaiting], -}); -const propsMilestoneSecondUncollected = getProposalWithCrowdFund({ - milestoneOverrides: [msPaid, { state: PAID, isPaid: false }, msWaiting], -}); -const propsMilestoneInitialSuccessNotPaid = getProposalWithCrowdFund({ - milestoneOverrides: [ - { state: ACTIVE, isPaid: false, payoutRequestVoteDeadline: Date.now() }, - ], -}); -const propsMilestoneAllPaid = getProposalWithCrowdFund({ - milestoneOverrides: [msPaid, msPaid, msPaid], -}); - -const trusteeInactiveFirstImmediateGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({ isContributor: false }), - propsFunded, -); -const trusteeActiveNotPaidFirstImmediateGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({ isContributor: false }), - propsMilestoneInitialSuccessNotPaid, -); -const trusteeInactiveFirstPaidGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({ isContributor: false }), - propsMilestoneFirstPaid, -); -const trusteeUncollectedSecondGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({ isContributor: false }), - propsMilestoneSecondUncollected, -); -const trusteeActiveFirstPaidGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({ isContributor: false }), - propsMilestoneActive, -); -const trusteeAllPaidGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({ isContributor: false }), - propsMilestoneAllPaid, -); - -const contributorInactiveGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({}), - propsFunded, -); -const contributorActiveGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({}), - propsMilestoneActive, -); -const contributorActiveOneVoteGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({}), - propsMilestoneActiveOneVote, -); -const contributorAllPaidGovernanceMilestoneProps = Object.assign( - getGovernanceMilestonesProps({}), - propsMilestoneAllPaid, -); - const CampaignBlocks = ({ style }: { style: any }) => (
@@ -138,125 +58,4 @@ storiesOf('Proposal', module)
- )) - .add('Governance/Milestones trustee', () => { - const style = { - maxWidth: '500px', - margin: '0 0 1.5em', - padding: '0.5em', - border: '1px solid #cccccc', - flex: '1 1 0%', - }; - return ( -
-
- Trustee - immediate initial payout -
- -
-
-
- Trustee - immediate initial payout, accepted not paid -
- -
-
-
- Trustee - first milestone paid -
- -
-
-
- Trustee - second milestone active -
- -
-
-
- Trustee - second milestone uncollected -
- -
-
-
- Trustee - all paid -
- -
-
-
- ); - }) - .add('Governance/Milestones contributor', () => { - const style = { - maxWidth: '500px', - margin: '0 0 1.5em', - padding: '0.5em', - border: '1px solid #cccccc', - }; - return ( -
-
- Contributor - innactive milestones -
- -
-
-
- Contributor - active milestone -
- -
-
-
- Contributor - active milestone - voted against -
- -
-
-
- Contributor - all paid -
- -
-
-
- ); - }) - .add('Milestones - waiting', () => ( -
- -
- )) - .add('Milestones - active', () => ( -
- -
- )) - .add('Milestones - rejected', () => ( -
- -
)); diff --git a/frontend/stories/ProposalMilestones.story.tsx b/frontend/stories/ProposalMilestones.story.tsx new file mode 100644 index 00000000..d393ca2a --- /dev/null +++ b/frontend/stories/ProposalMilestones.story.tsx @@ -0,0 +1,189 @@ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { Provider } from 'react-redux'; + +import { configureStore } from 'store/configure'; +import { combineInitialState } from 'store/reducers'; +import Milestones from 'components/Proposal/Milestones'; +import { MILESTONE_STATE } from 'modules/proposals/reducers'; +const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; + +import 'styles/style.less'; +import 'components/Proposal/style.less'; +import 'components/Proposal/Governance/style.less'; +import { getProposalWithCrowdFund } from './props'; + +const msWaiting = { state: WAITING, isPaid: false }; +const msPaid = { state: PAID, isPaid: true }; +const msActive = { state: ACTIVE, isPaid: false }; +const msRejected = { state: REJECTED, isPaid: false }; + +const dummyProposal = getProposalWithCrowdFund({}); +const trustee = dummyProposal.crowdFund.beneficiary; +const contributor = dummyProposal.crowdFund.contributors[0].address; + +const refundedProposal = getProposalWithCrowdFund({ amount: 5, funded: 5 }); +refundedProposal.crowdFund.percentVotingForRefund = 100; + +const geometryCases = [...Array(10).keys()].map(i => + getProposalWithCrowdFund({ milestoneCount: i + 1 }), +); + +const cases: { [index: string]: any } = { + // trustee - first + ['not funded']: getProposalWithCrowdFund({ + amount: 5, + funded: 0, + }), + ['first - waiting']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + }), + ['first - not paid']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [ + { state: PAID, isPaid: false, payoutRequestVoteDeadline: Date.now() }, + msWaiting, + msWaiting, + ], + }), + + // trustee - second + ['second - waiting']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [msPaid, msWaiting, msWaiting], + }), + ['second - active']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [msPaid, msActive, msWaiting], + }), + ['second - not paid']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [ + msPaid, + { state: PAID, isPaid: false, payoutRequestVoteDeadline: Date.now() }, + msWaiting, + ], + }), + ['second - no vote']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [ + msPaid, + { state: ACTIVE, isPaid: false, percentAgainstPayout: 33 }, + msWaiting, + ], + contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], + }), + ['second - rejected']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [msPaid, msRejected, msWaiting], + }), + + // trustee - third + ['final - waiting']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [msPaid, msPaid, msWaiting], + }), + ['final - active']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [msPaid, msPaid, msActive], + }), + ['final - not paid']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [ + msPaid, + msPaid, + { state: PAID, isPaid: false, payoutRequestVoteDeadline: Date.now() }, + ], + }), + ['final - no vote']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [ + msPaid, + msPaid, + { state: ACTIVE, isPaid: false, percentAgainstPayout: 33 }, + ], + contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], + }), + ['final - rejected']: getProposalWithCrowdFund({ + amount: 5, + funded: 5, + milestoneOverrides: [msPaid, msPaid, msRejected], + }), + + // refunded + ['refunded']: refundedProposal, +}; + +const initialStoreStateA = JSON.parse(JSON.stringify(combineInitialState)); +initialStoreStateA.web3.accounts = [trustee]; +const storeTrustee = configureStore(initialStoreStateA); + +const initialStoreStateB = JSON.parse(JSON.stringify(combineInitialState)); +initialStoreStateB.web3.accounts = [contributor]; +const storeContributor = configureStore(initialStoreStateB); + +const initialStoreStateC = JSON.parse(JSON.stringify(combineInitialState)); +initialStoreStateC.web3.accounts = ['0x0']; +const storeOutsider = configureStore(initialStoreStateC); + +const trusteeStories = storiesOf('Proposal/Milestones/trustee', module); + +for (const key of Object.keys(cases)) { + const value = cases[key]; + trusteeStories.add(key, () => ( +
+ + + +
+ )); +} + +const contributorStories = storiesOf('Proposal/Milestones/contributor', module); + +for (const key of Object.keys(cases)) { + const value = cases[key]; + contributorStories.add(key, () => ( +
+ + + +
+ )); +} + +const outsiderStories = storiesOf('Proposal/Milestones/outsider', module); + +for (const key of Object.keys(cases)) { + const value = cases[key]; + outsiderStories.add(key, () => ( +
+ + + +
+ )); +} + +const geometryStories = storiesOf('Proposal/Milestones/geometry', module); + +geometryCases.forEach((gc, idx) => + geometryStories.add(`${idx + 1} steps`, () => ( +
+ + + +
+ )), +); diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index cedd8817..79725eb7 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -3,6 +3,7 @@ import { Milestone, MILESTONE_STATE, ProposalWithCrowdFund, + ProposalMilestone, } from 'modules/proposals/reducers'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { @@ -42,6 +43,7 @@ export function getProposalWithCrowdFund({ deadline = Date.now() + 1000 * 60 * 60 * 10, milestoneOverrides = [], contributorOverrides = [], + milestoneCount = 3, }: { amount?: number; funded?: number; @@ -49,11 +51,12 @@ export function getProposalWithCrowdFund({ deadline?: number; milestoneOverrides?: Array>; contributorOverrides?: Array>; + milestoneCount?: number; }) { const amountBn = oneEth.mul(new BN(amount)); const fundedBn = oneEth.mul(new BN(funded)); - const contributors = [ + let contributors = [ { address: '0xAAA91bde2303f2f43325b2108d26f1eaba1e32b', contributionAmount: new BN(0), @@ -94,67 +97,59 @@ export function getProposalWithCrowdFund({ Object.assign(contributors[idx], co); }); - const milestones = [ - { - title: 'Milestone A', - body: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua.`, - content: '', - dateCreated: '2018-09-23T19:06:15.844399+00:00', - dateEstimated: '2018-10-01T00:00:00+00:00', - immediatePayout: true, - index: 0, - state: MILESTONE_STATE.WAITING, - amount: amountBn, - amountAgainstPayout: new BN(0), - percentAgainstPayout: 0, - payoutRequestVoteDeadline: 0, - isPaid: false, - isImmediatePayout: true, - payoutPercent: '33', - stage: 'NOT_REQUESTED', - }, - { - title: 'Milestone B', - body: `Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris - nisi ut aliquip ex ea commodo consequat.`, - content: '', - dateCreated: '2018-09-23T19:06:15.844399+00:00', - dateEstimated: '2018-11-01T00:00:00+00:00', - immediatePayout: false, - index: 1, - state: MILESTONE_STATE.WAITING, - amount: amountBn, - amountAgainstPayout: new BN(0), - percentAgainstPayout: 0, - payoutRequestVoteDeadline: Date.now(), - isPaid: false, - isImmediatePayout: false, - payoutPercent: '33', - stage: 'NOT_REQUESTED', - }, - { - title: 'Milestone C', - body: `Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris - nisi ut aliquip ex ea commodo consequat.`, - content: '', - dateCreated: '2018-09-23T19:06:15.844399+00:00', - dateEstimated: '2018-12-01T00:00:00+00:00', - immediatePayout: false, - index: 2, - state: MILESTONE_STATE.WAITING, - amount: amountBn, - amountAgainstPayout: new BN(0), - percentAgainstPayout: 0, - payoutRequestVoteDeadline: Date.now(), - isPaid: false, - isImmediatePayout: false, - payoutPercent: '33', - stage: 'NOT_REQUESTED', - }, - ]; + if (funded === 0) { + contributors = []; + } - const eachMilestoneAmount = fundedBn.div(new BN(milestones.length)); + const genMilestoneTitle = () => { + const ts = ['40chr ', 'Really ', 'Really ', 'Long ', 'Milestone Title']; + const rand = Math.floor(Math.random() * Math.floor(ts.length)); + return ts.slice(rand).join(''); + }; + + const genMilestone = (overrides: Partial = {}) => { + const now = new Date(); + if (overrides.index) { + const estimate = new Date(now.setMonth(now.getMonth() + overrides.index)); + overrides.dateEstimated = estimate.toISOString(); + } + + return Object.assign( + { + title: 'Milestone A', + body: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua.`, + content: '', + dateEstimated: '2018-10-01T00:00:00+00:00', + immediatePayout: true, + index: 0, + state: MILESTONE_STATE.WAITING, + amount: amountBn, + amountAgainstPayout: new BN(0), + percentAgainstPayout: 0, + payoutRequestVoteDeadline: 0, + isPaid: false, + isImmediatePayout: true, + payoutPercent: '33', + stage: 'NOT_REQUESTED', + }, + overrides, + ); + }; + + const milestones = [...Array(milestoneCount).keys()].map(i => { + const overrides = { + index: i, + title: genMilestoneTitle(), + immediatePayout: i === 0, + isImmediatePayout: i === 0, + payoutRequestVoteDeadline: i !== 0 ? Date.now() + 3600000 : 0, + payoutPercent: '' + (1 / milestoneCount) * 100, + }; + return genMilestone(overrides); + }); + + const eachMilestoneAmount = amountBn.div(new BN(milestones.length)); milestones.forEach(ms => (ms.amount = eachMilestoneAmount)); milestoneOverrides.forEach((mso, idx) => { Object.assign(milestones[idx], mso); From 24350ec77f62abdf68d6f835104e0ca551afbad9 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Wed, 3 Oct 2018 15:08:14 -0400 Subject: [PATCH 11/18] User Authentication UI (Pt 3 - Persistence) (#127) * Check in auth flow work. * More work on auth steps. Check in before redux state. * Create auth reducer and actions * Stubbed out profile page to test auth aware routes. Minor style fixes. * Fill out provider components * Handle missing origin * Fix reducer mistake. Show user info in profile page. * Reflect auth state in header. * tslint * Actual user creation. * Implement sign in * Fix redux types. * Add redux persist to config. * Add sagas, fix persistence. * Remove console log --- frontend/client/index.tsx | 12 +++++----- frontend/client/modules/auth/actions.ts | 5 ++++- frontend/client/modules/auth/index.ts | 5 ++++- frontend/client/modules/auth/persistence.ts | 9 ++++++++ frontend/client/modules/auth/reducers.ts | 5 ++++- frontend/client/modules/auth/sagas.ts | 25 +++++++++++++++++++++ frontend/client/modules/auth/selectors.ts | 4 ++++ frontend/client/store/configure.tsx | 17 ++++++-------- frontend/client/store/reducers.tsx | 9 ++++++-- frontend/client/store/sagas.ts | 6 +++++ frontend/client/store/sagas.tsx | 10 --------- frontend/package.json | 1 + frontend/server/index.tsx | 1 + frontend/server/render.tsx | 2 +- frontend/yarn.lock | 4 ++++ 15 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 frontend/client/modules/auth/persistence.ts create mode 100644 frontend/client/modules/auth/sagas.ts create mode 100644 frontend/client/modules/auth/selectors.ts create mode 100644 frontend/client/store/sagas.ts delete mode 100644 frontend/client/store/sagas.tsx diff --git a/frontend/client/index.tsx b/frontend/client/index.tsx index 02772ddd..6d6871e4 100644 --- a/frontend/client/index.tsx +++ b/frontend/client/index.tsx @@ -5,18 +5,20 @@ import { hydrate } from 'react-dom'; import { loadComponents } from 'loadable-components'; import { Provider } from 'react-redux'; import { BrowserRouter as Router } from 'react-router-dom'; - +import { PersistGate } from 'redux-persist/integration/react'; import { configureStore } from 'store/configure'; import Routes from './Routes'; const initialState = window && (window as any).__PRELOADED_STATE__; -const store = configureStore(initialState); +const { store, persistor } = configureStore(initialState); const App = hot(module)(() => ( - - - + + + + + )); diff --git a/frontend/client/modules/auth/actions.ts b/frontend/client/modules/auth/actions.ts index 0f6a3c00..9248b119 100644 --- a/frontend/client/modules/auth/actions.ts +++ b/frontend/client/modules/auth/actions.ts @@ -15,7 +15,10 @@ export function authUser(address: string) { const res = await apiGetUser(address); dispatch({ type: types.AUTH_USER_FULFILLED, - payload: res.data, + payload: { + user: res.data, + token: '123fake', // TODO: Use real token + }, }); } catch (err) { dispatch({ diff --git a/frontend/client/modules/auth/index.ts b/frontend/client/modules/auth/index.ts index 957a4970..60b291eb 100644 --- a/frontend/client/modules/auth/index.ts +++ b/frontend/client/modules/auth/index.ts @@ -1,7 +1,10 @@ import reducers, { AuthState, INITIAL_STATE } from './reducers'; import * as authActions from './actions'; import * as authTypes from './types'; +import authSagas from './sagas'; -export { authActions, authTypes, AuthState, INITIAL_STATE }; +export * from './persistence'; + +export { authActions, authTypes, authSagas, AuthState, INITIAL_STATE }; export default reducers; diff --git a/frontend/client/modules/auth/persistence.ts b/frontend/client/modules/auth/persistence.ts new file mode 100644 index 00000000..9f05aa69 --- /dev/null +++ b/frontend/client/modules/auth/persistence.ts @@ -0,0 +1,9 @@ +import { PersistConfig } from 'redux-persist'; +import storage from 'redux-persist/lib/storage'; + +export const authPersistConfig: PersistConfig = { + key: 'auth', + storage, + version: 1, + whitelist: ['token', 'tokenAddress'], +}; diff --git a/frontend/client/modules/auth/reducers.ts b/frontend/client/modules/auth/reducers.ts index 0e274bd6..9431ae97 100644 --- a/frontend/client/modules/auth/reducers.ts +++ b/frontend/client/modules/auth/reducers.ts @@ -48,7 +48,9 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action: case types.AUTH_USER_FULFILLED: return { ...state, - user: action.payload, + user: action.payload.user, + token: action.payload.token, // TODO: Make this the real token + tokenAddress: action.payload.user.ethAddress, isAuthingUser: false, }; case types.AUTH_USER_REJECTED: @@ -69,6 +71,7 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action: ...state, user: action.payload.user, token: action.payload.token, + tokenAddress: action.payload.user.ethAddress, isCreatingUser: false, checkedUsers: { ...state.checkedUsers, diff --git a/frontend/client/modules/auth/sagas.ts b/frontend/client/modules/auth/sagas.ts new file mode 100644 index 00000000..c1cd40d0 --- /dev/null +++ b/frontend/client/modules/auth/sagas.ts @@ -0,0 +1,25 @@ +import { SagaIterator } from 'redux-saga'; +import { select, put, all, takeEvery } from 'redux-saga/effects'; +import { REHYDRATE } from 'redux-persist'; +import { getAuthTokenAddress } from './selectors'; +import { authUser } from './actions'; + +export function* authFromToken(): SagaIterator { + const address: ReturnType = yield select( + getAuthTokenAddress, + ); + if (!address) { + return; + } + + // TODO: Figure out how to type redux-saga with thunks + yield put(authUser(address)); +} + +export default function* authSaga(): SagaIterator { + yield all([ + // Run authFromToken as soon as persisted state is hydrated + // TODO: Do this server-side at some point + takeEvery(REHYDRATE, authFromToken), + ]); +} diff --git a/frontend/client/modules/auth/selectors.ts b/frontend/client/modules/auth/selectors.ts new file mode 100644 index 00000000..ad55007e --- /dev/null +++ b/frontend/client/modules/auth/selectors.ts @@ -0,0 +1,4 @@ +import { AppState as S } from 'store/reducers'; + +export const getAuthToken = (s: S) => s.auth.token; +export const getAuthTokenAddress = (s: S) => s.auth.tokenAddress; diff --git a/frontend/client/store/configure.tsx b/frontend/client/store/configure.tsx index 5d166dd0..822971a6 100644 --- a/frontend/client/store/configure.tsx +++ b/frontend/client/store/configure.tsx @@ -3,8 +3,9 @@ import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; import thunkMiddleware, { ThunkMiddleware } from 'redux-thunk'; import promiseMiddleware from 'redux-promise-middleware'; import { composeWithDevTools } from 'redux-devtools-extension'; +import { persistStore } from 'redux-persist'; import rootReducer, { AppState, combineInitialState } from './reducers'; -// import rootSaga from './sagas'; +import rootSaga from './sagas'; const sagaMiddleware = createSagaMiddleware(); @@ -21,20 +22,15 @@ const bindMiddleware = (middleware: MiddleWare[]) => { return composeWithDevTools(applyMiddleware(...middleware)); }; -export function configureStore( - initialState: Partial = combineInitialState, -): Store { +export function configureStore(initialState: Partial = combineInitialState) { const store: Store = createStore( rootReducer, initialState, bindMiddleware([sagaMiddleware, thunkMiddleware, promiseMiddleware()]), ); + const persistor = process.env.SERVER_SIDE_RENDER ? undefined : persistStore(store); - // store.runSagaTask = () => { - // store.sagaTask = sagaMiddleware.run(rootSaga); - // }; - - // store.runSagaTask(); + sagaMiddleware.run(rootSaga); if (process.env.NODE_ENV === 'development') { if (module.hot) { @@ -43,5 +39,6 @@ export function configureStore( ); } } - return store; + + return { store, persistor }; } diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx index 2defeb8b..aea47b2a 100644 --- a/frontend/client/store/reducers.tsx +++ b/frontend/client/store/reducers.tsx @@ -1,12 +1,17 @@ import { combineReducers } from 'redux'; +import { persistReducer } from 'redux-persist'; import web3, { Web3State, INITIAL_STATE as web3InitialState } from 'modules/web3'; import proposal, { ProposalState, INITIAL_STATE as proposalInitialState, } from 'modules/proposals'; import create, { CreateState, INITIAL_STATE as createInitialState } from 'modules/create'; +import authReducer, { + AuthState, + INITIAL_STATE as authInitialState, + authPersistConfig, +} from 'modules/auth'; import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users'; -import auth, { AuthState, INITIAL_STATE as authInitialState } from 'modules/auth'; export interface AppState { proposal: ProposalState; @@ -28,6 +33,6 @@ export default combineReducers({ proposal, web3, create, + auth: persistReducer(authPersistConfig, authReducer), users, - auth, }); diff --git a/frontend/client/store/sagas.ts b/frontend/client/store/sagas.ts new file mode 100644 index 00000000..3371e379 --- /dev/null +++ b/frontend/client/store/sagas.ts @@ -0,0 +1,6 @@ +import { fork } from 'redux-saga/effects'; +import { authSagas } from 'modules/auth'; + +export default function* rootSaga() { + yield fork(authSagas); +} diff --git a/frontend/client/store/sagas.tsx b/frontend/client/store/sagas.tsx deleted file mode 100644 index 169f68ad..00000000 --- a/frontend/client/store/sagas.tsx +++ /dev/null @@ -1,10 +0,0 @@ -// SAGAS GO HERE -////////////////////////////////////////////////////////////// -// EXAMPLE -////////////////////////////////////////////////////////////// -// import { all, fork } from 'redux-saga/effects'; -// import { clockSagas } from 'modules/clock'; -// export default function* rootSaga() { -// yield all([clockSagas]); -// yield all(drizzleSagas.map(saga => fork(saga))); -// } diff --git a/frontend/package.json b/frontend/package.json index 0bb3b68d..e717b6ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -120,6 +120,7 @@ "redux": "^4.0.0", "redux-devtools-extension": "^2.13.2", "redux-logger": "^3.0.6", + "redux-persist": "5.10.0", "redux-promise-middleware": "^5.1.1", "redux-saga": "^0.16.0", "redux-thunk": "^2.3.0", diff --git a/frontend/server/index.tsx b/frontend/server/index.tsx index 8cfb2882..671060fa 100644 --- a/frontend/server/index.tsx +++ b/frontend/server/index.tsx @@ -12,6 +12,7 @@ import serverRender from './render'; // @ts-ignore import * as paths from '../config/paths'; +process.env.SERVER_SIDE_RENDER = 'true'; const isDev = process.env.NODE_ENV === 'development'; dotenv.config(); diff --git a/frontend/server/render.tsx b/frontend/server/render.tsx index e114a843..de290586 100644 --- a/frontend/server/render.tsx +++ b/frontend/server/render.tsx @@ -72,7 +72,7 @@ const chunkExtractFromLoadables = (loadableState: any) => }); const serverRenderer = () => async (req: Request, res: Response) => { - const store = configureStore(); + const { store } = configureStore(); const reactApp = ( diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 8dc933bd..10696e43 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -12384,6 +12384,10 @@ redux-logger@^3.0.6: dependencies: deep-diff "^0.3.5" +redux-persist@5.10.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-5.10.0.tgz#5d8d802c5571e55924efc1c3a9b23575283be62b" + redux-promise-middleware@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-5.1.1.tgz#37689339a58a33d1fda675ed1ba2053a2d196b8d" From b2ca92362d31ff0afbaf412e4b1d28616c1b3e99 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 3 Oct 2018 15:12:26 -0500 Subject: [PATCH 12/18] [RE-OPENING TO DEVELOP] Auth UI Menu (#130) * Check in auth flow work. * More work on auth steps. Check in before redux state. * Create auth reducer and actions * Stubbed out profile page to test auth aware routes. Minor style fixes. * Fill out provider components * Handle missing origin * Fix reducer mistake. Show user info in profile page. * Reflect auth state in header. * tslint * Actual user creation. * Implement sign in * Fix redux types. * Add redux persist to config. * Add sagas, fix persistence. * Remove console log * Split out header auth into own component. Add a menu, logout page, stub for settings page. * Add mobile menu drawer. * Adjust styles, fix sticky logout. * Tslint * Fix menu icon on transparent. * Fix configureStore changes. --- frontend/client/Routes.tsx | 6 +- frontend/client/components/Header/Auth.less | 64 ++++++++ frontend/client/components/Header/Auth.tsx | 143 ++++++++++++++++++ frontend/client/components/Header/Drawer.less | 28 ++++ frontend/client/components/Header/Drawer.tsx | 96 ++++++++++++ frontend/client/components/Header/index.tsx | 101 +++---------- frontend/client/components/Header/style.less | 77 ++++------ frontend/client/components/Home/style.less | 10 +- frontend/client/modules/auth/reducers.ts | 1 + frontend/client/pages/settings.tsx | 21 +++ frontend/client/pages/sign-out.tsx | 47 ++++++ frontend/client/static/images/menu.svg | 3 + frontend/stories/ProposalMilestones.story.tsx | 6 +- 13 files changed, 474 insertions(+), 129 deletions(-) create mode 100644 frontend/client/components/Header/Auth.less create mode 100644 frontend/client/components/Header/Auth.tsx create mode 100644 frontend/client/components/Header/Drawer.less create mode 100644 frontend/client/components/Header/Drawer.tsx create mode 100644 frontend/client/pages/settings.tsx create mode 100644 frontend/client/pages/sign-out.tsx create mode 100644 frontend/client/static/images/menu.svg diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index 778f1f78..93095525 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -10,7 +10,9 @@ const Create = loadable(() => import('pages/create')); const Proposals = loadable(() => import('pages/proposals')); const Proposal = loadable(() => import('pages/proposal')); const Auth = loadable(() => import('pages/auth')); +const SignOut = loadable(() => import('pages/sign-out')); const Profile = loadable(() => import('pages/profile')); +const Settings = loadable(() => import('pages/settings')); const Exception = loadable(() => import('pages/exception')); import 'styles/style.less'; @@ -24,8 +26,10 @@ class Routes extends React.Component { + - + + } /> ); diff --git a/frontend/client/components/Header/Auth.less b/frontend/client/components/Header/Auth.less new file mode 100644 index 00000000..c96b3e8d --- /dev/null +++ b/frontend/client/components/Header/Auth.less @@ -0,0 +1,64 @@ +.AuthButton { + // Needed to override inherited styles + &.Header-links-link { + display: flex; + align-items: center; + margin-right: -0.2rem; + transform: none; + transition: opacity 200ms ease, transform 200ms ease; + + &.is-loading { + opacity: 0; + transform: scale(0.9); + } + } + + &:hover { + .anticon { + opacity: 1; + } + } + + &-avatar { + position: relative; + height: 2.4rem; + width: 2.4rem; + margin-left: 0.6rem; + border-radius: 100%; + overflow: hidden; + background: rgba(#FFF, 0.2); + box-shadow: 0 0.5px 2px rgba(#000, 0.3); + + img { + height: 100%; + width: 100%; + border-radius: 100%; + } + + .ant-spin { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + line-height: 0; + } + + &-locked { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(#000, 0.2); + + .anticon { + color: #FFF; + font-size: 1.4rem; + opacity: 0.8; + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/Header/Auth.tsx b/frontend/client/components/Header/Auth.tsx new file mode 100644 index 00000000..3394f602 --- /dev/null +++ b/frontend/client/components/Header/Auth.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { Icon, Dropdown, Menu } from 'antd'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import classnames from 'classnames'; +import UserAvatar from 'components/UserAvatar'; +import Identicon from 'components/Identicon'; +import { web3Actions } from 'modules/web3'; +import { AppState } from 'store/reducers'; +import './Auth.less'; + +interface StateProps { + user: AppState['auth']['user']; + isAuthingUser: AppState['auth']['isAuthingUser']; + web3: AppState['web3']['web3']; + accounts: AppState['web3']['accounts']; + accountsLoading: AppState['web3']['accountsLoading']; + accountsError: AppState['web3']['accountsError']; +} + +interface DispatchProps { + setWeb3: typeof web3Actions['setWeb3']; + setAccounts: typeof web3Actions['setAccounts']; +} + +type Props = StateProps & DispatchProps; + +interface State { + isMenuOpen: boolean; +} + +class HeaderAuth extends React.Component { + state: State = { + isMenuOpen: false, + }; + + componentDidMount() { + this.props.setWeb3(); + } + + componentDidUpdate() { + const { web3, accounts, accountsLoading, accountsError } = this.props; + if (web3 && !accounts.length && !accountsLoading && !accountsError) { + this.props.setAccounts(); + } + } + + render() { + const { accounts, accountsLoading, user, isAuthingUser } = this.props; + const { isMenuOpen } = this.state; + const isAuthed = !!user; + + let avatar; + let isLoading; + if (user) { + avatar = ; + } else if (accounts && accounts[0]) { + avatar = ; + } else if (accountsLoading || isAuthingUser) { + avatar = ''; + isLoading = true; + } + + const link = ( + + {isAuthed ? '' : 'Sign in'} + {avatar && ( +
+ {avatar} + {!isAuthed && ( +
+ +
+ )} +
+ )} + + ); + + // If they're not authed, don't render the dropdown menu + if (!isAuthed) { + return link; + } + + const menu = ( + + + Profile + + + Settings + + + + Sign out + + + ); + + return ( + + {link} + + ); + } + + private toggleMenu = (ev?: React.MouseEvent) => { + if (ev) { + ev.preventDefault(); + } + this.setState({ isMenuOpen: !this.state.isMenuOpen }); + }; + + private handleVisibilityChange = (visibility: boolean) => { + // Handle the dropdown component's built in close events + this.setState({ isMenuOpen: visibility }); + }; +} + +export default connect( + state => ({ + user: state.auth.user, + isAuthingUser: state.auth.isAuthingUser, + web3: state.web3.web3, + accounts: state.web3.accounts, + accountsLoading: state.web3.accountsLoading, + accountsError: state.web3.accountsError, + }), + { + setWeb3: web3Actions.setWeb3, + setAccounts: web3Actions.setAccounts, + }, +)(HeaderAuth); diff --git a/frontend/client/components/Header/Drawer.less b/frontend/client/components/Header/Drawer.less new file mode 100644 index 00000000..d6a48e4d --- /dev/null +++ b/frontend/client/components/Header/Drawer.less @@ -0,0 +1,28 @@ +.HeaderDrawer { + .ant-drawer-body { + padding: 0.8rem 0; + } + + &-title { + font-size: 2rem; + padding-left: 1.25rem; + padding-bottom: 0.75rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid rgba(#000, 0.08); + } + + &-user { + .ant-menu-item-group-title { + display: flex; + align-items: center; + } + + &-avatar { + width: 2rem; + height: 2rem; + margin-right: 0.75rem; + border-radius: 100%; + box-shadow: 0 0.5px 2px rgba(#000, 0.3); + } + } +} \ No newline at end of file diff --git a/frontend/client/components/Header/Drawer.tsx b/frontend/client/components/Header/Drawer.tsx new file mode 100644 index 00000000..1277651d --- /dev/null +++ b/frontend/client/components/Header/Drawer.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Drawer, Menu } from 'antd'; +import { Link } from 'react-router-dom'; +import UserAvatar from 'components/UserAvatar'; +import Identicon from 'components/Identicon'; +import { AppState } from 'store/reducers'; +import './Drawer.less'; + +interface StateProps { + user: AppState['auth']['user']; + accounts: AppState['web3']['accounts']; +} + +interface OwnProps { + isOpen: boolean; + onClose(): void; +} + +type Props = StateProps & OwnProps; + +class HeaderDrawer extends React.Component { + componentDidMount() { + window.addEventListener('resize', this.props.onClose); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.props.onClose); + } + + render() { + const { isOpen, onClose, user, accounts } = this.props; + + let userTitle: React.ReactNode = 'Account'; + if (user) { + userTitle = ( + <> + + My account + + ); + } else if (accounts && accounts[0]) { + userTitle = ( + <> + + Account + + ); + } + + return ( + +
Grant.io
+ + + {user ? ( + [ + + Profile + , + + Settings + , + + Sign out + , + ] + ) : ( + + Sign in + + )} + + + + Browse proposals + + + Start a proposal + + + +
+ ); + } +} + +export default connect(state => ({ + user: state.auth.user, + accounts: state.web3.accounts, +}))(HeaderDrawer); diff --git a/frontend/client/components/Header/index.tsx b/frontend/client/components/Header/index.tsx index fbb6a83d..345b2b85 100644 --- a/frontend/client/components/Header/index.tsx +++ b/frontend/client/components/Header/index.tsx @@ -1,58 +1,27 @@ import React from 'react'; -import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; -import { Spin, Icon } from 'antd'; -import UserAvatar from 'components/UserAvatar'; -import Identicon from 'components/Identicon'; -import { web3Actions } from 'modules/web3'; -import { AppState } from 'store/reducers'; +import HeaderAuth from './Auth'; +import HeaderDrawer from './Drawer'; +import MenuIcon from 'static/images/menu.svg'; import './style.less'; -interface StateProps { - user: AppState['auth']['user']; - isAuthingUser: AppState['auth']['isAuthingUser']; - web3: AppState['web3']['web3']; - accounts: AppState['web3']['accounts']; - accountsLoading: AppState['web3']['accountsLoading']; - accountsError: AppState['web3']['accountsError']; -} - -interface DispatchProps { - setWeb3: typeof web3Actions['setWeb3']; - setAccounts: typeof web3Actions['setAccounts']; -} - -interface OwnProps { +interface Props { isTransparent?: boolean; } -type Props = StateProps & DispatchProps & OwnProps; +interface State { + isDrawerOpen: boolean; +} -class Header extends React.Component { - componentDidMount() { - this.props.setWeb3(); - } - - componentDidUpdate() { - const { web3, accounts, accountsLoading, accountsError } = this.props; - if (web3 && !accounts.length && !accountsLoading && !accountsError) { - this.props.setAccounts(); - } - } +export default class Header extends React.Component { + state: State = { + isDrawerOpen: false, + }; render() { - const { isTransparent, accounts, accountsLoading, user, isAuthingUser } = this.props; - const isAuthed = !!user; - - let avatar; - if (user) { - avatar = ; - } else if (accounts && accounts[0]) { - avatar = ; - } else if (accountsLoading || isAuthingUser) { - avatar = ; - } + const { isTransparent } = this.props; + const { isDrawerOpen } = this.state; return (
{ ['is-transparent']: isTransparent, })} > -
+
Browse @@ -70,46 +39,26 @@ class Header extends React.Component {
+
+ +
+ Grant.io
- - {isAuthed ? '' : 'Sign in'} - {avatar && ( -
- {avatar} - {!isAuthed && ( -
- -
- )} -
- )} - +
{!isTransparent &&
Alpha
} +
); } -} -export default connect( - state => ({ - user: state.auth.user, - isAuthingUser: state.auth.isAuthingUser, - web3: state.web3.web3, - accounts: state.web3.accounts, - accountsLoading: state.web3.accountsLoading, - accountsError: state.web3.accountsError, - }), - { - setWeb3: web3Actions.setWeb3, - setAccounts: web3Actions.setAccounts, - }, -)(Header); + private openDrawer = () => this.setState({ isDrawerOpen: true }); + private closeDrawer = () => this.setState({ isDrawerOpen: false }); +} diff --git a/frontend/client/components/Header/style.less b/frontend/client/components/Header/style.less index 318464bd..270cc044 100644 --- a/frontend/client/components/Header/style.less +++ b/frontend/client/components/Header/style.less @@ -1,5 +1,6 @@ @header-height: 62px; -@small-query: ~'(max-width: 520px)'; +@small-query: ~'(max-width: 660px)'; +@big-query: ~'(min-width: 661px)'; .Header { top: 0; @@ -23,6 +24,14 @@ background: transparent; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); box-shadow: none; + + svg { + fill: #fff; + } + + .Header-title { + transform: translateY(0px) translate(-50%, -50%); + } } &-title { @@ -60,6 +69,18 @@ margin-right: -0.75rem; } + &.is-desktop { + @media @small-query { + display: none; + } + } + + &.is-mobile { + @media @big-query { + display: none; + } + } + &-link { display: block; background: none; @@ -71,6 +92,7 @@ cursor: pointer; opacity: 0.8; transition: transform 100ms ease, opacity 100ms ease; + outline: none; &:hover, &:focus, @@ -80,6 +102,12 @@ color: inherit; text-decoration-color: transparent; } + + &-icon { + width: 1.8rem; + height: 1.8rem; + opacity: 0.8; + } } } @@ -103,50 +131,3 @@ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } } - -.AuthButton { - display: flex; - align-items: center; - margin-right: -0.2rem; - transform: none !important; - - &:hover { - .anticon { - opacity: 1; - } - } - - &-avatar { - position: relative; - height: 2.4rem; - width: 2.4rem; - margin-left: 0.6rem; - border-radius: 100%; - overflow: hidden; - box-shadow: 0 0.5px 2px rgba(#000, 0.3); - - img { - height: 100%; - width: 100%; - border-radius: 100%; - } - - &-locked { - display: flex; - align-items: center; - justify-content: center; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(#000, 0.2); - - .anticon { - color: #FFF; - font-size: 1.4rem; - opacity: 0.8; - } - } - } -} diff --git a/frontend/client/components/Home/style.less b/frontend/client/components/Home/style.less index a114879f..3e66406f 100644 --- a/frontend/client/components/Home/style.less +++ b/frontend/client/components/Home/style.less @@ -22,11 +22,19 @@ &-title { color: #fff; - font-size: 3.4rem; + font-size: 3.2rem; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7); letter-spacing: 0.06rem; text-align: center; margin-bottom: 2rem; + + @media (max-width: 980px) { + font-size: 2.6rem; + } + + @media (max-width: 680px) { + font-size: 2rem; + } } &-buttons { diff --git a/frontend/client/modules/auth/reducers.ts b/frontend/client/modules/auth/reducers.ts index 9431ae97..eb3274fc 100644 --- a/frontend/client/modules/auth/reducers.ts +++ b/frontend/client/modules/auth/reducers.ts @@ -136,6 +136,7 @@ export default function createReducer(state: AuthState = INITIAL_STATE, action: ...state, user: null, token: null, + tokenAddress: null, }; } return state; diff --git a/frontend/client/pages/settings.tsx b/frontend/client/pages/settings.tsx new file mode 100644 index 00000000..2e98d450 --- /dev/null +++ b/frontend/client/pages/settings.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import AntWrap from 'components/AntWrap'; +import { AppState } from 'store/reducers'; + +interface Props { + user: AppState['auth']['user']; +} + +class ProfilePage extends React.Component { + render() { + const { user } = this.props; + return ( + +

Settings for {user && user.name}

+
+ ); + } +} + +export default connect((state: AppState) => ({ user: state.auth.user }))(ProfilePage); diff --git a/frontend/client/pages/sign-out.tsx b/frontend/client/pages/sign-out.tsx new file mode 100644 index 00000000..16540691 --- /dev/null +++ b/frontend/client/pages/sign-out.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Button } from 'antd'; +import { Link } from 'react-router-dom'; +import Result from 'ant-design-pro/lib/Result'; +import { authActions } from 'modules/auth'; +import AntWrap from 'components/AntWrap'; + +interface Props { + logout: typeof authActions['logout']; +} + +class SignInPage extends React.Component { + componentDidMount() { + this.props.logout(); + } + + render() { + return ( + + + + + + + + + + } + /> + + ); + } +} + +export default connect( + undefined, + { logout: authActions.logout }, +)(SignInPage); diff --git a/frontend/client/static/images/menu.svg b/frontend/client/static/images/menu.svg new file mode 100644 index 00000000..a0c2dd3a --- /dev/null +++ b/frontend/client/static/images/menu.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/stories/ProposalMilestones.story.tsx b/frontend/stories/ProposalMilestones.story.tsx index d393ca2a..20e15042 100644 --- a/frontend/stories/ProposalMilestones.story.tsx +++ b/frontend/stories/ProposalMilestones.story.tsx @@ -127,15 +127,15 @@ const cases: { [index: string]: any } = { const initialStoreStateA = JSON.parse(JSON.stringify(combineInitialState)); initialStoreStateA.web3.accounts = [trustee]; -const storeTrustee = configureStore(initialStoreStateA); +const storeTrustee = configureStore(initialStoreStateA).store; const initialStoreStateB = JSON.parse(JSON.stringify(combineInitialState)); initialStoreStateB.web3.accounts = [contributor]; -const storeContributor = configureStore(initialStoreStateB); +const storeContributor = configureStore(initialStoreStateB).store; const initialStoreStateC = JSON.parse(JSON.stringify(combineInitialState)); initialStoreStateC.web3.accounts = ['0x0']; -const storeOutsider = configureStore(initialStoreStateC); +const storeOutsider = configureStore(initialStoreStateC).store; const trusteeStories = storiesOf('Proposal/Milestones/trustee', module); From ad0a153e7ad40cc9c64b4683811cb9ad199324b1 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Wed, 3 Oct 2018 21:42:20 -0500 Subject: [PATCH 13/18] Unify template & reduce web3 complexity (#132) * Check in auth flow work. * More work on auth steps. Check in before redux state. * Create auth reducer and actions * Stubbed out profile page to test auth aware routes. Minor style fixes. * Fill out provider components * Handle missing origin * Fix reducer mistake. Show user info in profile page. * Reflect auth state in header. * tslint * Actual user creation. * Implement sign in * Fix redux types. * Add redux persist to config. * Add sagas, fix persistence. * Remove console log * Split out header auth into own component. Add a menu, logout page, stub for settings page. * Add mobile menu drawer. * Adjust styles, fix sticky logout. * Tslint * Fix menu icon on transparent. * Fix configureStore changes. * All routes are config objects. Move template outside of routes. Combine AntWrap and Web3Page into one component. * Sagafy web3 bootstrapping, remove it from components. * Get errors rendering. Fix SSR issue with initial web3 error. * Fix auth menu, sign out page. * Simplify logic * Remove console logs --- frontend/client/Routes.tsx | 170 ++++++++++++++++-- frontend/client/components/AntWrap.tsx | 66 ------- frontend/client/components/Header/Auth.tsx | 52 ++---- frontend/client/components/Home/index.tsx | 67 ++++--- frontend/client/components/Profile/index.tsx | 101 +++++------ .../client/components/Template/Web3Error.less | 47 +++++ .../client/components/Template/Web3Error.tsx | 32 ++++ .../client/components/Template/index.less | 39 ++++ frontend/client/components/Template/index.tsx | 138 ++++++++++++++ frontend/client/components/Web3Page/index.tsx | 109 ----------- .../client/components/Web3Page/style.less | 56 ------ frontend/client/lib/Web3Container.tsx | 55 +----- frontend/client/modules/web3/index.ts | 3 +- frontend/client/modules/web3/sagas.ts | 23 +++ frontend/client/pages/auth.tsx | 7 +- frontend/client/pages/create.tsx | 9 +- frontend/client/pages/proposal.tsx | 8 +- frontend/client/pages/proposals.tsx | 5 +- frontend/client/pages/settings.tsx | 7 +- frontend/client/pages/sign-out.tsx | 39 ++-- frontend/client/store/sagas.ts | 2 + 21 files changed, 558 insertions(+), 477 deletions(-) delete mode 100644 frontend/client/components/AntWrap.tsx create mode 100644 frontend/client/components/Template/Web3Error.less create mode 100644 frontend/client/components/Template/Web3Error.tsx create mode 100644 frontend/client/components/Template/index.less create mode 100644 frontend/client/components/Template/index.tsx delete mode 100644 frontend/client/components/Web3Page/index.tsx delete mode 100644 frontend/client/components/Web3Page/style.less create mode 100644 frontend/client/modules/web3/sagas.ts diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index 93095525..e1abf13e 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -1,8 +1,16 @@ import React from 'react'; import { hot } from 'react-hot-loader'; -import { Switch, Route } from 'react-router'; +import { + Switch, + Route, + RouteProps, + RouteComponentProps, + withRouter, + matchPath, +} from 'react-router'; import loadable from 'loadable-components'; import AuthRoute from 'components/AuthRoute'; +import Template, { TemplateProps } from 'components/Template'; // wrap components in loadable...import & they will be split const Home = loadable(() => import('pages/index')); @@ -17,23 +25,155 @@ const Exception = loadable(() => import('pages/exception')); import 'styles/style.less'; -class Routes extends React.Component { +interface RouteConfig extends RouteProps { + route: RouteProps; + template: TemplateProps; + requiresWeb3?: boolean; + onlyLoggedIn?: boolean; + onlyLoggedOut?: boolean; +} + +const routeConfigs: RouteConfig[] = [ + { + // Homepage + route: { + path: '/', + component: Home, + exact: true, + }, + template: { + title: 'Home', + isHeaderTransparent: true, + isFullScreen: true, + }, + }, + { + // Create proposal + route: { + path: '/create', + component: Create, + }, + template: { + title: 'Create a Proposal', + isFullScreen: true, + hideFooter: true, + requiresWeb3: true, + }, + }, + { + // Browse proposals + route: { + path: '/proposals', + component: Proposals, + exact: true, + }, + template: { + title: 'Browse proposals', + requiresWeb3: true, + }, + }, + { + // Proposal detail page + route: { + path: '/proposals/:id', + component: Proposal, + }, + template: { + title: 'Proposal', + requiresWeb3: true, + }, + }, + { + // Self profile + route: { + path: '/profile', + component: Profile, + exact: true, + }, + template: { + title: 'Profile', + }, + onlyLoggedIn: true, + }, + { + // Settings page + route: { + path: '/profile/settings', + component: Settings, + exact: true, + }, + template: { + title: 'Settings', + }, + onlyLoggedIn: true, + }, + { + // User profile + route: { + path: '/profile/:id', + component: Profile, + }, + template: { + title: 'Profile', + }, + }, + { + // Sign in / sign up + route: { + path: '/auth', + component: Auth, + exact: true, + }, + template: { + title: 'Sign in', + }, + onlyLoggedOut: true, + }, + { + // Sign out + route: { + path: '/auth/sign-out', + component: SignOut, + exact: true, + }, + template: { + title: 'Signed out', + }, + }, + { + // 404 + route: { + path: '/*', + render: () => , + }, + template: { + title: 'Page not found', + }, + }, +]; + +type Props = RouteComponentProps; + +class Routes extends React.PureComponent { render() { + const { pathname } = this.props.location; + const currentRoute = routeConfigs.find(config => !!matchPath(pathname, config.route)); + const routeComponents = routeConfigs.map(config => { + const { route, onlyLoggedIn, onlyLoggedOut } = config; + if (onlyLoggedIn || onlyLoggedOut) { + return ; + } else { + return ; + } + }); + return ( - - - - - - - - - - - } /> - + ); } } -export default hot(module)(Routes); +const RouterAwareRoutes = withRouter(Routes); +export default hot(module)(RouterAwareRoutes); diff --git a/frontend/client/components/AntWrap.tsx b/frontend/client/components/AntWrap.tsx deleted file mode 100644 index a3cd8752..00000000 --- a/frontend/client/components/AntWrap.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { Layout, Breadcrumb } from 'antd'; -import BasicHead from './BasicHead'; -import Header from './Header'; -import Footer from './Footer'; - -export interface Props { - title: string; - isHeaderTransparent?: boolean; - isFullScreen?: boolean; - hideFooter?: boolean; - withBreadcrumb?: boolean | null; - centerContent?: boolean; -} - -const { Content } = Layout; - -class AntWrap extends React.Component { - render() { - const { - children, - withBreadcrumb, - title, - isHeaderTransparent, - isFullScreen, - hideFooter, - centerContent, - } = this.props; - return ( - -
-
- - {withBreadcrumb && ( - - Home - List - App - - )} -
- {children} -
-
- {!hideFooter &&
} -
-
- ); - } -} -export default AntWrap; diff --git a/frontend/client/components/Header/Auth.tsx b/frontend/client/components/Header/Auth.tsx index 3394f602..0253c400 100644 --- a/frontend/client/components/Header/Auth.tsx +++ b/frontend/client/components/Header/Auth.tsx @@ -5,25 +5,17 @@ import { Link } from 'react-router-dom'; import classnames from 'classnames'; import UserAvatar from 'components/UserAvatar'; import Identicon from 'components/Identicon'; -import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; import './Auth.less'; interface StateProps { user: AppState['auth']['user']; isAuthingUser: AppState['auth']['isAuthingUser']; - web3: AppState['web3']['web3']; accounts: AppState['web3']['accounts']; accountsLoading: AppState['web3']['accountsLoading']; - accountsError: AppState['web3']['accountsError']; } -interface DispatchProps { - setWeb3: typeof web3Actions['setWeb3']; - setAccounts: typeof web3Actions['setAccounts']; -} - -type Props = StateProps & DispatchProps; +type Props = StateProps; interface State { isMenuOpen: boolean; @@ -34,17 +26,6 @@ class HeaderAuth extends React.Component { isMenuOpen: false, }; - componentDidMount() { - this.props.setWeb3(); - } - - componentDidUpdate() { - const { web3, accounts, accountsLoading, accountsError } = this.props; - if (web3 && !accounts.length && !accountsLoading && !accountsError) { - this.props.setAccounts(); - } - } - render() { const { accounts, accountsLoading, user, isAuthingUser } = this.props; const { isMenuOpen } = this.state; @@ -65,7 +46,7 @@ class HeaderAuth extends React.Component { {isAuthed ? '' : 'Sign in'} {avatar && ( @@ -87,7 +68,7 @@ class HeaderAuth extends React.Component { } const menu = ( - + Profile @@ -114,30 +95,27 @@ class HeaderAuth extends React.Component { ); } - private toggleMenu = (ev?: React.MouseEvent) => { + private toggleMenu = (ev?: React.MouseEvent) => { + if (!this.props.user) { + return; + } if (ev) { ev.preventDefault(); } this.setState({ isMenuOpen: !this.state.isMenuOpen }); }; + private closeMenu = () => this.setState({ isMenuOpen: false }); + private handleVisibilityChange = (visibility: boolean) => { // Handle the dropdown component's built in close events this.setState({ isMenuOpen: visibility }); }; } -export default connect( - state => ({ - user: state.auth.user, - isAuthingUser: state.auth.isAuthingUser, - web3: state.web3.web3, - accounts: state.web3.accounts, - accountsLoading: state.web3.accountsLoading, - accountsError: state.web3.accountsError, - }), - { - setWeb3: web3Actions.setWeb3, - setAccounts: web3Actions.setAccounts, - }, -)(HeaderAuth); +export default connect(state => ({ + user: state.auth.user, + isAuthingUser: state.auth.isAuthingUser, + accounts: state.web3.accounts, + accountsLoading: state.web3.accountsLoading, +}))(HeaderAuth); diff --git a/frontend/client/components/Home/index.tsx b/frontend/client/components/Home/index.tsx index 4c0cd153..d987b045 100644 --- a/frontend/client/components/Home/index.tsx +++ b/frontend/client/components/Home/index.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import './style.less'; import { Link } from 'react-router-dom'; import { Icon } from 'antd'; -import AntWrap from 'components/AntWrap'; import TeamsSvg from 'static/images/intro-teams.svg'; import FundingSvg from 'static/images/intro-funding.svg'; import CommunitySvg from 'static/images/intro-community.svg'; +import './style.less'; const introBlobs = [ { @@ -25,45 +24,43 @@ const introBlobs = [ export default class Home extends React.Component { render() { return ( - -
-
-

- Decentralized funding for
Blockchain ecosystem improvements -

+
+
+

+ Decentralized funding for
Blockchain ecosystem improvements +

-
- - Propose a Project - - - Explore Projects - -
- - +
+ + Propose a Project + + + Explore Projects +
-
-

- Grant.io organizes creators and community members to incentivize ecosystem - improvements -

+ +
-
- {introBlobs.map((blob, i) => ( -
- -

{blob.text}

-
- ))} -
+
+

+ Grant.io organizes creators and community members to incentivize ecosystem + improvements +

+ +
+ {introBlobs.map((blob, i) => ( +
+ +

{blob.text}

+
+ ))}
- +
); } } diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx index 817dc293..3f558959 100644 --- a/frontend/client/components/Profile/index.tsx +++ b/frontend/client/components/Profile/index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import AntWrap from 'components/AntWrap'; import { UsersState } from 'modules/users/reducers'; import { withRouter, RouteComponentProps, Redirect } from 'react-router-dom'; import { usersActions } from 'modules/users'; @@ -47,7 +46,7 @@ class Profile extends React.Component { if (authUser.ethAddress) { return ; } else { - return ; + return ; } } @@ -55,11 +54,7 @@ class Profile extends React.Component { const waiting = !user || !user.hasFetched; if (waiting) { - return ( - - - - ); + return ; } if (user.fetchError) { @@ -72,55 +67,49 @@ class Profile extends React.Component { const noneCommented = user.hasFetchedComments && comments.length === 0; return ( - -
- - - -
- {noneCreated && ( - - )} - {createdProposals.map(p => ( - - ))} -
-
- -
- {noneFunded && ( - - )} - {createdProposals.map(p => ( - - ))} -
-
- -
- {noneCommented && ( - - )} - {comments.map(c => ( - - ))} -
-
-
-
-
+
+ + + +
+ {noneCreated && ( + + )} + {createdProposals.map(p => ( + + ))} +
+
+ +
+ {noneFunded && } + {createdProposals.map(p => ( + + ))} +
+
+ +
+ {noneCommented && } + {comments.map(c => ( + + ))} +
+
+
+
); } private fetchData() { diff --git a/frontend/client/components/Template/Web3Error.less b/frontend/client/components/Template/Web3Error.less new file mode 100644 index 00000000..0737a907 --- /dev/null +++ b/frontend/client/components/Template/Web3Error.less @@ -0,0 +1,47 @@ +@keyframes fade-in { + from { + transform: translateY(1rem); + opacity: 0; + } + to { + transform: translateY(0rem); + opacity: 1; + } +} + +.Web3Error { + text-align: center; + width: 100%; + max-width: 360px; + margin: 0 auto; + animation: fade-in 500ms ease; + + &-icon { + display: block; + height: 120px; + margin: 0 auto 2rem; + } + + &-message { + font-size: 1.1rem; + margin-bottom: 2rem; + } + + &-button { + display: block; + margin: 0 auto 2rem; + padding: 0; + height: 3rem; + line-height: 3rem; + max-width: 220px; + font-size: 1.2rem; + color: #fff; + background: #f88500; + border-radius: 4px; + + &:hover { + color: #fff; + opacity: 0.8; + } + } +} \ No newline at end of file diff --git a/frontend/client/components/Template/Web3Error.tsx b/frontend/client/components/Template/Web3Error.tsx new file mode 100644 index 00000000..9c8f7f58 --- /dev/null +++ b/frontend/client/components/Template/Web3Error.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import './Web3Error.less'; + +interface Props { + icon?: string; + message: React.ReactNode; + button?: { + text: React.ReactNode; + href?: string; + onClick?: (ev: React.MouseEvent) => void; + }; +} + +const Web3Error: React.SFC = ({ icon, message, button }) => ( +
+ {icon && } +

{message}

+ {button && ( + + {button.text} + + )} +
+); + +export default Web3Error; diff --git a/frontend/client/components/Template/index.less b/frontend/client/components/Template/index.less new file mode 100644 index 00000000..b5e7d806 --- /dev/null +++ b/frontend/client/components/Template/index.less @@ -0,0 +1,39 @@ +.Template { + display: flex; + flex-direction: column; + min-height: 100vh; + + &-content { + display: flex; + justify-content: center; + flex: 1; + padding: 0 2.5rem; + + .is-fullscreen & { + padding: 0; + } + + &-inner { + width: 100%; + padding-top: 2.5rem; + padding-bottom: 2.5rem; + min-height: 280px; + + .is-fullscreen & { + padding-top: 0; + padding-bottom: 0; + } + + .is-centered & { + align-self: center; + } + + &-loading { + display: flex; + align-items: center; + justify-content: center; + height: 280px; + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/Template/index.tsx b/frontend/client/components/Template/index.tsx new file mode 100644 index 00000000..04c7b984 --- /dev/null +++ b/frontend/client/components/Template/index.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Layout, Spin } from 'antd'; +import classnames from 'classnames'; +import BasicHead from 'components/BasicHead'; +import Header from 'components/Header'; +import Footer from 'components/Footer'; +import Web3Container from 'lib/Web3Container'; +import Web3Error from './Web3Error'; +import { web3Actions } from 'modules/web3'; +import { AppState } from 'store/reducers'; +import MetamaskIcon from 'static/images/metamask.png'; +import WrongNetworkIcon from 'static/images/wrong-network.png'; +import './index.less'; + +interface StateProps { + isMissingWeb3: boolean; + isWeb3Locked: boolean; + isWrongNetwork: boolean; +} + +interface DispatchProps { + setAccounts: typeof web3Actions['setAccounts']; +} + +export interface TemplateProps { + title: string; + isHeaderTransparent?: boolean; + isFullScreen?: boolean; + hideFooter?: boolean; + requiresWeb3?: boolean; +} + +type Props = StateProps & DispatchProps & TemplateProps; + +class Template extends React.PureComponent { + render() { + const { + children, + title, + isHeaderTransparent, + isFullScreen, + hideFooter, + requiresWeb3, + isMissingWeb3, + isWeb3Locked, + isWrongNetwork, + } = this.props; + + let content = children; + let isCentered = false; + if (requiresWeb3) { + if (isMissingWeb3) { + isCentered = true; + content = ( + + ); + } else if (isWeb3Locked) { + isCentered = true; + content = ( + + ); + } else if (isWrongNetwork) { + isCentered = true; + content = ( + + The Grant.io smart contract is currently only supported on the{' '} + Ropsten network. Please change your network to continue. + + } + /> + ); + } else { + content = ( + children} + renderLoading={() => ( +
+ +
+ )} + /> + ); + } + } + + const className = classnames( + 'Template', + isFullScreen && 'is-fullscreen', + isCentered && 'is-centered', + ); + return ( + +
+
+ +
{content}
+
+ {!hideFooter &&
} +
+
+ ); + } +} + +export default connect( + state => ({ + isMissingWeb3: state.web3.isMissingWeb3, + isWeb3Locked: state.web3.isWeb3Locked, + isWrongNetwork: state.web3.isWrongNetwork, + }), + { + setAccounts: web3Actions.setAccounts, + }, +)(Template); diff --git a/frontend/client/components/Web3Page/index.tsx b/frontend/client/components/Web3Page/index.tsx deleted file mode 100644 index bb415e75..00000000 --- a/frontend/client/components/Web3Page/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; -import { connect } from 'react-redux'; -import { AppState } from 'store/reducers'; -import { Spin } from 'antd'; -import AntWrap, { Props as AntWrapProps } from 'components/AntWrap'; -import { web3Actions } from 'modules/web3'; -import MetamaskIcon from 'static/images/metamask.png'; -import WrongNetworkIcon from 'static/images/wrong-network.png'; -import './style.less'; - -interface OwnProps extends AntWrapProps { - render(props: Web3RenderProps & any): React.ReactNode; -} - -interface StateProps { - isMissingWeb3: boolean; - isWeb3Locked: boolean; - isWrongNetwork: boolean; -} - -interface ActionProps { - setAccounts: typeof web3Actions['setAccounts']; -} - -type Props = OwnProps & StateProps & ActionProps; - -const Web3Page = (props: Props) => { - const { render, isMissingWeb3, isWeb3Locked, isWrongNetwork, ...rest } = props; - let content; - let centerContent = false; - if (isMissingWeb3) { - centerContent = true; - content = ( -
- -

- This page requires a web3 client to use. Either unlock or install the MetaMask - browser extension and refresh to continue. -

- - Get MetaMask - -
- ); - } else if (isWeb3Locked) { - centerContent = true; - content = ( -
- -

- It looks like your MetaMask account is locked. Please unlock it and click the - button below to continue. -

- - Try again - -
- ); - } else if (isWrongNetwork) { - centerContent = true; - content = ( -
- -

- The Grant.io smart contract is currently only supported on the{' '} - Ropsten network. Please change your network to continue. -

-
- ); - } else { - content = ( - ( -
- -
- )} - /> - ); - } - - return ( - -
{content}
-
- ); -}; - -function mapStateToProps(state: AppState): StateProps { - return { - isMissingWeb3: state.web3.isMissingWeb3, - isWeb3Locked: state.web3.isWeb3Locked, - isWrongNetwork: state.web3.isWrongNetwork, - }; -} - -export default connect( - mapStateToProps, - { - setAccounts: web3Actions.setAccounts, - }, -)(Web3Page); diff --git a/frontend/client/components/Web3Page/style.less b/frontend/client/components/Web3Page/style.less deleted file mode 100644 index ec5e98ba..00000000 --- a/frontend/client/components/Web3Page/style.less +++ /dev/null @@ -1,56 +0,0 @@ -.Web3Page { - &-error { - text-align: center; - width: 100%; - max-width: 360px; - margin: 0 auto; - animation: fade-in 500ms ease; - - &-icon { - display: block; - height: 120px; - margin: 0 auto 2rem; - } - - &-message { - font-size: 1.1rem; - margin-bottom: 2rem; - } - - &-metamaskButton { - display: block; - margin: 0 auto 2rem; - padding: 0; - height: 3rem; - line-height: 3rem; - max-width: 220px; - font-size: 1.2rem; - color: #fff; - background: #f88500; - border-radius: 4px; - - &:hover { - color: #fff; - opacity: 0.8; - } - } - - @keyframes fade-in { - from { - transform: translateY(1rem); - opacity: 0; - } - to { - transform: translateY(0rem); - opacity: 1; - } - } - } - - &-loading { - display: flex; - align-items: center; - justify-content: center; - height: 280px; - } -} diff --git a/frontend/client/lib/Web3Container.tsx b/frontend/client/lib/Web3Container.tsx index f71d378c..30ec77bb 100644 --- a/frontend/client/lib/Web3Container.tsx +++ b/frontend/client/lib/Web3Container.tsx @@ -1,11 +1,7 @@ import React from 'react'; import Web3 from 'web3'; -import { bindActionCreators, Dispatch } from 'redux'; import { connect } from 'react-redux'; import { AppState } from 'store/reducers'; -import { web3Actions } from 'modules/web3'; -/* tslint:disable no-var-requires --- TODO: find a better way to import json */ -const CrowdFundFactory = require('./contracts/CrowdFundFactory.json'); export interface Web3RenderProps { web3: Web3; @@ -20,48 +16,13 @@ interface OwnProps { interface StateProps { web3: Web3 | null; - isWeb3Locked: boolean; contracts: any[]; - contractsLoading: boolean; - contractsError: null | string; accounts: any[]; - accountsLoading: boolean; - accountsError: null | string; } -interface ActionProps { - setContract: typeof web3Actions['setContract']; - setAccounts: typeof web3Actions['setAccounts']; - setWeb3: typeof web3Actions['setWeb3']; -} - -type Props = OwnProps & StateProps & ActionProps; +type Props = OwnProps & StateProps; class Web3Container extends React.Component { - componentDidUpdate() { - const { - web3, - contracts, - contractsLoading, - contractsError, - accounts, - accountsLoading, - accountsError, - isWeb3Locked, - } = this.props; - if (web3 && !contracts.length && !contractsLoading && !contractsError) { - this.props.setContract(CrowdFundFactory); - } - - if (web3 && !accounts.length && !accountsLoading && !accountsError && !isWeb3Locked) { - this.props.setAccounts(); - } - } - - async componentDidMount() { - this.props.setWeb3(); - } - render() { const { web3, accounts, contracts } = this.props; @@ -74,21 +35,9 @@ class Web3Container extends React.Component { function mapStateToProps(state: AppState): StateProps { return { web3: state.web3.web3, - isWeb3Locked: state.web3.isWeb3Locked, contracts: state.web3.contracts, - contractsLoading: state.web3.contractsLoading, - contractsError: state.web3.contractsError, accounts: state.web3.accounts, - accountsLoading: state.web3.accountsLoading, - accountsError: state.web3.accountsError, }; } -function mapDispatchToProps(dispatch: Dispatch) { - return bindActionCreators(web3Actions, dispatch); -} - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(Web3Container); +export default connect(mapStateToProps)(Web3Container); diff --git a/frontend/client/modules/web3/index.ts b/frontend/client/modules/web3/index.ts index ba978bd9..ee55c51a 100644 --- a/frontend/client/modules/web3/index.ts +++ b/frontend/client/modules/web3/index.ts @@ -1,7 +1,8 @@ import reducers, { Web3State, INITIAL_STATE } from './reducers'; import * as web3Actions from './actions'; import * as web3Types from './types'; +import web3Sagas from './sagas'; -export { web3Actions, web3Types, Web3State, INITIAL_STATE }; +export { web3Actions, web3Types, web3Sagas, Web3State, INITIAL_STATE }; export default reducers; diff --git a/frontend/client/modules/web3/sagas.ts b/frontend/client/modules/web3/sagas.ts new file mode 100644 index 00000000..a6be3f54 --- /dev/null +++ b/frontend/client/modules/web3/sagas.ts @@ -0,0 +1,23 @@ +import { SagaIterator } from 'redux-saga'; +import { put, all, fork, take } from 'redux-saga/effects'; +import { setWeb3, setAccounts, setContract } from './actions'; +import types from './types'; + +/* tslint:disable no-var-requires --- TODO: find a better way to import contract */ +const CrowdFundFactory = require('lib/contracts/CrowdFundFactory.json'); + +export function* bootstrapWeb3(): SagaIterator { + // Don't attempt to bootstrap web3 on SSR + if (process.env.SERVER_SIDE_RENDER) { + return; + } + + yield put(setWeb3()); + yield take(types.WEB3_FULFILLED); + + yield all([put(setAccounts()), put(setContract(CrowdFundFactory))]); +} + +export default function* authSaga(): SagaIterator { + yield all([fork(bootstrapWeb3)]); +} diff --git a/frontend/client/pages/auth.tsx b/frontend/client/pages/auth.tsx index 9d145c4d..9bcfc760 100644 --- a/frontend/client/pages/auth.tsx +++ b/frontend/client/pages/auth.tsx @@ -1,11 +1,6 @@ import React from 'react'; -import AntWrap from 'components/AntWrap'; import AuthFlow from 'components/AuthFlow'; -const SignInPage = () => ( - - - -); +const SignInPage = () => ; export default SignInPage; diff --git a/frontend/client/pages/create.tsx b/frontend/client/pages/create.tsx index d160d3e4..dbd8df2a 100644 --- a/frontend/client/pages/create.tsx +++ b/frontend/client/pages/create.tsx @@ -1,17 +1,16 @@ import React from 'react'; -import Web3Page from 'components/Web3Page'; +import { Spin } from 'antd'; +import Web3Container from 'lib/Web3Container'; import CreateFlow from 'components/CreateFlow'; const Create = () => ( - } render={({ accounts }) => (
)} - isFullScreen={true} - hideFooter={true} /> ); diff --git a/frontend/client/pages/proposal.tsx b/frontend/client/pages/proposal.tsx index e0619a56..61b1136a 100644 --- a/frontend/client/pages/proposal.tsx +++ b/frontend/client/pages/proposal.tsx @@ -1,5 +1,4 @@ import React, { Component } from 'react'; -import Web3Page from 'components/Web3Page'; import Proposal from 'components/Proposal'; import { withRouter, RouteComponentProps } from 'react-router'; @@ -12,12 +11,7 @@ class ProposalPage extends Component { } render() { const proposalId = this.props.match.params.id; - return ( - } - /> - ); + return ; } } diff --git a/frontend/client/pages/proposals.tsx b/frontend/client/pages/proposals.tsx index 5a3742c2..27e6453d 100644 --- a/frontend/client/pages/proposals.tsx +++ b/frontend/client/pages/proposals.tsx @@ -1,9 +1,6 @@ import React from 'react'; -import Web3Page from 'components/Web3Page'; import Proposals from 'components/Proposals'; -const ProposalsPage = () => ( - } /> -); +const ProposalsPage = () => ; export default ProposalsPage; diff --git a/frontend/client/pages/settings.tsx b/frontend/client/pages/settings.tsx index 2e98d450..0f8e486f 100644 --- a/frontend/client/pages/settings.tsx +++ b/frontend/client/pages/settings.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { connect } from 'react-redux'; -import AntWrap from 'components/AntWrap'; import { AppState } from 'store/reducers'; interface Props { @@ -10,11 +9,7 @@ interface Props { class ProfilePage extends React.Component { render() { const { user } = this.props; - return ( - -

Settings for {user && user.name}

-
- ); + return

Settings for {user && user.name}

; } } diff --git a/frontend/client/pages/sign-out.tsx b/frontend/client/pages/sign-out.tsx index 16540691..cafc1a51 100644 --- a/frontend/client/pages/sign-out.tsx +++ b/frontend/client/pages/sign-out.tsx @@ -4,7 +4,6 @@ import { Button } from 'antd'; import { Link } from 'react-router-dom'; import Result from 'ant-design-pro/lib/Result'; import { authActions } from 'modules/auth'; -import AntWrap from 'components/AntWrap'; interface Props { logout: typeof authActions['logout']; @@ -17,26 +16,24 @@ class SignInPage extends React.Component { render() { return ( - - - - - - - - - - } - /> - + + + + + + + + + } + /> ); } } diff --git a/frontend/client/store/sagas.ts b/frontend/client/store/sagas.ts index 3371e379..4f65ad21 100644 --- a/frontend/client/store/sagas.ts +++ b/frontend/client/store/sagas.ts @@ -1,6 +1,8 @@ import { fork } from 'redux-saga/effects'; import { authSagas } from 'modules/auth'; +import { web3Sagas } from 'modules/web3'; export default function* rootSaga() { yield fork(authSagas); + yield fork(web3Sagas); } From dd2446db1c08292f917702451f333c65fdb14e26 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 3 Oct 2018 23:19:58 -0500 Subject: [PATCH 14/18] Simplify conditionals (#136) --- contract/contracts/CrowdFund.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/contract/contracts/CrowdFund.sol b/contract/contracts/CrowdFund.sol index ffb6bc99..0d2a8548 100644 --- a/contract/contracts/CrowdFund.sol +++ b/contract/contracts/CrowdFund.sol @@ -165,7 +165,7 @@ contract CrowdFund { contributors[msg.sender].milestoneNoVotes[index] = vote; if (!vote) { milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.sub(contributors[msg.sender].contributionAmount); - } else if (vote) { + } else { milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.add(contributors[msg.sender].contributionAmount); } } @@ -195,8 +195,7 @@ contract CrowdFund { contributors[msg.sender].refundVote = vote; if (!vote) { amountVotingForRefund = amountVotingForRefund.sub(contributors[msg.sender].contributionAmount); - } - else if (vote) { + } else { amountVotingForRefund = amountVotingForRefund.add(contributors[msg.sender].contributionAmount); } } @@ -299,4 +298,4 @@ contract CrowdFund { _; } -} \ No newline at end of file +} From 1aab0915c0a0b07ba0768c4349b9bce5880ec9d5 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Thu, 4 Oct 2018 23:27:02 -0500 Subject: [PATCH 15/18] Type Refactor (#133) --- frontend/client/api/api.ts | 3 +- .../client/components/AuthFlow/SignIn.tsx | 2 +- frontend/client/components/Comment/index.tsx | 2 +- frontend/client/components/Comments/index.tsx | 2 +- .../client/components/CreateFlow/Basics.tsx | 2 +- .../client/components/CreateFlow/Details.tsx | 2 +- .../components/CreateFlow/Governance.tsx | 2 +- .../components/CreateFlow/Milestones.tsx | 10 +- .../client/components/CreateFlow/Team.tsx | 2 +- .../components/CreateFlow/TeamMember.tsx | 4 +- .../client/components/CreateFlow/example.ts | 3 +- .../client/components/CreateFlow/index.tsx | 2 +- .../components/Profile/ProfileComment.tsx | 2 +- .../components/Profile/ProfileProposal.tsx | 2 +- .../client/components/Profile/ProfileUser.tsx | 4 +- .../Proposal/CampaignBlock/index.tsx | 2 +- .../components/Proposal/CancelModal.tsx | 2 +- .../components/Proposal/Comments/index.tsx | 2 +- .../components/Proposal/Community/index.tsx | 2 +- .../Proposal/Contributors/index.tsx | 2 +- .../Proposal/Governance/Refunds.tsx | 2 +- .../components/Proposal/Governance/index.tsx | 2 +- .../Proposal/Milestones/MilestoneAction.tsx | 2 +- .../components/Proposal/Milestones/index.tsx | 2 +- .../components/Proposal/TeamBlock/index.tsx | 2 +- .../components/Proposal/Updates/index.tsx | 2 +- frontend/client/components/Proposal/index.tsx | 2 +- .../Proposals/ProposalCard/index.tsx | 2 +- .../client/components/Proposals/index.tsx | 2 +- frontend/client/components/UserAvatar.tsx | 2 +- frontend/client/components/UserRow/index.tsx | 2 +- frontend/client/modules/auth/reducers.ts | 2 +- frontend/client/modules/create/actions.ts | 3 +- frontend/client/modules/create/reducers.ts | 3 +- frontend/client/modules/create/types.ts | 35 ------ frontend/client/modules/create/utils.ts | 8 +- frontend/client/modules/proposals/actions.ts | 2 +- frontend/client/modules/proposals/reducers.ts | 114 +----------------- .../client/modules/proposals/selectors.tsx | 2 +- frontend/client/modules/users/actions.ts | 5 +- frontend/client/modules/users/reducers.ts | 5 +- frontend/client/modules/users/types.ts | 19 --- frontend/client/modules/web3/actions.ts | 2 +- frontend/client/utils/api.ts | 2 +- frontend/client/utils/helpers.ts | 2 +- frontend/client/utils/social.tsx | 17 +-- frontend/client/web3interact/crowdFund.ts | 2 +- frontend/config/paths.js | 8 +- .../config/webpack.config.js/resolvers.js | 1 + frontend/stories/ProposalMilestones.story.tsx | 2 +- frontend/stories/props.tsx | 2 +- frontend/tsconfig.json | 3 +- frontend/types/comment.ts | 16 +++ frontend/types/create.ts | 16 +++ frontend/types/index.ts | 43 +++++++ frontend/types/milestone.ts | 38 ++++++ frontend/types/proposal.ts | 69 +++++++++++ frontend/types/social.ts | 17 +++ frontend/types/update.ts | 7 ++ frontend/types/user.ts | 21 ++++ 60 files changed, 299 insertions(+), 243 deletions(-) create mode 100644 frontend/types/comment.ts create mode 100644 frontend/types/create.ts create mode 100644 frontend/types/index.ts create mode 100644 frontend/types/milestone.ts create mode 100644 frontend/types/proposal.ts create mode 100644 frontend/types/social.ts create mode 100644 frontend/types/update.ts create mode 100644 frontend/types/user.ts diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 6d26b7be..3e54f9b5 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -1,6 +1,5 @@ import axios from './axios'; -import { Proposal } from 'modules/proposals/reducers'; -import { TeamMember } from 'modules/create/types'; +import { Proposal, TeamMember } from 'types'; import { formatTeamMemberForPost, formatTeamMemberFromGet } from 'utils/api'; import { PROPOSAL_CATEGORY } from './constants'; diff --git a/frontend/client/components/AuthFlow/SignIn.tsx b/frontend/client/components/AuthFlow/SignIn.tsx index 6e7e4ddd..a37cda7c 100644 --- a/frontend/client/components/AuthFlow/SignIn.tsx +++ b/frontend/client/components/AuthFlow/SignIn.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Button } from 'antd'; import { authActions } from 'modules/auth'; -import { TeamMember } from 'modules/create/types'; +import { TeamMember } from 'types'; import { AppState } from 'store/reducers'; import { AUTH_PROVIDER } from 'utils/auth'; import Identicon from 'components/Identicon'; diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index 0e2d25e3..f69510bd 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -6,7 +6,7 @@ import Markdown from 'components/Markdown'; import Identicon from 'components/Identicon'; import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor'; import { postProposalComment } from 'modules/proposals/actions'; -import { Comment as IComment, Proposal } from 'modules/proposals/reducers'; +import { Comment as IComment, Proposal } from 'types'; import { AppState } from 'store/reducers'; import './style.less'; diff --git a/frontend/client/components/Comments/index.tsx b/frontend/client/components/Comments/index.tsx index 4000efab..43a43548 100644 --- a/frontend/client/components/Comments/index.tsx +++ b/frontend/client/components/Comments/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Proposal, ProposalComments } from 'modules/proposals/reducers'; +import { Proposal, ProposalComments } from 'types'; import Comment from 'components/Comment'; interface Props { diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index 169061ad..64d0eb9a 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Input, Form, Icon, Select } from 'antd'; import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants'; -import { CreateFormState } from 'modules/create/types'; +import { CreateFormState } from 'types'; import { getCreateErrors } from 'modules/create/utils'; interface State { diff --git a/frontend/client/components/CreateFlow/Details.tsx b/frontend/client/components/CreateFlow/Details.tsx index c79e00cb..9057fd7b 100644 --- a/frontend/client/components/CreateFlow/Details.tsx +++ b/frontend/client/components/CreateFlow/Details.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Form } from 'antd'; import MarkdownEditor from 'components/MarkdownEditor'; -import { CreateFormState } from 'modules/create/types'; +import { CreateFormState } from 'types'; interface State { details: string; diff --git a/frontend/client/components/CreateFlow/Governance.tsx b/frontend/client/components/CreateFlow/Governance.tsx index 2ad65ea4..542cb884 100644 --- a/frontend/client/components/CreateFlow/Governance.tsx +++ b/frontend/client/components/CreateFlow/Governance.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Input, Form, Icon, Button, Radio } from 'antd'; import { RadioChangeEvent } from 'antd/lib/radio'; -import { CreateFormState } from 'modules/create/types'; +import { CreateFormState } from 'types'; import { getCreateErrors } from 'modules/create/utils'; import { ONE_DAY } from 'utils/time'; diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 9ec0b4cc..0cfe861f 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd'; import moment from 'moment'; -import { CreateFormState, Milestone } from 'modules/create/types'; +import { CreateFormState, CreateMilestone } from 'types'; import { getCreateErrors } from 'modules/create/utils'; interface State { - milestones: Milestone[]; + milestones: CreateMilestone[]; } interface Props { @@ -42,7 +42,7 @@ export default class CreateFlowMilestones extends React.Component } } - handleMilestoneChange = (index: number, milestone: Milestone) => { + handleMilestoneChange = (index: number, milestone: CreateMilestone) => { const milestones = [...this.state.milestones]; milestones[index] = milestone; this.setState({ milestones }, () => { @@ -107,9 +107,9 @@ export default class CreateFlowMilestones extends React.Component interface MilestoneFieldsProps { index: number; - milestone: Milestone; + milestone: CreateMilestone; error: null | false | string; - onChange(index: number, milestone: Milestone): void; + onChange(index: number, milestone: CreateMilestone): void; onRemove(index: number): void; } diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx index 386cb392..7a7cfb7c 100644 --- a/frontend/client/components/CreateFlow/Team.tsx +++ b/frontend/client/components/CreateFlow/Team.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Icon } from 'antd'; -import { CreateFormState, TeamMember } from 'modules/create/types'; +import { CreateFormState, TeamMember } from 'types'; import TeamMemberComponent from './TeamMember'; import './Team.less'; diff --git a/frontend/client/components/CreateFlow/TeamMember.tsx b/frontend/client/components/CreateFlow/TeamMember.tsx index 69daa021..e21091e5 100644 --- a/frontend/client/components/CreateFlow/TeamMember.tsx +++ b/frontend/client/components/CreateFlow/TeamMember.tsx @@ -1,8 +1,8 @@ import React from 'react'; import classnames from 'classnames'; import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd'; -import { SOCIAL_TYPE, SOCIAL_INFO } from 'utils/social'; -import { TeamMember } from 'modules/create/types'; +import { SOCIAL_INFO } from 'utils/social'; +import { SOCIAL_TYPE, TeamMember } from 'types'; import { getCreateTeamMemberError } from 'modules/create/utils'; import UserAvatar from 'components/UserAvatar'; import './TeamMember.less'; diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 056486eb..5496cd82 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,6 +1,5 @@ import { PROPOSAL_CATEGORY } from 'api/constants'; -import { SOCIAL_TYPE } from 'utils/social'; -import { CreateFormState } from 'modules/create/types'; +import { SOCIAL_TYPE, CreateFormState } from 'types'; const createExampleProposal = ( payOutAddress: string, diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 623a9db9..78d5eae2 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -15,7 +15,7 @@ import Preview from './Preview'; import Final from './Final'; import createExampleProposal from './example'; import { createActions } from 'modules/create'; -import { CreateFormState } from 'modules/create/types'; +import { CreateFormState } from 'types'; import { getCreateErrors } from 'modules/create/utils'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; diff --git a/frontend/client/components/Profile/ProfileComment.tsx b/frontend/client/components/Profile/ProfileComment.tsx index 5f848edd..e94279fe 100644 --- a/frontend/client/components/Profile/ProfileComment.tsx +++ b/frontend/client/components/Profile/ProfileComment.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import moment from 'moment'; -import { UserComment } from 'modules/users/types'; +import { UserComment } from 'types'; import './ProfileComment.less'; interface OwnProps { diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx index 5333bfb1..5253d050 100644 --- a/frontend/client/components/Profile/ProfileProposal.tsx +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { UserProposal } from 'modules/users/types'; +import { UserProposal } from 'types'; import './ProfileProposal.less'; import UserRow from 'components/UserRow'; import UnitDisplay from 'components/UnitDisplay'; diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx index 1add7649..82573dd9 100644 --- a/frontend/client/components/Profile/ProfileUser.tsx +++ b/frontend/client/components/Profile/ProfileUser.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { TeamMember } from 'modules/create/types'; +import { SocialInfo, TeamMember } from 'types'; import UserAvatar from 'components/UserAvatar'; import './ProfileUser.less'; -import { SOCIAL_INFO, SocialInfo, socialAccountToUrl } from 'utils/social'; +import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social'; import ShortAddress from 'components/ShortAddress'; interface OwnProps { diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 2790ddb6..43c413e0 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import moment from 'moment'; import { Spin, Form, Input, Button, Icon } from 'antd'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import './style.less'; import classnames from 'classnames'; diff --git a/frontend/client/components/Proposal/CancelModal.tsx b/frontend/client/components/Proposal/CancelModal.tsx index 2ddd8532..e3cab626 100644 --- a/frontend/client/components/Proposal/CancelModal.tsx +++ b/frontend/client/components/Proposal/CancelModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Modal, Alert } from 'antd'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; diff --git a/frontend/client/components/Proposal/Comments/index.tsx b/frontend/client/components/Proposal/Comments/index.tsx index e4131ba1..f7c36c43 100644 --- a/frontend/client/components/Proposal/Comments/index.tsx +++ b/frontend/client/components/Proposal/Comments/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Spin, Button } from 'antd'; import { AppState } from 'store/reducers'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions'; import { getProposalComments, diff --git a/frontend/client/components/Proposal/Community/index.tsx b/frontend/client/components/Proposal/Community/index.tsx index cec9bc93..16e1b82a 100644 --- a/frontend/client/components/Proposal/Community/index.tsx +++ b/frontend/client/components/Proposal/Community/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; interface Props { proposalId: ProposalWithCrowdFund['proposalId']; diff --git a/frontend/client/components/Proposal/Contributors/index.tsx b/frontend/client/components/Proposal/Contributors/index.tsx index 0c16e60d..20c9a74e 100644 --- a/frontend/client/components/Proposal/Contributors/index.tsx +++ b/frontend/client/components/Proposal/Contributors/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Spin } from 'antd'; -import { CrowdFund } from 'modules/proposals/reducers'; +import { CrowdFund } from 'types'; import AddressRow from 'components/AddressRow'; import Placeholder from 'components/Placeholder'; import UnitDisplay from 'components/UnitDisplay'; diff --git a/frontend/client/components/Proposal/Governance/Refunds.tsx b/frontend/client/components/Proposal/Governance/Refunds.tsx index eb5b2573..880d4f8c 100644 --- a/frontend/client/components/Proposal/Governance/Refunds.tsx +++ b/frontend/client/components/Proposal/Governance/Refunds.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Spin, Progress, Button, Alert } from 'antd'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; diff --git a/frontend/client/components/Proposal/Governance/index.tsx b/frontend/client/components/Proposal/Governance/index.tsx index e4e4cef4..fbf3a25f 100644 --- a/frontend/client/components/Proposal/Governance/index.tsx +++ b/frontend/client/components/Proposal/Governance/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import GovernanceRefunds from './Refunds'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import './style.less'; interface Props { diff --git a/frontend/client/components/Proposal/Milestones/MilestoneAction.tsx b/frontend/client/components/Proposal/Milestones/MilestoneAction.tsx index 0a8c5f5e..c4d453b5 100644 --- a/frontend/client/components/Proposal/Milestones/MilestoneAction.tsx +++ b/frontend/client/components/Proposal/Milestones/MilestoneAction.tsx @@ -2,7 +2,7 @@ import React from 'react'; import moment from 'moment'; import { connect } from 'react-redux'; import { Button, Progress, Alert } from 'antd'; -import { ProposalWithCrowdFund, MILESTONE_STATE } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund, MILESTONE_STATE } from 'types'; import { web3Actions } from 'modules/web3'; import { AppState } from 'store/reducers'; import UnitDisplay from 'components/UnitDisplay'; diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 05f43d4a..47606ce5 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -2,7 +2,7 @@ import lodash from 'lodash'; import React from 'react'; import moment from 'moment'; import { Alert, Steps, Spin } from 'antd'; -import { ProposalWithCrowdFund, MILESTONE_STATE } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund, MILESTONE_STATE } from 'types'; import UnitDisplay from 'components/UnitDisplay'; import MilestoneAction from './MilestoneAction'; import { AppState } from 'store/reducers'; diff --git a/frontend/client/components/Proposal/TeamBlock/index.tsx b/frontend/client/components/Proposal/TeamBlock/index.tsx index cd98b31b..2b50a15e 100644 --- a/frontend/client/components/Proposal/TeamBlock/index.tsx +++ b/frontend/client/components/Proposal/TeamBlock/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Spin } from 'antd'; -import { Proposal } from 'modules/proposals/reducers'; +import { Proposal } from 'types'; import UserRow from 'components/UserRow'; interface Props { diff --git a/frontend/client/components/Proposal/Updates/index.tsx b/frontend/client/components/Proposal/Updates/index.tsx index 9391182c..c775ba74 100644 --- a/frontend/client/components/Proposal/Updates/index.tsx +++ b/frontend/client/components/Proposal/Updates/index.tsx @@ -4,7 +4,7 @@ import { Spin } from 'antd'; import Markdown from 'components/Markdown'; import moment from 'moment'; import { AppState } from 'store/reducers'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import { fetchProposalUpdates } from 'modules/proposals/actions'; import { getProposalUpdates, diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index b397efbd..433eb9ec 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -5,7 +5,7 @@ import Markdown from 'components/Markdown'; import { proposalActions } from 'modules/proposals'; import { bindActionCreators, Dispatch } from 'redux'; import { AppState } from 'store/reducers'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import { getProposal } from 'modules/proposals/selectors'; import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd'; import CampaignBlock from './CampaignBlock'; diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index 96bcf541..8727b07a 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -4,7 +4,7 @@ import { Progress, Icon, Spin } from 'antd'; import moment from 'moment'; import { Redirect } from 'react-router-dom'; import { CATEGORY_UI } from 'api/constants'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import './style.less'; import { Dispatch, bindActionCreators } from 'redux'; import * as web3Actions from 'modules/web3/actions'; diff --git a/frontend/client/components/Proposals/index.tsx b/frontend/client/components/Proposals/index.tsx index c0aa2d41..f3333622 100644 --- a/frontend/client/components/Proposals/index.tsx +++ b/frontend/client/components/Proposals/index.tsx @@ -3,7 +3,7 @@ import { compose } from 'recompose'; import { connect } from 'react-redux'; import { proposalActions } from 'modules/proposals'; import { getProposals } from 'modules/proposals/selectors'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund } from 'types'; import { bindActionCreators, Dispatch } from 'redux'; import { AppState } from 'store/reducers'; import { Input, Divider, Spin, Drawer, Icon, Button } from 'antd'; diff --git a/frontend/client/components/UserAvatar.tsx b/frontend/client/components/UserAvatar.tsx index 31744146..f16166d6 100644 --- a/frontend/client/components/UserAvatar.tsx +++ b/frontend/client/components/UserAvatar.tsx @@ -1,6 +1,6 @@ import React from 'react'; import Identicon from 'components/Identicon'; -import { TeamMember } from 'modules/create/types'; +import { TeamMember } from 'types'; import defaultUserImg from 'static/images/default-user.jpg'; interface Props { diff --git a/frontend/client/components/UserRow/index.tsx b/frontend/client/components/UserRow/index.tsx index a9c77584..82da12db 100644 --- a/frontend/client/components/UserRow/index.tsx +++ b/frontend/client/components/UserRow/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import UserAvatar from 'components/UserAvatar'; -import { TeamMember } from 'modules/create/types'; +import { TeamMember } from 'types'; import { Link } from 'react-router-dom'; import './style.less'; diff --git a/frontend/client/modules/auth/reducers.ts b/frontend/client/modules/auth/reducers.ts index eb3274fc..67dc3eb5 100644 --- a/frontend/client/modules/auth/reducers.ts +++ b/frontend/client/modules/auth/reducers.ts @@ -1,6 +1,6 @@ import types from './types'; // TODO: Use a common User type instead of this -import { TeamMember } from 'modules/create/types'; +import { TeamMember } from 'types'; export interface AuthState { user: TeamMember | null; diff --git a/frontend/client/modules/create/actions.ts b/frontend/client/modules/create/actions.ts index dab6701e..38b42a9b 100644 --- a/frontend/client/modules/create/actions.ts +++ b/frontend/client/modules/create/actions.ts @@ -1,5 +1,6 @@ import { Dispatch } from 'redux'; -import types, { CreateFormState } from './types'; +import { CreateFormState } from 'types'; +import types from './types'; import { sleep } from 'utils/helpers'; import { AppState } from 'store/reducers'; import { createCrowdFund } from 'modules/web3/actions'; diff --git a/frontend/client/modules/create/reducers.ts b/frontend/client/modules/create/reducers.ts index 61e8f6df..fa83d8bc 100644 --- a/frontend/client/modules/create/reducers.ts +++ b/frontend/client/modules/create/reducers.ts @@ -1,4 +1,5 @@ -import types, { CreateFormState } from './types'; +import types from './types'; +import { CreateFormState } from 'types'; import { ONE_DAY } from 'utils/time'; export interface CreateState { diff --git a/frontend/client/modules/create/types.ts b/frontend/client/modules/create/types.ts index 692faf2a..c22857ea 100644 --- a/frontend/client/modules/create/types.ts +++ b/frontend/client/modules/create/types.ts @@ -1,6 +1,3 @@ -import { PROPOSAL_CATEGORY } from 'api/constants'; -import { SocialAccountMap } from 'utils/social'; - enum CreateTypes { UPDATE_FORM = 'UPDATE_FORM', @@ -23,35 +20,3 @@ enum CreateTypes { } export default CreateTypes; - -export interface Milestone { - title: string; - description: string; - date: string; - payoutPercent: number; - immediatePayout: boolean; -} - -// 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; -} - -export interface CreateFormState { - title: string; - brief: string; - category: PROPOSAL_CATEGORY | null; - amountToRaise: string; - details: string; - payOutAddress: string; - trustees: string[]; - milestones: Milestone[]; - team: TeamMember[]; - deadline: number | null; - milestoneDeadline: number | null; -} diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index bc8f1ab6..5adebbe8 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,7 +1,7 @@ -import { CreateFormState, Milestone, TeamMember } from './types'; +import { CreateFormState, CreateMilestone } from 'types'; +import { TeamMember } from 'types'; import { isValidEthAddress, getAmountError } from 'utils/validators'; -import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; -import { MILESTONE_STATE } from 'modules/proposals/reducers'; +import { MILESTONE_STATE, ProposalWithCrowdFund } from 'types'; import { ProposalContractData, ProposalBackendData } from 'modules/web3/actions'; import { Wei, toWei } from 'utils/units'; @@ -164,7 +164,7 @@ export function getCreateTeamMemberError(user: TeamMember) { return ''; } -function milestoneToMilestoneAmount(milestone: Milestone, raiseGoal: Wei) { +function milestoneToMilestoneAmount(milestone: CreateMilestone, raiseGoal: Wei) { return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent.toString())); } diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 4ecbaeea..2399ffbf 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -7,7 +7,7 @@ import { } from 'api/api'; import { Dispatch } from 'redux'; import Web3 from 'web3'; -import { ProposalWithCrowdFund, Proposal, Comment } from 'modules/proposals/reducers'; +import { ProposalWithCrowdFund, Proposal, Comment } from 'types'; import { signData } from 'modules/web3/actions'; import getContract from 'lib/getContract'; import CrowdFund from 'lib/contracts/CrowdFund.json'; diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index a6c5aa68..ea9aa693 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -1,118 +1,6 @@ import types from './types'; -import { PROPOSAL_CATEGORY } from 'api/constants'; -import { Wei } from 'utils/units'; import { findComment } from 'utils/helpers'; -import { TeamMember } from 'modules/create/types'; - -export interface User { - accountAddress: string; - userid: number | string; - username: string; - title: string; - avatar: { - '120x120': string; - }; -} - -export interface Contributor { - address: string; - contributionAmount: Wei; - refundVote: boolean; - refunded: boolean; - proportionalContribution: string; - milestoneNoVotes: boolean[]; -} - -export enum MILESTONE_STATE { - WAITING = 'WAITING', - ACTIVE = 'ACTIVE', - REJECTED = 'REJECTED', - PAID = 'PAID', -} - -export interface Milestone { - index: number; - state: MILESTONE_STATE; - amount: Wei; - amountAgainstPayout: Wei; - percentAgainstPayout: number; - payoutRequestVoteDeadline: number; - isPaid: boolean; - isImmediatePayout: boolean; -} - -// TODO - have backend camelCase keys before response -export interface ProposalMilestone extends Milestone { - body: string; - content: string; - immediatePayout: boolean; - dateEstimated: string; - payoutPercent: string; - stage: string; - title: string; -} - -export interface CrowdFund { - immediateFirstMilestonePayout: boolean; - balance: Wei; - funded: Wei; - percentFunded: number; - target: Wei; - amountVotingForRefund: Wei; - percentVotingForRefund: number; - beneficiary: string; - deadline: number; - trustees: string[]; - contributors: Contributor[]; - milestones: Milestone[]; - milestoneVotingPeriod: number; - isFrozen: boolean; - isRaiseGoalReached: boolean; -} - -export interface Proposal { - proposalId: string; - dateCreated: number; - title: string; - body: string; - stage: string; - category: PROPOSAL_CATEGORY; - milestones: ProposalMilestone[]; - team: TeamMember[]; -} - -export interface ProposalWithCrowdFund extends Proposal { - crowdFund: CrowdFund | null; - crowdFundContract: any; -} - -export interface Comment { - commentId: number | string; - body: string; - dateCreated: number; - author: User; - replies: Comment[]; -} - -export interface ProposalComments { - proposalId: ProposalWithCrowdFund['proposalId']; - totalComments: number; - comments: Comment[]; -} - -export interface Update { - updateId: number | string; - title: string; - body: string; - dateCreated: number; - totalComments: number; -} - -export interface ProposalUpdates { - proposalId: ProposalWithCrowdFund['proposalId']; - totalUpdates: number; - updates: Update[]; -} +import { ProposalWithCrowdFund, ProposalComments, ProposalUpdates, Comment } from 'types'; export interface ProposalState { proposals: ProposalWithCrowdFund[]; diff --git a/frontend/client/modules/proposals/selectors.tsx b/frontend/client/modules/proposals/selectors.tsx index 39503417..3057c58e 100644 --- a/frontend/client/modules/proposals/selectors.tsx +++ b/frontend/client/modules/proposals/selectors.tsx @@ -1,5 +1,5 @@ import { AppState } from 'store/reducers'; -import { ProposalWithCrowdFund, ProposalComments, ProposalUpdates } from './reducers'; +import { ProposalWithCrowdFund, ProposalComments, ProposalUpdates } from 'types'; export function getProposals(state: AppState) { return state.proposal.proposals; diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts index 166832e3..13e1cac9 100644 --- a/frontend/client/modules/users/actions.ts +++ b/frontend/client/modules/users/actions.ts @@ -1,7 +1,8 @@ -import types, { UserProposal, UserComment } from './types'; +import { UserProposal, UserComment } from 'types'; +import types from './types'; import { getUser, getProposals } from 'api/api'; import { Dispatch } from 'redux'; -import { Proposal } from 'modules/proposals/reducers'; +import { Proposal } from 'types'; import BN from 'bn.js'; export function fetchUser(userFetchId: string) { diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index 79bd21b1..11aed13c 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -1,6 +1,7 @@ import lodash from 'lodash'; -import types, { UserProposal, UserComment } from './types'; -import { TeamMember } from 'modules/create/types'; +import { UserProposal, UserComment } from 'types'; +import types from './types'; +import { TeamMember } from 'types'; export interface UserState extends TeamMember { isFetching: boolean; diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts index cdfcd2fa..a87af6c8 100644 --- a/frontend/client/modules/users/types.ts +++ b/frontend/client/modules/users/types.ts @@ -1,22 +1,3 @@ -import { TeamMember } from 'modules/create/types'; -import { Wei } from 'utils/units'; - -export interface UserProposal { - proposalId: string; - title: string; - brief: string; - team: TeamMember[]; - funded: Wei; - target: Wei; -} - -export interface UserComment { - commentId: number | string; - body: string; - dateCreated: number; - proposal: UserProposal; -} - enum UsersActions { FETCH_USER = 'FETCH_USER', FETCH_USER_PENDING = 'FETCH_USER_PENDING', diff --git a/frontend/client/modules/web3/actions.ts b/frontend/client/modules/web3/actions.ts index cc4139d9..aaaf0247 100644 --- a/frontend/client/modules/web3/actions.ts +++ b/frontend/client/modules/web3/actions.ts @@ -8,7 +8,7 @@ import { fetchProposal, fetchProposals } from 'modules/proposals/actions'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { AppState } from 'store/reducers'; import { Wei } from 'utils/units'; -import { TeamMember } from 'modules/create/types'; +import { TeamMember } from 'types'; type GetState = () => AppState; diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 9e39f7b7..aa07b746 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -1,4 +1,4 @@ -import { TeamMember } from 'modules/create/types'; +import { TeamMember } from 'types'; import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social'; export function formatTeamMemberForPost(user: TeamMember) { diff --git a/frontend/client/utils/helpers.ts b/frontend/client/utils/helpers.ts index 1a700ec0..7d667b26 100644 --- a/frontend/client/utils/helpers.ts +++ b/frontend/client/utils/helpers.ts @@ -1,4 +1,4 @@ -import { Comment } from 'modules/proposals/reducers'; +import { Comment } from 'types'; export function isNumeric(n: any) { return !isNaN(parseFloat(n)) && isFinite(n); diff --git a/frontend/client/utils/social.tsx b/frontend/client/utils/social.tsx index 34881a58..74946bd8 100644 --- a/frontend/client/utils/social.tsx +++ b/frontend/client/utils/social.tsx @@ -1,20 +1,7 @@ import React from 'react'; import { Icon } from 'antd'; import keybaseIcon from 'static/images/keybase.svg'; - -export enum SOCIAL_TYPE { - GITHUB = 'GITHUB', - TWITTER = 'TWITTER', - LINKEDIN = 'LINKEDIN', - KEYBASE = 'KEYBASE', -} - -export interface SocialInfo { - type: SOCIAL_TYPE; - name: string; - format: string; - icon: React.ReactNode; -} +import { SOCIAL_TYPE, SocialAccountMap, SocialInfo } from 'types'; const accountNameRegex = '([a-zA-Z0-9-_]*)'; export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = { @@ -44,8 +31,6 @@ export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = { }, }; -export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>; - function urlToAccount(format: string, url: string): string | false { const matches = url.match(new RegExp(format)); return matches && matches[1] ? matches[1] : false; diff --git a/frontend/client/web3interact/crowdFund.ts b/frontend/client/web3interact/crowdFund.ts index 4996edd2..5afe5374 100644 --- a/frontend/client/web3interact/crowdFund.ts +++ b/frontend/client/web3interact/crowdFund.ts @@ -1,5 +1,5 @@ import Web3 from 'web3'; -import { CrowdFund, Milestone, MILESTONE_STATE } from 'modules/proposals/reducers'; +import { CrowdFund, Milestone, MILESTONE_STATE } from 'types'; import { collectArrayElements } from 'utils/web3Utils'; import { Wei } from 'utils/units'; import BN from 'bn.js'; diff --git a/frontend/config/paths.js b/frontend/config/paths.js index e720e225..a02d025d 100644 --- a/frontend/config/paths.js +++ b/frontend/config/paths.js @@ -17,8 +17,14 @@ const paths = { serverBuild: resolveApp('build/server'), srcClient: resolveApp('client'), srcServer: resolveApp('server'), + srcTypes: resolveApp('types'), }; -paths.resolveModules = [paths.srcClient, paths.srcServer, resolveApp('node_modules')]; +paths.resolveModules = [ + paths.srcClient, + paths.srcServer, + paths.srcTypes, + resolveApp('node_modules'), +]; module.exports = paths; diff --git a/frontend/config/webpack.config.js/resolvers.js b/frontend/config/webpack.config.js/resolvers.js index 0bf027e3..7afd58d5 100644 --- a/frontend/config/webpack.config.js/resolvers.js +++ b/frontend/config/webpack.config.js/resolvers.js @@ -15,6 +15,7 @@ module.exports = { store: `${paths.srcClient}/store`, styles: `${paths.srcClient}/styles`, typings: `${paths.srcClient}/typings`, + types: `${paths.srcTypes}`, utils: `${paths.srcClient}/utils`, web3interact: `${paths.srcClient}/web3interact`, }, diff --git a/frontend/stories/ProposalMilestones.story.tsx b/frontend/stories/ProposalMilestones.story.tsx index 20e15042..15ecb681 100644 --- a/frontend/stories/ProposalMilestones.story.tsx +++ b/frontend/stories/ProposalMilestones.story.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import { configureStore } from 'store/configure'; import { combineInitialState } from 'store/reducers'; import Milestones from 'components/Proposal/Milestones'; -import { MILESTONE_STATE } from 'modules/proposals/reducers'; +import { MILESTONE_STATE } from 'types'; const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; import 'styles/style.less'; diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 79725eb7..76576246 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -4,7 +4,7 @@ import { MILESTONE_STATE, ProposalWithCrowdFund, ProposalMilestone, -} from 'modules/proposals/reducers'; +} from 'types'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { fundCrowdFund, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 9c6791dc..d111edc8 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,10 +28,11 @@ "store/*": ["./client/store/*"], "styles/*": ["./client/styles/*"], "typings/*": ["./client/typings/*"], + "types/*": ["./types/*"], "utils/*": ["./client/utils/*"], "web3interact/*": ["./client/web3interact/*"] } }, - "include": ["./client/**/*", "./server/**/*", "./stories/**/*"], + "include": ["./client/**/*", "./server/**/*", "./stories/**/*", "./types/**/*"], "exclude": ["./client/static"] } diff --git a/frontend/types/comment.ts b/frontend/types/comment.ts new file mode 100644 index 00000000..9b4d91f8 --- /dev/null +++ b/frontend/types/comment.ts @@ -0,0 +1,16 @@ +import { User, UserProposal } from 'types'; + +export interface Comment { + commentId: number | string; + body: string; + dateCreated: number; + author: User; + replies: Comment[]; +} + +export interface UserComment { + commentId: number | string; + body: string; + dateCreated: number; + proposal: UserProposal; +} diff --git a/frontend/types/create.ts b/frontend/types/create.ts new file mode 100644 index 00000000..c3d1826a --- /dev/null +++ b/frontend/types/create.ts @@ -0,0 +1,16 @@ +import { PROPOSAL_CATEGORY } from 'api/constants'; +import { TeamMember, CreateMilestone } from 'types'; + +export interface CreateFormState { + title: string; + brief: string; + category: PROPOSAL_CATEGORY | null; + amountToRaise: string; + details: string; + payOutAddress: string; + trustees: string[]; + milestones: CreateMilestone[]; + team: TeamMember[]; + deadline: number | null; + milestoneDeadline: number | null; +} diff --git a/frontend/types/index.ts b/frontend/types/index.ts new file mode 100644 index 00000000..816293d1 --- /dev/null +++ b/frontend/types/index.ts @@ -0,0 +1,43 @@ +import { User, TeamMember } from './user'; +import { SocialAccountMap, SOCIAL_TYPE, SocialInfo } from './social'; +import { CreateFormState } from './create'; +import { Comment, UserComment } from './comment'; +import { + MILESTONE_STATE, + Milestone, + ProposalMilestone, + CreateMilestone, +} from './milestone'; +import { Update } from './update'; +import { + Contributor, + CrowdFund, + Proposal, + ProposalWithCrowdFund, + ProposalComments, + ProposalUpdates, + UserProposal, +} from './proposal'; + +export { + User, + UserComment, + UserProposal, + TeamMember, + SocialAccountMap, + SOCIAL_TYPE, + SocialInfo, + CreateFormState, + CreateMilestone, + Contributor, + MILESTONE_STATE, + Milestone, + ProposalMilestone, + CrowdFund, + Proposal, + ProposalWithCrowdFund, + Comment, + ProposalComments, + Update, + ProposalUpdates, +}; diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts new file mode 100644 index 00000000..431d896c --- /dev/null +++ b/frontend/types/milestone.ts @@ -0,0 +1,38 @@ +import { Wei } from 'utils/units'; + +export enum MILESTONE_STATE { + WAITING = 'WAITING', + ACTIVE = 'ACTIVE', + REJECTED = 'REJECTED', + PAID = 'PAID', +} + +export interface Milestone { + index: number; + state: MILESTONE_STATE; + amount: Wei; + amountAgainstPayout: Wei; + percentAgainstPayout: number; + payoutRequestVoteDeadline: number; + isPaid: boolean; + isImmediatePayout: boolean; +} + +// TODO - have backend camelCase keys before response +export interface ProposalMilestone extends Milestone { + body: string; + content: string; + immediatePayout: boolean; + dateEstimated: string; + payoutPercent: string; + stage: string; + title: string; +} + +export interface CreateMilestone { + title: string; + description: string; + date: string; + payoutPercent: number; + immediatePayout: boolean; +} diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts new file mode 100644 index 00000000..3c27694e --- /dev/null +++ b/frontend/types/proposal.ts @@ -0,0 +1,69 @@ +import { TeamMember } from 'types'; +import { Wei } from 'utils/units'; +import { PROPOSAL_CATEGORY } from 'api/constants'; +import { Comment } from 'types'; +import { Milestone, ProposalMilestone, Update } from 'types'; + +export interface Contributor { + address: string; + contributionAmount: Wei; + refundVote: boolean; + refunded: boolean; + proportionalContribution: string; + milestoneNoVotes: boolean[]; +} + +export interface CrowdFund { + immediateFirstMilestonePayout: boolean; + balance: Wei; + funded: Wei; + percentFunded: number; + target: Wei; + amountVotingForRefund: Wei; + percentVotingForRefund: number; + beneficiary: string; + deadline: number; + trustees: string[]; + contributors: Contributor[]; + milestones: Milestone[]; + milestoneVotingPeriod: number; + isFrozen: boolean; + isRaiseGoalReached: boolean; +} + +export interface Proposal { + proposalId: string; + dateCreated: number; + title: string; + body: string; + stage: string; + category: PROPOSAL_CATEGORY; + milestones: ProposalMilestone[]; + team: TeamMember[]; +} + +export interface ProposalWithCrowdFund extends Proposal { + crowdFund: CrowdFund | null; + crowdFundContract: any; +} + +export interface ProposalComments { + proposalId: ProposalWithCrowdFund['proposalId']; + totalComments: number; + comments: Comment[]; +} + +export interface ProposalUpdates { + proposalId: ProposalWithCrowdFund['proposalId']; + totalUpdates: number; + updates: Update[]; +} + +export interface UserProposal { + proposalId: string; + title: string; + brief: string; + team: TeamMember[]; + funded: Wei; + target: Wei; +} diff --git a/frontend/types/social.ts b/frontend/types/social.ts new file mode 100644 index 00000000..cf740aaf --- /dev/null +++ b/frontend/types/social.ts @@ -0,0 +1,17 @@ +import React from 'react'; + +export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>; + +export interface SocialInfo { + type: SOCIAL_TYPE; + name: string; + format: string; + icon: React.ReactNode; +} + +export enum SOCIAL_TYPE { + GITHUB = 'GITHUB', + TWITTER = 'TWITTER', + LINKEDIN = 'LINKEDIN', + KEYBASE = 'KEYBASE', +} diff --git a/frontend/types/update.ts b/frontend/types/update.ts new file mode 100644 index 00000000..d9d5c0e1 --- /dev/null +++ b/frontend/types/update.ts @@ -0,0 +1,7 @@ +export interface Update { + updateId: number | string; + title: string; + body: string; + dateCreated: number; + totalComments: number; +} diff --git a/frontend/types/user.ts b/frontend/types/user.ts new file mode 100644 index 00000000..8b5a4497 --- /dev/null +++ b/frontend/types/user.ts @@ -0,0 +1,21 @@ +import { SocialAccountMap } from 'types'; + +export interface User { + accountAddress: string; + userid: number | string; + username: 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; +} From 3dd4253acb3a34fd22a954b256c6142a9647c05f Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Mon, 8 Oct 2018 18:06:41 -0700 Subject: [PATCH 16/18] TREZOR & Ledger Address Selection (#139) * Add trezor selection * Fix SSR by making providers lodable. * Adjust identity styles. * Add packages. * Adjust style. * Ledger address selection. * Common use component for ledger and trezor. --- .../components/AuthFlow/ProvideIdentity.tsx | 10 +- .../AuthFlow/providers/ChooseAddress.less | 110 +++++++ .../AuthFlow/providers/ChooseAddress.tsx | 162 +++++++++++ .../components/AuthFlow/providers/Ledger.less | 25 ++ .../components/AuthFlow/providers/Ledger.tsx | 112 ++++++- .../components/AuthFlow/providers/Trezor.tsx | 61 +++- frontend/client/static/images/trezor.svg | 1 + .../client/typings/ledger/hw-app-eth.d.ts | 63 ++++ .../typings/ledger/hw-transport-u2f.d.ts | 84 ++++++ .../client/typings/ledger/hw-transport.d.ts | 274 ++++++++++++++++++ frontend/client/typings/trezor-connect.d.ts | 81 ++++++ frontend/client/utils/wallet.ts | 79 +++++ frontend/package.json | 7 + frontend/yarn.lock | 83 +++++- 14 files changed, 1143 insertions(+), 9 deletions(-) create mode 100644 frontend/client/components/AuthFlow/providers/ChooseAddress.less create mode 100644 frontend/client/components/AuthFlow/providers/ChooseAddress.tsx create mode 100644 frontend/client/components/AuthFlow/providers/Ledger.less create mode 100644 frontend/client/static/images/trezor.svg create mode 100644 frontend/client/typings/ledger/hw-app-eth.d.ts create mode 100644 frontend/client/typings/ledger/hw-transport-u2f.d.ts create mode 100644 frontend/client/typings/ledger/hw-transport.d.ts create mode 100644 frontend/client/typings/trezor-connect.d.ts create mode 100644 frontend/client/utils/wallet.ts diff --git a/frontend/client/components/AuthFlow/ProvideIdentity.tsx b/frontend/client/components/AuthFlow/ProvideIdentity.tsx index 0cd358d8..6aca95c7 100644 --- a/frontend/client/components/AuthFlow/ProvideIdentity.tsx +++ b/frontend/client/components/AuthFlow/ProvideIdentity.tsx @@ -1,11 +1,13 @@ import React from 'react'; +import loadable from 'loadable-components'; import { AUTH_PROVIDER } from 'utils/auth'; -import AddressProvider from './providers/Address'; -import LedgerProvider from './providers/Ledger'; -import TrezorProvider from './providers/Trezor'; -import Web3Provider from './providers/Web3'; import './ProvideIdentity.less'; +const AddressProvider = loadable(() => import('./providers/Address')); +const LedgerProvider = loadable(() => import('./providers/Ledger')); +const TrezorProvider = loadable(() => import('./providers/Trezor')); +const Web3Provider = loadable(() => import('./providers/Web3')); + const PROVIDER_COMPONENTS = { [AUTH_PROVIDER.ADDRESS]: AddressProvider, [AUTH_PROVIDER.LEDGER]: LedgerProvider, diff --git a/frontend/client/components/AuthFlow/providers/ChooseAddress.less b/frontend/client/components/AuthFlow/providers/ChooseAddress.less new file mode 100644 index 00000000..934462b6 --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/ChooseAddress.less @@ -0,0 +1,110 @@ +@addresses-max-width: 36rem; +@addresses-width: 10rem; +@addresses-padding: 1rem; + + +.ChooseAddress { + display: flex; + flex-direction: column; + justify-content: center; + + // Shared styles between addresses and loader + &-addresses, + &-loading { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + max-width: 36rem; + margin-bottom: 1rem; + } + + &-buttons { + display: flex; + justify-content: center; + + &-button { + margin: 0 0.25rem; + } + } + + &-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 30rem; + margin: 0 auto 2rem; + + .ant-alert { + margin-bottom: 1rem; + } + } +} + +.AddressChoice { + width: 10rem; + padding: 1rem; + margin: 0 0.75rem 1rem; + background: #FFF; + border: 1px solid rgba(#000, 0.12); + border-radius: 4px; + cursor: pointer; + transition: transform 100ms ease, border-color 100ms ease; + outline: none; + + &:hover, + &:focus { + transform: translateY(-2px); + border-color: rgba(#000, 0.2); + } + + &:active { + transform: translateY(0); + border-color: rgba(#000, 0.28); + } + + &-avatar { + display: block; + width: 6rem; + height: 6rem; + margin: 0 auto 1rem; + border-radius: 100%; + + .is-fake & { + background: #000; + color: #000; + opacity: 0.2; + } + } + + &-name, + &-address { + margin: 0 auto; + + .is-fake & { + background: #000; + color: #000; + transform: scaleY(0.8); + } + } + + &-name { + font-size: 1rem; + + .is-fake & { + opacity: 0.2; + width: 60%; + } + } + + &-address { + opacity: 0.6; + font-size: 0.8rem; + + .is-fake & { + opacity: 0.1; + width: 80%; + } + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/providers/ChooseAddress.tsx b/frontend/client/components/AuthFlow/providers/ChooseAddress.tsx new file mode 100644 index 00000000..b8f3de42 --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/ChooseAddress.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { Button, Spin, Icon, Alert } from 'antd'; +import classnames from 'classnames'; +import Identicon from 'components/Identicon'; +import ShortAddress from 'components/ShortAddress'; +import './ChooseAddress.less'; + +interface Props { + addresses: string[]; + loadingMessage: string; + handleDeriveAddresses(index: number, numNeeded: number): Promise; + onSelectAddress(address: string): void; +} + +interface State { + index: number; + isLoading: boolean; + error: null | string; +} + +const ADDRESSES_PER_PAGE = 6; + +export default class ChooseAddress extends React.PureComponent { + state: State = { + index: 0, + isLoading: false, + error: null, + }; + + componentDidMount() { + this.deriveAddresses(); + } + + componentDidUpdate(prevProps: Props) { + // Detect resets of the array, kick off derive + if (prevProps.addresses !== this.props.addresses && !this.props.addresses.length) { + this.setState({ index: 0 }, () => { + this.deriveAddresses(); + }); + } + } + + render() { + const { addresses } = this.props; + const { index, isLoading, error } = this.state; + + let content; + if (error) { + content = ( +
+ + +
+ ); + } else { + if (isLoading) { + content = ( + +
+ {new Array(ADDRESSES_PER_PAGE).fill(null).map((_, idx) => ( + + ))} +
+
+ ); + } else { + const pageAddresses = addresses.slice(index, index + ADDRESSES_PER_PAGE); + content = ( +
+ {pageAddresses.map(address => ( + + ))} +
+ ); + } + + content = ( + <> + {content} +
+ + +
+ + ); + } + + return
{content}
; + } + + private deriveAddresses = () => { + this.setState( + { + isLoading: true, + error: null, + }, + () => { + this.props + .handleDeriveAddresses(this.state.index, ADDRESSES_PER_PAGE) + .then(() => this.setState({ isLoading: false })) + .catch(err => this.setState({ isLoading: false, error: err.message })); + }, + ); + }; + + private next = () => { + this.setState({ index: this.state.index + ADDRESSES_PER_PAGE }, () => { + if (!this.props.addresses[this.state.index + ADDRESSES_PER_PAGE]) { + this.deriveAddresses(); + } + }); + }; + + private prev = () => { + this.setState({ index: Math.max(0, this.state.index - ADDRESSES_PER_PAGE) }); + }; +} + +interface AddressChoiceProps { + address: string; + name: string; + isFake?: boolean; + onClick?(address: string): void; +} + +const AddressChoice: React.SFC = props => ( + +); diff --git a/frontend/client/components/AuthFlow/providers/Ledger.less b/frontend/client/components/AuthFlow/providers/Ledger.less new file mode 100644 index 00000000..2e233421 --- /dev/null +++ b/frontend/client/components/AuthFlow/providers/Ledger.less @@ -0,0 +1,25 @@ +.LedgerProvider { + display: flex; + flex-direction: column; + justify-content: center; + + &-type { + display: flex; + justify-content: center; + margin-top: -0.5rem; + margin-bottom: 1.25rem; + + .ant-radio-button-wrapper { + min-width: 5rem; + text-align: center; + } + } + + &-hint { + opacity: 0.7; + font-size: 0.8rem; + text-align: center; + margin-top: 1.5rem; + margin-bottom: -1rem; + } +} \ No newline at end of file diff --git a/frontend/client/components/AuthFlow/providers/Ledger.tsx b/frontend/client/components/AuthFlow/providers/Ledger.tsx index 50ca6d5e..e2e518be 100644 --- a/frontend/client/components/AuthFlow/providers/Ledger.tsx +++ b/frontend/client/components/AuthFlow/providers/Ledger.tsx @@ -1,7 +1,117 @@ import React from 'react'; +import TransportU2F from '@ledgerhq/hw-transport-u2f'; +import LedgerEth from '@ledgerhq/hw-app-eth'; +import { Radio } from 'antd'; +import { RadioChangeEvent } from 'antd/lib/radio'; +import ChooseAddress from './ChooseAddress'; +import { deriveAddressesFromPubKey, parseLedgerError } from 'utils/wallet'; +import './Ledger.less'; + +enum ADDRESS_TYPE { + LEGACY = 'LEGACY', + LIVE = 'LIVE', +} interface Props { onSelectAddress(addr: string): void; } -export default (_: Props) =>
Not yet implemented
; +interface State { + publicKey: null | string; + chainCode: null | string; + addresses: string[]; + addressType: ADDRESS_TYPE; +} + +const DPATHS = { + LEGACY: `m/44'/60'/0'/0`, + LIVE: `m/44'/60'/$index'/0/0`, +}; + +export default class LedgerProvider extends React.Component { + state: State = { + publicKey: null, + chainCode: null, + addresses: [], + addressType: ADDRESS_TYPE.LIVE, + }; + + render() { + const { addresses, addressType } = this.state; + return ( +
+
+ + Live + Legacy + +
+ + + +
+ Don't see your address? Try changing between Live and Legacy addresses. +
+
+ ); + } + + private deriveAddresses = async (index: number, numAddresses: number) => { + const { addressType } = this.state; + let addresses = [...this.state.addresses]; + + try { + if (addressType === ADDRESS_TYPE.LIVE) { + const app = await this.getEthApp(); + for (let i = index; i < index + numAddresses; i++) { + const res = await app.getAddress(DPATHS.LIVE.replace('$index', i.toString())); + addresses.push(res.address); + } + } else { + let { chainCode, publicKey } = this.state; + if (!chainCode || !publicKey) { + const app = await this.getEthApp(); + const res = await app.getAddress(DPATHS.LEGACY, false, true); + chainCode = res.chainCode; + publicKey = res.publicKey; + this.setState({ chainCode, publicKey }); + } + + addresses = addresses.concat( + deriveAddressesFromPubKey({ + chainCode, + publicKey, + index, + numAddresses, + }), + ); + } + } catch (err) { + const msg = parseLedgerError(err); + throw new Error(msg); + } + + this.setState({ addresses }); + }; + + private getEthApp = async () => { + const transport = await TransportU2F.create(); + return new LedgerEth(transport); + }; + + private changeAddressType = (ev: RadioChangeEvent) => { + const addressType = ev.target.value as ADDRESS_TYPE; + if (addressType === this.state.addressType) { + return; + } + this.setState({ + addresses: [], + addressType, + }); + }; +} diff --git a/frontend/client/components/AuthFlow/providers/Trezor.tsx b/frontend/client/components/AuthFlow/providers/Trezor.tsx index 50ca6d5e..7f27c541 100644 --- a/frontend/client/components/AuthFlow/providers/Trezor.tsx +++ b/frontend/client/components/AuthFlow/providers/Trezor.tsx @@ -1,7 +1,66 @@ import React from 'react'; +import TrezorConnect from 'trezor-connect'; +import ChooseAddress from './ChooseAddress'; +import { deriveAddressesFromPubKey } from 'utils/wallet'; interface Props { onSelectAddress(addr: string): void; } -export default (_: Props) =>
Not yet implemented
; +interface State { + publicKey: null | string; + chainCode: null | string; + addresses: string[]; +} + +const DPATHS = { + MAINNET: `m/44'/60'/0'/0`, + TESTNET: `m/44'/1'/0'/0`, +}; + +export default class TrezorProvider extends React.Component { + state: State = { + publicKey: null, + chainCode: null, + addresses: [], + }; + + render() { + return ( + + ); + } + + private deriveAddresses = async (index: number, numAddresses: number) => { + let { chainCode, publicKey } = this.state; + if (!chainCode || !publicKey) { + const res = await this.getPublicKey(); + chainCode = res.chainCode; + publicKey = res.publicKey; + this.setState({ chainCode, publicKey }); + } + + const addresses = this.state.addresses.concat( + deriveAddressesFromPubKey({ + chainCode, + publicKey, + index, + numAddresses, + }), + ); + this.setState({ addresses }); + }; + + private getPublicKey = async () => { + const res = await TrezorConnect.getPublicKey({ path: DPATHS.TESTNET }); + if (res.success === false) { + throw new Error(res.payload.error); + } + return res.payload; + }; +} diff --git a/frontend/client/static/images/trezor.svg b/frontend/client/static/images/trezor.svg new file mode 100644 index 00000000..b8d85e3a --- /dev/null +++ b/frontend/client/static/images/trezor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/client/typings/ledger/hw-app-eth.d.ts b/frontend/client/typings/ledger/hw-app-eth.d.ts new file mode 100644 index 00000000..aeccdcd8 --- /dev/null +++ b/frontend/client/typings/ledger/hw-app-eth.d.ts @@ -0,0 +1,63 @@ +declare module '@ledgerhq/hw-app-eth' { + import LedgerTransport from '@ledgerhq/hw-transport'; + + export default class Eth> { + constructor(transport: T); + + /** + * + * @description get Ethereum address for a given BIP 32 path. + * @param {string} path a path in BIP 32 format + * @param {boolean} [boolDisplay] enable or not the display + * @param {boolean} [boolChaincode] enable or not the chaincode request + * @returns {Promise<{ publicKey: string; address: string; chainCode?: string }>} + * @memberof Eth + */ + public getAddress( + path: string, + boolDisplay?: boolean, + boolChaincode?: BoolChaincode, + ): Promise<{ + publicKey: string; + address: string; + chainCode: BoolChaincode extends true ? string : undefined; + }>; + + /** + * + * @description signs a raw transaction and returns v,r,s + * @param {string} path + * @param {string} rawTxHex + * @returns {Promise<{s: string, v: string, r: string}>} + * @memberof Eth + */ + public signTransaction( + path: string, + rawTxHex: string, + ): Promise<{ s: string; v: string; r: string }>; + + /** + * + * + * @returns {Promise<{ arbitraryDataEnabled: number; version: string }>} + * @memberof Eth + */ + public getAppConfiguration(): Promise<{ + arbitraryDataEnabled: number; + version: string; + }>; + + /** + * + * @description sign a message according to eth_sign RPC call + * @param {string} path + * @param {string} messageHex + * @returns {Promise<{v: number, s: string, r: string}>} + * @memberof Eth + */ + public signPersonalMessage( + path: string, + messageHex: string, + ): Promise<{ v: number; s: string; r: string }>; + } +} diff --git a/frontend/client/typings/ledger/hw-transport-u2f.d.ts b/frontend/client/typings/ledger/hw-transport-u2f.d.ts new file mode 100644 index 00000000..3e7d95a2 --- /dev/null +++ b/frontend/client/typings/ledger/hw-transport-u2f.d.ts @@ -0,0 +1,84 @@ +declare module '@ledgerhq/hw-transport-u2f' { + import LedgerTransport, { + Observer, + DescriptorEvent, + Subscription, + TransportError, + } from '@ledgerhq/hw-transport'; + import { isSupported, sign } from 'u2f-api'; + + export default class TransportU2F extends LedgerTransport { + public static isSupported: typeof isSupported; + + /** + * @description this transport is not discoverable but we are going to guess if it is here with isSupported() + * @static + * @template Descriptor An array with [null] if supported device + * @returns {Descriptor} + * @memberof TransportU2F + */ + public static list(): Descriptor; + + /** + * + * @description Listen all device events for a given Transport. + * The method takes an Observer of DescriptorEvent and returns a Subscription + * according to Observable paradigm https://github.com/tc39/proposal-observable + * a DescriptorEvent is a { descriptor, type } object. + * Type can be "add" or "remove" and descriptor is a value you can pass to open(descriptor). + * Each listen() call will first emit all potential device already connected and then will emit events can come over times, + * @static + * @template Descriptor + * @template Device + * @template Err + * @param {Observer< + * DescriptorEvent, + * ErrParam + * >} observer + * @returns {Subscription} + * @memberof TransportU2F + */ + public static listen( + observer: Observer, Err>, + ): Subscription; + + /** + * + * @description static function to create a new Transport from a connected + * Ledger device discoverable via U2F (browser support) + * + * @static + * @param {*} _ + * @param {number} [_openTimeout] + * @returns {Promise} + * @memberof TransportU2F + */ + public static open(_?: any, __?: number): Promise; + + /** + * + * @description Low level api to communicate with the device. + * @param {Buffer} adpu + * @returns {Promise} + * @memberof TransportU2F + */ + public exchange(adpu: Buffer): Promise; + + /** + * + * @description Set the "scramble key" for the next exchange with the device. + * Each App can have a different scramble key and they internally will set it at instantiation. + * @param {string} scrambleKey + * @memberof TransportU2F + */ + public setScrambleKey(scrambleKey: string): void; + + /** + * + * @description Close the exchange with the device. + * @returns {Promise} + * @memberof TransportU2F + */ + public close(): Promise; + } +} diff --git a/frontend/client/typings/ledger/hw-transport.d.ts b/frontend/client/typings/ledger/hw-transport.d.ts new file mode 100644 index 00000000..802052d3 --- /dev/null +++ b/frontend/client/typings/ledger/hw-transport.d.ts @@ -0,0 +1,274 @@ +declare module '@ledgerhq/hw-transport' { + /** + * @description all possible status codes. + * @see https://github.com/LedgerHQ/blue-app-btc/blob/d8a03d10f77ca5ef8b22a5d062678eef788b824a/include/btchip_apdu_constants.h#L85-L115 + * @example + * import { StatusCodes } from "@ledgerhq/hw-transport"; + * @export + * @enum {number} + */ + export enum StatusCodes { + PIN_REMAINING_ATTEMPTS = 0x63c0, + INCORRECT_LENGTH = 0x6700, + COMMAND_INCOMPATIBLE_FILE_STRUCTURE = 0x6981, + SECURITY_STATUS_NOT_SATISFIED = 0x6982, + CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985, + INCORRECT_DATA = 0x6a80, + NOT_ENOUGH_MEMORY_SPACE = 0x6a84, + REFERENCED_DATA_NOT_FOUND = 0x6a88, + FILE_ALREADY_EXISTS = 0x6a89, + INCORRECT_P1_P2 = 0x6b00, + INS_NOT_SUPPORTED = 0x6d00, + CLA_NOT_SUPPORTED = 0x6e00, + TECHNICAL_PROBLEM = 0x6f00, + OK = 0x9000, + MEMORY_PROBLEM = 0x9240, + NO_EF_SELECTED = 0x9400, + INVALID_OFFSET = 0x9402, + FILE_NOT_FOUND = 0x9404, + INCONSISTENT_FILE = 0x9408, + ALGORITHM_NOT_SUPPORTED = 0x9484, + INVALID_KCV = 0x9485, + CODE_NOT_INITIALIZED = 0x9802, + ACCESS_CONDITION_NOT_FULFILLED = 0x9804, + CONTRADICTION_SECRET_CODE_STATUS = 0x9808, + CONTRADICTION_INVALIDATION = 0x9810, + CODE_BLOCKED = 0x9840, + MAX_VALUE_REACHED = 0x9850, + GP_AUTH_FAILED = 0x6300, + LICENSING = 0x6f42, + HALTED = 0x6faa, + } + + export enum AltStatusCodes { + 'Incorrect length' = 0x6700, + 'Security not satisfied (dongle locked or have invalid access rights)' = 0x6982, + 'Condition of use not satisfied (denied by the user?)' = 0x6985, + 'Invalid data received' = 0x6a80, + 'Invalid parameter received' = 0x6b00, + INTERNAL_ERROR = 'Internal error, please report', + } + + export interface Subscription { + unsubscribe: () => void; + } + + export interface ITransportError extends Error { + name: 'TransportError'; + message: string; + stack?: string; + id: string; + } + + /** + * TransportError is used for any generic transport errors. + * e.g. Error thrown when data received by exchanges are incorrect or if exchanged failed to communicate with the device for various reason. + */ + export class TransportError extends Error { + new(message: string, id: string): ITransportError; + } + + export interface ITransportStatusError extends Error { + name: 'TransportStatusError'; + message: string; + stack?: string; + statusCode: number; + statusText: keyof typeof StatusCodes | 'UNKNOWN_ERROR'; + } + + /** + * Error thrown when a device returned a non success status. + * the error.statusCode is one of the `StatusCodes` exported by this library. + */ + export class TransportStatusError extends Error { + new(statusCode: number): ITransportStatusError; + } + + export interface Observer { + next: (event: Ev) => void; + error: (e: Err) => void; + complete: () => void; + } + + export interface DescriptorEvent { + type: 'add' | 'remove'; + descriptor: Descriptor; + device?: Device; + } + + export type FunctionPropertyNames = { + [K in keyof T]: T[K] extends Function ? K : never + }[keyof T]; + + export type ExtractPromise = T extends Promise ? U : T; + + export default abstract class LedgerTransport { + /** + * + * @description Check if a transport is supported on the user's platform/browser. + * @static + * @returns {Promise} + * @memberof LedgerTransport + */ + public static isSupported(): Promise; + + /** + * + * @description List once all available descriptors. For a better granularity, checkout listen(). + * @static + * @template Descriptor + * @returns {Promise} + * @memberof LedgerTransport + */ + public static list(): Promise; + + /** + * + * @description Listen all device events for a given Transport. + * The method takes an Observer of DescriptorEvent and returns a Subscription + * according to Observable paradigm https://github.com/tc39/proposal-observable + * a DescriptorEvent is a { descriptor, type } object. + * Type can be "add" or "remove" and descriptor is a value you can pass to open(descriptor). + * Each listen() call will first emit all potential device already connected and then will emit events can come over times, + * for instance if you plug a USB device after listen() or a bluetooth device become discoverable. + * @static + * @template Descriptor + * @template Device + * @template Err + * @param {Observer, Err>} observer + * @returns {Subscription} + * @memberof LedgerTransport + */ + public static listen( + observer: Observer, Err>, + ): Subscription; + + /** + * + * @description Attempt to create a Transport instance with potentially a descriptor. + * @static + * @template Descriptor + * @param {Descriptor} descriptor + * @param {number} [timeout] + * @returns {Promise>} + * @memberof LedgerTransport + */ + public static open( + descriptor: Descriptor, + timeout?: number, + ): Promise>; + + /** + * + * @description create() attempts open the first descriptor available or throw if: + * - there is no descriptor + * - if either timeout is reached + * + * This is a light alternative to using listen() and open() that you may need for any advanced usecases + * @static + * @template Descriptor + * @param {number} [openTimeout] + * @param {number} [listenTimeout] + * @returns {Promise>} + * @memberof LedgerTransport + */ + public static create( + openTimeout?: number, + listenTimeout?: number, + ): Promise>; + + /** + * + * @description Low level api to communicate with the device. + * This method is for implementations to implement but should not be directly called. + * Instead, the recommended way is to use send() method + * @param {Buffer} apdu + * @returns {Promise} + * @memberof LedgerTransport + */ + public abstract exchange(apdu: Buffer): Promise; + + /** + * + * @description Set the "scramble key" for the next exchange with the device. + * Each App can have a different scramble key and they internally will set it at instantiation. + * @param {string} scrambleKey + * @memberof LedgerTransport + */ + public setScrambleKey(scrambleKey: string): void; + + /** + * + * @description Close the exchange with the device. + * @returns {Promise} + * @memberof LedgerTransport + */ + public close(): Promise; + + /** + * + * @description Listen to an event on an instance of transport. + * Transport implementation can have specific events. Here are the common events: + * - "disconnect" : triggered if Transport is disconnected + * @param {string} eventName + * @param {Listener} cb + * @memberof LedgerTransport + */ + public on(eventName: string | 'listen', cb: (...args: any[]) => any): void; + + /** + * + * @description Stop listening to an event on an instance of transport. + * @param {string} eventName + * @param {Listener} cb + * @memberof LedgerTransport + */ + public off(eventName: string, cb: (...args: any[]) => any): void; + + /** + * + * @description Toggle logs of binary exchange + * @param {boolean} debug + * @memberof LedgerTransport + */ + public setDebugMode(debug: boolean): void; + + /** + * @description Set a timeout (in milliseconds) for the exchange call. + * Only some transport might implement it. (e.g. U2F) + * @param {number} exchangeTimeout + * @memberof LedgerTransport + */ + public setExchangeTimeout?(exchangeTimeout: number): void; + + /** + * @description Used to decorate all callable public methods of an app so that they + * are mutually exclusive. Scramble key is application specific, e.g hw-app-eth will set + * its own scramblekey + * @param self + * @param methods + * @param scrambleKey + */ + public decorateAppAPIMethods( + self: T, + methods: FunctionPropertyNames[], + scrambleKey: string, + ): void; + + /** + * @description Decorates a function so that it uses a global mutex, if an + * exchange is already in process, then calling the function will throw an + * error about being locked + * @param methodName + * @param functionToDecorate + * @param thisContext + * @param scrambleKey + */ + public decorateAppAPIMethod( + methodName: FunctionPropertyNames, + functionToDecorate: (...args: FArgs[]) => FRet, + thisContext: T, + scrambleKey: string, + ): (...args: FArgs[]) => Promise>; // make sure we dont wrap promises twice + } +} diff --git a/frontend/client/typings/trezor-connect.d.ts b/frontend/client/typings/trezor-connect.d.ts new file mode 100644 index 00000000..7e20388f --- /dev/null +++ b/frontend/client/typings/trezor-connect.d.ts @@ -0,0 +1,81 @@ +declare module 'trezor-connect' { + type Path = number[] | string; + + interface ErrorResponse { + success: false; + payload: { + error: string; + }; + } + type SuccessResponse = { + success: true; + payload: T; + }; + type Response = ErrorResponse | SuccessResponse; + + interface OptionalCommonParams { + device?: { + path: string; + state?: string; + instance?: number; + }; + useEmptyPassphrase?: boolean; + keepSession?: boolean; + } + + namespace TrezorConnect { + // ethereumGetAddress (single & bundle overloads) + interface EthereumGetAddressParams { + path: Path; + showOnTrezor: boolean; + } + interface EthereumGetAddressPayload { + address: string; + path: number[]; + serializedPath: string; + } + type EthereumGetAddressResponse = Response; + export function ethereumGetAddress( + params: EthereumGetAddressParams, + ): Promise; + + interface EthereumGetAddressBundleParams { + bundle: EthereumGetAddressParams[]; + } + type EthereumGetAddressBundleResponse = Response; + export function ethereumGetAddress( + params: EthereumGetAddressBundleParams, + ): Promise; + + // getPublicKey (single & bundle overloads) + interface GetPublicKeyParams { + path: string; + coin?: string; + } + interface GetPublicKeyPayload { + path: Array; + serializedPath: string; + xpub: string; + xpubSegwit?: string; + chainCode: string; + childNum: number; + publicKey: string; + fingerprint: number; + depth: number; + } + type GetPublicKeyResponse = Response; + export function getPublicKey( + params: GetPublicKeyParams, + ): Promise; + + interface GetPublicKeyBundleParams { + bundle: GetPublicKeyParams[]; + } + type GetPublicKeyBundleResponse = Response; + export function getPublicKey( + params: GetPublicKeyBundleParams, + ): Promise; + } + + export default TrezorConnect; +} diff --git a/frontend/client/utils/wallet.ts b/frontend/client/utils/wallet.ts new file mode 100644 index 00000000..d17ba349 --- /dev/null +++ b/frontend/client/utils/wallet.ts @@ -0,0 +1,79 @@ +import HDKey from 'hdkey'; +import { pubToAddress, toChecksumAddress } from 'ethereumjs-util'; + +interface DeriveAddressesParams { + chainCode: string; + publicKey: string; + index: number; + numAddresses: number; +} +export function deriveAddressesFromPubKey(params: DeriveAddressesParams): string[] { + const addresses = []; + const hdkey = new HDKey(); + hdkey.chainCode = new Buffer(params.chainCode, 'hex'); + hdkey.publicKey = new Buffer(params.publicKey, 'hex'); + + for (let i = params.index; i < params.index + params.numAddresses; i++) { + const dkey = hdkey.derive(`m/${i}`); + const address = (pubToAddress(dkey.publicKey, true) as Buffer).toString('hex'); + addresses.push(toChecksumAddress(address)); + } + + return addresses; +} + +// Ledger throws a few types of errors +interface U2FError { + metaData: { + type: string; + code: number; + }; +} +interface ErrorWithId { + id: string; + message: string; + name: string; + stack: string; +} +type LedgerError = U2FError | ErrorWithId | Error | string; + +const isU2FError = (err: LedgerError): err is U2FError => + !!err && !!(err as U2FError).metaData; +const isStringError = (err: LedgerError): err is string => typeof err === 'string'; +const isErrorWithId = (err: LedgerError): err is ErrorWithId => + err.hasOwnProperty('id') && err.hasOwnProperty('message'); + +export function parseLedgerError(err: LedgerError): string { + // https://developers.yubico.com/U2F/Libraries/Client_error_codes.html + if (isU2FError(err)) { + // Timeout + if (err.metaData.code === 5) { + return 'The request timed out'; + } + + return err.metaData.type; + } + + if (isStringError(err)) { + // Wrong app logged into + if (err.includes('6804')) { + return 'Wrong application selected on your ledger device. Make sure you’ve selected the ETH app.'; + } + // Ledger locked + if (err.includes('6801')) { + return 'Your Ledger device is locked'; + } + + return err; + } + + if (isErrorWithId(err)) { + // Browser doesn't support U2F + if (err.message.includes('U2F not supported')) { + return 'Your browser doesn’t support Ledger. Please try updating it, or using a different one.'; + } + } + + // Other + return err.message || err.toString(); +} diff --git a/frontend/package.json b/frontend/package.json index e717b6ba..cde811e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,8 @@ "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.0.0", "@babel/register": "^7.0.0", + "@ledgerhq/hw-app-eth": "4.23.0", + "@ledgerhq/hw-transport-u2f": "4.21.0", "@svgr/webpack": "^2.4.0", "@types/classnames": "^2.2.6", "@types/cors": "^2.8.4", @@ -91,7 +93,10 @@ "font-awesome": "^4.7.0", "fork-ts-checker-webpack-plugin": "^0.4.2", "fs-extra": "^7.0.0", + "global": "4.3.2", + "hdkey": "1.1.0", "http-proxy-middleware": "^0.18.0", + "https-proxy": "0.0.2", "husky": "^1.0.0-rc.8", "js-cookie": "^2.2.0", "less": "^3.7.1", @@ -127,6 +132,7 @@ "showdown": "^1.8.6", "stats-webpack-plugin": "^0.7.0", "style-loader": "^0.23.0", + "trezor-connect": "5.0.33", "ts-loader": "^5.1.1", "tslint": "^5.10.0", "tslint-config-airbnb": "^5.9.2", @@ -153,6 +159,7 @@ "@storybook/react": "4.0.0-alpha.22", "@types/bn.js": "4.11.1", "@types/ethereumjs-util": "5.2.0", + "@types/hdkey": "0.7.0", "@types/query-string": "6.1.0", "@types/showdown": "1.7.5", "@types/storybook__react": "^3.0.9", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 10696e43..87130104 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1333,6 +1333,25 @@ reflect-metadata "^0.1.12" tslib "^1.8.1" +"@ledgerhq/hw-app-eth@4.23.0": + version "4.23.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-app-eth/-/hw-app-eth-4.23.0.tgz#f253faf56143403182b9701fb0b6454c255de7ff" + dependencies: + "@ledgerhq/hw-transport" "^4.21.0" + +"@ledgerhq/hw-transport-u2f@4.21.0": + version "4.21.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-u2f/-/hw-transport-u2f-4.21.0.tgz#85695d7e853a5c21af7e7dcb0ce3919f2b65cdd9" + dependencies: + "@ledgerhq/hw-transport" "^4.21.0" + u2f-api "0.2.7" + +"@ledgerhq/hw-transport@^4.21.0": + version "4.21.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-4.21.0.tgz#50f85cfe115ba3f9d5bf94755c701e927175794f" + dependencies: + events "^2.0.0" + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -1648,6 +1667,12 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/hdkey@0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@types/hdkey/-/hdkey-0.7.0.tgz#6734d138e3c597f241be8fae2e60c2949bc3af87" + dependencies: + "@types/node" "*" + "@types/history@*": version "4.7.0" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.0.tgz#2fac51050c68f7d6f96c5aafc631132522f4aa3f" @@ -3498,6 +3523,10 @@ bs-logger@0.x: dependencies: fast-json-stable-stringify "^2.0.0" +bs58@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-2.0.1.tgz#55908d58f1982aba2008fa1bed8f91998a29bf8d" + bser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" @@ -4003,6 +4032,13 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" +coinstring@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/coinstring/-/coinstring-2.3.0.tgz#cdb63363a961502404a25afb82c2e26d5ff627a4" + dependencies: + bs58 "^2.0.1" + create-hash "^1.1.1" + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" @@ -4377,6 +4413,16 @@ create-hash@^1.1.0, create-hash@^1.1.2: ripemd160 "^2.0.0" sha.js "^2.4.0" +create-hash@^1.1.1: + version "1.2.0" + resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: version "1.1.6" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06" @@ -5705,10 +5751,14 @@ eventlistener@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/eventlistener/-/eventlistener-0.0.1.tgz#ed2baabb852227af2bcf889152c72c63ca532eb8" -events@^1.0.0: +events@^1.0.0, events@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" +events@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/events/-/events-2.1.0.tgz#2a9a1e18e6106e0e812aa9ebd4a819b3c29c0ba5" + events@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" @@ -6691,7 +6741,7 @@ global-prefix@^1.0.1: is-windows "^1.0.1" which "^1.2.14" -global@^4.3.0, global@^4.3.2, global@~4.3.0: +global@4.3.2, global@^4.3.0, global@^4.3.2, global@~4.3.0: version "4.3.2" resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" dependencies: @@ -7034,6 +7084,14 @@ hawk@~6.0.2: hoek "4.x.x" sntp "2.x.x" +hdkey@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hdkey/-/hdkey-1.1.0.tgz#e74e7b01d2c47f797fa65d1d839adb7a44639f29" + dependencies: + coinstring "^2.0.0" + safe-buffer "^5.1.1" + secp256k1 "^3.0.1" + he@1.1.x: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" @@ -7211,7 +7269,7 @@ http-proxy-middleware@^0.18.0, http-proxy-middleware@~0.18.0: lodash "^4.17.5" micromatch "^3.1.9" -http-proxy@^1.16.2: +http-proxy@^1.16.2, http-proxy@^1.8.1: version "1.17.0" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" dependencies: @@ -7238,6 +7296,13 @@ https-proxy-agent@^2.2.0, https-proxy-agent@^2.2.1: agent-base "^4.1.0" debug "^3.1.0" +https-proxy@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/https-proxy/-/https-proxy-0.0.2.tgz#9e7d542f1ce8d37c06e1f940a8a9a227bb48ddf0" + dependencies: + http-proxy "^1.8.1" + optimist "^0.6.1" + humanize-ms@^1.2.0, humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -14227,6 +14292,14 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" +trezor-connect@5.0.33: + version "5.0.33" + resolved "https://registry.yarnpkg.com/trezor-connect/-/trezor-connect-5.0.33.tgz#1acdb16439e03f3ef017671b5bd3be1d89b16238" + dependencies: + babel-runtime "^6.26.0" + events "^1.1.1" + whatwg-fetch "^2.0.4" + trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" @@ -14425,6 +14498,10 @@ typescript@3.0.3, typescript@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" +u2f-api@0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/u2f-api/-/u2f-api-0.2.7.tgz#17bf196b242f6bf72353d9858e6a7566cc192720" + ua-parser-js@^0.7.18: version "0.7.18" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" From 9cb71923d5816d12afcc36a2378714c006e1297f Mon Sep 17 00:00:00 2001 From: AMStrix Date: Mon, 8 Oct 2018 21:21:40 -0700 Subject: [PATCH 17/18] Autofill first team member with auth'd user (#140) --- .../client/components/CreateFlow/Team.tsx | 27 ++++++++++++++++--- .../components/CreateFlow/TeamMember.tsx | 14 +++++----- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx index 7a7cfb7c..3b8c75df 100644 --- a/frontend/client/components/CreateFlow/Team.tsx +++ b/frontend/client/components/CreateFlow/Team.tsx @@ -1,18 +1,26 @@ import React from 'react'; +import { connect } from 'react-redux'; import { Icon } from 'antd'; import { CreateFormState, TeamMember } from 'types'; import TeamMemberComponent from './TeamMember'; import './Team.less'; +import { AppState } from 'store/reducers'; interface State { team: TeamMember[]; } -interface Props { +interface StateProps { + authUser: AppState['auth']['user']; +} + +interface OwnProps { initialState?: Partial; updateForm(form: Partial): void; } +type Props = OwnProps & StateProps; + const MAX_TEAM_SIZE = 6; const DEFAULT_STATE: State = { team: [ @@ -27,7 +35,7 @@ const DEFAULT_STATE: State = { ], }; -export default class CreateFlowTeam extends React.PureComponent { +class CreateFlowTeam extends React.Component { constructor(props: Props) { super(props); this.state = { @@ -36,18 +44,23 @@ export default class CreateFlowTeam extends React.PureComponent { }; // Don't allow for empty team array - // TODO: Default first user to auth'd user 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) { + this.state.team[0] = { + ...props.authUser, + }; + } } render() { const { team } = this.state; - return (
{team.map((user, idx) => ( @@ -99,3 +112,9 @@ export default class CreateFlowTeam extends React.PureComponent { this.props.updateForm({ team }); }; } + +const withConnect = connect((state: AppState) => ({ + authUser: state.auth.user, +})); + +export default withConnect(CreateFlowTeam); diff --git a/frontend/client/components/CreateFlow/TeamMember.tsx b/frontend/client/components/CreateFlow/TeamMember.tsx index e21091e5..15ad6869 100644 --- a/frontend/client/components/CreateFlow/TeamMember.tsx +++ b/frontend/client/components/CreateFlow/TeamMember.tsx @@ -159,13 +159,15 @@ export default class CreateFlowTeamMember extends React.PureComponent - {index !== 0 && ( - + <> + + + )} )} From b1099770f4ecd7a7b0dcbd88af5500cbac89b47c Mon Sep 17 00:00:00 2001 From: AMStrix Date: Tue, 9 Oct 2018 12:30:09 -0700 Subject: [PATCH 18/18] Use UserAvatar on Review (#141) --- frontend/client/components/CreateFlow/Review.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 262985f9..9a25150f 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -7,8 +7,8 @@ import Markdown from 'components/Markdown'; import { AppState } from 'store/reducers'; import { CREATE_STEP } from './index'; import { CATEGORY_UI } from 'api/constants'; -import defaultUserImg from 'static/images/default-user.jpg'; import './Review.less'; +import UserAvatar from 'components/UserAvatar'; interface OwnProps { setStep(step: CREATE_STEP): void; @@ -138,7 +138,7 @@ class CreateReview extends React.Component { return (
{sections.map(s => ( -
+
{s.fields.map(f => (
@@ -191,7 +191,7 @@ const ReviewMilestones = ({ }) => ( {milestones.map(m => ( - +
{m.title}
@@ -210,7 +210,7 @@ const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => (
{team.map((u, idx) => (
- +
{u.name}
{u.title}