1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-27 17:19:43 +02:00

Initial commit

This commit is contained in:
Maksim Eltyshev 2019-08-31 04:07:25 +05:00
commit 5ffef61fe7
613 changed files with 91659 additions and 0 deletions

View file

@ -0,0 +1,85 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
Button, Form, Header, Modal,
} from 'semantic-ui-react';
import { Input } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import styles from './AddProjectModal.module.css';
const AddProjectModal = React.memo(({
defaultData, isSubmitting, onCreate, onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
}, [onCreate, data]);
useEffect(() => {
nameField.current.select();
}, []);
return (
<Modal open basic closeIcon size="tiny" onClose={onClose}>
<Modal.Content>
<Header inverted size="huge">
{t('common.createProject', {
context: 'title',
})}
</Header>
<p>{t('common.enterProjectTitle')}</p>
<Form onSubmit={handleSubmit}>
<Input
fluid
inverted
ref={nameField}
name="name"
value={data.name}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
inverted
color="green"
icon="checkmark"
content={t('action.createProject')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Modal.Content>
</Modal>
);
});
AddProjectModal.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isSubmitting: PropTypes.bool.isRequired,
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default AddProjectModal;

View file

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

View file

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

View file

@ -0,0 +1,148 @@
import isEmail from 'validator/lib/isEmail';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDeepCompareCallback, useDeepCompareEffect, useForm, usePrevious,
} from '../../hooks';
import styles from './AddUserPopup.module.css';
const AddUserPopup = React.memo(
({
defaultData, isSubmitting, error, onCreate, onMessageDismiss, onClose,
}) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange] = useForm(() => ({
email: '',
password: '',
name: '',
...defaultData,
}));
const emailField = useRef(null);
const passwordField = useRef(null);
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
name: data.name.trim(),
};
if (!isEmail(cleanData.email)) {
emailField.current.select();
return;
}
if (!cleanData.password) {
passwordField.current.focus();
return;
}
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
}, [onCreate, data]);
useEffect(() => {
emailField.current.select();
}, []);
useDeepCompareEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (!error) {
onClose();
} else if (error.message === 'userIsAlreadyExist') {
emailField.current.select();
}
}
}, [isSubmitting, wasSubmitting, error, onClose]);
return (
<>
<Popup.Header>
{t('common.addUser', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{error && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[error.type || 'error']: true,
}}
visible
content={t(`common.${error.message}`)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.email')}</div>
<Input
fluid
ref={emailField}
name="email"
value={data.email}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.password')}</div>
<Input
fluid
ref={passwordField}
name="password"
value={data.password}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.name')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
positive
content={t('action.addUser')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
},
);
AddUserPopup.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
onCreate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
AddUserPopup.defaultProps = {
error: undefined,
};
export default withPopup(AddUserPopup);

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 AddUserPopup from './AddUserPopup';
export default AddUserPopup;

23
client/src/components/App.jsx Executable file
View file

@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import HeaderContainer from '../containers/HeaderContainer';
import ProjectsContainer from '../containers/ProjectsContainer';
import UsersModalContainer from '../containers/UsersModalContainer';
import AddProjectModalContainer from '../containers/AddProjectModalContainer';
const App = ({ isUsersModalOpened, isAddProjectModalOpened }) => (
<>
<HeaderContainer />
<ProjectsContainer />
{isUsersModalOpened && <UsersModalContainer />}
{isAddProjectModalOpened && <AddProjectModalContainer />}
</>
);
App.propTypes = {
isUsersModalOpened: PropTypes.bool.isRequired,
isAddProjectModalOpened: PropTypes.bool.isRequired,
};
export default App;

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import AppContainer from '../containers/AppContainer';
import SocketStatusContainer from '../containers/SocketStatusContainer';
const AppWrapper = React.memo(({ isInitializing }) => (
<>
{isInitializing ? <Loader active size="massive" /> : <AppContainer />}
<SocketStatusContainer />
</>
));
AppWrapper.propTypes = {
isInitializing: PropTypes.bool.isRequired,
};
export default AppWrapper;

View file

@ -0,0 +1,129 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import {
useClosableForm,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
} from '../../hooks';
import styles from './AddList.module.css';
const DEFAULT_DATA = {
name: '',
};
const AddList = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectNameFieldState, selectNameField] = useToggle();
const nameField = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
}, []);
const close = useCallback(() => {
setIsOpened(false);
}, []);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
close();
}
},
[close],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
selectNameField();
}, [onCreate, data, setData, selectNameField]);
useEffect(() => {
if (isOpened) {
nameField.current.select();
}
}, [isOpened]);
useDidUpdate(() => {
nameField.current.select();
}, [selectNameFieldState]);
if (!isOpened) {
return React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form className={styles.wrapper} onSubmit={handleSubmit}>
<Input
ref={nameField}
name="name"
value={data.name}
placeholder={t('common.enterListTitle')}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.addList')}
className={styles.submitButton}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
AddList.propTypes = {
children: PropTypes.element.isRequired,
onCreate: PropTypes.func.isRequired,
};
export default React.memo(AddList);

View file

@ -0,0 +1,31 @@
.controls {
margin-top: 4px;
}
.field {
border: none;
border-radius: 3px !important;
box-shadow: 0 1px 0 #ccc !important;
color: #333 !important;
outline: none !important;
overflow: hidden !important;
width: 100% !important;
}
.field:focus {
border-color: #298fca;
box-shadow: 0 0 2px #298fca;
}
.submitButton {
min-height: 30px;
vertical-align: top;
}
.wrapper {
background-color: #e2e4e6;
border-radius: 3px;
padding: 4px;
transition: opacity 40ms ease-in;
width: 272px;
}

View file

@ -0,0 +1,138 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { closePopup } from '../../lib/popup';
import DroppableTypes from '../../constants/DroppableTypes';
import ListContainer from '../../containers/ListContainer';
import CardModalContainer from '../../containers/CardModalContainer';
import AddList from './AddList';
import Filter from './Filter';
import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-icon.svg';
import styles from './Board.module.css';
const parseDNDId = (dndId) => parseInt(dndId.split(':').pop(), 10);
const Board = React.memo(
({
listIds,
filterUsers,
filterLabels,
allProjectMemberships,
allLabels,
isCardModalOpened,
onListCreate,
onListMove,
onCardMove,
onUserToFilterAdd,
onUserFromFilterRemove,
onLabelToFilterAdd,
onLabelFromFilterRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
}) => {
const [t] = useTranslation();
const handleDragStart = useCallback(() => {
closePopup();
}, []);
const handleDragEnd = useCallback(
({
draggableId, type, source, destination,
}) => {
if (
!destination
|| (source.droppableId === destination.droppableId && source.index === destination.index)
) {
return;
}
const id = parseDNDId(draggableId);
switch (type) {
case DroppableTypes.LIST:
onListMove(id, destination.index);
break;
case DroppableTypes.CARD:
onCardMove(id, parseDNDId(destination.droppableId), destination.index);
break;
default:
}
},
[onListMove, onCardMove],
);
return (
<>
<Filter
users={filterUsers}
labels={filterLabels}
allProjectMemberships={allProjectMemberships}
allLabels={allLabels}
onUserAdd={onUserToFilterAdd}
onUserRemove={onUserFromFilterRemove}
onLabelAdd={onLabelToFilterAdd}
onLabelRemove={onLabelFromFilterRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
/>
<div className={styles.wrapper}>
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="board" type={DroppableTypes.LIST} direction="horizontal">
{({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} data-drag-scroller ref={innerRef} className={styles.lists}>
{listIds.map((listId, index) => (
<ListContainer key={listId} id={listId} index={index} />
))}
{placeholder}
<div data-drag-scroller className={styles.list}>
<AddList onCreate={onListCreate}>
<button type="button" className={styles.addListButton}>
<PlusMathIcon className={styles.addListButtonIcon} />
<span className={styles.addListButtonText}>
{listIds.length > 0 ? t('action.addAnotherList') : t('action.addList')}
</span>
</button>
</AddList>
</div>
</div>
)}
</Droppable>
</DragDropContext>
</div>
{isCardModalOpened && <CardModalContainer />}
</>
);
},
);
Board.propTypes = {
/* eslint-disable react/forbid-prop-types */
listIds: PropTypes.array.isRequired,
filterUsers: PropTypes.array.isRequired,
filterLabels: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isCardModalOpened: PropTypes.bool.isRequired,
onListCreate: PropTypes.func.isRequired,
onListMove: PropTypes.func.isRequired,
onCardMove: PropTypes.func.isRequired,
onUserToFilterAdd: PropTypes.func.isRequired,
onUserFromFilterRemove: PropTypes.func.isRequired,
onLabelToFilterAdd: PropTypes.func.isRequired,
onLabelFromFilterRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
};
export default Board;

View file

@ -0,0 +1,52 @@
.addListButton {
background-color: rgba(0, 0, 0, 0.12);
border: none;
border-radius: 3px;
color: hsla(0, 0%, 100%, 0.7);
cursor: pointer;
display: block;
fill: hsla(0, 0%, 100%, 0.7);
font-weight: normal;
height: 42px;
padding: 11px;
text-align: left;
transition: background 85ms ease-in, opacity 40ms ease-in,
border-color 85ms ease-in;
width: 100%;
}
.addListButton:active {
outline: none;
}
.addListButton:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.addListButtonIcon {
height: 20px;
padding: 0.64px;
width: 20px;
}
.addListButtonText {
display: inline-block;
font-size: 14px;
line-height: 20px;
vertical-align: top;
}
.list {
margin: 0 20px 0 4px;
width: 272px;
}
.lists {
display: inline-flex;
height: 100%;
min-width: 100%;
}
.wrapper {
height: 100%;
}

View file

@ -0,0 +1,120 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import User from '../User';
import Label from '../Label';
import ProjectMembershipsPopup from '../ProjectMembershipsPopup';
import LabelsPopup from '../LabelsPopup';
import styles from './Filter.module.css';
const Filter = React.memo(
({
users,
labels,
allProjectMemberships,
allLabels,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
}) => {
const [t] = useTranslation();
const handleUserRemoveClick = useCallback(
(id) => {
onUserRemove(id);
},
[onUserRemove],
);
const handleLabelRemoveClick = useCallback(
(id) => {
onLabelRemove(id);
},
[onLabelRemove],
);
return (
<div className={styles.filters}>
<span className={styles.filter}>
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={users.map((user) => user.id)}
title={t('common.filterByMembers', {
context: 'title',
})}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
{users.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</ProjectMembershipsPopup>
{users.map((user) => (
<span key={user.id} className={styles.filterItem}>
<User
name={user.name}
avatar={user.avatar}
size="small"
onClick={() => handleUserRemoveClick(user.id)}
/>
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
items={allLabels}
currentIds={labels.map((label) => label.id)}
title={t('common.filterByLabels', {
context: 'title',
})}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labels.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</LabelsPopup>
{labels.map((label) => (
<span key={label.id} className={styles.filterItem}>
<Label
name={label.name}
color={label.color}
size="small"
onClick={() => handleLabelRemoveClick(label.id)}
/>
</span>
))}
</span>
</div>
);
},
);
Filter.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
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,
};
export default Filter;

View file

@ -0,0 +1,51 @@
.filter {
display: inline-block;
line-height: 0 !important;
margin-right: 16px;
}
.filterButton {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
outline: none;
padding: 0;
}
.filterItem {
display: inline-block;
font-size: 0;
line-height: 0;
margin-right: 4px;
max-width: 190px;
vertical-align: top;
}
.filterLabel {
background: #2d3034;
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 8px;
}
.filterLabel:hover {
opacity: 0.75;
}
.filterTitle {
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 12px;
}
.filters {
line-height: 0 !important;
margin-bottom: 12px;
}

View file

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

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import BoardContainer from '../containers/BoardContainer';
const BoardWrapper = React.memo(({ isFetching }) => {
if (isFetching) {
return <Loader active />;
}
return <BoardContainer />;
});
BoardWrapper.propTypes = {
isFetching: PropTypes.bool.isRequired,
};
export default BoardWrapper;

View file

@ -0,0 +1,69 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import styles from './AddPopup.module.css';
const AddStep = React.memo(({ onCreate, onClose }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm({
name: '',
});
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
onClose();
}, [onCreate, onClose, data]);
useEffect(() => {
nameField.current.select();
}, []);
return (
<>
<Popup.Header>
{t('common.createBoard', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.createBoard')} />
</Form>
</Popup.Content>
</>
);
});
AddStep.propTypes = {
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(AddStep);

View file

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

View file

@ -0,0 +1,183 @@
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next';
import { Link } from 'react-router-dom';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react';
import { closePopup } from '../../lib/popup';
import { DragScroller } from '../../lib/custom-ui';
import Paths from '../../constants/Paths';
import DroppableTypes from '../../constants/DroppableTypes';
import BoardWrapperContainer from '../../containers/BoardWrapperContainer';
import AddPopup from './AddPopup';
import EditPopup from './EditPopup';
import styles from './Boards.module.css';
const Boards = React.memo(
({
items, currentId, isEditable, onCreate, onUpdate, onMove, onDelete,
}) => {
const [t] = useTranslation();
const handleDragStart = useCallback(() => {
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
if (!destination || source.index === destination.index) {
return;
}
onMove(draggableId, destination.index);
},
[onMove],
);
const handleUpdate = useCallback(
(id, data) => {
onUpdate(id, data);
},
[onUpdate],
);
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const renderItems = useCallback(
(safeItems) => safeItems.map((item) => (
<div key={item.id} className={styles.tabWrapper}>
<div className={classNames(styles.tab, item.id === currentId && styles.tabActive)}>
{item.isPersisted ? (
<Link
to={Paths.BOARDS.replace(':id', item.id)}
title={item.name}
className={styles.link}
>
{item.name}
</Link>
) : (
<span className={styles.link}>{item.name}</span>
)}
</div>
</div>
)),
[currentId],
);
const renderEditableItems = useCallback(
(safeItems) => safeItems.map((item, index) => (
<Draggable
key={item.id}
draggableId={item.id}
index={index}
isDragDisabled={!item.isPersisted}
>
{({ innerRef, draggableProps, dragHandleProps }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} ref={innerRef} className={styles.tabWrapper}>
<div className={classNames(styles.tab, item.id === currentId && styles.tabActive)}>
{item.isPersisted ? (
<Link
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
to={Paths.BOARDS.replace(':id', item.id)}
title={item.name}
className={styles.link}
>
{item.name}
</Link>
) : (
// eslint-disable-next-line react/jsx-props-no-spreading
<span {...dragHandleProps} className={styles.link}>
{item.name}
</span>
)}
{item.isPersisted && (
<EditPopup
defaultData={pick(item, 'name')}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
>
<Button className={classNames(styles.editButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
</div>
)}
</Draggable>
)),
[currentId, handleUpdate, handleDelete],
);
return (
<div className={styles.wrapper}>
{isEditable ? (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="boards" type={DroppableTypes.BOARD} direction="horizontal">
{({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} ref={innerRef} className={styles.tabs}>
{renderEditableItems(items)}
{placeholder}
<AddPopup onCreate={onCreate}>
<Button icon="plus" className={styles.addButton} />
</AddPopup>
</div>
)}
</Droppable>
</DragDropContext>
) : (
<div className={styles.tabs}>{renderItems(items)}</div>
)}
<DragScroller className={styles.board}>
{currentId ? (
<BoardWrapperContainer />
) : (
<div className={styles.message}>
<Icon
inverted
name="hand point up outline"
size="huge"
className={styles.messageIcon}
/>
<h1 className={styles.messageTitle}>
{t('common.openBoard', {
context: 'title',
})}
</h1>
<div className={styles.messageContent}>
<Trans i18nKey="common.createNewOneOrSelectExistingOne" />
</div>
</div>
)}
</DragScroller>
</div>
);
},
);
Boards.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
currentId: PropTypes.number,
isEditable: PropTypes.bool.isRequired,
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
Boards.defaultProps = {
currentId: undefined,
};
export default Boards;

View file

@ -0,0 +1,115 @@
.addButton {
background: transparent !important;
color: #fff !important;
margin-right: 0 !important;
vertical-align: top !important;
}
.addButton:hover {
background: rgba(34, 36, 38, 0.3) !important;
}
.board {
display: flex;
flex: 1 1 auto;
flex-direction: column;
margin: 0 -20px 8px;
overflow: auto;
padding: 0 20px;
}
.editButton {
background: transparent !important;
color: #fff !important;
line-height: 32px !important;
margin-right: 0 !important;
opacity: 0;
padding: 0 !important;
position: absolute;
right: 2px;
top: 2px;
width: 32px;
}
.editButton:hover {
background: rgba(34, 36, 38, 0.3) !important;
}
.link {
color: #fff !important;
display: block;
line-height: 20px;
padding: 10px 34px 6px 14px;
text-overflow: ellipsis;
max-width: 400px;
overflow: hidden;
}
.message {
align-content: space-between;
align-items: center;
color: #fff;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
}
.messageIcon {
margin-top: -84px;
}
.messageTitle {
font-size: 32px;
margin: 24px 0 8px;
}
.messageContent {
font-size: 18px;
line-height: 1.4;
margin: 4px 0 0;
text-align: center;
}
.tab {
border-radius: 3px 3px 0 0;
min-width: 160px;
position: relative;
transition: all 0.1s ease;
}
.tab:hover {
background: #353a3f !important;
}
.tab:hover .target {
opacity: 1 !important;
}
.tabActive {
background: #2c3035 !important;
}
.tabActive:hover {
background: #353a3f !important;
}
.tabWrapper {
display: flex;
flex: 0 0 auto;
}
.tabs {
border-bottom: 2px solid #2c3035;
display: flex;
height: 38px;
flex: 0 0 auto;
margin-bottom: 16px;
white-space: nowrap;
}
.wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
}

View file

@ -0,0 +1,108 @@
import dequal from 'dequal';
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 { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
import DeleteStep from '../DeleteStep';
import styles from './EditPopup.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const EditStep = React.memo(({
defaultData, onUpdate, onDelete, onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
onClose();
}, [defaultData, onUpdate, onClose, data]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
useEffect(() => {
nameField.current.select();
}, []);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteBoard', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisBoard')}
buttonContent={t('action.deleteBoard')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.editBoard', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content>
</>
);
});
EditStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(EditStep);

View file

@ -0,0 +1,10 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.field {
margin-bottom: 8px;
}

View file

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

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);

View file

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

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;

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;
}

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);

View file

@ -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;
}

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;

View file

@ -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;
}

View file

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

View file

@ -0,0 +1,101 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
Comment, Icon, Loader, Visibility,
} from 'semantic-ui-react';
import { ActionTypes } from '../../../constants/Enums';
import AddComment from './AddComment';
import Item from './Item';
import styles from './Actions.module.css';
const Actions = React.memo(
({
items,
isFetching,
isAllFetched,
isEditable,
onFetch,
onCommentCreate,
onCommentUpdate,
onCommentDelete,
}) => {
const [t] = useTranslation();
const handleCommentUpdate = useCallback(
(id, data) => {
onCommentUpdate(id, data);
},
[onCommentUpdate],
);
const handleCommentDelete = useCallback(
(id) => {
onCommentDelete(id);
},
[onCommentDelete],
);
return (
<>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="comment outline" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.addComment')}</div>
<AddComment onCreate={onCommentCreate} />
</div>
</div>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="list ul" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.actions')}</div>
<div className={styles.wrapper}>
<Comment.Group>
{items.map((item) => (item.type === ActionTypes.COMMENT_CARD ? (
<Item.Comment
key={item.id}
data={item.data}
createdAt={item.createdAt}
isPersisted={item.isPersisted}
user={item.user}
isEditable={isEditable}
onUpdate={(data) => handleCommentUpdate(item.id, data)}
onDelete={() => handleCommentDelete(item.id)}
/>
) : (
<Item
key={item.id}
type={item.type}
data={item.data}
createdAt={item.createdAt}
user={item.user}
/>
)))}
</Comment.Group>
</div>
{isFetching ? (
<Loader active inverted inline="centered" size="small" className={styles.loader} />
) : (
!isAllFetched && <Visibility fireOnMount onOnScreen={onFetch} />
)}
</div>
</div>
</>
);
},
);
Actions.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isFetching: PropTypes.bool.isRequired,
isAllFetched: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
onFetch: PropTypes.func.isRequired,
onCommentCreate: PropTypes.func.isRequired,
onCommentUpdate: PropTypes.func.isRequired,
onCommentDelete: PropTypes.func.isRequired,
};
export default Actions;

View file

@ -0,0 +1,38 @@
.contentModule {
margin-bottom: 24px;
}
.loader {
margin-top: 10px !important;
}
.moduleHeader {
color: #17394d;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0 0 4px;
padding: 8px 0;
}
.moduleIcon {
color: #17394d;
font-size: 17px !important;
height: 32px !important;
left: -40px;
line-height: 32px;
margin-right: 0 !important;
position: absolute;
top: 2px;
width: 32px !important;
}
.moduleWrapper {
margin: 0 0 0 40px;
position: relative;
}
.wrapper {
margin-left: -40px;
margin-top: 12px;
}

View file

@ -0,0 +1,75 @@
import React, { useCallback, useRef } 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 { useDeepCompareCallback, useForm } from '../../../hooks';
import styles from './AddComment.module.css';
const DEFAULT_DATA = {
text: '',
};
const AddComment = React.memo(({ onCreate }) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const textField = useRef(null);
const submit = useDeepCompareCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
};
if (!cleanData.text) {
textField.current.ref.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
}, [onCreate, data, setData]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.ctrlKey && event.key === 'Enter') {
submit();
}
},
[submit],
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={textField}
as={TextareaAutosize}
name="text"
value={data.text}
placeholder={t('common.writeComment')}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
/>
<div className={styles.controls}>
<Button positive content={t('action.addComment')} disabled={!data.text} />
</div>
</Form>
);
});
AddComment.propTypes = {
onCreate: PropTypes.func.isRequired,
};
export default AddComment;

View file

@ -0,0 +1,23 @@
.controls {
clear: both;
margin-top: 6px;
}
.field {
background: #fff !important;
border: 0 !important;
box-sizing: border-box;
color: #333 !important;
display: block;
line-height: 1.5 !important;
font-size: 14px !important;
margin-bottom: 6px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
width: 100% !important;
}
.field:focus {
outline: none;
}

View file

@ -0,0 +1,122 @@
import dequal from 'dequal';
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, useDeepCompareCallback, useForm } from '../../../hooks';
import styles from './EditComment.module.css';
const EditComment = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(null);
const textField = useRef(null);
const open = useDeepCompareCallback(() => {
setIsOpened(true);
setData({
text: '',
...defaultData,
});
}, [defaultData, setData]);
const close = useCallback(() => {
setIsOpened(false);
setData(null);
}, [setData]);
const submit = useDeepCompareCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
};
if (!cleanData.text) {
textField.current.ref.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
close();
}, [defaultData, onUpdate, data, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.ctrlKey && event.key === 'Enter') {
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
textField.current.ref.current.select();
}
}, [isOpened]);
if (!isOpened) {
return children;
}
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={textField}
as={TextareaAutosize}
name="text"
value={data.text}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditComment.propTypes = {
children: PropTypes.element.isRequired,
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(EditComment);

View file

@ -0,0 +1,24 @@
.controls {
clear: both;
margin-top: 6px;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
box-sizing: border-box;
color: #333 !important;
display: block;
line-height: 1.4 !important;
font-size: 14px !important;
margin-bottom: 4px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
width: 100% !important;
}
.field:focus {
outline: none;
}

View file

@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next';
import { Comment } from 'semantic-ui-react';
import { ActionTypes } from '../../../constants/Enums';
import ItemComment from './ItemComment';
import User from '../../User';
import styles from './Item.module.css';
const Item = React.memo(({
type, data, createdAt, user,
}) => {
const [t] = useTranslation();
let contentNode;
switch (type) {
case ActionTypes.CREATE_CARD:
contentNode = (
<Trans
i18nKey="common.userAddedThisCardToList"
values={{
user: user.name,
list: data.list.name,
}}
>
<span className={styles.author}>{user.name}</span>
<span className={styles.text}>
{' added this card to '}
{data.list.name}
</span>
</Trans>
);
break;
case ActionTypes.MOVE_CARD:
contentNode = (
<Trans
i18nKey="common.userMovedThisCardFromListToList"
values={{
user: user.name,
fromList: data.fromList.name,
toList: data.toList.name,
}}
>
<span className={styles.author}>{user.name}</span>
<span className={styles.text}>
{' moved this card from '}
{data.fromList.name}
{' to '}
{data.toList.name}
</span>
</Trans>
);
break;
default:
contentNode = null;
}
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
</span>
<div className={classNames(styles.content)}>
<div>{contentNode}</div>
<span className={styles.date}>
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
</Comment>
);
});
Item.Comment = ItemComment;
Item.propTypes = {
type: PropTypes.string.isRequired,
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
createdAt: PropTypes.instanceOf(Date).isRequired,
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default Item;

View file

@ -0,0 +1,31 @@
.author {
color: #17394d;
display: inline-block;
font-weight: bold;
line-height: 20px;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
}
.date {
color: #6b808c;
display: inline-block;
font-size: 12px;
line-height: 20px;
}
.text {
line-height: 20px;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}

View file

@ -0,0 +1,87 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Comment } from 'semantic-ui-react';
import EditComment from './EditComment';
import User from '../../User';
import DeletePopup from '../../DeletePopup';
import styles from './ItemComment.module.css';
const ItemComment = React.memo(
({
data, createdAt, isPersisted, user, isEditable, onUpdate, onDelete,
}) => {
const [t] = useTranslation();
const editComment = useRef(null);
const handleEditClick = useCallback(() => {
editComment.current.open();
}, []);
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
</span>
<div className={classNames(styles.content)}>
<div className={styles.title}>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
<EditComment ref={editComment} defaultData={data} onUpdate={onUpdate}>
<>
<p className={styles.text}>{data.text}</p>
<Comment.Actions>
{user.isCurrent && (
<Comment.Action
as="button"
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
)}
{(user.isCurrent || isEditable) && (
<DeletePopup
title={t('common.deleteComment', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisComment')}
buttonContent={t('action.deleteComment')}
onConfirm={onDelete}
>
<Comment.Action
as="button"
content={t('action.delete')}
disabled={!isPersisted}
/>
</DeletePopup>
)}
</Comment.Actions>
</>
</EditComment>
</div>
</Comment>
);
},
);
ItemComment.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
createdAt: PropTypes.instanceOf(Date).isRequired,
isPersisted: PropTypes.bool.isRequired,
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default ItemComment;

View file

@ -0,0 +1,49 @@
.author {
color: #17394d;
display: inline-block;
font-weight: bold;
line-height: 20px;
margin-right: 8px;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
}
.date {
color: #6b808c;
display: inline-block;
font-size: 12px;
line-height: 20px;
}
.text {
background-color: #fff;
border-radius: 0px 8px 8px;
box-shadow: 0 1px 2px -1px rgba(9, 45, 66, 0.25),
0 0 0 1px rgba(9, 45, 66, 0.08);
box-sizing: border-box;
color: #17394d;
display: inline-block;
margin: 1px 2px 4px 1px;
max-width: 100%;
overflow: hidden;
padding: 8px 12px;
text-overflow: ellipsis;
white-space: pre-line;
word-break: break-word;
}
.title {
padding-bottom: 4px;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}

View file

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

View file

@ -0,0 +1,388 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import {
Button, Grid, Icon, Modal,
} from 'semantic-ui-react';
import NameField from './NameField';
import EditDescription from './EditDescription';
import Tasks from './Tasks';
import Actions from './Actions';
import User from '../User';
import Label from '../Label';
import Deadline from '../Deadline';
import Timer from '../Timer';
import ProjectMembershipsPopup from '../ProjectMembershipsPopup';
import LabelsPopup from '../LabelsPopup';
import EditDeadlinePopup from '../EditDeadlinePopup';
import EditTimerPopup from '../EditTimerPopup';
import DeletePopup from '../DeletePopup';
import styles from './CardModal.module.css';
const CardModal = React.memo(
({
name,
description,
deadline,
timer,
isSubscribed,
isActionsFetching,
isAllActionsFetched,
users,
labels,
tasks,
actions,
allProjectMemberships,
allLabels,
isEditable,
onUpdate,
onDelete,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
onTaskCreate,
onTaskUpdate,
onTaskDelete,
onActionsFetch,
onCommentActionCreate,
onCommentActionUpdate,
onCommentActionDelete,
onClose,
}) => {
const [t] = useTranslation();
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleDescriptionUpdate = useCallback(
(newDescription) => {
onUpdate({
description: newDescription,
});
},
[onUpdate],
);
const handleDeadlineUpdate = useCallback(
(newDeadline) => {
onUpdate({
deadline: newDeadline,
});
},
[onUpdate],
);
const handleTimerUpdate = useCallback(
(newTimer) => {
onUpdate({
timer: newTimer,
});
},
[onUpdate],
);
const handleToggleSubscribeClick = useCallback(() => {
onUpdate({
isSubscribed: !isSubscribed,
});
}, [isSubscribed, onUpdate]);
const userIds = users.map((user) => user.id);
const labelIds = labels.map((label) => label.id);
return (
<Modal
open
closeIcon
size="small"
centered={false}
className={styles.wrapper}
onClose={onClose}
>
<Grid className={styles.grid}>
<Grid.Row className={styles.headerPadding}>
<Grid.Column width={16} className={styles.headerPadding}>
<div className={styles.headerWrapper}>
<Icon name="list alternate outline" className={styles.moduleIcon} />
<div className={styles.headerTitle}>
<NameField defaultValue={name} onUpdate={handleNameUpdate} />
</div>
</div>
</Grid.Column>
</Grid.Row>
<Grid.Row className={styles.modalPadding}>
<Grid.Column width={12} className={styles.contentPadding}>
{(users.length > 0 || labels.length > 0 || deadline || timer) && (
<div className={styles.moduleWrapper}>
{users.length > 0 && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.members', {
context: 'title',
})}
</div>
{users.map((user) => (
<span key={user.id} className={styles.attachment}>
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<User name={user.name} avatar={user.avatar} />
</ProjectMembershipsPopup>
</span>
))}
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<button
type="button"
className={classNames(styles.attachment, styles.deadline)}
>
<Icon name="add" size="small" className={styles.addAttachment} />
</button>
</ProjectMembershipsPopup>
</div>
)}
{labels.length > 0 && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.labels', {
context: 'title',
})}
</div>
{labels.map((label) => (
<span key={label.id} className={styles.attachment}>
<LabelsPopup
key={label.id}
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<Label name={label.name} color={label.color} />
</LabelsPopup>
</span>
))}
<LabelsPopup
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<button
type="button"
className={classNames(styles.attachment, styles.deadline)}
>
<Icon name="add" size="small" className={styles.addAttachment} />
</button>
</LabelsPopup>
</div>
)}
{deadline && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.deadline', {
context: 'title',
})}
</div>
<span className={styles.attachment}>
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
<Deadline value={deadline} />
</EditDeadlinePopup>
</span>
</div>
)}
{timer && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.timer', {
context: 'title',
})}
</div>
<span className={styles.attachment}>
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
<Timer startedAt={timer.startedAt} total={timer.total} />
</EditTimerPopup>
</span>
</div>
)}
</div>
)}
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="align justify" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.description')}</div>
<EditDescription defaultValue={description} onUpdate={handleDescriptionUpdate}>
{description ? (
<button type="button" className={styles.descriptionText}>
{description}
</button>
) : (
<button type="button" className={styles.descriptionButton}>
<span className={styles.descriptionButtonText}>
{t('action.addMoreDetailedDescription')}
</span>
</button>
)}
</EditDescription>
</div>
</div>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="check square outline" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.tasks')}</div>
<Tasks
items={tasks}
onCreate={onTaskCreate}
onUpdate={onTaskUpdate}
onDelete={onTaskDelete}
/>
</div>
</div>
<Actions
items={actions}
isFetching={isActionsFetching}
isAllFetched={isAllActionsFetched}
isEditable={isEditable}
onFetch={onActionsFetch}
onCommentCreate={onCommentActionCreate}
onCommentUpdate={onCommentActionUpdate}
onCommentDelete={onCommentActionDelete}
/>
</Grid.Column>
<Grid.Column width={4} className={styles.sidebarPadding}>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<Button fluid className={styles.actionButton}>
<Icon name="user outline" className={styles.actionIcon} />
{t('common.members')}
</Button>
</ProjectMembershipsPopup>
<LabelsPopup
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<Button fluid className={styles.actionButton}>
<Icon name="bookmark outline" className={styles.actionIcon} />
{t('common.labels')}
</Button>
</LabelsPopup>
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
<Button fluid className={styles.actionButton}>
<Icon name="calendar check outline" className={styles.actionIcon} />
{t('common.deadline')}
</Button>
</EditDeadlinePopup>
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
<Button fluid className={styles.actionButton}>
<Icon name="clock outline" className={styles.actionIcon} />
{t('common.timer')}
</Button>
</EditTimerPopup>
</div>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('common.actions')}</span>
<Button fluid className={styles.actionButton} onClick={handleToggleSubscribeClick}>
<Icon name="paper plane outline" className={styles.actionIcon} />
{isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
</Button>
<DeletePopup
title={t('common.deleteCard', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisCard')}
buttonContent={t('action.deleteCard')}
onConfirm={onDelete}
>
<Button fluid className={styles.actionButton}>
<Icon name="trash alternate outline" className={styles.actionIcon} />
{t('action.delete')}
</Button>
</DeletePopup>
</div>
</Grid.Column>
</Grid.Row>
</Grid>
</Modal>
);
},
);
CardModal.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
deadline: PropTypes.instanceOf(Date),
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired,
isActionsFetching: PropTypes.bool.isRequired,
isAllActionsFetched: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
actions: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.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,
onTaskCreate: PropTypes.func.isRequired,
onTaskUpdate: PropTypes.func.isRequired,
onTaskDelete: PropTypes.func.isRequired,
onActionsFetch: PropTypes.func.isRequired,
onCommentActionCreate: PropTypes.func.isRequired,
onCommentActionUpdate: PropTypes.func.isRequired,
onCommentActionDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
CardModal.defaultProps = {
description: undefined,
deadline: undefined,
timer: undefined,
};
export default CardModal;

View file

@ -0,0 +1,211 @@
.actionButton {
background: #ebeef0 !important;
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.13) !important;
color: #444 !important;
margin-top: 8px !important;
padding: 6px 8px 6px 18px !important;
text-align: left !important;
transition: background 85ms ease !important;
}
.actionButton:hover {
background: #dfe3e6 !important;
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.25) !important;
color: #4c4c4c !important;
}
.actionIcon {
color: #17394d !important;
margin-right: 8px !important;
}
.actions {
margin-bottom: 24px;
}
.actionsTitle {
color: #8c8c8c;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
margin-top: 16px;
text-transform: uppercase;
line-height: 20px;
margin-bottom: -4px;
}
.addAttachment {
margin: 0 -4.3px !important;
text-decoration: none !important;
}
.attachment {
cursor: pointer;
display: inline-block;
margin: 0 4px 4px 0;
max-width: 100%;
}
.attachments {
display: inline-block;
margin: 0 8px 8px 0;
max-width: 100%;
vertical-align: top;
}
.contentModule {
margin-bottom: 24px;
}
.contentPadding {
padding: 8px 8px 0 16px !important;
}
.deadline {
background: #dce0e4;
border: none;
border-radius: 3px;
color: #6b808c;
line-height: 20px;
outline: none;
padding: 6px 14px;
text-align: left;
text-decoration: underline;
transition: background 0.3s ease;
vertical-align: top;
}
.deadline:hover {
background: #d2d8dc;
color: #17394d;
}
.descriptionButton {
background: rgba(9, 45, 66, 0.08);
border: none;
border-radius: 3px;
display: block;
color: #6b808c;
cursor: pointer;
min-height: 54px;
outline: none;
padding: 8px 12px;
position: relative;
text-align: left;
text-decoration: none;
width: 100%;
}
.descriptionButton:hover {
background-color: rgba(9, 45, 66, 0.13);
color: #092d42;
}
.descriptionButtonText {
position: absolute;
top: 12px;
}
.descriptionText {
background: transparent;
border: none;
color: #17394d;
cursor: pointer;
line-height: 1.5;
font-size: 15px;
margin-bottom: 8px;
outline: none;
overflow: hidden;
overflow-wrap: break-word;
padding: 0;
text-align: left;
white-space: pre-line;
width: 100%;
}
.grid {
background: #f5f6f7;
margin: 0 !important;
}
.headerPadding {
padding: 0 !important;
}
.headerTitle {
margin: 4px 0;
padding: 6px 0 0;
}
.headerWrapper {
margin: 12px 48px 12px 56px;
position: relative;
}
.labels {
border: none;
border-radius: 3px;
box-sizing: border-box;
color: #fff;
display: inline-block;
font-size: 12px;
font-weight: 600;
line-height: 32px;
margin: 0 4px 4px 0;
max-width: 100%;
min-width: 40px;
outline: none;
overflow: hidden;
padding: 0 12px;
text-overflow: ellipsis;
text-shadow: rgba(0, 0, 0, 0.2) 1px 1px 0;
white-space: nowrap;
}
.modalPadding {
padding: 0px !important;
}
.moduleHeader {
color: #17394d;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0 0 4px;
padding: 8px 0;
}
.moduleIcon {
color: #17394d;
font-size: 17px !important;
height: 32px !important;
left: -40px;
line-height: 32px;
margin-right: 0 !important;
position: absolute;
top: 2px;
width: 32px !important;
}
.moduleWrapper {
margin: 0 0 0 40px;
position: relative;
}
.text {
color: #6b808c;
font-size: 12px;
font-weight: bold;
letter-spacing: 0.3px;
line-height: 20px;
margin: 0 8px 4px 0;
text-transform: uppercase;
}
.sidebarPadding {
padding: 8px 16px 0 8px !important;
}
.wrapper {
width: 768px !important;
}

View file

@ -0,0 +1,122 @@
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 './EditDescription.module.css';
const EditDescription = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(null);
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() || null;
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
close();
}, [defaultValue, onUpdate, value, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
},
[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 React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
placeholder={t('common.enterDescription')}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditDescription.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string,
onUpdate: PropTypes.func.isRequired,
};
EditDescription.defaultProps = {
defaultValue: undefined,
};
export default React.memo(EditDescription);

View file

@ -0,0 +1,22 @@
.controls {
clear: both !important;
margin-top: 6px !important;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
color: #17394d !important;
display: block !important;
font-size: 14px !important;
line-height: 1.5 !important;
margin-bottom: 4px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
}
.field:focus {
outline: none !important;
}

View file

@ -0,0 +1,67 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import TextareaAutosize from 'react-textarea-autosize';
import { TextArea } from 'semantic-ui-react';
import { useDidUpdate, useField, usePrevious } from '../../hooks';
import styles from './NameField.module.css';
const NameField = React.memo(({ defaultValue, onUpdate }) => {
const prevDefaultValue = usePrevious(defaultValue);
const [value, handleChange, setValue] = useField(defaultValue);
const isFocused = useRef(false);
const handleFocus = useCallback(() => {
isFocused.current = true;
}, []);
const handleKeyDown = useCallback((event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
}, []);
const handleBlur = useCallback(() => {
isFocused.current = false;
const cleanValue = value.trim();
if (cleanValue) {
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
} else {
setValue(defaultValue);
}
}, [defaultValue, onUpdate, value, setValue]);
useDidUpdate(() => {
if (!isFocused.current && defaultValue !== prevDefaultValue) {
setValue(defaultValue);
}
}, [defaultValue, prevDefaultValue, setValue]);
return (
<TextArea
as={TextareaAutosize}
value={value}
spellCheck={false}
className={styles.field}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleBlur}
/>
);
});
NameField.propTypes = {
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default NameField;

View file

@ -0,0 +1,22 @@
.field {
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
box-shadow: none;
color: #17394d;
font-size: 20px;
font-weight: 700;
line-height: 24px;
margin: -5px;
overflow: hidden;
padding: 4px;
resize: none;
width: 100%;
}
.field:focus {
background: #fff;
border-color: #5ba4cf;
box-shadow: 0 0 2px 0 #5ba4cf;
outline: 0;
}

View file

@ -0,0 +1,75 @@
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 DeleteStep from '../../DeleteStep';
import styles from './ActionsPopup.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const ActionsStep = React.memo(({ onNameEdit, onDelete, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleNameEditClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteTask', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisTask')}
buttonContent={t('action.deleteTask')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.taskActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
{t('action.editDescription', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteTask', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ActionsStep.propTypes = {
onNameEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(ActionsStep);

View file

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

View file

@ -0,0 +1,138 @@
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,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
} from '../../../hooks';
import styles from './Add.module.css';
const DEFAULT_DATA = {
name: '',
};
const Add = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectNameFieldState, selectNameField] = useToggle();
const nameField = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
}, []);
const close = useCallback(() => {
setIsOpened(false);
}, []);
const submit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
selectNameField();
}, [onCreate, data, setData, selectNameField]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
nameField.current.ref.current.select();
}
}, [isOpened]);
useDidUpdate(() => {
nameField.current.ref.current.select();
}, [selectNameFieldState]);
if (!isOpened) {
return React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form className={styles.wrapper} onSubmit={handleSubmit}>
<TextArea
ref={nameField}
as={TextareaAutosize}
name="name"
value={data.name}
placeholder={t('common.enterTaskDescription')}
minRows={2}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.addTask')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
Add.propTypes = {
children: PropTypes.element.isRequired,
onCreate: PropTypes.func.isRequired,
};
export default React.memo(Add);

View file

@ -0,0 +1,23 @@
.controls {
clear: both !important;
margin-top: 6px !important;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
color: #17394d !important;
display: block !important;
line-height: 1.5 !important;
font-size: 14px !important;
margin-bottom: 4px !important;
overflow: hidden;
padding: 8px 12px !important;
resize: none !important;
}
.wrapper {
margin-top: 6px !important;
padding-bottom: 8px !important;
}

View file

@ -0,0 +1,116 @@
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(null);
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) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
},
[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} className={styles.wrapper}>
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
minRows={2}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
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);

View file

@ -0,0 +1,26 @@
.controls {
clear: both;
margin-top: 6px !important;
}
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border-radius: 3px !important;
box-sizing: border-box !important;
color: #17394d !important;
display: block !important;
font-size: 14px !important;
line-height: 1.5 !important;
overflow: hidden !important;
padding: 8px 12px !important;
resize: none !important;
}
.field:focus {
outline: none;
}
.wrapper {
padding: 9px 32px 16px 40px;
}

View file

@ -0,0 +1,83 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Button, Checkbox, Icon } from 'semantic-ui-react';
import EditName from './EditName';
import ActionsPopup from './ActionsPopup';
import styles from './Item.module.css';
const Item = React.memo(({
name, isCompleted, isPersisted, onUpdate, onDelete,
}) => {
const editName = useRef(null);
const handleClick = useCallback(() => {
if (isPersisted) {
editName.current.open();
}
}, [isPersisted]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleToggleChange = useCallback(() => {
onUpdate({
isCompleted: !isCompleted,
});
}, [isCompleted, onUpdate]);
const handleNameEdit = useCallback(() => {
editName.current.open();
}, []);
return (
<div className={styles.wrapper}>
<span className={styles.checkboxWrapper}>
<Checkbox
checked={isCompleted}
disabled={!isPersisted}
className={styles.checkbox}
onChange={handleToggleChange}
/>
</span>
<EditName ref={editName} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={styles.content}>
{/* eslint-disable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span className={styles.text} onClick={handleClick}>
{/* eslint-enable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
{name}
</span>
</span>
{isPersisted && (
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
)}
</div>
</EditName>
</div>
);
});
Item.propTypes = {
name: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
isPersisted: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Item;

View file

@ -0,0 +1,73 @@
.button {
background: transparent !important;
box-shadow: none !important;
line-height: 28px !important;
margin: 0 !important;
min-height: auto !important;
opacity: 0;
padding: 0 !important;
position: absolute;
right: 2px;
top: 2px;
width: 28px;
}
.button:hover {
background-color: rgba(9, 45, 66, 0.13) !important;
}
.checkboxWrapper {
display: inline-block;
padding: 10px 15px 0px 8px;
position: absolute;
text-align: center;
top: 0;
left: 0;
vertical-align: top;
z-index: 2000;
line-height: 1;
height: 32px;
}
.content:hover {
background-color: rgba(9, 45, 66, 0.08);
}
.content:hover .target {
opacity: 1;
}
.task {
display: inline-block;
overflow: hidden;
overflow-wrap: break-word;
padding: 8px 0;
vertical-align: top;
width: 100%;
}
.taskCompleted {
text-decoration: line-through;
}
.text {
background: transparent;
border-radius: 3px;
color: #17394d;
cursor: pointer;
display: inline-block;
font-size: 15px;
line-height: 1.5;
min-height: 32px;
padding: 0 32px 0 40px;
width: 100%;
}
.wrapper {
border-radius: 3px;
margin-left: -40px;
min-height: 32px;
position: relative;
transition: all 0.14s ease-in;
width: calc(100% + 40px);
}

View file

@ -0,0 +1,72 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Progress } from 'semantic-ui-react';
import Item from './Item';
import Add from './Add';
import styles from './Tasks.module.css';
const Tasks = React.memo(({
items, onCreate, onUpdate, onDelete,
}) => {
const [t] = useTranslation();
const handleUpdate = useCallback(
(id, data) => {
onUpdate(id, data);
},
[onUpdate],
);
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const completedItems = items.filter((item) => item.isCompleted);
return (
<>
{items.length > 0 && (
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
)}
{items.map((item) => (
<Item
key={item.id}
name={item.name}
isCompleted={item.isCompleted}
isPersisted={item.isPersisted}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
/>
))}
<Add onCreate={onCreate}>
<button type="button" className={styles.taskButton}>
<span className={styles.taskButtonText}>
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
</span>
</button>
</Add>
</>
);
});
Tasks.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Tasks;

View file

@ -0,0 +1,30 @@
.progress {
margin: 0 0 16px !important;
}
.taskButton {
background: transparent;
border: none;
border-radius: 3px;
color: #6b808c;
cursor: pointer;
display: block;
margin-top: 6px;
min-height: 54px;
outline: none;
padding: 8px 12px;
position: relative;
text-align: left;
text-decoration: none;
width: 100%;
}
.taskButton:hover {
background-color: rgba(9, 45, 66, 0.13);
color: #092d42;
}
.taskButtonText {
position: absolute;
top: 12px;
}

View file

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

View file

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

View file

@ -0,0 +1,78 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import styles from './Deadline.module.css';
const SIZES = {
TINY: 'tiny',
SMALL: 'small',
MEDIUM: 'medium',
};
// TODO: move to styles
const STYLES = {
tiny: {
fontSize: '12px',
lineHeight: '20px',
padding: '0px 6px',
},
small: {
fontSize: '12px',
lineHeight: '20px',
padding: '2px 6px',
},
medium: {
lineHeight: '20px',
padding: '6px 12px',
},
};
const FORMATS = {
tiny: 'longDate',
small: 'longDate',
medium: 'longDateTime',
};
const Deadline = React.memo(({
value, size, isDisabled, onClick,
}) => {
const [t] = useTranslation();
const style = {
...STYLES[size],
};
const contentNode = (
<span className={classNames(styles.wrapper, onClick && styles.hoverable)} style={style}>
{t(`format:${FORMATS[size]}`, {
value,
postProcess: 'formatDate',
})}
</span>
);
return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode}
</button>
) : (
contentNode
);
});
Deadline.propTypes = {
value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(SIZES)),
isDisabled: PropTypes.bool,
onClick: PropTypes.func,
};
Deadline.defaultProps = {
size: SIZES.MEDIUM,
isDisabled: false,
onClick: undefined,
};
export default Deadline;

View file

@ -0,0 +1,25 @@
.button {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
outline: none;
padding: 0;
}
.hoverable:hover {
background: #d2d8dc;
color: #17394d;
}
.wrapper {
background: #dce0e4;
border: none;
border-radius: 3px;
color: #6a808b;
display: inline-block;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}

View file

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

View file

@ -0,0 +1,5 @@
import { withPopup } from '../lib/popup';
import DeleteStep from './DeleteStep';
export default withPopup(DeleteStep);

View file

@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import styles from './DeleteStep.module.css';
const DeleteStep = React.memo(({
title, content, buttonContent, onConfirm, onBack,
}) => (
<>
<Popup.Header onBack={onBack}>{title}</Popup.Header>
<Popup.Content>
<div className={styles.content}>{content}</div>
<Button fluid negative content={buttonContent} onClick={onConfirm} />
</Popup.Content>
</>
));
DeleteStep.propTypes = {
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
buttonContent: PropTypes.string.isRequired,
onConfirm: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
DeleteStep.defaultProps = {
onBack: undefined,
};
export default DeleteStep;

View file

@ -0,0 +1,5 @@
.content {
color: #212121;
padding-bottom: 6px;
padding-left: 2px;
}

View file

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

View file

@ -0,0 +1,5 @@
import { withPopup } from '../lib/popup';
import EditDeadlineStep from './EditDeadlineStep';
export default withPopup(EditDeadlineStep);

View file

@ -0,0 +1,152 @@
import React, {
useCallback, useEffect, useMemo, useRef,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import DatePicker from 'react-datepicker';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDeepCompareCallback, useDidUpdate, useForm, useToggle,
} from '../../hooks';
import styles from './EditDeadlineStep.module.css';
const EditDeadlineStep = React.memo(({
defaultValue, onUpdate, onBack, onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(() => {
const date = defaultValue || new Date().setHours(12, 0, 0, 0);
return {
date: t('format:date', {
postProcess: 'formatDate',
value: date,
}),
time: t('format:time', {
postProcess: 'formatDate',
value: date,
}),
};
});
const [selectTimeFieldState, selectTimeField] = useToggle();
const dateField = useRef(null);
const timeField = useRef(null);
const nullableDate = useMemo(() => {
const date = t('format:date', {
postProcess: 'parseDate',
value: data.date,
});
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}, [data.date, t]);
const handleDatePickerChange = useCallback(
(date) => {
setData((prevData) => ({
...prevData,
date: t('format:date', {
postProcess: 'formatDate',
value: date,
}),
}));
selectTimeField();
},
[setData, selectTimeField, t],
);
const handleSubmit = useDeepCompareCallback(() => {
if (!nullableDate) {
dateField.current.select();
return;
}
const value = t('format:dateTime', {
postProcess: 'parseDate',
value: `${data.date} ${data.time}`,
});
if (Number.isNaN(value.getTime())) {
timeField.current.select();
return;
}
if (!defaultValue || value.getTime() !== defaultValue.getTime()) {
onUpdate(value);
}
onClose();
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
const handleClearClick = useDeepCompareCallback(() => {
if (defaultValue) {
onUpdate(null);
}
onClose();
}, [defaultValue, onUpdate, onClose]);
useEffect(() => {
dateField.current.select();
}, []);
useDidUpdate(() => {
timeField.current.select();
}, [selectTimeFieldState]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editDeadline', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.fieldWrapper}>
<div className={styles.fieldBox}>
<div className={styles.text}>{t('common.date')}</div>
<Input ref={dateField} name="date" value={data.date} onChange={handleFieldChange} />
</div>
<div className={styles.fieldBox}>
<div className={styles.text}>{t('common.time')}</div>
<Input ref={timeField} name="time" value={data.time} onChange={handleFieldChange} />
</div>
</div>
<DatePicker inline selected={nullableDate} onChange={handleDatePickerChange} />
<Button positive content={t('action.save')} />
</Form>
<Button
negative
content={t('action.remove')}
className={styles.deleteButton}
onClick={handleClearClick}
/>
</Popup.Content>
</>
);
});
EditDeadlineStep.propTypes = {
defaultValue: PropTypes.instanceOf(Date),
onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditDeadlineStep.defaultProps = {
defaultValue: undefined,
onBack: undefined,
};
export default EditDeadlineStep;

View file

@ -0,0 +1,24 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.fieldBox {
display: inline-block;
margin: 0 4px 12px;
width: calc(50% - 8px);
}
.fieldWrapper {
margin: 0 -4px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 4px;
padding-left: 2px;
}

View file

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

View file

@ -0,0 +1,5 @@
import { withPopup } from '../lib/popup';
import EditTimerStep from './EditTimerStep';
export default withPopup(EditTimerStep);

View file

@ -0,0 +1,189 @@
import dequal from 'dequal';
import React, { 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 { useDeepCompareCallback, useForm, useToggle } from '../../hooks';
import {
createTimer, getTimerParts, startTimer, stopTimer, updateTimer,
} from '../../utils/timer';
import styles from './EditTimerStep.module.css';
const createData = (timer) => {
if (!timer) {
return {
hours: '0',
minutes: '0',
seconds: '0',
};
}
const { hours, minutes, seconds } = getTimerParts(timer);
return {
hours: `${hours}`,
minutes: `${minutes}`,
seconds: `${seconds}`,
};
};
const EditTimerStep = React.memo(({
defaultValue, onUpdate, onBack, onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(() => createData(defaultValue));
const [isEditing, toggleEdit] = useToggle();
const hoursField = useRef(null);
const minutesField = useRef(null);
const secondsField = useRef(null);
const handleStartClick = useDeepCompareCallback(() => {
onUpdate(startTimer(defaultValue));
onClose();
}, [defaultValue, onUpdate, onClose]);
const handleStopClick = useDeepCompareCallback(() => {
onUpdate(stopTimer(defaultValue));
}, [defaultValue, onUpdate]);
const handleClearClick = useDeepCompareCallback(() => {
if (defaultValue) {
onUpdate(null);
}
onClose();
}, [defaultValue, onUpdate, onClose]);
const handleToggleEditClick = useDeepCompareCallback(() => {
setData(createData(defaultValue));
toggleEdit();
}, [defaultValue, setData, toggleEdit]);
const handleSubmit = useDeepCompareCallback(() => {
const parts = {
hours: parseInt(data.hours, 10),
minutes: parseInt(data.minutes, 10),
seconds: parseInt(data.seconds, 10),
};
if (Number.isNaN(parts.hours)) {
hoursField.current.select();
return;
}
if (Number.isNaN(parts.minutes) || parts.minutes > 60) {
minutesField.current.select();
return;
}
if (Number.isNaN(parts.seconds) || parts.seconds > 60) {
secondsField.current.select();
return;
}
if (defaultValue) {
if (!dequal(parts, getTimerParts(defaultValue))) {
onUpdate(updateTimer(defaultValue, parts));
}
} else {
onUpdate(createTimer(parts));
}
onClose();
}, [defaultValue, onUpdate, onClose, data]);
useEffect(() => {
if (isEditing) {
hoursField.current.select();
}
}, [isEditing]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editTimer', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.fieldWrapper}>
<div className={styles.fieldBox}>
<div className={styles.text}>{t('common.hours')}</div>
<Input
ref={hoursField}
name="hours"
value={data.hours}
mask="9999"
maskChar={null}
disabled={!isEditing}
onChange={handleFieldChange}
/>
</div>
<div className={styles.fieldBox}>
<div className={styles.text}>{t('common.minutes')}</div>
<Input
ref={minutesField}
name="minutes"
value={data.minutes}
mask="99"
maskChar={null}
disabled={!isEditing}
onChange={handleFieldChange}
/>
</div>
<div className={styles.fieldBox}>
<div className={styles.text}>{t('common.seconds')}</div>
<Input
ref={secondsField}
name="seconds"
value={data.seconds}
mask="99"
maskChar={null}
disabled={!isEditing}
onChange={handleFieldChange}
/>
</div>
<Button
type="button"
icon={isEditing ? 'close' : 'edit'}
className={styles.iconButton}
onClick={handleToggleEditClick}
/>
</div>
{isEditing && <Button positive content={t('action.save')} />}
</Form>
{!isEditing
&& (defaultValue && defaultValue.startedAt ? (
<Button positive content={t('action.stop')} icon="pause" onClick={handleStopClick} />
) : (
<Button positive content={t('action.start')} icon="play" onClick={handleStartClick} />
))}
<Button
negative
content={t('action.remove')}
className={styles.deleteButton}
onClick={handleClearClick}
/>
</Popup.Content>
</>
);
});
EditTimerStep.propTypes = {
defaultValue: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditTimerStep.defaultProps = {
defaultValue: undefined,
onBack: undefined,
};
export default EditTimerStep;

View file

@ -0,0 +1,35 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.fieldBox {
display: inline-block;
margin: 0 4px 12px;
width: calc(33.3333% - 22px);
}
.fieldWrapper {
margin: 0 -4px;
}
.iconButton {
background: transparent !important;
box-shadow: none !important;
margin: 0 4px 0 1px !important;
width: 36px;
}
.iconButton:hover {
background: #e9e9e9;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 4px;
padding-left: 2px;
}

View file

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

View file

@ -0,0 +1,78 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Icon, Menu } from 'semantic-ui-react';
import Paths from '../../constants/Paths';
import NotificationsPopup from './NotificationsPopup';
import UserPopup from './UserPopup';
import styles from './Header.module.css';
const Header = React.memo(
({
user,
notifications,
isEditable,
onUserUpdate,
onUserAvatarUpload,
onNotificationDelete,
onUsers,
onLogout,
}) => {
const [t] = useTranslation();
return (
<div className={styles.wrapper}>
<Link to={Paths.ROOT} className={styles.logo}>
Planka
</Link>
<Menu inverted size="large" className={styles.menu}>
{isEditable && (
<Menu.Item className={styles.item} onClick={onUsers}>
{t('common.users', {
context: 'title',
})}
</Menu.Item>
)}
<Menu.Menu position="right">
<NotificationsPopup items={notifications} onDelete={onNotificationDelete}>
<Menu.Item className={styles.item}>
<Icon name="bell" className={styles.icon} />
{notifications.length > 0 && (
<span className={styles.notification}>{notifications.length}</span>
)}
</Menu.Item>
</NotificationsPopup>
<UserPopup
name={user.name}
avatar={user.avatar}
isAvatarUploading={user.isAvatarUploading}
onUpdate={onUserUpdate}
onAvatarUpload={onUserAvatarUpload}
onLogout={onLogout}
>
<Menu.Item className={styles.item}>{user.name}</Menu.Item>
</UserPopup>
</Menu.Menu>
</Menu>
</div>
);
},
);
Header.propTypes = {
/* eslint-disable react/forbid-prop-types */
user: PropTypes.object.isRequired,
notifications: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired,
onUserUpdate: PropTypes.func.isRequired,
onUserAvatarUpload: PropTypes.func.isRequired,
onNotificationDelete: PropTypes.func.isRequired,
onUsers: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
};
export default Header;

View file

@ -0,0 +1,58 @@
.icon {
margin: 0 !important;
}
.item:before {
background: none !important;
}
.logo {
background: #2d3035;
color: #fff !important;
flex: 0 0 auto;
font-size: 20px;
font-weight: bold;
letter-spacing: 3.5px;
line-height: 50px;
padding: 0 16px;
text-transform: uppercase;
width: 130px;
}
.logo:before {
background: none !important;
}
.menu {
background: #2d3035 !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
color: #fff !important;
flex: 1 1 auto;
height: 50px;
margin: 0 !important;
width: 100%;
z-index: 1;
}
.notification {
background: #eb5a46;
border-radius: 8px;
color: #fff;
display: inline-block;
font-size: 14px;
font-weight: bold;
height: 16px;
line-height: 16px;
position: absolute;
right: 8px;
text-align: center;
top: 8px;
width: 16px;
}
.wrapper {
display: flex;
flex: 0 0 auto;
}

View file

@ -0,0 +1,114 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation, Trans } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Button } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Popup } from '../../lib/custom-ui';
import Paths from '../../constants/Paths';
import { ActionTypes } from '../../constants/Enums';
import User from '../User';
import styles from './NotificationsPopup.module.css';
const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
const [t] = useTranslation();
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const renderItemContent = useCallback(
({ action, card }) => {
switch (action.type) {
case ActionTypes.MOVE_CARD:
return (
<Trans
i18nKey="common.userMovedCardFromListToList"
values={{
user: action.user.name,
card: card.name,
fromList: action.data.fromList.name,
toList: action.data.toList.name,
}}
>
{action.user.name}
{' moved '}
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
{card.name}
</Link>
{' from '}
{action.data.fromList.name}
{' to '}
{action.data.toList.name}
</Trans>
);
case ActionTypes.COMMENT_CARD:
return (
<Trans
i18nKey="common.userLeftNewCommentToCard"
values={{
user: action.user.name,
comment: action.data.text,
card: card.name,
}}
>
{action.user.name}
{` left a new comment «${action.data.text}» to `}
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
{card.name}
</Link>
</Trans>
);
default:
}
return null;
},
[onClose],
);
return (
<>
<Popup.Header>{t('common.notifications')}</Popup.Header>
<Popup.Content>
{items.length > 0
? items.map((item) => (
<div key={item.id} className={styles.wrapper}>
{item.card && item.action ? (
<>
<User
name={item.action.user.name}
avatar={item.action.user.avatar}
size="large"
/>
<span className={styles.content}>{renderItemContent(item)}</span>
</>
) : (
<div className={styles.deletedContent}>{t('common.cardOrActionAreDeleted')}</div>
)}
<Button
type="button"
icon="close"
className={styles.button}
onClick={() => handleDelete(item.id)}
/>
</div>
))
: t('common.noUnreadNotifications')}
</Popup.Content>
</>
);
});
NotificationsStep.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(NotificationsStep);

View file

@ -0,0 +1,45 @@
.button {
background: transparent !important;
box-shadow: none !important;
float: right !important;
height: 20px !important;
line-height: 20px !important;
margin: 0 !important;
min-height: auto !important;
padding: 5px 0 !important;
transition: background 0.3s ease !important;
width: 20px !important;
}
.button:hover {
background: #e9e9e9 !important;
}
.content {
display: inline-block !important;
font-size: 12px;
min-height: 36px !important;
overflow: hidden;
padding: 0 4px 0 8px !important;
vertical-align: top !important;
width: calc(100% - 56px) !important;
word-break: break-word;
}
.deletedContent {
display: inline-block !important;
line-height: 20px !important;
min-height: 20px !important;
padding: 0 4px 0 8px !important;
vertical-align: top !important;
width: calc(100% - 20px) !important;
}
.wrapper {
margin: 0 -12px !important;
padding: 12px !important;
}
.wrapper:hover {
background: #f0f0f0 !important;
}

View file

@ -0,0 +1,74 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import User from '../../User';
import styles from './EditAvatarStep.module.css';
const EditAvatarStep = React.memo(
({
defaultValue, name, isUploading, onUpload, onClear, onBack,
}) => {
const [t] = useTranslation();
const field = useRef(null);
const handleFieldChange = useCallback(
({ target }) => {
if (target.files[0]) {
onUpload(target.files[0]);
target.value = null; // eslint-disable-line no-param-reassign
}
},
[onUpload],
);
useEffect(() => {
field.current.focus();
}, []);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editAvatar', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<User name={name} avatar={defaultValue} size="large" />
<div className={styles.input}>
<Button content={t('action.uploadNewAvatar')} className={styles.customButton} />
<input
ref={field}
type="file"
accept="image/*"
disabled={isUploading}
className={styles.file}
onChange={handleFieldChange}
/>
</div>
{defaultValue && <Button negative content={t('action.deleteAvatar')} onClick={onClear} />}
</Popup.Content>
</>
);
},
);
EditAvatarStep.propTypes = {
defaultValue: PropTypes.string,
name: PropTypes.string.isRequired,
isUploading: PropTypes.bool.isRequired,
onUpload: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
EditAvatarStep.defaultProps = {
defaultValue: undefined,
};
export default EditAvatarStep;

View file

@ -0,0 +1,35 @@
.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;
margin-left: 8px;
position: relative;
transition: background 0.3s ease;
width: calc(100% - 44px);
}
.input:hover {
background: #e9e9e9 !important;
}

View file

@ -0,0 +1,68 @@
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 './EditNameStep.module.css';
const EditNameStep = 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.editName', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Input
fluid
ref={field}
value={value}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
</Popup.Content>
</>
);
});
EditNameStep.propTypes = {
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default EditNameStep;

View file

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

View file

@ -0,0 +1,116 @@
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 EditNameStep from './EditNameStep';
import EditAvatarStep from './EditAvatarStep';
import styles from './UserPopup.module.css';
const StepTypes = {
EDIT_NAME: 'EDIT_NAME',
EDIT_AVATAR: 'EDIT_AVATAR',
};
const UserStep = React.memo(
({
name, avatar, isAvatarUploading, onUpdate, onAvatarUpload, onLogout, onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleNameEditClick = useCallback(() => {
openStep(StepTypes.EDIT_NAME);
}, [openStep]);
const handleAvatarEditClick = useCallback(() => {
openStep(StepTypes.EDIT_AVATAR);
}, [openStep]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleAvatarClear = useCallback(() => {
onUpdate({
avatar: null,
});
}, [onUpdate]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_NAME:
return (
<EditNameStep
defaultValue={name}
onUpdate={handleNameUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_AVATAR:
return (
<EditAvatarStep
defaultValue={avatar}
name={name}
isUploading={isAvatarUploading}
onUpload={onAvatarUpload}
onClear={handleAvatarClear}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header>{name}</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
{t('action.editName', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleAvatarEditClick}>
{t('action.editAvatar', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={onLogout}>
{t('action.logOut', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
},
);
UserStep.propTypes = {
name: PropTypes.string.isRequired,
avatar: PropTypes.string,
isAvatarUploading: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onAvatarUpload: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
UserStep.defaultProps = {
avatar: undefined,
};
export default withPopup(UserStep);

View file

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

View file

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

View file

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

View file

@ -0,0 +1,78 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import LabelColors from '../../constants/LabelColors';
import styles from './Label.module.css';
const SIZES = {
TINY: 'tiny',
SMALL: 'small',
MEDIUM: 'medium',
};
// TODO: move to styles
const STYLES = {
tiny: {
fontSize: '12px',
lineHeight: '20px',
maxWidth: '176px',
padding: '0px 6px',
},
small: {
fontSize: '12px',
lineHeight: '20px',
maxWidth: '176px',
padding: '2px 8px',
},
medium: {
fontSize: '14px',
lineHeight: '32px',
maxWidth: '230px',
padding: '0 12px',
},
};
const Label = React.memo(({
name, color, size, isDisabled, onClick,
}) => {
const style = {
...STYLES[size],
background: LabelColors.MAP[color],
};
const contentNode = (
<div
title={name}
className={classNames(styles.wrapper, onClick && styles.hoverable)}
style={style}
>
{name}
</div>
);
return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode}
</button>
) : (
contentNode
);
});
Label.propTypes = {
name: PropTypes.string.isRequired,
color: PropTypes.oneOf(LabelColors.KEYS).isRequired, // TODO: without color
size: PropTypes.oneOf(Object.values(SIZES)),
isDisabled: PropTypes.bool,
onClick: PropTypes.func,
};
Label.defaultProps = {
size: SIZES.MEDIUM,
isDisabled: false,
onClick: undefined,
};
export default Label;

View file

@ -0,0 +1,27 @@
.button {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
max-width: 100%;
outline: none;
padding: 0;
}
.hoverable:hover {
opacity: 0.75;
}
.wrapper {
border-radius: 3px;
box-sizing: border-box;
color: #fff;
display: inline-block;
font-weight: 400;
min-width: 40px;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: rgba(0, 0, 0, 0.2) 1px 1px 0;
white-space: nowrap;
}

View file

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

View file

@ -0,0 +1,5 @@
import { withPopup } from '../lib/popup';
import LabelsStep from './LabelsStep';
export default withPopup(LabelsStep);

View file

@ -0,0 +1,60 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import LabelColors from '../../constants/LabelColors';
import Editor from './Editor';
import styles from './AddStep.module.css';
const AddStep = React.memo(({ onCreate, onBack }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
color: LabelColors.KEYS[0],
}));
const editor = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
editor.current.selectNameField();
return;
}
onCreate(cleanData);
onBack();
}, [data, onCreate, onBack]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.createLabel', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Editor ref={editor} data={data} onFieldChange={handleFieldChange} />
<Button positive content={t('action.createLabel')} className={styles.submitButton} />
</Form>
</Popup.Content>
</>
);
});
AddStep.propTypes = {
onCreate: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
export default AddStep;

View file

@ -0,0 +1,3 @@
.submitButton {
margin-top: 12px;
}

View file

@ -0,0 +1,99 @@
import dequal from 'dequal';
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
import LabelColors from '../../constants/LabelColors';
import Editor from './Editor';
import DeleteStep from '../DeleteStep';
import styles from './EditStep.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const EditStep = React.memo(({
defaultData, onUpdate, onDelete, onBack,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
color: LabelColors.KEYS[0],
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const editor = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
editor.current.selectNameField();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
onBack();
}, [defaultData, data, onUpdate, onBack]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteLabel', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisLabel')}
buttonContent={t('action.deleteLabel')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editLabel', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Editor ref={editor} data={data} onFieldChange={handleFieldChange} />
<Button positive content={t('action.save')} />
</Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content>
</>
);
});
EditStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
export default EditStep;

View file

@ -0,0 +1,6 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}

View file

@ -0,0 +1,74 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { Input } from '../../lib/custom-ui';
import LabelColors from '../../constants/LabelColors';
import styles from './Editor.module.css';
const Editor = React.forwardRef(({ data, onFieldChange }, ref) => {
const [t] = useTranslation();
const nameField = useRef(null);
const selectNameField = useCallback(() => {
nameField.current.select();
}, []);
useImperativeHandle(
ref,
() => ({
selectNameField,
}),
[selectNameField],
);
useEffect(() => {
selectNameField();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={onFieldChange}
/>
<div className={styles.text}>{t('common.color')}</div>
<div className={styles.colorButtons}>
{LabelColors.PAIRS.map(([name, hex]) => (
<Button
key={name}
type="button"
name="color"
value={name}
className={classNames(
styles.colorButton,
name === data.color && styles.colorButtonActive,
)}
style={{
background: hex,
}}
onClick={onFieldChange}
/>
))}
</div>
</>
);
});
Editor.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onFieldChange: PropTypes.func.isRequired,
};
export default React.memo(Editor);

View file

@ -0,0 +1,49 @@
.colorButton {
float: left;
height: 40px;
margin: 4px !important;
padding: 0 !important;
position: relative;
width: 49.6px;
}
.colorButton:hover {
opacity: 0.9;
}
.colorButtonActive: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;
}
.colorButtons {
margin: -4px;
padding-bottom: 16px;
}
.colorButtons:after {
content: "";
display: table;
clear: both;
}
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View file

@ -0,0 +1,55 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Button } from 'semantic-ui-react';
import LabelColors from '../../constants/LabelColors';
import styles from './Item.module.css';
const Item = React.memo(({
name, color, isPersisted, isActive, onSelect, onDeselect, onEdit,
}) => {
const handleToggleClick = useCallback(() => {
if (isActive) {
onDeselect();
} else {
onSelect();
}
}, [isActive, onSelect, onDeselect]);
return (
<div className={styles.wrapper}>
<Button
fluid
content={name}
active={isActive}
disabled={!isPersisted}
className={classNames(styles.labelButton, isActive && styles.labelButtonActive)}
style={{
background: LabelColors.MAP[color],
}}
onClick={handleToggleClick}
/>
<Button
icon="pencil"
size="small"
disabled={!isPersisted}
className={styles.editButton}
onClick={onEdit}
/>
</div>
);
});
Item.propTypes = {
name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
};
export default Item;

View file

@ -0,0 +1,51 @@
.editButton {
background: transparent !important;
box-shadow: none !important;
flex: 0 0 auto;
font-weight: normal !important;
margin: 0 !important;
padding: 8px 10px !important;
text-decoration: underline !important;
}
.editButton:hover {
background: #e9e9e9 !important;
}
.labelButton {
color: #fff !important;
flex: 1 1 auto;
font-size: 14px !important;
font-weight: bold !important;
overflow: hidden;
padding: 8px 32px 8px 10px !important;
position: relative;
text-align: left !important;
text-overflow: ellipsis;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2) !important;
}
.labelButton:hover {
opacity: 0.9;
}
.labelButtonActive:before {
bottom: 1px;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
text-shadow: -1px 1px 0 rgba(0, 0, 0, 0.2);
transform: rotate(-135deg);
width: 36px;
}
.wrapper {
display: flex;
margin-bottom: 4px;
max-width: 280px;
white-space: nowrap;
}

Some files were not shown because too many files have changed in this diff Show more