feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev
2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions

View File

@@ -0,0 +1,212 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import styles from './EditUserEmailStep.module.scss';
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 EditUserEmailStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const {
email,
emailUpdateForm: { data: defaultData, isSubmitting, error },
} = useSelector((state) => selectUserById(state, id));
const dispatch = useDispatch();
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 [emailFieldRef, handleEmailFieldRef] = useNestedRef('inputRef');
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
};
if (!isEmail(cleanData.email)) {
emailFieldRef.current.select();
return;
}
if (cleanData.email === email) {
onClose();
return;
}
if (withPasswordConfirmation) {
if (!cleanData.currentPassword) {
currentPasswordFieldRef.current.focus();
return;
}
} else {
delete cleanData.currentPassword;
}
dispatch(entryActions.updateUserEmail(id, cleanData));
}, [
id,
withPasswordConfirmation,
onClose,
email,
dispatch,
data,
emailFieldRef,
currentPasswordFieldRef,
]);
const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearUserEmailUpdateError(id));
}, [id, dispatch]);
useEffect(() => {
emailFieldRef.current.focus({
preventScroll: true,
});
}, [emailFieldRef]);
useDidUpdate(() => {
if (wasSubmitting && !isSubmitting) {
if (error) {
switch (error.message) {
case 'Email already in use':
emailFieldRef.current.select();
break;
case 'Invalid current password':
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
break;
default:
}
} else {
onClose();
}
}
}, [isSubmitting, wasSubmitting, error, onClose]);
useDidUpdate(() => {
currentPasswordFieldRef.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editEmail', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={handleMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newEmail')}</div>
<Input
fluid
ref={handleEmailFieldRef}
name="email"
value={data.email}
placeholder={email}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
{withPasswordConfirmation && (
<>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={handleCurrentPasswordFieldRef}
name="currentPassword"
value={data.currentPassword}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
</>
)}
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
});
EditUserEmailStep.propTypes = {
id: PropTypes.string.isRequired,
withPasswordConfirmation: PropTypes.bool,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserEmailStep.defaultProps = {
withPasswordConfirmation: false,
onBack: undefined,
};
export default EditUserEmailStep;

View File

@@ -0,0 +1,17 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import EditUserEmailStep from './EditUserEmailStep';
export default EditUserEmailStep;

View File

@@ -0,0 +1,116 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import { dequal } from 'dequal';
import omit from 'lodash/omit';
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import styles from './EditUserInformation.module.scss';
const EditUserInformation = React.memo(({ id, onUpdate }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, id));
const dispatch = useDispatch();
const [t] = useTranslation();
const defaultData = useMemo(
() => ({
name: user.name,
phone: user.phone,
organization: user.organization,
}),
[user.name, user.phone, user.organization],
);
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
phone: defaultData.phone || '',
organization: defaultData.organization || '',
}));
const cleanData = useMemo(
() => ({
...data,
name: data.name.trim(),
phone: data.phone.trim() || null,
organization: data.organization.trim() || null,
}),
[data],
);
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const isNameEditable = !user.lockedFieldNames.includes('name');
const handleSubmit = useCallback(() => {
if (isNameEditable && !cleanData.name) {
nameFieldRef.current.select();
return;
}
dispatch(entryActions.updateUser(id, isNameEditable ? cleanData : omit(cleanData, 'name')));
if (onUpdate) {
onUpdate();
}
}, [id, onUpdate, dispatch, cleanData, nameFieldRef, isNameEditable]);
return (
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.name')}</div>
<Input
fluid
ref={handleNameFieldRef}
name="name"
value={data.name}
maxLength={128}
disabled={!isNameEditable}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.phone')}</div>
<Input
fluid
name="phone"
value={data.phone}
maxLength={128}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.organization')}</div>
<Input
fluid
name="organization"
value={data.organization}
maxLength={128}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive disabled={dequal(cleanData, defaultData)} content={t('action.save')} />
</Form>
);
});
EditUserInformation.propTypes = {
id: PropTypes.string.isRequired,
onUpdate: PropTypes.func,
};
EditUserInformation.defaultProps = {
onUpdate: undefined,
};
export default EditUserInformation;

View File

@@ -0,0 +1,17 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import EditUserInformation from './EditUserInformation';
export default EditUserInformation;

View File

@@ -0,0 +1,44 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Popup } from '../../lib/custom-ui';
import EditUserInformation from './EditUserInformation';
const EditUserInformationStep = React.memo(({ id, onBack, onClose }) => {
const [t] = useTranslation();
const handleUpdate = useCallback(() => {
onClose();
}, [onClose]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editInformation', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<EditUserInformation id={id} onUpdate={handleUpdate} />
</Popup.Content>
</>
);
});
EditUserInformationStep.propTypes = {
id: PropTypes.string.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserInformationStep.defaultProps = {
onBack: undefined,
};
export default EditUserInformationStep;

View File

@@ -0,0 +1,181 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import omit from 'lodash/omit';
import React, { useCallback, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import { isPassword } from '../../../utils/validator';
import styles from './EditUserPasswordStep.module.scss';
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 EditUserPasswordStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const {
data: defaultData,
isSubmitting,
error,
} = useSelector((state) => selectUserById(state, id).passwordUpdateForm);
const dispatch = useDispatch();
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 [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef');
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
if (!data.password || !isPassword(data.password)) {
passwordFieldRef.current.select();
return;
}
if (withPasswordConfirmation && !data.currentPassword) {
currentPasswordFieldRef.current.focus();
return;
}
dispatch(
entryActions.updateUserPassword(
id,
withPasswordConfirmation ? data : omit(data, 'currentPassword'),
),
);
}, [id, withPasswordConfirmation, dispatch, data, passwordFieldRef, currentPasswordFieldRef]);
const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearUserPasswordUpdateError(id));
}, [id, dispatch]);
useEffect(() => {
passwordFieldRef.current.focus({
preventScroll: true,
});
}, [passwordFieldRef]);
useDidUpdate(() => {
if (wasSubmitting && !isSubmitting) {
if (!error) {
onClose();
} else if (error.message === 'Invalid current password') {
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
}
}
}, [isSubmitting, wasSubmitting, error, onClose]);
useDidUpdate(() => {
currentPasswordFieldRef.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editPassword', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={handleMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newPassword')}</div>
<Input.Password
withStrengthBar
fluid
ref={handlePasswordFieldRef}
name="password"
value={data.password}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
{withPasswordConfirmation && (
<>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={handleCurrentPasswordFieldRef}
name="currentPassword"
value={data.currentPassword}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
</>
)}
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
});
EditUserPasswordStep.propTypes = {
id: PropTypes.string.isRequired,
withPasswordConfirmation: PropTypes.bool,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserPasswordStep.defaultProps = {
withPasswordConfirmation: false,
onBack: undefined,
};
export default EditUserPasswordStep;

View File

@@ -0,0 +1,17 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import EditUserPasswordStep from './EditUserPasswordStep';
export default EditUserPasswordStep;

View File

@@ -0,0 +1,212 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import { isUsername } from '../../../utils/validator';
import styles from './EditUserUsernameStep.module.scss';
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 EditUserUsernameStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const {
username,
usernameUpdateForm: { data: defaultData, isSubmitting, error },
} = useSelector((state) => selectUserById(state, id));
const dispatch = useDispatch();
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 [usernameFieldRef, handleUsernameFieldRef] = useNestedRef('inputRef');
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
username: data.username.trim() || null,
};
if (!cleanData.username || !isUsername(cleanData.username)) {
usernameFieldRef.current.select();
return;
}
if (cleanData.username === username) {
onClose();
return;
}
if (withPasswordConfirmation) {
if (!cleanData.currentPassword) {
currentPasswordFieldRef.current.focus();
return;
}
} else {
delete cleanData.currentPassword;
}
dispatch(entryActions.updateUserUsername(id, cleanData));
}, [
id,
withPasswordConfirmation,
onClose,
username,
dispatch,
data,
usernameFieldRef,
currentPasswordFieldRef,
]);
const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearUserUsernameUpdateError(id));
}, [id, dispatch]);
useEffect(() => {
usernameFieldRef.current.focus({
preventScroll: true,
});
}, [usernameFieldRef]);
useDidUpdate(() => {
if (wasSubmitting && !isSubmitting) {
if (error) {
switch (error.message) {
case 'Username already in use':
usernameFieldRef.current.select();
break;
case 'Invalid current password':
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
break;
default:
}
} else {
onClose();
}
}
}, [isSubmitting, wasSubmitting, error, onClose]);
useDidUpdate(() => {
currentPasswordFieldRef.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editUsername', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={handleMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newUsername')}</div>
<Input
fluid
ref={handleUsernameFieldRef}
name="username"
value={data.username}
placeholder={username}
maxLength={16}
className={styles.field}
onChange={handleFieldChange}
/>
{withPasswordConfirmation && (
<>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={handleCurrentPasswordFieldRef}
name="currentPassword"
value={data.currentPassword}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
</>
)}
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
});
EditUserUsernameStep.propTypes = {
id: PropTypes.string.isRequired,
withPasswordConfirmation: PropTypes.bool,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserUsernameStep.defaultProps = {
withPasswordConfirmation: false,
onBack: undefined,
};
export default EditUserUsernameStep;

View File

@@ -0,0 +1,17 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import EditUserUsernameStep from './EditUserUsernameStep';
export default EditUserUsernameStep;

View File

@@ -0,0 +1,113 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import initials from 'initials';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import selectors from '../../../selectors';
import { StaticUserIds } from '../../../constants/StaticUsers';
import styles from './UserAvatar.module.scss';
const Sizes = {
TINY: 'tiny',
SMALL: 'small',
MEDIUM: 'medium',
LARGE: 'large',
MASSIVE: 'massive',
};
const COLORS = [
'emerald',
'peter-river',
'wisteria',
'carrot',
'alizarin',
'turquoise',
'midnight-blue',
];
const getColor = (name) => {
let sum = 0;
for (let i = 0; i < name.length; i += 1) {
sum += name.charCodeAt(i);
}
return COLORS[sum % COLORS.length];
};
const UserAvatar = React.memo(
({ id, size, isDisabled, withCreatorIndicator, className, onClick }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, id));
const [t] = useTranslation();
const contentNode = (
<span
title={
user.id === StaticUserIds.DELETED
? t(`common.${user.name}`, {
context: 'title',
})
: user.name
}
className={classNames(
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
onClick && styles.wrapperHoverable,
!user.avatar && styles[`background${upperFirst(camelCase(getColor(user.name)))}`],
)}
style={{
background: user.avatar && `url("${user.avatar.thumbnailUrls.cover180}") center / cover`,
}}
>
{!user.avatar && <span className={styles.initials}>{initials(user.name).slice(0, 2)}</span>}
{withCreatorIndicator && <span className={styles.creatorIndicator}>+</span>}
</span>
);
return onClick ? (
<button
data-id={id}
type="button"
disabled={isDisabled}
className={classNames(styles.button, className)}
onClick={onClick}
>
{contentNode}
</button>
) : (
<span className={className}>{contentNode}</span>
);
},
);
UserAvatar.propTypes = {
id: PropTypes.string,
size: PropTypes.oneOf(Object.values(Sizes)),
isDisabled: PropTypes.bool,
withCreatorIndicator: PropTypes.bool,
className: PropTypes.string,
onClick: PropTypes.func,
};
UserAvatar.defaultProps = {
id: undefined,
size: Sizes.MEDIUM,
isDisabled: false,
withCreatorIndicator: false,
className: undefined,
onClick: undefined,
};
export default UserAvatar;

View File

@@ -0,0 +1,116 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.button {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
outline: none;
padding: 0;
}
// TODO: provide for different sizes
.creatorIndicator {
background: #fff;
border-radius: 50%;
bottom: 0;
color: #6a808b;
font-size: 12px;
height: 10px;
line-height: 10px;
position: absolute;
right: 0;
width: 10px;
}
.initials {
margin: 0 auto;
text-transform: capitalize;
}
.wrapper {
border-radius: 50%;
color: #fff;
display: inline-block;
line-height: 1;
position: relative;
text-align: center;
vertical-align: top;
}
.wrapperHoverable:hover {
opacity: 0.75;
}
/* Sizes */
.wrapperTiny {
font-size: 10px;
height: 24px;
line-height: 20px;
padding: 2px 0;
width: 24px;
}
.wrapperSmall {
font-size: 12px;
height: 28px;
padding: 8px 0;
width: 28px;
}
.wrapperMedium {
font-size: 14px;
height: 32px;
padding: 10px 0;
width: 32px;
}
.wrapperLarge {
font-size: 14px;
height: 36px;
padding: 12px 0 10px;
width: 36px;
}
.wrapperMassive {
font-size: 36px;
height: 100px;
padding: 32px 0 10px;
width: 100px;
}
/* Backgrounds */
.backgroundEmerald {
background: #2ecc71;
}
.backgroundPeterRiver {
background: #3498db;
}
.backgroundWisteria {
background: #8e44ad;
}
.backgroundCarrot {
background: #e67e22;
}
.backgroundAlizarin {
background: #e74c3c;
}
.backgroundTurquoise {
background: #1abc9c;
}
.backgroundMidnightBlue {
background: #2c3e50;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import UserAvatar from './UserAvatar';
export default UserAvatar;

View File

@@ -0,0 +1,22 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import { Image, Tab } from 'semantic-ui-react';
import version from '../../../version';
import logo from '../../../assets/images/logo.png';
import styles from './AboutPane.module.scss';
const AboutPane = React.memo(() => (
<Tab.Pane attached={false} className={styles.wrapper}>
<Image centered src={logo} size="large" />
<div className={styles.version}>{version}</div>
</Tab.Pane>
));
export default AboutPane;

View File

@@ -0,0 +1,16 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.version {
font-weight: bold;
text-align: center;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,128 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Dropdown, Header, Tab } from 'semantic-ui-react';
import { usePopupInClosableContext } from '../../../../hooks';
import locales from '../../../../locales';
import EditAvatarStep from './EditAvatarStep';
import EditUserInformation from '../../EditUserInformation';
import EditUserUsernameStep from '../../EditUserUsernameStep';
import EditUserEmailStep from '../../EditUserEmailStep';
import EditUserPasswordStep from '../../EditUserPasswordStep';
import UserAvatar from '../../UserAvatar';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './AccountPane.module.scss';
const AccountPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleLanguageChange = useCallback(
(_, { value }) => {
// FIXME: hack
dispatch(entryActions.updateCurrentUserLanguage(value === 'auto' ? null : value));
},
[dispatch],
);
const EditAvatarPopup = usePopupInClosableContext(EditAvatarStep);
const EditUserUsernamePopup = usePopupInClosableContext(EditUserUsernameStep);
const EditUserEmailPopup = usePopupInClosableContext(EditUserEmailStep);
const EditUserPasswordPopup = usePopupInClosableContext(EditUserPasswordStep);
const isUsernameEditable = !user.lockedFieldNames.includes('username');
const isEmailEditable = !user.lockedFieldNames.includes('email');
const isPasswordEditable = !user.lockedFieldNames.includes('password');
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<EditAvatarPopup>
<UserAvatar id={user.id} size="massive" isDisabled={user.isAvatarUpdating} />
</EditAvatarPopup>
<br />
<br />
<EditUserInformation id={user.id} />
<Divider horizontal section>
<Header as="h4">
{t('common.language', {
context: 'title',
})}
</Header>
</Divider>
<Dropdown
fluid
selection
options={[
{
value: 'auto',
text: t('common.detectAutomatically'),
},
...locales.map((locale) => ({
value: locale.language,
flag: locale.country,
text: locale.name,
})),
]}
value={user.language || 'auto'}
onChange={handleLanguageChange}
/>
{(isUsernameEditable || isEmailEditable || isPasswordEditable) && (
<>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
{isUsernameEditable && (
<div className={styles.action}>
<EditUserUsernamePopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</EditUserUsernamePopup>
</div>
)}
{isEmailEditable && (
<div className={styles.action}>
<EditUserEmailPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</EditUserEmailPopup>
</div>
)}
{isPasswordEditable && (
<div className={styles.action}>
<EditUserPasswordPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</EditUserPasswordPopup>
</div>
)}
</>
)}
</Tab.Pane>
);
});
export default AccountPane;

View File

@@ -0,0 +1,38 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:hover {
background: #e9e9e9;
}
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,82 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './EditAvatarStep.module.scss';
const EditAvatarStep = React.memo(({ onClose }) => {
const defaultValue = useSelector((state) => selectors.selectCurrentUser(state).avatar);
const dispatch = useDispatch();
const [t] = useTranslation();
const fieldRef = useRef(null);
const handleFileSelect = useCallback(
(file) => {
dispatch(
entryActions.updateCurrentUserAvatar({
file,
}),
);
onClose();
},
[onClose, dispatch],
);
const handleDeleteClick = useCallback(() => {
dispatch(
entryActions.updateCurrentUser({
avatar: null,
}),
);
onClose();
}, [onClose, dispatch]);
useEffect(() => {
fieldRef.current.focus();
}, []);
return (
<>
<Popup.Header>
{t('common.editAvatar', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={fieldRef}
content={t('action.uploadNewAvatar')}
className={styles.actionButton}
/>
</FilePicker>
</div>
{defaultValue && (
<Button negative content={t('action.deleteAvatar')} onClick={handleDeleteClick} />
)}
</Popup.Content>
</>
);
});
EditAvatarStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default EditAvatarStep;

View File

@@ -0,0 +1,33 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.action {
border: none;
border-radius: 0.28571429rem;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:hover {
background: #e9e9e9;
}
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import AccountPane from './AccountPane';
export default AccountPane;

View File

@@ -0,0 +1,35 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import NotificationServices from '../../notification-services/NotificationServices';
import styles from './NotificationsPane.module.scss';
const NotificationsPane = React.memo(() => {
const notificationServiceIds = useSelector(selectors.selectNotificationServiceIdsForCurrentUser);
const dispatch = useDispatch();
const handleCreate = useCallback(
(data) => {
dispatch(entryActions.createNotificationServiceInCurrentUser(data));
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<NotificationServices ids={notificationServiceIds} onCreate={handleCreate} />
</Tab.Pane>
);
});
export default NotificationsPane;

View File

@@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,63 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Radio, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import styles from './PreferencesPane.module.scss';
const PreferencesPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleChange = useCallback(
(_, { name: fieldName, checked }) => {
dispatch(
entryActions.updateCurrentUser({
[fieldName]: checked,
}),
);
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Radio
toggle
name="subscribeToOwnCards"
checked={user.subscribeToOwnCards}
label={t('common.subscribeToMyOwnCardsByDefault')}
className={styles.radio}
onChange={handleChange}
/>
<Radio
toggle
name="subscribeToCardWhenCommenting"
checked={user.subscribeToCardWhenCommenting}
label={t('common.subscribeToCardWhenCommenting')}
className={styles.radio}
onChange={handleChange}
/>
<Radio
toggle
name="turnOffRecentCardHighlighting"
checked={user.turnOffRecentCardHighlighting}
label={t('common.turnOffRecentCardHighlighting')}
className={styles.radio}
onChange={handleChange}
/>
</Tab.Pane>
);
});
export default PreferencesPane;

View File

@@ -0,0 +1,20 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.radio {
margin-bottom: 16px;
width: 100%;
&:last-child {
margin-bottom: 0;
}
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -0,0 +1,70 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane';
import NotificationsPane from './NotificationsPane';
import AboutPane from './AboutPane';
const UserSettingsModal = React.memo(() => {
const dispatch = useDispatch();
const [t] = useTranslation();
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const [ClosableModal] = useClosableModal();
const panes = [
{
menuItem: t('common.account', {
context: 'title',
}),
render: () => <AccountPane />,
},
{
menuItem: t('common.preferences', {
context: 'title',
}),
render: () => <PreferencesPane />,
},
{
menuItem: t('common.notifications', {
context: 'title',
}),
render: () => <NotificationsPane />,
},
{
menuItem: t('common.aboutPlanka', {
context: 'title',
}),
render: () => <AboutPane />,
},
];
return (
<ClosableModal open closeIcon size="small" centered={false} onClose={handleClose}>
<ClosableModal.Content>
<Tab
menu={{
secondary: true,
pointing: true,
}}
panes={panes}
/>
</ClosableModal.Content>
</ClosableModal>
);
});
export default UserSettingsModal;

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import UserSettingsModal from './UserSettingsModal';
export default UserSettingsModal;

View File

@@ -0,0 +1,97 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { UserRoles } from '../../../constants/Enums';
import styles from './UserStep.module.scss';
const UserStep = React.memo(({ onClose }) => {
const isLogouting = useSelector(selectors.selectIsLogouting);
const withAdministration = useSelector(
(state) => selectors.selectCurrentUser(state).role === UserRoles.ADMIN,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleSettingsClick = useCallback(() => {
dispatch(entryActions.openUserSettingsModal());
onClose();
}, [onClose, dispatch]);
const handleLogoutClick = useCallback(() => {
dispatch(entryActions.logout());
}, [dispatch]);
const handleAdministrationClick = useCallback(() => {
dispatch(entryActions.openAdministrationModal());
onClose();
}, [onClose, dispatch]);
let logoutMenuItemProps;
if (isLogouting) {
logoutMenuItemProps = {
as: Button,
fluid: true,
basic: true,
loading: true,
disabled: true,
};
}
return (
<>
<Popup.Header>
{t('common.userActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleSettingsClick}>
{t('common.settings', {
context: 'title',
})}
</Menu.Item>
<Menu.Item
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
className={styles.menuItem}
onClick={handleLogoutClick}
>
{t('action.logOut', {
context: 'title',
})}
</Menu.Item>
{withAdministration && (
<>
<hr className={styles.divider} />
<Menu.Item className={styles.menuItem} onClick={handleAdministrationClick}>
{t('common.administration', {
context: 'title',
})}
</Menu.Item>
</>
)}
</Menu>
</Popup.Content>
</>
);
});
UserStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default UserStep;

View File

@@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.divider {
background: #eee;
border: 0;
height: 1px;
margin-bottom: 8px;
}
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
}

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import UserStep from './UserStep';
export default UserStep;