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:
parent
48fd47f8d9
commit
7ee4c8f4c0
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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); }
|
||||||
})}
|
})}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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'
|
|
||||||
};
|
|
||||||
|
@ -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'
|
||||||
|
};
|
||||||
|
@ -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">
|
||||||
|
@ -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: {
|
||||||
|
@ -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";
|
||||||
|
|
||||||
|
34
app/frontend/src/stylesheets/modules/user/avatar-input.scss
Normal file
34
app/frontend/src/stylesheets/modules/user/avatar-input.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user