diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 28bb24e1..b7dc60e7 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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("/", 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 diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index 935be031..c2e13388 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -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"]) diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 3e54f9b5..7d892c43 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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; + }); +} diff --git a/frontend/client/components/Profile/ProfileEdit.less b/frontend/client/components/Profile/ProfileEdit.less new file mode 100644 index 00000000..728700f7 --- /dev/null +++ b/frontend/client/components/Profile/ProfileEdit.less @@ -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; + } +} diff --git a/frontend/client/components/Profile/ProfileEdit.tsx b/frontend/client/components/Profile/ProfileEdit.tsx new file mode 100644 index 00000000..974b2a1b --- /dev/null +++ b/frontend/client/components/Profile/ProfileEdit.tsx @@ -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 { + 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 ( + <> +
+
+ + + {fields.avatarUrl && ( +
+
+
+ + + + + + + + + + + + + + + + + + {Object.values(SOCIAL_INFO).map(s => ( + + + this.handleSocialChange(ev, s.type)} + addonBefore={s.icon} + /> + + + ))} + + + {!isMissingField && + error && ( + + )} + + + + + + + {this.state.showError && + this.props.user.updateError && ( + + )} +
+
+
+ + ); + } + + private handleSave = (evt: React.SyntheticEvent) => { + evt.preventDefault(); + this.props.onEdit(this.state.fields); + }; + + private handleCancel = () => { + this.props.onDone(); + }; + + private handleChangeField = (ev: React.ChangeEvent) => { + 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, + 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); + }; +} diff --git a/frontend/client/components/Profile/ProfileUser.less b/frontend/client/components/Profile/ProfileUser.less index 3cda5834..7e126fa3 100644 --- a/frontend/client/components/Profile/ProfileUser.less +++ b/frontend/client/components/Profile/ProfileUser.less @@ -51,6 +51,7 @@ &-social { display: flex; + margin-bottom: 1rem; & a { display: block; diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx index c204f147..33cbf998 100644 --- a/frontend/client/components/Profile/ProfileUser.tsx +++ b/frontend/client/components/Profile/ProfileUser.tsx @@ -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 { +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 { + state: State = { + isEditing: false, + }; + render() { const { + authUser, user, user: { socialAccounts }, } = this.props; + + const isSelf = !!authUser && authUser.ethAddress === user.ethAddress; + + if (this.state.isEditing) { + return ( + this.setState({ isEditing: false })} + onEdit={this.props.updateUser} + /> + ); + } + return (
@@ -38,19 +76,28 @@ export default class Profile extends React.Component {
)}
-
- {typedKeys(SOCIAL_INFO).map( - s => - (socialAccounts[s] && ( - - )) || - null, - )} -
+ {Object.keys(socialAccounts).length > 0 && ( +
+ {typedKeys(SOCIAL_INFO).map( + s => + (socialAccounts[s] && ( + + )) || + null, + )} +
+ )} + {isSelf && ( +
+ +
+ )}
); @@ -64,3 +111,14 @@ const Social = ({ account, info }: { account: string; info: SocialInfo }) => { ); }; + +const connectedProfileUser = connect( + state => ({ + authUser: state.auth.user, + }), + { + updateUser: usersActions.updateUser, + }, +)(ProfileUser); + +export default connectedProfileUser; diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts index 13e1cac9..531f4aae 100644 --- a/frontend/client/modules/users/actions.ts +++ b/frontend/client/modules/users/actions.ts @@ -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) => { @@ -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) => { + 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) => { dispatch({ type: types.FETCH_USER_CREATED_PENDING, payload: { userFetchId } }); diff --git a/frontend/client/modules/users/index.ts b/frontend/client/modules/users/index.ts index b63c8ae2..e6627473 100644 --- a/frontend/client/modules/users/index.ts +++ b/frontend/client/modules/users/index.ts @@ -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; diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index 11aed13c..02f8d882 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -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), }, }; } diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts index a87af6c8..9ed9fc40 100644 --- a/frontend/client/modules/users/types.ts +++ b/frontend/client/modules/users/types.ts @@ -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', diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index aa07b746..a6548bbe 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: user.avatarUrl ? { link: user.avatarUrl } : undefined, + avatar: user.avatarUrl ? { link: user.avatarUrl } : {}, socialMedias: socialAccountsToUrls(user.socialAccounts).map(url => ({ link: url, })), diff --git a/frontend/client/utils/helpers.ts b/frontend/client/utils/helpers.ts index 07aea956..14a71d63 100644 --- a/frontend/client/utils/helpers.ts +++ b/frontend/client/utils/helpers.ts @@ -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(keySource: T, target: Partial) { + 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) {