Initial commit

This commit is contained in:
Maksim Eltyshev
2019-08-31 04:07:25 +05:00
commit 36fe34e8e1
583 changed files with 91539 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
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 ProjectMembershipsStep from '../ProjectMembershipsStep';
import LabelsStep from '../LabelsStep';
import EditDeadlineStep from '../EditDeadlineStep';
import EditTimerStep from '../EditTimerStep';
import DeleteStep from '../DeleteStep';
import styles from './ActionsPopup.module.css';
const StepTypes = {
USERS: 'USERS',
LABELS: 'LABELS',
EDIT_DEADLINE: 'EDIT_DEADLINE',
EDIT_TIMER: 'EDIT_TIMER',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({
card,
projectMemberships,
currentUserIds,
labels,
currentLabelIds,
onNameEdit,
onUpdate,
onDelete,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleNameEditClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleUsersClick = useCallback(() => {
openStep(StepTypes.USERS);
}, [openStep]);
const handleLabelsClick = useCallback(() => {
openStep(StepTypes.LABELS);
}, [openStep]);
const handleEditDeadlineClick = useCallback(() => {
openStep(StepTypes.EDIT_DEADLINE);
}, [openStep]);
const handleEditTimerClick = useCallback(() => {
openStep(StepTypes.EDIT_TIMER);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleDeadlineUpdate = useCallback(
(deadline) => {
onUpdate({
deadline,
});
},
[onUpdate],
);
const handleTimerUpdate = useCallback(
(timer) => {
onUpdate({
timer,
});
},
[onUpdate],
);
if (step) {
switch (step.type) {
case StepTypes.USERS:
return (
<ProjectMembershipsStep
items={projectMemberships}
currentUserIds={currentUserIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
onBack={handleBack}
/>
);
case StepTypes.LABELS:
return (
<LabelsStep
items={labels}
currentIds={currentLabelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
onBack={handleBack}
/>
);
case StepTypes.EDIT_DEADLINE:
return (
<EditDeadlineStep
defaultValue={card.deadline}
onUpdate={handleDeadlineUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_TIMER:
return (
<EditTimerStep
defaultValue={card.timer}
onUpdate={handleTimerUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.DELETE:
return (
<DeleteStep
title={t('common.deleteCard', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisCard')}
buttonContent={t('action.deleteCard')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header>
{t('common.cardActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
{t('common.members', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
{t('common.labels', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditDeadlineClick}>
{t('action.editDeadline', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditTimerClick}>
{t('action.editTimer', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteCard', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
},
);
ActionsStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
card: PropTypes.object.isRequired,
projectMemberships: PropTypes.array.isRequired,
currentUserIds: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
currentLabelIds: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onNameEdit: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(ActionsStep);
@@ -0,0 +1,9 @@
.menu {
margin: -7px -12px -5px !important;
width: calc(100% + 24px) !important;
}
.menuItem {
margin: 0 !important;
padding-left: 14px !important;
}
+201
View File
@@ -0,0 +1,201 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Button, Icon } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd';
import Paths from '../../constants/Paths';
import Tasks from './Tasks';
import EditName from './EditName';
import ActionsPopup from './ActionsPopup';
import User from '../User';
import Label from '../Label';
import Deadline from '../Deadline';
import Timer from '../Timer';
import styles from './Card.module.css';
const Card = React.memo(
({
id,
index,
name,
deadline,
timer,
isPersisted,
notificationsTotal,
users,
labels,
tasks,
allProjectMemberships,
allLabels,
onUpdate,
onDelete,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
}) => {
const editName = useRef(null);
const handleClick = useCallback(() => {
if (document.activeElement) {
document.activeElement.blur();
}
}, []);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleNameEdit = useCallback(() => {
editName.current.open();
}, []);
const contentNode = (
<>
{labels.length > 0 && (
<span className={styles.labels}>
{labels.map((label) => (
<span key={label.id} className={classNames(styles.attachment, styles.attachmentLeft)}>
<Label name={label.name} color={label.color} size="tiny" />
</span>
))}
</span>
)}
<div className={styles.name}>{name}</div>
{tasks.length > 0 && <Tasks items={tasks} />}
{(deadline || timer) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
className={classNames(
styles.attachment,
styles.attachmentLeft,
styles.notification,
)}
>
{notificationsTotal}
</span>
)}
{deadline && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Deadline value={deadline} size="tiny" />
</span>
)}
{timer && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Timer startedAt={timer.startedAt} total={timer.total} size="tiny" />
</span>
)}
</span>
)}
{users.length > 0 && (
<span className={classNames(styles.attachments, styles.attachmentsRight)}>
{users.map((user) => (
<span key={user.id} className={classNames(styles.attachment, styles.attachmentRight)}>
<User name={user.name} avatar={user.avatar} size="tiny" />
</span>
))}
</span>
)}
</>
);
return (
<Draggable draggableId={`card:${id}`} index={index} isDragDisabled={!isPersisted}>
{({ innerRef, draggableProps, dragHandleProps }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} {...dragHandleProps} ref={innerRef} className={styles.wrapper}>
<EditName ref={editName} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={styles.card}>
{isPersisted ? (
<>
<Link
to={isPersisted && Paths.CARDS.replace(':id', id)}
className={styles.content}
onClick={handleClick}
>
{contentNode}
</Link>
<ActionsPopup
card={{
id,
name,
deadline,
timer,
isPersisted,
}}
projectMemberships={allProjectMemberships}
currentUserIds={users.map((user) => user.id)}
labels={allLabels}
currentLabelIds={labels.map((label) => label.id)}
onNameEdit={handleNameEdit}
onUpdate={onUpdate}
onDelete={onDelete}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
onLabelAdd={onLabelAdd}
onLabelRemove={onLabelRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
>
<Button className={classNames(styles.actionsButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
</>
) : (
<span className={styles.content}>{contentNode}</span>
)}
</div>
</EditName>
</div>
)}
</Draggable>
);
},
);
Card.propTypes = {
id: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
deadline: PropTypes.instanceOf(Date),
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isPersisted: PropTypes.bool.isRequired,
notificationsTotal: PropTypes.number.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
};
Card.defaultProps = {
deadline: undefined,
timer: undefined,
};
export default Card;
+112
View File
@@ -0,0 +1,112 @@
.actionsButton {
background: none !important;
box-shadow: none !important;
border-radius: 3px !important;
box-sizing: content-box;
color: #798d99 !important;
display: inline-block !important;
margin: 0 !important;
min-height: auto !important;
opacity: 0;
outline: none;
padding: 4px !important;
position: absolute;
right: 2px;
top: 2px;
transition: background 85ms ease !important;
width: 20px;
}
.actionsButton:hover {
background: #ebeef0 !important;
color: #516b7a !important;
}
.attachment {
display: inline-block;
line-height: 0;
margin: 0 0 6px 0;
max-width: 100%;
vertical-align: top;
}
.attachmentLeft {
margin-right: 4px;
}
.attachmentRight {
margin-left: 2px;
}
.attachments {
display: inline-block;
padding-bottom: 2px;
}
.attachmentsRight {
float: right;
line-height: 0;
}
.card {
background-color: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 #ccc;
position: relative;
cursor: pointer;
}
.card:hover {
background-color: #f5f6f7;
border-bottom-color: rgba(9, 45, 66, 0.25);
}
.card:hover .target {
opacity: 1 !important;
}
.content {
cursor: grab;
display: block;
padding: 6px 8px 0;
}
.content:after {
content: "";
display: table;
clear: both;
}
.labels {
display: block;
max-width: 100%;
overflow: hidden;
}
.name {
color: #17394d;
font-size: 14px;
line-height: 18px;
padding-bottom: 6px;
word-wrap: break-word;
}
.notification {
background: #eb5a46;
color: #fff;
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
border: none;
border-radius: 3px;
display: inline-block;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
.wrapper {
display: block;
margin-bottom: 8px;
}
+128
View File
@@ -0,0 +1,128 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../hooks';
import styles from './EditName.module.css';
const EditName = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(defaultValue);
const field = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
setValue(defaultValue);
}, [defaultValue, setValue]);
const close = useCallback(() => {
setIsOpened(false);
setValue(null);
}, [setValue]);
const submit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
field.current.ref.current.select();
return;
}
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
close();
}, [defaultValue, onUpdate, value, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleFieldKeyDown = useCallback(
(event) => {
switch (event.key) {
case 'Enter':
event.preventDefault();
submit();
break;
case 'Escape':
close();
break;
default:
}
},
[close, submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
field.current.ref.current.select();
}
}, [isOpened]);
if (!isOpened) {
return children;
}
return (
<Form onSubmit={handleSubmit}>
<div className={styles.fieldWrapper}>
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
minRows={3}
maxRows={8}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
</div>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
className={styles.submitButton}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditName.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(EditName);
@@ -0,0 +1,24 @@
.field {
border: none !important;
margin-bottom: 4px !important;
outline: none !important;
overflow: hidden !important;
padding: 0 !important;
resize: none !important;
width: 100% !important;
word-wrap: break-word !important;
}
.fieldWrapper {
background-color: #fff !important;
border-radius: 3px !important;
box-shadow: 0 1px 0 #ccc !important;
margin-bottom: 8px !important;
min-height: 20px !important;
padding: 6px 8px 2px !important;
}
.submitButton {
margin-bottom: 8px;
vertical-align: top;
}
+69
View File
@@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Progress } from 'semantic-ui-react';
import { useToggle } from '../../hooks';
import styles from './Tasks.module.css';
const Tasks = React.memo(({ items }) => {
const [isOpened, toggleOpened] = useToggle();
const handleToggleClick = useCallback(
(event) => {
event.preventDefault();
toggleOpened();
},
[toggleOpened],
);
const completedItems = items.filter((item) => item.isCompleted);
return (
<>
{/* eslint-disable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div className={styles.button} onClick={handleToggleClick}>
{/* eslint-enable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span className={styles.progressWrapper}>
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
</span>
<span
className={classNames(styles.count, isOpened ? styles.countOpened : styles.countClosed)}
>
{completedItems.length}
{'/'}
{items.length}
</span>
</div>
{isOpened && (
<ul className={styles.tasks}>
{items.map((item) => (
<li
key={item.id}
className={classNames(styles.task, item.isCompleted && styles.taskCompleted)}
>
{item.name}
</li>
))}
</ul>
)}
</>
);
});
Tasks.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default Tasks;
@@ -0,0 +1,83 @@
.button {
background: transparent;
border: none;
cursor: pointer;
line-height: 0;
margin: 0 -8px;
outline: none;
padding: 0px 8px 8px;
width: calc(100% + 16px);
}
.count {
color: #888;
cursor: pointer;
display: inline-block;
font-size: 12px;
line-height: 12px;
text-align: right;
vertical-align: top;
width: 50px;
}
.count:after {
content: "";
opacity: 0.4;
}
.count:hover {
opacity: 0.75;
}
.countOpened:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIPnI+py+0/hJzz0IruwjsVADs=")
no-repeat center right;
margin-left: 2px;
padding: 6px 6px 0px;
}
.countClosed:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIRnC2nKLnT4or00Puy3rx7VQAAOw==")
no-repeat center right;
margin-left: 2px;
padding: 0 6px 6px;
}
.progress {
margin: 0 !important;
}
.progressWrapper {
display: inline-block;
padding: 3px 0;
vertical-align: top;
width: calc(100% - 50px);
}
.task {
display: block;
font-size: 12px;
line-height: 14px;
padding-bottom: 6px;
padding-left: 14px;
word-break: break-all;
}
.task:before {
content: "–";
position: absolute;
left: 10px;
}
.taskCompleted {
color: #aaa;
text-decoration: line-through;
}
.tasks {
color: #333;
cursor: grab;
list-style: none;
margin: -2px 0 0;
padding-left: 0;
}
+3
View File
@@ -0,0 +1,3 @@
import Card from './Card';
export default Card;