1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-12-01 12:24:28 +01:00

(ui) avatar input

This commit is contained in:
Sylvain 2022-05-03 15:46:08 +02:00
parent 48fd47f8d9
commit 7ee4c8f4c0
12 changed files with 147 additions and 36 deletions

View File

@ -1,5 +1,6 @@
import apiClient from './clients/api-client'; import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter } from '../models/user'; import { User, UserIndexFilter } from '../models/user';
export default class MemberAPI { export default class MemberAPI {
@ -9,12 +10,28 @@ export default class MemberAPI {
} }
static async create (user: User): Promise<User> { static async create (user: User): Promise<User> {
const res: AxiosResponse<User> = await apiClient.post('/api/members/create', { user }); const data = serialize({ user });
if (user.profile_attributes.user_avatar_attributes.attachment_files[0]) {
data.set('user[profile_attributes][user_avatar_attributes][attachment]', user.profile_attributes.user_avatar_attributes.attachment_files[0]);
}
const res: AxiosResponse<User> = await apiClient.post('/api/members/create', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data; return res?.data;
} }
static async update (user: User): Promise<User> { static async update (user: User): Promise<User> {
const res: AxiosResponse<User> = await apiClient.patch(`/api/members/${user.id}`, { user }); const data = serialize({ user });
if (user.profile_attributes.user_avatar_attributes.attachment_files[0]) {
data.set('user[profile_attributes][user_avatar_attributes][attachment]', user.profile_attributes.user_avatar_attributes.attachment_files[0]);
}
const res: AxiosResponse<User> = await apiClient.patch(`/api/members/${user.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data; return res?.data;
} }
} }

View File

@ -58,6 +58,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
{...register(id as FieldPath<TFieldValues>, { {...register(id as FieldPath<TFieldValues>, {
...rules, ...rules,
valueAsNumber: type === 'number', valueAsNumber: type === 'number',
valueAsDate: type === 'date',
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>, value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); } onChange: (e) => { handleChange(e); }
})} })}

View File

@ -42,7 +42,7 @@ export const RequiredTrainingModal: React.FC<RequiredTrainingModalProps> = ({ is
const header = (): ReactNode => { const header = (): ReactNode => {
return ( return (
<div className="user-info"> <div className="user-info">
<Avatar user={user} /> <Avatar userName={user?.name} avatar={user?.profile_attributes?.user_avatar_attributes?.attachment} />
<span className="user-name">{user?.name}</span> <span className="user-name">{user?.name}</span>
</div> </div>
); );

View File

@ -1,29 +1,36 @@
import React from 'react'; import React, { useState } from 'react';
import noAvatar from '../../../../images/no_avatar.png';
import { FabButton } from '../base/fab-button'; import { FabButton } from '../base/fab-button';
import { Path, UseFormRegister } from 'react-hook-form'; import { Path, UseFormRegister } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path'; import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input'; import { FormInput } from '../form/form-input';
import { Avatar } from './avatar';
interface AvatarInputProps<TFieldValues> { interface AvatarInputProps<TFieldValues> {
register: UseFormRegister<TFieldValues>, register: UseFormRegister<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>, setValue: UseFormSetValue<TFieldValues>,
currentAvatar: string,
userName: string,
size?: 'small' | 'large'
} }
/** /**
* This component allows to set the user's avatar, in forms managed by react-hook-form. * This component allows to set the user's avatar, in forms managed by react-hook-form.
*/ */
export const AvatarInput: React.FC = <TFieldValues extends FieldValues>({ register, setValue }: AvatarInputProps<FieldValues>) => { export const AvatarInput = <TFieldValues extends FieldValues>({ currentAvatar, userName, register, setValue, size }: AvatarInputProps<TFieldValues>) => {
const [avatar, setAvatar] = useState<string|ArrayBuffer>(currentAvatar);
/** /**
* Check if the provided user has a configured avatar * Check if the provided user has a configured avatar
*/ */
const hasAvatar = (): boolean => { const hasAvatar = (): boolean => {
return !!user?.profile_attributes?.user_avatar_attributes?.attachment_url; return !!avatar;
}; };
/**
* Callback triggered when the user starts to select a file.
*/
const onAddAvatar = (): void => { const onAddAvatar = (): void => {
setValue( setValue(
'profile_attributes.user_avatar_attributes._destroy' as Path<TFieldValues>, 'profile_attributes.user_avatar_attributes._destroy' as Path<TFieldValues>,
@ -31,22 +38,52 @@ export const AvatarInput: React.FC = <TFieldValues extends FieldValues>({ regist
); );
}; };
/**
* Callback triggered when the user clicks on the delete button.
*/
function onRemoveAvatar () {
setValue(
'profile_attributes.user_avatar_attributes._destroy' as Path<TFieldValues>,
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
setAvatar(null);
}
/**
* Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled).
*/
function onFileSelected (event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target?.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (): void => {
setAvatar(reader.result);
};
reader.readAsDataURL(file);
} else {
setAvatar(null);
}
}
return ( return (
<div className={`avatar ${className || ''}`}> <div className={`avatar-input avatar-input--${size}`}>
{!hasAvatar() && <img src={noAvatar} alt="avatar placeholder"/>} <Avatar avatar={avatar} userName={userName} size={size} />
{hasAvatar() && <img src={user.profile_attributes.user_avatar_attributes.attachment_url} alt="user's avatar"/>} <div className="buttons">
{mode === 'edit' && <div className="edition"> <FabButton onClick={onAddAvatar} className="select-button">
<FabButton onClick={onAddAvatar}>
{!hasAvatar() && <span>Add an avatar</span>} {!hasAvatar() && <span>Add an avatar</span>}
{hasAvatar() && <span>Change</span>} {hasAvatar() && <span>Change</span>}
<FormInput type="file" accept="image/*" register={register} id="profile_attributes.user_avatar_attributes.attachment"/> <FormInput className="avatar-file-input"
type="file"
accept="image/*"
register={register}
id="profile_attributes.user_avatar_attributes.attachment_files"
onChange={onFileSelected}/>
</FabButton> </FabButton>
{hasAvatar() && <FabButton>Remove</FabButton>} {hasAvatar() && <FabButton onClick={onRemoveAvatar} icon={<i className="fa fa-trash-o"/>} className="delete-avatar" />}
</div>} <FormInput register={register}
id="profile_attributes.user_avatar_attributes.id"
type="hidden" />
</div>
</div> </div>
); );
}; };
Avatar.defaultProps = {
mode: 'display'
};

View File

@ -1,28 +1,25 @@
import React from 'react'; import React from 'react';
import { User } from '../../models/user';
import noAvatar from '../../../../images/no_avatar.png'; import noAvatar from '../../../../images/no_avatar.png';
interface AvatarProps { interface AvatarProps {
user: User, avatar?: string | ArrayBuffer,
userName: string,
className?: string, className?: string,
size?: 'small' | 'large',
} }
/** /**
* This component renders the user-profile's picture or a placeholder. * This component renders the user-profile's picture or a placeholder.
*/ */
export const Avatar: React.FC<AvatarProps> = ({ user, className }) => { export const Avatar: React.FC<AvatarProps> = ({ avatar, className, userName, size }) => {
/**
* Check if the provided user has a configured avatar
*/
const hasAvatar = (): boolean => {
return !!user?.profile_attributes?.user_avatar_attributes?.attachment_url;
};
return ( return (
<div className={`avatar ${className || ''}`}> <div className={`avatar avatar--${size} ${className || ''}`}>
{!hasAvatar() && <img src={noAvatar} alt="avatar placeholder"/>} <img src={avatar || noAvatar} alt={userName} />
{hasAvatar() && <img src={user.profile_attributes.user_avatar_attributes.attachment_url} alt="user's avatar"/>}
</div> </div>
); );
}; };
Avatar.defaultProps = {
size: 'small'
};

View File

@ -7,13 +7,13 @@ import { IApplication } from '../../models/application';
import { Loader } from '../base/loader'; import { Loader } from '../base/loader';
import { FormInput } from '../form/form-input'; import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from './avatar';
import { GenderInput } from './gender-input'; import { GenderInput } from './gender-input';
import { ChangePassword } from './change-password'; import { ChangePassword } from './change-password';
import { PasswordInput } from './password-input'; import { PasswordInput } from './password-input';
import { FormSwitch } from '../form/form-switch'; import { FormSwitch } from '../form/form-switch';
import { FormRichText } from '../form/form-rich-text'; import { FormRichText } from '../form/form-rich-text';
import MemberAPI from '../../api/member'; import MemberAPI from '../../api/member';
import { AvatarInput } from './avatar-input';
declare const Application: IApplication; declare const Application: IApplication;
@ -33,7 +33,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
const phoneRegex = /^((00|\+)[0-9]{2,3})?[0-9]{4,14}$/; const phoneRegex = /^((00|\+)[0-9]{2,3})?[0-9]{4,14}$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/; const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;
const { handleSubmit, register, control, formState } = useForm<User>({ defaultValues: { ...user } }); const { handleSubmit, register, control, formState, setValue } = useForm<User>({ defaultValues: { ...user } });
const output = useWatch<User>({ control }); const output = useWatch<User>({ control });
const [isOrganization, setIsOrganization] = React.useState<boolean>(!_isNil(user.invoicing_profile_attributes.organization_attributes)); const [isOrganization, setIsOrganization] = React.useState<boolean>(!_isNil(user.invoicing_profile_attributes.organization_attributes));
@ -50,7 +50,11 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
return ( return (
<form className={`user-profile-form user-profile-form--${size} ${className}`} onSubmit={handleSubmit(onSubmit)}> <form className={`user-profile-form user-profile-form--${size} ${className}`} onSubmit={handleSubmit(onSubmit)}>
<div className="avatar-group"> <div className="avatar-group">
<Avatar user={user} /> <AvatarInput currentAvatar={output.profile_attributes.user_avatar_attributes?.attachment_url}
userName={`${output.profile_attributes.first_name} ${output.profile_attributes.last_name}`}
register={register}
setValue={setValue}
size={size} />
</div> </div>
<div className="fields-group"> <div className="fields-group">
<div className="personnal-data"> <div className="personnal-data">

View File

@ -45,7 +45,10 @@ export interface User {
flickr: string, flickr: string,
user_avatar_attributes: { user_avatar_attributes: {
id: number, id: number,
attachment_url: string attachment?: File,
attachment_url?: string,
attachment_files: FileList,
_destroy?: boolean
} }
}, },
invoicing_profile_attributes: { invoicing_profile_attributes: {

View File

@ -76,6 +76,7 @@
@import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal"; @import "modules/subscriptions/renew-modal";
@import "modules/user/avatar"; @import "modules/user/avatar";
@import "modules/user/avatar-input";
@import "modules/user/gender-input"; @import "modules/user/gender-input";
@import "modules/user/user-profile-form"; @import "modules/user/user-profile-form";

View File

@ -0,0 +1,34 @@
.avatar-input {
margin-right: 20px;
.avatar {
background-color: #fff;
border: 1px solid var(--gray-soft);
padding: 4px;
}
.buttons {
display: flex;
justify-content: center;
margin-top: 20px;
.select-button {
position: relative;
.avatar-file-input {
position: absolute;
z-index: 2;
opacity: 0;
top: 0;
left: 0;
}
}
.delete-avatar {
background-color: var(--error);
color: white;
}
}
&--large {
margin: 80px 40px;
}
}

View File

@ -2,5 +2,16 @@
display: inline-block; display: inline-block;
img { img {
border-radius: 50%; border-radius: 50%;
object-fit: cover;
}
&--small img {
width: 50px;
height: 50px;
}
&--large img {
width: 180px;
height: 180px;
} }
} }

View File

@ -125,6 +125,7 @@
"ngUpload": "0.5", "ngUpload": "0.5",
"ngtemplate-loader": "^2.1.0", "ngtemplate-loader": "^2.1.0",
"nvd3": "1.8", "nvd3": "1.8",
"object-to-formdata": "^4.4.2",
"phosphor-react": "^1.4.0", "phosphor-react": "^1.4.0",
"process": "^0.11.10", "process": "^0.11.10",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",

View File

@ -5297,6 +5297,11 @@ object-keys@^1.0.12, object-keys@^1.1.1:
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
object-to-formdata@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/object-to-formdata/-/object-to-formdata-4.4.2.tgz#f89013f90493c58cb5f6ab9f50b7aeec30745ea6"
integrity sha512-fu6UDjsqIfFUu/B3GXJ2IFnNAL/YbsC1PPzqDIFXcfkhdYjTD3K4zqhyD/lZ6+KdP9O/64YIPckIOiS5ouXwLA==
object.assign@^4.1.0, object.assign@^4.1.2: object.assign@^4.1.0, object.assign@^4.1.2:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940"