Profile edit (#160)

* cleanClone helper

* redux/store support for user update

* ProfileEdit and supporting UI

* ts fix

* fix spelling

* backend update user basics

* delete photo

* remove comments

* ignore leftover errors from previous update attempts

* fix ts unused arg

* make update user endpoint params optional

* connect ProfileUser for authUser & updateUser

* improve cleanClone function
This commit is contained in:
AMStrix 2018-10-30 09:40:21 -05:00 committed by William O'Beirne
parent 357b4248c7
commit 2d75150dff
13 changed files with 646 additions and 40 deletions

View File

@ -2,7 +2,7 @@ from animal_case import animalify
from flask import Blueprint, g, jsonify
from flask_yoloapi import endpoint, parameter
from .models import User, users_schema, user_schema, db
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
from ..email.send import send_email
from ..proposal.models import Proposal, proposal_team
from ..utils.auth import requires_sm
@ -74,3 +74,41 @@ def create_user(account_address, email_address, display_name, title):
result = user_schema.dump(user)
return result
@blueprint.route("/<user_identity>", methods=["PUT"])
@endpoint.api(
parameter('displayName', type=str, required=False),
parameter('title', type=str, required=False),
parameter('socialMedias', type=list, required=False),
parameter('avatar', type=dict, required=False)
)
def update_user(user_identity, display_name, title, social_medias, avatar):
user = User.get_by_email_or_account_address(email_address=user_identity, account_address=user_identity)
if not user:
return {"message": "User with that address or email not found"}, 404
if display_name is not None:
user.display_name = display_name
if title is not None:
user.title = title
if social_medias is not None:
sm_query = SocialMedia.query.filter_by(user_id=user.id)
sm_query.delete()
for social_media in social_medias:
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
db.session.add(sm)
if avatar is not None:
Avatar.query.filter_by(user_id=user.id).delete()
avatar_link = avatar.get('link')
if avatar_link:
avatar_obj = Avatar(image_url=avatar_link, user_id=user.id)
db.session.add(avatar_obj)
db.session.commit()
result = user_schema.dump(user)
return result

View File

@ -211,3 +211,59 @@ class TestAPI(BaseTestConfig):
)
self.assertEqual(response.status_code, 409)
def test_update_user_remove_social_and_avatar(self):
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
updated_user = copy.deepcopy(team[0])
updated_user['displayName'] = 'Billy'
updated_user['title'] = 'Commander'
updated_user['socialMedias'] = []
updated_user['avatar'] = {}
user_update_resp = self.app.put(
"/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]),
data=json.dumps(updated_user),
content_type='application/json'
)
users_json = user_update_resp.json
self.assertFalse(users_json["avatar"])
self.assertFalse(len(users_json["socialMedias"]))
self.assertEqual(users_json["displayName"], updated_user["displayName"])
self.assertEqual(users_json["title"], updated_user["title"])
def test_update_user(self):
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
updated_user = copy.deepcopy(team[0])
updated_user['displayName'] = 'Billy'
updated_user['title'] = 'Commander'
updated_user['socialMedias'] = [
{
"link": "https://github.com/billyman"
}
]
updated_user['avatar'] = {
"link": "https://x.io/avatar.png"
}
user_update_resp = self.app.put(
"/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]),
data=json.dumps(updated_user),
content_type='application/json'
)
users_json = user_update_resp.json
self.assertEqual(users_json["avatar"]["imageUrl"], updated_user["avatar"]["link"])
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], updated_user["socialMedias"][0]["link"])
self.assertEqual(users_json["displayName"], updated_user["displayName"])
self.assertEqual(users_json["title"], updated_user["title"])

View File

@ -64,3 +64,12 @@ export function createUser(payload: {
return res;
});
}
export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
return axios
.put(`/api/v1/users/${user.ethAddress}`, formatTeamMemberForPost(user))
.then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
}

View File

@ -0,0 +1,121 @@
@small-query: ~'(max-width: 500px)';
.ProfileEditShade {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.ProfileEdit {
position: relative;
z-index: 1001;
display: flex;
align-items: center;
padding: 1rem;
margin: -1rem;
background: #fff;
border-radius: 0.2rem;
@media @small-query {
flex-direction: column;
}
&.is-editing {
align-items: flex-start;
}
&-avatar {
position: relative;
height: 10.5rem;
width: 10.5rem;
margin-right: 1.25rem;
align-self: start;
@media @small-query {
margin-bottom: 1rem;
}
&-img {
height: 100%;
width: 100%;
border-radius: 1rem;
}
&-change {
position: absolute;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 1.2rem;
font-weight: 600;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border: none;
}
&:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
&-icon {
font-size: 2rem;
}
}
&-delete {
position: absolute;
top: 0.2rem;
right: 0.2rem;
cursor: pointer;
color: #ffffff;
background: transparent;
border: none;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
}
}
&-info {
flex: 1;
.ant-form-item {
margin-bottom: 0.25rem;
}
.ant-btn {
margin-right: 0.5rem;
&:last-child {
margin: 0;
}
}
}
&-alert {
margin-top: 1rem;
}
}

View File

@ -0,0 +1,254 @@
import React from 'react';
import lodash from 'lodash';
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
import { SOCIAL_INFO } from 'utils/social';
import { SOCIAL_TYPE, TeamMember } from 'types';
import { UserState } from 'modules/users/reducers';
import { getCreateTeamMemberError } from 'modules/create/utils';
import UserAvatar from 'components/UserAvatar';
import './ProfileEdit.less';
interface Props {
user: UserState;
onDone(): void;
onEdit(user: TeamMember): void;
}
interface State {
fields: TeamMember;
isChanged: boolean;
showError: boolean;
}
export default class ProfileEdit extends React.PureComponent<Props, State> {
state: State = {
fields: { ...this.props.user } as TeamMember,
isChanged: false,
showError: false,
};
componentDidUpdate(prevProps: Props, _: State) {
if (
prevProps.user.isUpdating &&
!this.props.user.isUpdating &&
!this.state.showError
) {
this.setState({ showError: true });
}
if (
prevProps.user.isUpdating &&
!this.props.user.isUpdating &&
!this.props.user.updateError
) {
this.props.onDone();
}
}
render() {
const { fields } = this.state;
const error = getCreateTeamMemberError(fields);
const isMissingField =
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
const isDisabled = !!error || isMissingField || !this.state.isChanged;
return (
<>
<div className="ProfileEdit">
<div className="ProfileEdit-avatar">
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
<Button
className="ProfileEdit-avatar-change"
onClick={this.handleChangePhoto}
>
<Icon
className="ProfileEdit-avatar-change-icon"
type={fields.avatarUrl ? 'picture' : 'plus-circle'}
/>
<div>{fields.avatarUrl ? 'Change photo' : 'Add photo'}</div>
</Button>
{fields.avatarUrl && (
<Button
className="ProfileEdit-avatar-delete"
icon="delete"
shape="circle"
onClick={this.handleDeletePhoto}
/>
)}
</div>
<div className="ProfileEdit-info">
<Form
className="ProfileEdit-info-form"
layout="vertical"
onSubmit={this.handleSave}
>
<Form.Item>
<Input
name="name"
autoComplete="off"
placeholder="Display name (Required)"
value={fields.name}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="title"
autoComplete="off"
placeholder="Title (Required)"
value={fields.title}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="emailAddress"
disabled={true}
placeholder="Email address (Required)"
type="email"
autoComplete="email"
value={fields.emailAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="ethAddress"
disabled={true}
autoComplete="ethAddress"
placeholder="Ethereum address (Required)"
value={fields.ethAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
<Row gutter={12}>
{Object.values(SOCIAL_INFO).map(s => (
<Col xs={24} sm={12} key={s.type}>
<Form.Item>
<Input
placeholder={`${s.name} account`}
autoComplete="off"
value={fields.socialAccounts[s.type]}
onChange={ev => this.handleSocialChange(ev, s.type)}
addonBefore={s.icon}
/>
</Form.Item>
</Col>
))}
</Row>
{!isMissingField &&
error && (
<Alert
type="error"
message={error}
showIcon
style={{ marginBottom: '0.75rem' }}
/>
)}
<Row>
<Button
type="primary"
htmlType="submit"
disabled={isDisabled}
loading={this.props.user.isUpdating}
>
Save changes
</Button>
<Button type="ghost" htmlType="button" onClick={this.handleCancel}>
Cancel
</Button>
</Row>
</Form>
{this.state.showError &&
this.props.user.updateError && (
<Alert
className="ProfileEdit-alert"
message={`There was an error attempting to update your profile. (code ${
this.props.user.updateError
})`}
type="error"
/>
)}
</div>
</div>
<div className="ProfileEditShade" />
</>
);
}
private handleSave = (evt: React.SyntheticEvent<any>) => {
evt.preventDefault();
this.props.onEdit(this.state.fields);
};
private handleCancel = () => {
this.props.onDone();
};
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = ev.currentTarget;
const fields = {
...this.state.fields,
[name as any]: value,
};
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
fields,
});
};
private handleSocialChange = (
ev: React.ChangeEvent<HTMLInputElement>,
type: SOCIAL_TYPE,
) => {
const { value } = ev.currentTarget;
const fields = {
...this.state.fields,
socialAccounts: {
...this.state.fields.socialAccounts,
[type]: value,
},
};
// delete key for empty string
if (!value) {
delete fields.socialAccounts[type];
}
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
fields,
});
};
private handleChangePhoto = () => {
// TODO: Actual file uploading
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
const num = Math.floor(Math.random() * 80);
const fields = {
...this.state.fields,
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
};
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
fields,
});
};
private handleDeletePhoto = () => {
const fields = lodash.clone(this.state.fields);
delete fields.avatarUrl;
const isChanged = this.isChangedCheck(fields);
this.setState({ isChanged, fields });
};
private isChangedCheck = (a: TeamMember) => {
return !lodash.isEqual(a, this.props.user);
};
}

View File

@ -51,6 +51,7 @@
&-social {
display: flex;
margin-bottom: 1rem;
& a {
display: block;

View File

@ -1,21 +1,59 @@
import React from 'react';
import { SocialInfo, TeamMember } from 'types';
import { connect } from 'react-redux';
import { Button } from 'antd';
import { SocialInfo } from 'types';
import { usersActions } from 'modules/users';
import { UserState } from 'modules/users/reducers';
import { typedKeys } from 'utils/ts';
import ProfileEdit from './ProfileEdit';
import UserAvatar from 'components/UserAvatar';
import './ProfileUser.less';
import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social';
import ShortAddress from 'components/ShortAddress';
import { typedKeys } from 'utils/ts';
import './ProfileUser.less';
import { AppState } from 'store/reducers';
interface OwnProps {
user: TeamMember;
user: UserState;
}
export default class Profile extends React.Component<OwnProps> {
interface StateProps {
authUser: AppState['auth']['user'];
}
interface DispatchProps {
updateUser: typeof usersActions['updateUser'];
}
interface State {
isEditing: boolean;
}
type Props = OwnProps & StateProps & DispatchProps;
class ProfileUser extends React.Component<Props> {
state: State = {
isEditing: false,
};
render() {
const {
authUser,
user,
user: { socialAccounts },
} = this.props;
const isSelf = !!authUser && authUser.ethAddress === user.ethAddress;
if (this.state.isEditing) {
return (
<ProfileEdit
user={user}
onDone={() => this.setState({ isEditing: false })}
onEdit={this.props.updateUser}
/>
);
}
return (
<div className="ProfileUser">
<div className="ProfileUser-avatar">
@ -38,19 +76,28 @@ export default class Profile extends React.Component<OwnProps> {
</div>
)}
</div>
<div className="ProfileUser-info-social">
{typedKeys(SOCIAL_INFO).map(
s =>
(socialAccounts[s] && (
<Social
key={s}
account={socialAccounts[s] as string}
info={SOCIAL_INFO[s]}
/>
)) ||
null,
)}
</div>
{Object.keys(socialAccounts).length > 0 && (
<div className="ProfileUser-info-social">
{typedKeys(SOCIAL_INFO).map(
s =>
(socialAccounts[s] && (
<Social
key={s}
account={socialAccounts[s] as string}
info={SOCIAL_INFO[s]}
/>
)) ||
null,
)}
</div>
)}
{isSelf && (
<div>
<Button onClick={() => this.setState({ isEditing: true })}>
Edit profile
</Button>
</div>
)}
</div>
</div>
);
@ -64,3 +111,14 @@ const Social = ({ account, info }: { account: string; info: SocialInfo }) => {
</a>
);
};
const connectedProfileUser = connect<StateProps, DispatchProps, {}, AppState>(
state => ({
authUser: state.auth.user,
}),
{
updateUser: usersActions.updateUser,
},
)(ProfileUser);
export default connectedProfileUser;

View File

@ -1,9 +1,11 @@
import { UserProposal, UserComment } from 'types';
import { UserProposal, UserComment, TeamMember } from 'types';
import types from './types';
import { getUser, getProposals } from 'api/api';
import { getUser, updateUser as apiUpdateUser, getProposals } from 'api/api';
import { Dispatch } from 'redux';
import { Proposal } from 'types';
import BN from 'bn.js';
import { cleanClone } from 'utils/helpers';
import { INITIAL_TEAM_MEMBER_STATE } from 'modules/users/reducers';
export function fetchUser(userFetchId: string) {
return async (dispatch: Dispatch<any>) => {
@ -20,6 +22,22 @@ export function fetchUser(userFetchId: string) {
};
}
export function updateUser(user: TeamMember) {
const userClone = cleanClone(INITIAL_TEAM_MEMBER_STATE, user);
return async (dispatch: Dispatch<any>) => {
dispatch({ type: types.UPDATE_USER_PENDING, payload: { user } });
try {
const { data: updatedUser } = await apiUpdateUser(userClone);
dispatch({
type: types.UPDATE_USER_FULFILLED,
payload: { user: updatedUser },
});
} catch (error) {
dispatch({ type: types.UPDATE_USER_REJECTED, payload: { user, error } });
}
};
}
export function fetchUserCreated(userFetchId: string) {
return async (dispatch: Dispatch<any>) => {
dispatch({ type: types.FETCH_USER_CREATED_PENDING, payload: { userFetchId } });

View File

@ -1,7 +1,7 @@
import reducers, { UsersState, INITIAL_STATE } from './reducers';
import reducers, { UserState, UsersState, INITIAL_STATE } from './reducers';
import * as usersActions from './actions';
import * as usersTypes from './types';
export { usersActions, usersTypes, UsersState, INITIAL_STATE };
export { usersActions, usersTypes, UsersState, UserState, INITIAL_STATE };
export default reducers;

View File

@ -7,6 +7,8 @@ export interface UserState extends TeamMember {
isFetching: boolean;
hasFetched: boolean;
fetchError: number | null;
isUpdating: boolean;
updateError: number | null;
isFetchingCreated: boolean;
hasFetchedCreated: boolean;
fetchErrorCreated: number | null;
@ -25,16 +27,22 @@ export interface UsersState {
map: { [index: string]: UserState };
}
export const INITIAL_USER_STATE: UserState = {
export const INITIAL_TEAM_MEMBER_STATE: TeamMember = {
ethAddress: '',
avatarUrl: '',
name: '',
emailAddress: '',
socialAccounts: {},
title: '',
};
export const INITIAL_USER_STATE: UserState = {
...INITIAL_TEAM_MEMBER_STATE,
isFetching: false,
hasFetched: false,
fetchError: null,
isUpdating: false,
updateError: null,
isFetchingCreated: false,
hasFetchedCreated: false,
fetchErrorCreated: null,
@ -58,73 +66,97 @@ export default (state = INITIAL_STATE, action: any) => {
const userFetchId = payload && payload.userFetchId;
const proposals = payload && payload.proposals;
const comments = payload && payload.comments;
const errorStatus = payload && payload.error && payload.error.response.status;
const errorStatus =
(payload &&
payload.error &&
payload.error.response &&
payload.error.response.status) ||
999;
switch (action.type) {
// fetch
case types.FETCH_USER_PENDING:
return updateState(state, userFetchId, { isFetching: true, fetchError: null });
return updateStateFetch(state, userFetchId, { isFetching: true, fetchError: null });
case types.FETCH_USER_FULFILLED:
return updateState(
return updateStateFetch(
state,
userFetchId,
{ isFetching: false, hasFetched: true },
payload.user,
);
case types.FETCH_USER_REJECTED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetching: false,
hasFetched: true,
fetchError: errorStatus,
});
// update
case types.UPDATE_USER_PENDING:
return updateStateFetch(state, payload.user.ethAddress, {
isUpdating: true,
updateError: null,
});
case types.UPDATE_USER_FULFILLED:
return updateStateFetch(
state,
payload.user.ethAddress,
{ isUpdating: false },
payload.user,
);
case types.UPDATE_USER_REJECTED:
return updateStateFetch(state, payload.user.ethAddress, {
isUpdating: false,
updateError: errorStatus,
});
// created proposals
case types.FETCH_USER_CREATED_PENDING:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingCreated: true,
fetchErrorCreated: null,
});
case types.FETCH_USER_CREATED_FULFILLED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingCreated: false,
hasFetchedCreated: true,
createdProposals: proposals,
});
case types.FETCH_USER_CREATED_REJECTED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingCreated: false,
hasFetchedCreated: true,
fetchErrorCreated: errorStatus,
});
// funded proposals
case types.FETCH_USER_FUNDED_PENDING:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingFunded: true,
fetchErrorFunded: null,
});
case types.FETCH_USER_FUNDED_FULFILLED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingFunded: false,
hasFetchedFunded: true,
fundedProposals: proposals,
});
case types.FETCH_USER_FUNDED_REJECTED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingFunded: false,
hasFetchedFunded: true,
fetchErrorFunded: errorStatus,
});
// comments
case types.FETCH_USER_COMMENTS_PENDING:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingComments: true,
fetchErrorComments: null,
});
case types.FETCH_USER_COMMENTS_FULFILLED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingComments: false,
hasFetchedComments: true,
comments,
});
case types.FETCH_USER_COMMENTS_REJECTED:
return updateState(state, userFetchId, {
return updateStateFetch(state, userFetchId, {
isFetchingComments: false,
hasFetchedComments: true,
fetchErrorComments: errorStatus,
@ -134,12 +166,17 @@ export default (state = INITIAL_STATE, action: any) => {
}
};
function updateState(state: UsersState, id: string, updates: object, loaded?: UserState) {
function updateStateFetch(
state: UsersState,
id: string,
updates: object,
loaded?: UserState,
) {
return {
...state,
map: {
...state.map,
[id]: lodash.defaultsDeep(updates, loaded, state.map[id] || INITIAL_USER_STATE),
[id]: lodash.defaults(updates, loaded, state.map[id] || INITIAL_USER_STATE),
},
};
}

View File

@ -4,6 +4,11 @@ enum UsersActions {
FETCH_USER_FULFILLED = 'FETCH_USER_FULFILLED',
FETCH_USER_REJECTED = 'FETCH_USER_REJECTED',
UPDATE_USER = 'UPDATE_USER',
UPDATE_USER_PENDING = 'UPDATE_USER_PENDING',
UPDATE_USER_FULFILLED = 'UPDATE_USER_FULFILLED',
UPDATE_USER_REJECTED = 'UPDATE_USER_REJECTED',
FETCH_USER_CREATED = 'FETCH_USER_CREATED',
FETCH_USER_CREATED_PENDING = 'FETCH_USER_CREATED_PENDING',
FETCH_USER_CREATED_FULFILLED = 'FETCH_USER_CREATED_FULFILLED',

View File

@ -7,7 +7,7 @@ export function formatTeamMemberForPost(user: TeamMember) {
title: user.title,
accountAddress: user.ethAddress,
emailAddress: user.emailAddress,
avatar: user.avatarUrl ? { link: user.avatarUrl } : undefined,
avatar: user.avatarUrl ? { link: user.avatarUrl } : {},
socialMedias: socialAccountsToUrls(user.socialAccounts).map(url => ({
link: url,
})),

View File

@ -1,3 +1,4 @@
import { pick } from 'lodash';
import { Comment } from 'types';
export function isNumeric(n: any) {
@ -26,6 +27,14 @@ export function findComment(
return null;
}
// clone and filter keys by keySource object's keys
export function cleanClone<T extends object>(keySource: T, target: Partial<T>) {
const sourceKeys = Object.keys(keySource);
const fullClone = { ...(target as object) };
const clone = pick(fullClone, sourceKeys);
return clone as T;
}
export function urlToPublic(url: string) {
let withPublicHost = url.match(/^https?:/) ? url : process.env.PUBLIC_HOST_URL + url;
if (process.env.NODE_ENV === 'development' && process.env.PUBLIC_HOST_URL) {