Project managers, board members, auto-update after reconnection, refactoring

This commit is contained in:
Maksim Eltyshev
2021-06-24 01:05:22 +05:00
parent 7956503a46
commit fe91b5241e
478 changed files with 21226 additions and 19495 deletions

View File

@@ -1,142 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import { useSteps } from '../../../hooks';
import NameEditStep from './NameEditStep';
import BackgroundEditStep from './BackgroundEditStep';
import DeleteStep from '../../DeleteStep';
import styles from './ActionsPopup.module.scss';
const StepTypes = {
EDIT_NAME: 'EDIT_NAME',
EDIT_BACKGROUND: 'EDIT_BACKGROUND',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({ project, onUpdate, onBackgroundImageUpdate, onDelete, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleEditNameClick = useCallback(() => {
openStep(StepTypes.EDIT_NAME);
}, [openStep]);
const handleEditBackgroundClick = useCallback(() => {
openStep(StepTypes.EDIT_BACKGROUND);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleBackgroundUpdate = useCallback(
(newBackground) => {
onUpdate({
background: newBackground,
});
},
[onUpdate],
);
const handleBackgroundImageDelete = useCallback(() => {
onUpdate({
backgroundImage: null,
});
}, [onUpdate]);
if (step) {
if (step) {
switch (step.type) {
case StepTypes.EDIT_NAME:
return (
<NameEditStep
defaultValue={project.name}
onUpdate={handleNameUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_BACKGROUND:
return (
<BackgroundEditStep
defaultValue={project.background}
imageCoverUrl={project.backgroundImage && project.backgroundImage.coverUrl}
isImageUpdating={project.isBackgroundImageUpdating}
onUpdate={handleBackgroundUpdate}
onImageUpdate={onBackgroundImageUpdate}
onImageDelete={handleBackgroundImageDelete}
onBack={handleBack}
/>
);
case StepTypes.DELETE:
return (
<DeleteStep
title={t('common.deleteProject', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisProject')}
buttonContent={t('action.deleteProject')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
default:
}
}
}
return (
<>
<Popup.Header>
{t('common.projectActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditBackgroundClick}>
{t('action.editBackground', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteProject', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
},
);
ActionsStep.propTypes = {
project: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onBackgroundImageUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(ActionsStep);

View File

@@ -1,11 +0,0 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
}

View File

@@ -1,176 +0,0 @@
import { dequal } from 'dequal';
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Image } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import ProjectBackgroundGradients from '../../../constants/ProjectBackgroundGradients';
import { ProjectBackgroundTypes } from '../../../constants/Enums';
import styles from './BackgroundEditStep.module.scss';
import globalStyles from '../../../styles.module.scss';
const BackgroundEditStep = React.memo(
({
defaultValue,
imageCoverUrl,
isImageUpdating,
onUpdate,
onImageUpdate,
onImageDelete,
onBack,
}) => {
const [t] = useTranslation();
const field = useRef(null);
const handleGradientClick = useCallback(
(_, { value }) => {
const background = {
type: ProjectBackgroundTypes.GRADIENT,
name: value,
};
if (!dequal(background, defaultValue)) {
onUpdate(background);
}
},
[defaultValue, onUpdate],
);
const handleImageClick = useCallback(() => {
const background = {
type: ProjectBackgroundTypes.IMAGE,
};
if (!dequal(background, defaultValue)) {
onUpdate(background);
}
}, [defaultValue, onUpdate]);
const handleFileSelect = useCallback(
(file) => {
onImageUpdate({
file,
});
},
[onImageUpdate],
);
const handleDeleteImageClick = useCallback(() => {
onImageDelete();
}, [onImageDelete]);
const handleRemoveClick = useCallback(() => {
onUpdate(null);
}, [onUpdate]);
useEffect(() => {
field.current.focus();
}, []);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editBackground', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.gradientButtons}>
{ProjectBackgroundGradients.map((gradient) => (
<Button
key={gradient}
type="button"
name="gradient"
value={gradient}
className={classNames(
styles.gradientButton,
defaultValue &&
defaultValue.type === ProjectBackgroundTypes.GRADIENT &&
gradient === defaultValue.name &&
styles.gradientButtonActive,
globalStyles[`background${upperFirst(camelCase(gradient))}`],
)}
onClick={handleGradientClick}
/>
))}
</div>
{imageCoverUrl && (
/* TODO: wrap in button */
<Image
src={imageCoverUrl}
label={
defaultValue &&
defaultValue.type === 'image' && {
corner: 'left',
size: 'small',
icon: {
name: 'star',
color: 'grey',
inverted: true,
},
className: styles.imageLabel,
}
}
className={styles.image}
onClick={handleImageClick}
/>
)}
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={field}
content={t('action.uploadNewImage')}
loading={isImageUpdating}
disabled={isImageUpdating}
className={styles.actionButton}
/>
</FilePicker>
</div>
{imageCoverUrl && (
<div className={styles.action}>
<Button
content={t('action.deleteImage')}
disabled={isImageUpdating}
className={styles.actionButton}
onClick={handleDeleteImageClick}
/>
</div>
)}
{defaultValue && (
<div className={styles.action}>
<Button
content={t('action.removeBackground')}
disabled={isImageUpdating}
className={styles.actionButton}
onClick={handleRemoveClick}
/>
</div>
)}
</Popup.Content>
</>
);
},
);
BackgroundEditStep.propTypes = {
defaultValue: PropTypes.object, // eslint-disable-line react/forbid-prop-types
imageCoverUrl: PropTypes.string,
isImageUpdating: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onImageUpdate: PropTypes.func.isRequired,
onImageDelete: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
BackgroundEditStep.defaultProps = {
defaultValue: undefined,
imageCoverUrl: undefined,
};
export default BackgroundEditStep;

View File

@@ -1,79 +0,0 @@
:global(#app) {
.action {
border: none;
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%;
}
.gradientButton {
float: left;
height: 40px;
margin: 4px;
padding: 0;
position: relative;
width: 49.6px;
&:hover {
opacity: 0.9;
}
}
.gradientButtonActive:before {
bottom: 3px;
color: #ffffff;
content: "Г";
font-size: 18px;
line-height: 36px;
position: absolute;
right: 6px;
text-align: center;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
top: 0;
transform: rotate(-135deg);
width: 36px;
}
.gradientButtons {
margin: -4px;
padding-bottom: 8px;
&:after {
content: "";
display: table;
clear: both;
}
}
.image {
cursor: pointer;
margin-bottom: 8px;
&:hover {
opacity: 0.9;
}
}
.imageLabel {
border-color: rgba(29, 46, 63, 0.8);
}
}

View File

@@ -1,67 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../../lib/custom-ui';
import { useField } from '../../../hooks';
import styles from './NameEditStep.module.scss';
const NameEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) => {
const [t] = useTranslation();
const [value, handleFieldChange] = useField(defaultValue);
const field = useRef(null);
const handleSubmit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
field.current.select();
return;
}
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
onClose();
}, [defaultValue, onUpdate, onClose, value]);
useEffect(() => {
field.current.select();
}, []);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editTitle', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={field}
value={value}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
</Popup.Content>
</>
);
});
NameEditStep.propTypes = {
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default NameEditStep;

View File

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

View File

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

View File

@@ -1,58 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import UserItem from './UserItem';
import styles from './MembershipAddPopup.module.scss';
const MembershipAddStep = React.memo(({ users, currentUserIds, onCreate, onClose }) => {
const [t] = useTranslation();
const handleUserSelect = useCallback(
(id) => {
onCreate({
userId: id,
});
onClose();
},
[onCreate, onClose],
);
return (
<>
<Popup.Header>
{t('common.addMember', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.menu}>
{users.map((user) => (
<UserItem
key={user.id}
name={user.name}
avatarUrl={user.avatarUrl}
isActive={currentUserIds.includes(user.id)}
onSelect={() => handleUserSelect(user.id)}
/>
))}
</div>
</Popup.Content>
</>
);
});
MembershipAddStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
currentUserIds: PropTypes.array.isRequired,
/* eslint-disable react/forbid-prop-types */
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(MembershipAddStep);

View File

@@ -1,7 +0,0 @@
:global(#app) {
.menu {
border: none;
margin: -7px auto -5px;
width: 100%;
}
}

View File

@@ -1,31 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import User from '../../User';
import styles from './UserItem.module.scss';
const UserItem = React.memo(({ name, avatarUrl, isActive, onSelect }) => (
<button type="button" disabled={isActive} className={styles.menuItem} onClick={onSelect}>
<span className={styles.user}>
<User name={name} avatarUrl={avatarUrl} />
</span>
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
{name}
</div>
</button>
));
UserItem.propTypes = {
name: PropTypes.string.isRequired,
avatarUrl: PropTypes.string,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
UserItem.defaultProps = {
avatarUrl: undefined,
};
export default UserItem;

View File

@@ -1,45 +0,0 @@
:global(#app) {
.menuItem {
background: transparent;
border: none;
cursor: pointer;
display: block;
margin: 0;
outline: 0;
padding: 4px;
text-align: left;
width: 100%;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
}
.menuItemText {
display: inline-block;
line-height: 32px;
position: relative;
width: calc(100% - 40px);
}
.menuItemTextActive:before {
bottom: 2px;
color: #798d99;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
transform: rotate(-135deg);
width: 36px;
}
.user {
display: inline-block;
line-height: 32px;
padding-right: 8px;
width: 40px;
}
}

View File

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

View File

@@ -1,65 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { useSteps } from '../../hooks';
import User from '../User';
import DeleteStep from '../DeleteStep';
import styles from './MembershipEditPopup.module.scss';
const StepTypes = {
DELETE: 'DELETE',
};
const MembershipEditStep = React.memo(({ user, isEditable, onDelete }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.removeMember', {
context: 'title',
})}
content={t('common.areYouSureYouWantToRemoveThisMemberFromProject')}
buttonContent={t('action.removeMember')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<span className={styles.user}>
<User name={user.name} avatarUrl={user.avatarUrl} size="large" />
</span>
<span className={styles.content}>
<div className={styles.name}>{user.name}</div>
<div className={styles.email}>{user.email}</div>
{!user.isCurrent && isEditable && (
<Button
content={t('action.removeFromProject')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
)}
</span>
</>
);
});
MembershipEditStep.propTypes = {
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isEditable: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default withPopup(MembershipEditStep);

View File

@@ -1,42 +0,0 @@
:global(#app) {
.content {
display: inline-block;
width: calc(100% - 44px);
}
.deleteButton {
background: transparent;
box-shadow: none;
flex: 0 0 auto;
font-weight: normal;
margin: 0 0 0 -10px;
padding: 11px 10px;
text-decoration: underline;
&:hover {
background: #e9e9e9;
}
}
.email {
color: #888888;
font-size: 14px;
line-height: 1.2;
padding: 4px 0 4px 2px;
}
.name {
color: #212121;
font-size: 16px;
font-weight: bold;
line-height: 1.2;
padding: 9px 28px 0 2px;
}
.user {
display: inline-block;
padding-right: 8px;
padding-top: 8px;
vertical-align: top;
}
}

View File

@@ -1,123 +1,24 @@
import React, { useCallback, useEffect } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Grid } from 'semantic-ui-react';
import BoardsContainer from '../../containers/BoardsContainer';
import ActionsPopup from './ActionsPopup';
import MembershipAddPopup from './MembershipAddPopup';
import MembershipEditPopup from './MembershipEditPopup';
import User from '../User';
import ProjectSettingsModalContainer from '../../containers/ProjectSettingsModalContainer';
import styles from './Project.module.scss';
const Project = React.memo(
({
name,
background,
backgroundImage,
isBackgroundImageUpdating,
memberships,
allUsers,
isEditable,
onUpdate,
onBackgroundImageUpdate,
onDelete,
onMembershipCreate,
onMembershipDelete,
}) => {
const handleMembershipDelete = useCallback(
(id) => {
onMembershipDelete(id);
},
[onMembershipDelete],
);
useEffect(() => {
return () => {
document.body.style.background = null;
};
}, []);
return (
const Project = React.memo(({ isSettingsModalOpened }) => {
return (
<>
<div className={styles.wrapper}>
<Grid className={styles.header}>
<Grid.Row>
<Grid.Column>
{isEditable ? (
<ActionsPopup
project={{
name,
background,
backgroundImage,
isBackgroundImageUpdating,
}}
onUpdate={onUpdate}
onBackgroundImageUpdate={onBackgroundImageUpdate}
onDelete={onDelete}
>
<Button content={name} disabled={!isEditable} className={styles.name} />
</ActionsPopup>
) : (
<span className={styles.name}>{name}</span>
)}
<span className={styles.users}>
{memberships.map((membership) => (
<span key={membership.id} className={styles.user}>
<MembershipEditPopup
user={membership.user}
isEditable={isEditable}
onDelete={() => handleMembershipDelete(membership.id)}
>
<User
name={membership.user.name}
avatarUrl={membership.user.avatarUrl}
size="large"
isDisabled={!membership.isPersisted}
/>
</MembershipEditPopup>
</span>
))}
</span>
{isEditable && (
<MembershipAddPopup
users={allUsers}
currentUserIds={memberships.map((membership) => membership.user.id)}
onCreate={onMembershipCreate}
>
<Button icon="add user" className={styles.addUser} />
</MembershipAddPopup>
)}
</Grid.Column>
</Grid.Row>
</Grid>
<BoardsContainer />
</div>
);
},
);
{isSettingsModalOpened && <ProjectSettingsModalContainer />}
</>
);
});
Project.propTypes = {
name: PropTypes.string.isRequired,
/* eslint-disable react/forbid-prop-types */
background: PropTypes.object,
backgroundImage: PropTypes.object,
/* eslint-enable react/forbid-prop-types */
isBackgroundImageUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
memberships: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onBackgroundImageUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onMembershipCreate: PropTypes.func.isRequired,
onMembershipDelete: PropTypes.func.isRequired,
};
Project.defaultProps = {
background: undefined,
backgroundImage: undefined,
isSettingsModalOpened: PropTypes.bool.isRequired,
};
export default Project;

View File

@@ -1,56 +1,9 @@
:global(#app) {
.addUser {
background: rgba(0, 0, 0, 0.24);
border-radius: 50%;
box-shadow: none;
color: #fff;
line-height: 36px;
margin: 0;
padding: 0;
transition: all 0.1s ease 0s;
vertical-align: top;
width: 36px;
&:hover {
background: rgba(0, 0, 0, 0.32);
}
}
.header {
flex: 0 0 auto;
margin: 0 -1rem;
}
.name {
background: transparent;
box-shadow: none;
color: #fff;
display: inline-block;
font-size: 32px;
font-weight: bold;
line-height: 36px;
margin-right: 8px;
padding: 0;
}
.user {
display: inline-block;
margin: 0 -4px 0 0;
vertical-align: top;
line-height: 0;
}
.users {
display: inline-block;
margin-left: 8px;
vertical-align: top;
}
.wrapper {
background: rgba(0, 0, 0, 0.16);
display: flex;
flex-direction: column;
height: 100%;
padding: 0 20px;
padding: 10px 20px 0;
}
}