Add user settings modal

This commit is contained in:
Maksim Eltyshev
2020-04-08 21:12:58 +05:00
parent af00e3e191
commit c6ecf126d0
29 changed files with 570 additions and 455 deletions

View File

@@ -0,0 +1,144 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Header, Tab } from 'semantic-ui-react';
import EditInformation from './EditInformation';
import EditAvatarPopup from './EditAvatarPopup';
import EditUsernamePopup from './EditUsernamePopup';
import EditEmailPopup from './EditEmailPopup';
import EditPasswordPopup from './EditPasswordPopup';
import User from '../../User';
import styles from './AccountPane.module.css';
const AccountPane = React.memo(
({
email,
name,
username,
avatar,
isAvatarUploading,
usernameUpdateForm,
emailUpdateForm,
passwordUpdateForm,
onUpdate,
onAvatarUpload,
onUsernameUpdate,
onUsernameUpdateMessageDismiss,
onEmailUpdate,
onEmailUpdateMessageDismiss,
onPasswordUpdate,
onPasswordUpdateMessageDismiss,
}) => {
const [t] = useTranslation();
const handleAvatarDelete = useCallback(() => {
onUpdate({
avatar: null,
});
}, [onUpdate]);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<EditAvatarPopup
defaultValue={avatar}
onUpload={onAvatarUpload}
onDelete={handleAvatarDelete}
>
<User name={name} avatar={avatar} size="massive" isDisabled={isAvatarUploading} />
</EditAvatarPopup>
<br />
<br />
<EditInformation
defaultData={{
name,
}}
onUpdate={onUpdate}
/>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<EditUsernamePopup
defaultData={usernameUpdateForm.data}
username={username}
isSubmitting={usernameUpdateForm.isSubmitting}
error={usernameUpdateForm.error}
onUpdate={onUsernameUpdate}
onMessageDismiss={onUsernameUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</EditUsernamePopup>
</div>
<div className={styles.action}>
<EditEmailPopup
defaultData={emailUpdateForm.data}
email={email}
isSubmitting={emailUpdateForm.isSubmitting}
error={emailUpdateForm.error}
onUpdate={onEmailUpdate}
onMessageDismiss={onEmailUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</EditEmailPopup>
</div>
<div className={styles.action}>
<EditPasswordPopup
defaultData={passwordUpdateForm.data}
isSubmitting={passwordUpdateForm.isSubmitting}
error={passwordUpdateForm.error}
onUpdate={onPasswordUpdate}
onMessageDismiss={onPasswordUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</EditPasswordPopup>
</div>
</Tab.Pane>
);
},
);
AccountPane.propTypes = {
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
username: PropTypes.string,
avatar: PropTypes.string,
isAvatarUploading: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onAvatarUpload: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
onEmailUpdate: PropTypes.func.isRequired,
onEmailUpdateMessageDismiss: PropTypes.func.isRequired,
onPasswordUpdate: PropTypes.func.isRequired,
onPasswordUpdateMessageDismiss: PropTypes.func.isRequired,
};
AccountPane.defaultProps = {
username: undefined,
avatar: undefined,
};
export default AccountPane;

View File

@@ -0,0 +1,30 @@
.action {
border: none;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
}
.action:hover {
background: #e9e9e9 !important;
}
.actionButton {
background: transparent !important;
color: #6b808c !important;
font-weight: normal !important;
height: 36px;
line-height: 24px !important;
padding: 6px 12px !important;
text-align: left !important;
text-decoration: underline !important;
width: 100%;
}
.wrapper {
border: none !important;
box-shadow: none !important;
}

View File

@@ -0,0 +1,71 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import styles from './EditAvatarPopup.module.css';
const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }) => {
const [t] = useTranslation();
const field = useRef(null);
const handleFieldChange = useCallback(
({ target }) => {
if (target.files[0]) {
onUpload(target.files[0]);
onClose();
}
},
[onUpload, onClose],
);
const handleDeleteClick = useCallback(() => {
onDelete();
onClose();
}, [onDelete, onClose]);
useEffect(() => {
field.current.focus();
}, []);
return (
<>
<Popup.Header>
{t('common.editAvatar', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.input}>
<Button content={t('action.uploadNewAvatar')} className={styles.customButton} />
<input
ref={field}
type="file"
accept="image/*"
className={styles.file}
onChange={handleFieldChange}
/>
</div>
{defaultValue && (
<Button negative content={t('action.deleteAvatar')} onClick={handleDeleteClick} />
)}
</Popup.Content>
</>
);
});
EditAvatarStep.propTypes = {
defaultValue: PropTypes.string,
onUpload: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditAvatarStep.defaultProps = {
defaultValue: undefined,
};
export default withPopup(EditAvatarStep);

View File

@@ -0,0 +1,34 @@
.customButton {
background: transparent !important;
color: #6b808c !important;
font-weight: normal !important;
height: 36px;
line-height: 24px !important;
padding: 6px 12px !important;
text-align: left !important;
text-decoration: underline !important;
}
.file {
bottom: 0;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
z-index: 1;
}
.input {
border: none;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
}
.input:hover {
background: #e9e9e9 !important;
}

View File

@@ -0,0 +1,177 @@
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { withPopup } from '../../../lib/popup';
import { Input, Popup } from '../../../lib/custom-ui';
import { useForm } from '../../../hooks';
import styles from './EditEmailPopup.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Invalid current password':
return {
type: 'error',
content: 'common.invalidCurrentPassword',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const EditEmailStep = React.memo(
({ defaultData, email, isSubmitting, error, onUpdate, onMessageDismiss, onClose }) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm({
email: '',
currentPassword: '',
...defaultData,
});
const message = useMemo(() => createMessage(error), [error]);
const [focusCurrentPasswordFieldState, focusCurrentPasswordField] = useToggle();
const emailField = useRef(null);
const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
};
if (!isEmail(cleanData.email)) {
emailField.current.select();
return;
}
if (cleanData.email === email) {
onClose();
return;
}
if (!cleanData.currentPassword) {
currentPasswordField.current.focus();
return;
}
onUpdate(cleanData);
}, [email, onUpdate, onClose, data]);
useEffect(() => {
emailField.current.select();
}, []);
useEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (error) {
switch (error.message) {
case 'Email already in use':
emailField.current.select();
break;
case 'Invalid current password':
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
break;
default:
}
} else {
onClose();
}
}
}, [isSubmitting, wasSubmitting, error, onClose, setData, focusCurrentPasswordField]);
useDidUpdate(() => {
currentPasswordField.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header>
{t('common.editEmail', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newEmail')}</div>
<Input
fluid
ref={emailField}
name="email"
value={data.email}
placeholder={email}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={currentPasswordField}
name="currentPassword"
value={data.currentPassword}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
},
);
EditEmailStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
email: PropTypes.string.isRequired,
isSubmitting: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditEmailStep.defaultProps = {
error: undefined,
};
export default withPopup(EditEmailStep);

View File

@@ -0,0 +1,10 @@
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View File

@@ -0,0 +1,56 @@
import dequal from 'dequal';
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import { useForm } from '../../../hooks';
import styles from './EditInformation.module.css';
const EditInformation = React.memo(({ defaultData, onUpdate }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm({
name: '',
...defaultData,
});
const nameField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onUpdate(cleanData);
}, [onUpdate, data]);
return (
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.name')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive disabled={dequal(data, defaultData)} content={t('action.save')} />
</Form>
);
});
EditInformation.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
};
export default EditInformation;

View File

@@ -0,0 +1,10 @@
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View File

@@ -0,0 +1,149 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { withPopup } from '../../../lib/popup';
import { Input, Popup } from '../../../lib/custom-ui';
import { useForm } from '../../../hooks';
import styles from './EditPasswordPopup.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Invalid current password':
return {
type: 'error',
content: 'common.invalidCurrentPassword',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const EditPasswordStep = React.memo(
({ defaultData, isSubmitting, error, onUpdate, onMessageDismiss, onClose }) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm({
password: '',
currentPassword: '',
...defaultData,
});
const message = useMemo(() => createMessage(error), [error]);
const [focusCurrentPasswordFieldState, focusCurrentPasswordField] = useToggle();
const passwordField = useRef(null);
const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => {
if (!data.password) {
passwordField.current.select();
return;
}
if (!data.currentPassword) {
currentPasswordField.current.focus();
return;
}
onUpdate(data);
}, [onUpdate, data]);
useEffect(() => {
passwordField.current.select();
}, []);
useEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (!error) {
onClose();
} else if (error.message === 'Invalid current password') {
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
}
}
}, [isSubmitting, wasSubmitting, error, onClose, setData, focusCurrentPasswordField]);
useDidUpdate(() => {
currentPasswordField.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header>
{t('common.editPassword', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newPassword')}</div>
<Input.Password
fluid
ref={passwordField}
name="password"
value={data.password}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={currentPasswordField}
name="currentPassword"
value={data.currentPassword}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
},
);
EditPasswordStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isSubmitting: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditPasswordStep.defaultProps = {
error: undefined,
};
export default withPopup(EditPasswordStep);

View File

@@ -0,0 +1,3 @@
.field {
margin-bottom: 8px;
}

View File

@@ -0,0 +1,178 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { withPopup } from '../../../lib/popup';
import { Input, Popup } from '../../../lib/custom-ui';
import { useForm } from '../../../hooks';
import { isUsername } from '../../../utils/validator';
import styles from './EditUsernamePopup.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Invalid current password':
return {
type: 'error',
content: 'common.invalidCurrentPassword',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const EditUsernameStep = React.memo(
({ defaultData, username, isSubmitting, error, onUpdate, onMessageDismiss, onClose }) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm({
username: '',
currentPassword: '',
...defaultData,
});
const message = useMemo(() => createMessage(error), [error]);
const [focusCurrentPasswordFieldState, focusCurrentPasswordField] = useToggle();
const usernameField = useRef(null);
const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
username: data.username.trim() || null,
};
if (cleanData.username && !isUsername(cleanData.username)) {
usernameField.current.select();
return;
}
if (cleanData.username === username) {
onClose();
return;
}
if (!cleanData.currentPassword) {
currentPasswordField.current.focus();
return;
}
onUpdate(cleanData);
}, [username, onUpdate, onClose, data]);
useEffect(() => {
usernameField.current.select();
}, []);
useEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (error) {
switch (error.message) {
case 'Username already in use':
usernameField.current.select();
break;
case 'Invalid current password':
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
break;
default:
}
} else {
onClose();
}
}
}, [isSubmitting, wasSubmitting, error, onClose, setData, focusCurrentPasswordField]);
useDidUpdate(() => {
currentPasswordField.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header>
{t('common.editUsername', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newUsername')}</div>
<Input
fluid
ref={usernameField}
name="username"
value={data.username}
placeholder={username}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={currentPasswordField}
name="currentPassword"
value={data.currentPassword}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
},
);
EditUsernameStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
username: PropTypes.string,
isSubmitting: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditUsernameStep.defaultProps = {
username: undefined,
error: undefined,
};
export default withPopup(EditUsernameStep);

View File

@@ -0,0 +1,10 @@
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View File

@@ -0,0 +1,3 @@
import AccountPane from './AccountPane';
export default AccountPane;

View File

@@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Modal, Tab } from 'semantic-ui-react';
import AccountPane from './AccountPane';
const UserSettingsModal = React.memo(
({
email,
name,
username,
avatar,
isAvatarUploading,
usernameUpdateForm,
emailUpdateForm,
passwordUpdateForm,
onUpdate,
onAvatarUpload,
onUsernameUpdate,
onUsernameUpdateMessageDismiss,
onEmailUpdate,
onEmailUpdateMessageDismiss,
onPasswordUpdate,
onPasswordUpdateMessageDismiss,
onClose,
}) => {
const [t] = useTranslation();
const panes = [
{
menuItem: t('common.account', {
context: 'title',
}),
render: () => (
<AccountPane
email={email}
name={name}
username={username}
avatar={avatar}
isAvatarUploading={isAvatarUploading}
usernameUpdateForm={usernameUpdateForm}
emailUpdateForm={emailUpdateForm}
passwordUpdateForm={passwordUpdateForm}
onUpdate={onUpdate}
onAvatarUpload={onAvatarUpload}
onUsernameUpdate={onUsernameUpdate}
onUsernameUpdateMessageDismiss={onUsernameUpdateMessageDismiss}
onEmailUpdate={onEmailUpdate}
onEmailUpdateMessageDismiss={onEmailUpdateMessageDismiss}
onPasswordUpdate={onPasswordUpdate}
onPasswordUpdateMessageDismiss={onPasswordUpdateMessageDismiss}
/>
),
},
];
return (
<Modal open closeIcon size="small" centered={false} onClose={onClose}>
<Modal.Content>
<Tab menu={{ secondary: true, pointing: true }} panes={panes} />
</Modal.Content>
</Modal>
);
},
);
UserSettingsModal.propTypes = {
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
username: PropTypes.string,
avatar: PropTypes.string,
isAvatarUploading: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onAvatarUpload: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
onEmailUpdate: PropTypes.func.isRequired,
onEmailUpdateMessageDismiss: PropTypes.func.isRequired,
onPasswordUpdate: PropTypes.func.isRequired,
onPasswordUpdateMessageDismiss: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
UserSettingsModal.defaultProps = {
username: undefined,
avatar: undefined,
};
export default UserSettingsModal;

View File

@@ -0,0 +1,3 @@
import UserSettingsModal from './UserSettingsModal';
export default UserSettingsModal;