Simple page de modification de profil

This commit is contained in:
wpetit 2020-07-16 20:21:58 +02:00
parent 05dd505d6b
commit 758c166f27
14 changed files with 284 additions and 29 deletions

View File

@ -3,6 +3,7 @@ import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
import { HomePage } from './HomePage/HomePage'; import { HomePage } from './HomePage/HomePage';
import { store } from '../store/store'; import { store } from '../store/store';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { ProfilePage } from './ProfilePage/ProfilePage';
export class App extends React.Component { export class App extends React.Component {
render() { render() {
@ -11,6 +12,7 @@ export class App extends React.Component {
<BrowserRouter> <BrowserRouter>
<Switch> <Switch>
<Route path="/" exact component={HomePage} /> <Route path="/" exact component={HomePage} />
<Route path="/profile" exact component={ProfilePage} />
<Route component={() => <Redirect to="/" />} /> <Route component={() => <Redirect to="/" />} />
</Switch> </Switch>
</BrowserRouter> </BrowserRouter>

View File

@ -7,7 +7,7 @@ export function HomePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser); const currentUser = useSelector((state: RootState) => state.auth.currentUser);
return ( return (
<Page title="Daddy - Accueil"> <Page title="Accueil">
<div className="container is-fluid"> <div className="container is-fluid">
<section className="section"> <section className="section">
<div className="columns"> <div className="columns">
@ -15,7 +15,7 @@ export function HomePage() {
<div className="box"> <div className="box">
{ {
currentUser && currentUser.email ? currentUser && currentUser.email ?
<p>Bonjour <span className="has-text-weight-bold">{currentUser.email}</span> !</p> : <p>Bonjour <span className="has-text-weight-bold">{currentUser.name ? currentUser.name : currentUser.email}</span> !</p> :
<p>Veuillez vous authentifier.</p> <p>Veuillez vous authentifier.</p>
} }
</div> </div>

View File

@ -1,44 +1,62 @@
import React from 'react'; import React, { Fragment, useState } from 'react';
import logo from '../resources/logo.svg'; import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '../store/reducers/root'; import { RootState } from '../store/reducers/root';
import { Config } from '../config'; import { Config } from '../config';
import { Link } from 'react-router-dom';
export function Navbar() { export function Navbar() {
const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated); const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated);
const [ isActive, setActive ] = useState(false);
const toggleMenu = () => {
setActive(active => !active);
};
return ( return (
<nav className="navbar" role="navigation" aria-label="main navigation"> <nav className="navbar" role="navigation" aria-label="main navigation">
<div className="container is-fluid"> <div className="container is-fluid">
<div className="navbar-brand"> <div className="navbar-brand">
<a className="navbar-item" href="#/"> <Link className="navbar-item" to="/">
<img src={logo} style={{marginRight:'5px',width:'28px',height:'28px'}} /> <img src={logo} style={{marginRight:'5px',width:'28px',height:'28px'}} />
<h1 className="is-size-4">Daddy</h1> <h1 className="is-size-4">Daddy</h1>
</a> </Link>
<a role="button" className="navbar-burger" aria-label="menu" aria-expanded="false"> <a role="button"
className={`navbar-burger ${isActive ? 'is-active' : ''}`}
onClick={toggleMenu}
aria-label="menu"
aria-expanded="false">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
</div> </div>
<div className="navbar-menu"> <div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
<div className="navbar-end"> <div className="navbar-end">
<div className="navbar-item"> <div className="navbar-item">
<div className="buttons">
{ {
isAuthenticated ? isAuthenticated ?
<a className="button is-small" href={Config.logoutURL}> <Fragment>
<span className="icon"> <Link to="/profile" className="button">
<i className="fas fa-sign-out-alt"></i> <span className="icon">
</span> <i className="fas fa-user"></i>
<span>Se déconnecter</span> </span>
</a> : </Link>
<a className="button is-small" href={Config.loginURL}> <a className="button" href={Config.logoutURL}>
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
</a>
</Fragment> :
<a className="button" href={Config.loginURL}>
<span className="icon"> <span className="icon">
<i className="fas fa-sign-in-alt"></i> <i className="fas fa-sign-in-alt"></i>
</span> </span>
<span>Se connecter</span>
</a> </a>
} }
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -25,6 +25,6 @@ export class Page extends React.PureComponent<PageProps> {
updateTitle() { updateTitle() {
const { title } = this.props; const { title } = this.props;
if (title !== undefined) window.document.title = title; if (title !== undefined) window.document.title = title + ' - Daddy';
} }
} }

View File

@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import { Page } from '../Page';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store/reducers/root';
import { UserForm } from '../UserForm';
import { Loader } from '../Loader';
import { User } from '../../types/user';
import { updateProfile } from '../../store/actions/profile';
export function ProfilePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
const dispatch = useDispatch();
const onUserChange = (user: User) => {
if (currentUser.name !== user.name) {
dispatch(updateProfile({ name: user.name }))
}
};
return (
<Page title="Mon profil">
<div className="container is-fluid">
<section className="section">
<div className="columns">
<div className="column is-6 is-offset-3">
<h2 className="is-size-2 subtitle">Mon profil</h2>
{
currentUser ?
<UserForm onChange={onUserChange} user={currentUser} /> :
<Loader />
}
</div>
</div>
</section>
</div>
</Page>
);
}

View File

@ -0,0 +1,80 @@
import React, { useState, ChangeEvent, useEffect } from 'react';
import { User } from '../types/user';
export interface UserFormProps {
user: User
onChange?: (user: User) => void
}
export function UserForm({ user, onChange }: UserFormProps) {
const [ state, setState ] = useState({
changed: false,
user: {
name: '',
email: '',
createdAt: null,
connectedAt: null,
...user,
}
});
const onSaveClick = () => {
if (!state.changed) return;
if (typeof onChange !== 'function') return;
onChange(state.user);
setState(state => {
return {
...state,
changed: false,
};
})
};
const onUserAttrChange = function(attrName: string, evt: ChangeEvent<HTMLInputElement>) {
const value = evt.currentTarget.value;
setState(state => {
return {
...state,
changed: true,
user: {
...state.user,
[attrName]: value,
}
};
});
};
return (
<div className="form">
<div className="field">
<label className="label">Nom d'utilisateur</label>
<div className="control">
<input type="text" className="input" value={state.user.name}
onChange={onUserAttrChange.bind(null, "name")} />
</div>
</div>
<div className="field">
<label className="label">Adresse courriel</label>
<div className="control">
<p className="input is-static">{state.user.email}</p>
</div>
</div>
<div className="field">
<label className="label">Date de dernière connexion</label>
<div className="control">
<p className="input is-static">{state.user.connectedAt}</p>
</div>
</div>
<div className="field">
<label className="label">Date de création</label>
<div className="control">
<p className="input is-static">{state.user.createdAt}</p>
</div>
</div>
<div className="buttons is-right">
<button disabled={!state.changed}
className="button is-primary" onClick={onSaveClick}>Enregistrer</button>
</div>
</div>
);
}

View File

@ -16,4 +16,25 @@ export interface fetchProfileSuccessAction extends Action {
export function fetchProfile(): fetchProfileRequestAction { export function fetchProfile(): fetchProfileRequestAction {
return { type: FETCH_PROFILE_REQUEST } return { type: FETCH_PROFILE_REQUEST }
}
export const UPDATE_PROFILE_REQUEST = 'UPDATE_PROFILE_REQUEST';
export const UPDATE_PROFILE_SUCCESS = 'UPDATE_PROFILE_SUCCESS';
export const UPDATE_PROFILE_FAILURE = 'UPDATE_PROFILE_FAILURE';
export interface ProfileChanges {
name?: string
}
export interface updateProfileRequestAction extends Action {
changes: ProfileChanges
}
export interface updateProfileSuccessAction extends Action {
profile: User
}
export function updateProfile(changes: ProfileChanges): updateProfileRequestAction {
return { type: UPDATE_PROFILE_REQUEST, changes }
} }

View File

@ -1,7 +1,7 @@
import { Action } from "redux"; import { Action } from "redux";
import { User } from "../../types/user"; import { User } from "../../types/user";
import { SET_CURRENT_USER, setCurrentUserAction } from "../actions/auth"; import { SET_CURRENT_USER, setCurrentUserAction } from "../actions/auth";
import { FETCH_PROFILE_SUCCESS, fetchProfileSuccessAction } from "../actions/profile"; import { FETCH_PROFILE_SUCCESS, fetchProfileSuccessAction, updateProfileSuccessAction, UPDATE_PROFILE_SUCCESS, updateProfileRequestAction } from "../actions/profile";
export interface AuthState { export interface AuthState {
isAuthenticated: boolean isAuthenticated: boolean
@ -19,6 +19,8 @@ export function authReducer(state = defaultState, action: Action): AuthState {
return handleSetCurrentUser(state, action as setCurrentUserAction); return handleSetCurrentUser(state, action as setCurrentUserAction);
case FETCH_PROFILE_SUCCESS: case FETCH_PROFILE_SUCCESS:
return handleFetchProfileSuccess(state, action as fetchProfileSuccessAction); return handleFetchProfileSuccess(state, action as fetchProfileSuccessAction);
case UPDATE_PROFILE_SUCCESS:
return handleFetchProfileSuccess(state, action as updateProfileSuccessAction);
} }
return state; return state;
@ -39,9 +41,17 @@ function handleFetchProfileSuccess(state: AuthState, { profile }: fetchProfileSu
...state, ...state,
isAuthenticated: true, isAuthenticated: true,
currentUser: { currentUser: {
email: profile.email, ...profile,
connectedAt: profile.connectedAt, }
createdAt: profile.createdAt, };
};
function handleUpdateProfileSuccess(state: AuthState, { profile }: updateProfileSuccessAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
...profile,
} }
}; };
}; };

View File

@ -1,15 +1,15 @@
import { DaddyClient, getClient } from "../../util/daddy"; import { DaddyClient, getClient } from "../../util/daddy";
import { Config } from "../../config"; import { Config } from "../../config";
import { all, takeLatest, put, select } from "redux-saga/effects"; import { all, takeLatest, put, select } from "redux-saga/effects";
import { FETCH_PROFILE_REQUEST, fetchProfile, FETCH_PROFILE_FAILURE, FETCH_PROFILE_SUCCESS } from "../actions/profile"; import { FETCH_PROFILE_REQUEST, fetchProfile, FETCH_PROFILE_FAILURE, FETCH_PROFILE_SUCCESS, updateProfileRequestAction, UPDATE_PROFILE_REQUEST, UPDATE_PROFILE_FAILURE, UPDATE_PROFILE_SUCCESS } from "../actions/profile";
import { SET_CURRENT_USER } from "../actions/auth"; import { SET_CURRENT_USER } from "../actions/auth";
import { RootState } from "../reducers/root";
import { User } from "../../types/user"; import { User } from "../../types/user";
export function* usersRootSaga() { export function* profileRootSaga() {
yield all([ yield all([
takeLatest(SET_CURRENT_USER, onCurrentUserChangeSaga), takeLatest(SET_CURRENT_USER, onCurrentUserChangeSaga),
takeLatest(FETCH_PROFILE_REQUEST, fetchProfileSaga), takeLatest(FETCH_PROFILE_REQUEST, fetchProfileSaga),
takeLatest(UPDATE_PROFILE_REQUEST, updateProfileSaga),
]); ]);
} }
@ -23,11 +23,24 @@ export function* fetchProfileSaga() {
let profile: User; let profile: User;
try { try {
profile = yield client.fetchProfile().then(result => result.userProfile); profile = yield client.fetchProfile().then(result => result.userProfile);
console.log(profile);
} catch(err) { } catch(err) {
yield put({ type: FETCH_PROFILE_FAILURE, err }); yield put({ type: FETCH_PROFILE_FAILURE, err });
return; return;
} }
yield put({type: FETCH_PROFILE_SUCCESS, profile }); yield put({type: FETCH_PROFILE_SUCCESS, profile });
}
export function* updateProfileSaga({ changes }: updateProfileRequestAction) {
const client = getClient(Config.graphQLEndpoint, Config.subscriptionEndpoint);
let profile: User;
try {
profile = yield client.updateProfile(changes).then(result => result.updateProfile);
} catch(err) {
yield put({ type: UPDATE_PROFILE_FAILURE, err });
return;
}
yield put({type: UPDATE_PROFILE_SUCCESS, profile });
} }

View File

@ -1,12 +1,12 @@
import { all } from 'redux-saga/effects'; import { all } from 'redux-saga/effects';
import { failureRootSaga } from './failure'; import { failureRootSaga } from './failure';
import { initRootSaga } from './init'; import { initRootSaga } from './init';
import { usersRootSaga } from './users'; import { profileRootSaga } from './profile';
export function* rootSaga() { export function* rootSaga() {
yield all([ yield all([
initRootSaga(), initRootSaga(),
failureRootSaga(), failureRootSaga(),
usersRootSaga(), profileRootSaga(),
]); ]);
} }

View File

@ -1,5 +1,6 @@
export interface User { export interface User {
email: string email: string
name?: string
connectedAt?: Date connectedAt?: Date
createdAt?: Date createdAt?: Date
} }

View File

@ -1,10 +1,11 @@
import ApolloClient from 'apollo-client'; import ApolloClient, { DefaultOptions } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory'; import { InMemoryCache } from 'apollo-cache-inmemory';
import { split } from 'apollo-link'; import { split } from 'apollo-link';
import { HttpLink } from 'apollo-link-http'; import { HttpLink } from 'apollo-link-http';
import { WebSocketLink } from 'apollo-link-ws'; import { WebSocketLink } from 'apollo-link-ws';
import { getMainDefinition } from 'apollo-utilities'; import { getMainDefinition, variablesInOperation } from 'apollo-utilities';
import gql from 'graphql-tag'; import gql from 'graphql-tag';
import { ProfileChanges } from '../store/actions/profile';
export class UnauthorizedError extends Error { export class UnauthorizedError extends Error {
constructor(...args: any[]) { constructor(...args: any[]) {
@ -54,10 +55,22 @@ export class DaddyClient {
wsLink, wsLink,
httpLink, httpLink,
); );
const defaultOptions: DefaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore',
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all',
},
};
this.gql = new ApolloClient<any>({ this.gql = new ApolloClient<any>({
link: link, link: link,
cache: new InMemoryCache(), cache: new InMemoryCache(),
defaultOptions,
}); });
} }
@ -66,6 +79,7 @@ export class DaddyClient {
query: gql` query: gql`
query { query {
userProfile { userProfile {
name,
email, email,
createdAt, createdAt,
connectedAt connectedAt
@ -75,6 +89,24 @@ export class DaddyClient {
.then(this.assertAuthorization) .then(this.assertAuthorization)
} }
updateProfile(changes: ProfileChanges) {
return this.gql.mutate({
variables: {
changes,
},
mutation: gql`
mutation updateProfile($changes: ProfileChanges!) {
updateProfile(changes: $changes) {
name,
email,
createdAt,
connectedAt
}
}`,
})
.then(this.assertAuthorization)
}
assertAuthorization({ status, data }: any) { assertAuthorization({ status, data }: any) {
if (status === 401) return Promise.reject(new UnauthorizedError()); if (status === 401) return Promise.reject(new UnauthorizedError());
return data; return data;

View File

@ -31,5 +31,28 @@ func handleUserProfile(ctx context.Context) (*model.User, error) {
} }
func handleUpdateUserProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) { func handleUpdateUserProfile(ctx context.Context, changes model.ProfileChanges) (*model.User, error) {
return nil, nil db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
userEmail, err := session.UserEmail(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewUserRepository(db)
userChanges := &model.User{}
if changes.Name != nil {
userChanges.Name = changes.Name
}
user, err := repo.UpdateUserByEmail(ctx, userEmail, userChanges)
if err != nil {
return nil, errors.WithStack(err)
}
return user, nil
} }

View File

@ -52,6 +52,23 @@ func (r *UserRepository) FindUserByEmail(ctx context.Context, email string) (*Us
return user, nil return user, nil
} }
func (r *UserRepository) UpdateUserByEmail(ctx context.Context, email string, changes *User) (*User, error) {
user := &User{
Email: email,
}
err := r.db.First(user, "email = ?", email).Error
if err != nil {
return nil, errors.Wrap(err, "could not find user")
}
if err := r.db.Model(user).Updates(changes).Error; err != nil {
return nil, errors.Wrap(err, "could not update user")
}
return user, nil
}
func NewUserRepository(db *gorm.DB) *UserRepository { func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db} return &UserRepository{db}
} }