1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-28 09:24:24 +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 { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter } from '../models/user';
export default class MemberAPI {
@ -9,12 +10,28 @@ export default class MemberAPI {
}
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;
}
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;
}
}

View File

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

View File

@ -42,7 +42,7 @@ export const RequiredTrainingModal: React.FC<RequiredTrainingModalProps> = ({ is
const header = (): ReactNode => {
return (
<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>
</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 { Path, UseFormRegister } from 'react-hook-form';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { Avatar } from './avatar';
interface AvatarInputProps<TFieldValues> {
register: UseFormRegister<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.
*/
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
*/
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 => {
setValue(
'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 (
<div className={`avatar ${className || ''}`}>
{!hasAvatar() && <img src={noAvatar} alt="avatar placeholder"/>}
{hasAvatar() && <img src={user.profile_attributes.user_avatar_attributes.attachment_url} alt="user's avatar"/>}
{mode === 'edit' && <div className="edition">
<FabButton onClick={onAddAvatar}>
<div className={`avatar-input avatar-input--${size}`}>
<Avatar avatar={avatar} userName={userName} size={size} />
<div className="buttons">
<FabButton onClick={onAddAvatar} className="select-button">
{!hasAvatar() && <span>Add an avatar</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>
{hasAvatar() && <FabButton>Remove</FabButton>}
</div>}
{hasAvatar() && <FabButton onClick={onRemoveAvatar} icon={<i className="fa fa-trash-o"/>} className="delete-avatar" />}
<FormInput register={register}
id="profile_attributes.user_avatar_attributes.id"
type="hidden" />
</div>
</div>
);
};
Avatar.defaultProps = {
mode: 'display'
};

View File

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

View File

@ -7,13 +7,13 @@ import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next';
import { Avatar } from './avatar';
import { GenderInput } from './gender-input';
import { ChangePassword } from './change-password';
import { PasswordInput } from './password-input';
import { FormSwitch } from '../form/form-switch';
import { FormRichText } from '../form/form-rich-text';
import MemberAPI from '../../api/member';
import { AvatarInput } from './avatar-input';
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 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 [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 (
<form className={`user-profile-form user-profile-form--${size} ${className}`} onSubmit={handleSubmit(onSubmit)}>
<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 className="fields-group">
<div className="personnal-data">

View File

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

View File

@ -76,6 +76,7 @@
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@import "modules/user/avatar";
@import "modules/user/avatar-input";
@import "modules/user/gender-input";
@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;
img {
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",
"ngtemplate-loader": "^2.1.0",
"nvd3": "1.8",
"object-to-formdata": "^4.4.2",
"phosphor-react": "^1.4.0",
"process": "^0.11.10",
"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"
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:
version "4.1.2"
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940"