feat: Version 2

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

View File

@@ -1,38 +0,0 @@
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { ProjectBackgroundTypes } from '../../constants/Enums';
import styles from './Background.module.scss';
import globalStyles from '../../styles.module.scss';
function Background({ type, name, imageUrl }) {
return (
<div
className={classNames(
styles.wrapper,
type === ProjectBackgroundTypes.GRADIENT &&
globalStyles[`background${upperFirst(camelCase(name))}`],
)}
style={{
background: type === 'image' && `url("${imageUrl}") center / cover`,
}}
/>
);
}
Background.propTypes = {
type: PropTypes.string.isRequired,
name: PropTypes.string,
imageUrl: PropTypes.string,
};
Background.defaultProps = {
name: undefined,
imageUrl: undefined,
};
export default Background;

View File

@@ -1,10 +0,0 @@
:global(#app) {
.wrapper {
height: 100%;
max-height: 100vh;
max-width: 100vw;
position: fixed;
width: 100%;
z-index: -1;
}
}

View File

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

View File

@@ -1,201 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } 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 ListAdd from './ListAdd';
import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-icon.svg';
import styles from './Board.module.scss';
import globalStyles from '../../styles.module.scss';
const parseDndId = (dndId) => dndId.split(':')[1];
const Board = React.memo(
({ listIds, isCardModalOpened, canEdit, onListCreate, onListMove, onCardMove }) => {
const [t] = useTranslation();
const [isListAddOpened, setIsListAddOpened] = useState(false);
const wrapper = useRef(null);
const prevPosition = useRef(null);
const handleAddListClick = useCallback(() => {
setIsListAddOpened(true);
}, []);
const handleAddListClose = useCallback(() => {
setIsListAddOpened(false);
}, []);
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, type, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
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],
);
const handleMouseDown = useCallback(
(event) => {
// If button is defined and not equal to 0 (left click)
if (event.button) {
return;
}
if (event.target !== wrapper.current && !event.target.dataset.dragScroller) {
return;
}
prevPosition.current = event.clientX;
window.getSelection().removeAllRanges();
document.body.classList.add(globalStyles.dragScrolling);
},
[wrapper],
);
const handleWindowMouseMove = useCallback(
(event) => {
if (prevPosition.current === null) {
return;
}
event.preventDefault();
window.scrollBy({
left: prevPosition.current - event.clientX,
});
prevPosition.current = event.clientX;
},
[prevPosition],
);
const handleWindowMouseRelease = useCallback(() => {
if (prevPosition.current === null) {
return;
}
prevPosition.current = null;
document.body.classList.remove(globalStyles.dragScrolling);
}, [prevPosition]);
useEffect(() => {
document.body.style.overflowX = 'auto';
return () => {
document.body.style.overflowX = null;
};
}, []);
useEffect(() => {
if (isListAddOpened) {
window.scroll(document.body.scrollWidth, 0);
}
}, [listIds, isListAddOpened]);
useEffect(() => {
window.addEventListener('mousemove', handleWindowMouseMove);
window.addEventListener('mouseup', handleWindowMouseRelease);
window.addEventListener('blur', handleWindowMouseRelease);
window.addEventListener('contextmenu', handleWindowMouseRelease);
return () => {
window.removeEventListener('mousemove', handleWindowMouseMove);
window.removeEventListener('mouseup', handleWindowMouseRelease);
window.removeEventListener('blur', handleWindowMouseRelease);
window.removeEventListener('contextmenu', handleWindowMouseRelease);
};
}, [handleWindowMouseMove, handleWindowMouseRelease]);
return (
<>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div ref={wrapper} className={styles.wrapper} onMouseDown={handleMouseDown}>
<div>
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="board" type={DroppableTypes.LIST} direction="horizontal">
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
data-drag-scroller
ref={innerRef}
className={styles.lists}
>
{listIds.map((listId, index) => (
<ListContainer key={listId} id={listId} index={index} />
))}
{placeholder}
{canEdit && (
<div data-drag-scroller className={styles.list}>
{isListAddOpened ? (
<ListAdd onCreate={onListCreate} onClose={handleAddListClose} />
) : (
<button
type="button"
className={styles.addListButton}
onClick={handleAddListClick}
>
<PlusMathIcon className={styles.addListButtonIcon} />
<span className={styles.addListButtonText}>
{listIds.length > 0
? t('action.addAnotherList')
: t('action.addList')}
</span>
</button>
)}
</div>
)}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
{isCardModalOpened && <CardModalContainer />}
</>
);
},
);
Board.propTypes = {
listIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isCardModalOpened: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onListCreate: PropTypes.func.isRequired,
onListMove: PropTypes.func.isRequired,
onCardMove: PropTypes.func.isRequired,
};
export default Board;

View File

@@ -1,89 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import { useDidUpdate, useToggle } from '../../lib/hooks';
import { useClosableForm, useForm } from '../../hooks';
import styles from './ListAdd.module.scss';
const DEFAULT_DATA = {
name: '',
};
const ListAdd = React.memo(({ onCreate, onClose }) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [focusNameFieldState, focusNameField] = useToggle();
const nameField = useRef(null);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
onClose();
}
},
[onClose],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(onClose);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
focusNameField();
}, [onCreate, data, setData, focusNameField]);
useEffect(() => {
nameField.current.focus();
}, []);
useDidUpdate(() => {
nameField.current.focus();
}, [focusNameFieldState]);
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.button}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
ListAdd.propTypes = {
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ListAdd;

View File

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

View File

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

View File

@@ -1,97 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import Filters from './Filters';
import Memberships from '../Memberships';
import BoardMembershipPermissionsSelectStep from '../BoardMembershipPermissionsSelectStep';
import styles from './BoardActions.module.scss';
const BoardActions = React.memo(
({
memberships,
labels,
filterUsers,
filterLabels,
filterText,
allUsers,
canEdit,
canEditMemberships,
onMembershipCreate,
onMembershipUpdate,
onMembershipDelete,
onUserToFilterAdd,
onUserFromFilterRemove,
onLabelToFilterAdd,
onLabelFromFilterRemove,
onLabelCreate,
onLabelUpdate,
onLabelMove,
onLabelDelete,
onTextFilterUpdate,
}) => {
return (
<div className={styles.wrapper}>
<div className={styles.actions}>
<div className={styles.action}>
<Memberships
items={memberships}
allUsers={allUsers}
permissionsSelectStep={BoardMembershipPermissionsSelectStep}
canEdit={canEditMemberships}
onCreate={onMembershipCreate}
onUpdate={onMembershipUpdate}
onDelete={onMembershipDelete}
/>
</div>
<div className={styles.action}>
<Filters
users={filterUsers}
labels={filterLabels}
filterText={filterText}
allBoardMemberships={memberships}
allLabels={labels}
canEdit={canEdit}
onUserAdd={onUserToFilterAdd}
onUserRemove={onUserFromFilterRemove}
onLabelAdd={onLabelToFilterAdd}
onLabelRemove={onLabelFromFilterRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelMove={onLabelMove}
onLabelDelete={onLabelDelete}
onTextFilterUpdate={onTextFilterUpdate}
/>
</div>
</div>
</div>
);
},
);
BoardActions.propTypes = {
/* eslint-disable react/forbid-prop-types */
memberships: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
filterUsers: PropTypes.array.isRequired,
filterLabels: PropTypes.array.isRequired,
filterText: PropTypes.string.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
canEditMemberships: PropTypes.bool.isRequired,
onMembershipCreate: PropTypes.func.isRequired,
onMembershipUpdate: PropTypes.func.isRequired,
onMembershipDelete: 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,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onTextFilterUpdate: PropTypes.func.isRequired,
};
export default BoardActions;

View File

@@ -1,26 +0,0 @@
:global(#app) {
.action {
align-items: center;
display: flex;
flex: 0 0 auto;
}
.actions {
align-items: center;
display: flex;
gap: 20px;
justify-content: flex-start;
margin: 20px 20px;
}
.wrapper {
overflow-x: auto;
overflow-y: hidden;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}

View File

@@ -1,190 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import { Input } from '../../lib/custom-ui';
import User from '../User';
import Label from '../Label';
import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep';
import styles from './Filters.module.scss';
const Filters = React.memo(
({
users,
labels,
filterText,
allBoardMemberships,
allLabels,
canEdit,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelMove,
onLabelDelete,
onTextFilterUpdate,
}) => {
const [t] = useTranslation();
const [isSearchFocused, setIsSearchFocused] = useState(false);
const searchFieldRef = useRef(null);
const cancelSearch = useCallback(() => {
onTextFilterUpdate('');
searchFieldRef.current.blur();
}, [onTextFilterUpdate]);
const handleRemoveUserClick = useCallback(
(id) => {
onUserRemove(id);
},
[onUserRemove],
);
const handleRemoveLabelClick = useCallback(
(id) => {
onLabelRemove(id);
},
[onLabelRemove],
);
const handleSearchChange = useCallback(
(_, { value }) => {
onTextFilterUpdate(value);
},
[onTextFilterUpdate],
);
const handleSearchFocus = useCallback(() => {
setIsSearchFocused(true);
}, []);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
cancelSearch();
}
},
[cancelSearch],
);
const handleSearchBlur = useCallback(() => {
setIsSearchFocused(false);
}, []);
const handleCancelSearchClick = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const isSearchActive = filterText || isSearchFocused;
return (
<>
<span className={styles.filter}>
<BoardMembershipsPopup
items={allBoardMemberships}
currentUserIds={users.map((user) => user.id)}
title="common.filterByMembers"
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>
</BoardMembershipsPopup>
{users.map((user) => (
<span key={user.id} className={styles.filterItem}>
<User
name={user.name}
avatarUrl={user.avatarUrl}
size="tiny"
onClick={() => handleRemoveUserClick(user.id)}
/>
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
items={allLabels}
currentIds={labels.map((label) => label.id)}
title="common.filterByLabels"
canEdit={canEdit}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onMove={onLabelMove}
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={() => handleRemoveLabelClick(label.id)}
/>
</span>
))}
</span>
<span className={styles.filter}>
<Input
ref={searchFieldRef}
value={filterText}
placeholder={t('common.searchCards')}
icon={
isSearchActive ? (
<Icon link name="cancel" onClick={handleCancelSearchClick} />
) : (
'search'
)
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
</span>
</>
);
},
);
Filters.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
filterText: PropTypes.string.isRequired,
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.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,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onTextFilterUpdate: PropTypes.func.isRequired,
};
export default Filters;

View File

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

View File

@@ -1,107 +0,0 @@
import { dequal } from 'dequal';
import omit from 'lodash/omit';
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Menu, Radio, Segment } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { BoardMembershipRoles } from '../../constants/Enums';
import styles from './BoardMembershipPermissionsSelectStep.module.scss';
const BoardMembershipPermissionsSelectStep = React.memo(
({ defaultData, title, buttonContent, onSelect, onBack, onClose }) => {
const [t] = useTranslation();
const [data, setData] = useState(() => ({
role: BoardMembershipRoles.EDITOR,
canComment: null,
...defaultData,
}));
const handleSelectRoleClick = useCallback((role) => {
setData((prevData) => ({
...prevData,
role,
canComment: role === BoardMembershipRoles.VIEWER ? !!prevData.canComment : null,
}));
}, []);
const handleSettingChange = useCallback((_, { name: fieldName, checked: value }) => {
setData((prevData) => ({
...prevData,
[fieldName]: value,
}));
}, []);
const handleSubmit = useCallback(() => {
if (!dequal(data, defaultData)) {
onSelect(data.role === BoardMembershipRoles.VIEWER ? data : omit(data, 'canComment'));
}
onClose();
}, [defaultData, onSelect, onClose, data]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
active={data.role === BoardMembershipRoles.EDITOR}
onClick={() => handleSelectRoleClick(BoardMembershipRoles.EDITOR)}
>
<div className={styles.menuItemTitle}>{t('common.editor')}</div>
<div className={styles.menuItemDescription}>
{t('common.canEditContentOfBoard')}
</div>
</Menu.Item>
<Menu.Item
active={data.role === BoardMembershipRoles.VIEWER}
onClick={() => handleSelectRoleClick(BoardMembershipRoles.VIEWER)}
>
<div className={styles.menuItemTitle}>{t('common.viewer')}</div>
<div className={styles.menuItemDescription}>{t('common.canOnlyViewBoard')}</div>
</Menu.Item>
</Menu>
{data.role === BoardMembershipRoles.VIEWER && (
<Segment basic className={styles.settings}>
<Radio
toggle
name="canComment"
checked={data.canComment}
label={t('common.canComment')}
onChange={handleSettingChange}
/>
</Segment>
)}
<Button positive content={t(buttonContent)} />
</Form>
</Popup.Content>
</>
);
},
);
BoardMembershipPermissionsSelectStep.propTypes = {
defaultData: PropTypes.object, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
buttonContent: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
BoardMembershipPermissionsSelectStep.defaultProps = {
defaultData: undefined,
title: 'common.selectPermissions',
buttonContent: 'action.selectPermissions',
};
export default BoardMembershipPermissionsSelectStep;

View File

@@ -1,18 +0,0 @@
:global(#app) {
.menu {
margin: 0 auto 8px;
width: 100%;
}
.menuItemDescription {
opacity: 0.5;
}
.menuItemTitle {
margin-bottom: 8px;
}
.settings {
margin: 0 0 8px;
}
}

View File

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

View File

@@ -1,103 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import { useField } from '../../hooks';
import Item from './Item';
import styles from './BoardMembershipsStep.module.scss';
const BoardMembershipsStep = React.memo(
({ items, currentUserIds, title, onUserSelect, onUserDeselect, onBack }) => {
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredItems = useMemo(
() =>
items.filter(
({ user }) =>
user.email.includes(cleanSearch) ||
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[items, cleanSearch],
);
const searchField = useRef(null);
const handleUserSelect = useCallback(
(id) => {
onUserSelect(id);
},
[onUserSelect],
);
const handleUserDeselect = useCallback(
(id) => {
onUserDeselect(id);
},
[onUserDeselect],
);
useEffect(() => {
searchField.current.focus({
preventScroll: true,
});
}, []);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={searchField}
value={search}
placeholder={t('common.searchMembers')}
icon="search"
onChange={handleSearchChange}
/>
{filteredItems.length > 0 && (
<Menu secondary vertical className={styles.menu}>
{filteredItems.map((item) => (
<Item
key={item.id}
isPersisted={item.isPersisted}
isActive={currentUserIds.includes(item.user.id)}
user={item.user}
onUserSelect={() => handleUserSelect(item.user.id)}
onUserDeselect={() => handleUserDeselect(item.user.id)}
/>
))}
</Menu>
)}
</Popup.Content>
</>
);
},
);
BoardMembershipsStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
items: PropTypes.array.isRequired,
currentUserIds: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
title: PropTypes.string,
onUserSelect: PropTypes.func.isRequired,
onUserDeselect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
BoardMembershipsStep.defaultProps = {
title: 'common.members',
onBack: undefined,
};
export default BoardMembershipsStep;

View File

@@ -1,21 +0,0 @@
:global(#app) {
.menu {
margin: 8px auto 0;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
width: 100%;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
}

View File

@@ -1,44 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Menu } from 'semantic-ui-react';
import User from '../User';
import styles from './Item.module.scss';
const Item = React.memo(({ isPersisted, isActive, user, onUserSelect, onUserDeselect }) => {
const handleToggleClick = useCallback(() => {
if (isActive) {
onUserDeselect();
} else {
onUserSelect();
}
}, [isActive, onUserSelect, onUserDeselect]);
return (
<Menu.Item
active={isActive}
disabled={!isPersisted}
className={classNames(styles.menuItem, isActive && styles.menuItemActive)}
onClick={handleToggleClick}
>
<span className={styles.user}>
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
{user.name}
</div>
</Menu.Item>
);
});
Item.propTypes = {
isPersisted: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUserSelect: PropTypes.func.isRequired,
onUserDeselect: PropTypes.func.isRequired,
};
export default Item;

View File

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

View File

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

View File

@@ -1,145 +0,0 @@
import pick from 'lodash/pick';
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react';
import { closePopup, usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths';
import DroppableTypes from '../../constants/DroppableTypes';
import AddStep from './AddStep';
import EditStep from './EditStep';
import styles from './Boards.module.scss';
import globalStyles from '../../styles.module.scss';
const Boards = React.memo(({ items, currentId, canEdit, onCreate, onUpdate, onMove, onDelete }) => {
const tabsWrapper = useRef(null);
const handleWheel = useCallback(({ deltaY }) => {
tabsWrapper.current.scrollBy({
left: deltaY,
});
}, []);
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
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 AddPopup = usePopup(AddStep);
const EditPopup = usePopup(EditStep);
const itemsNode = items.map((item, index) => (
<Draggable
key={item.id}
draggableId={item.id}
index={index}
isDragDisabled={!item.isPersisted || !canEdit}
>
{({ 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>
{canEdit && (
<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>
)}
</>
) : (
// eslint-disable-next-line react/jsx-props-no-spreading
<span {...dragHandleProps} className={styles.link}>
{item.name}
</span>
)}
</div>
</div>
)}
</Draggable>
));
return (
<div className={styles.wrapper} onWheel={handleWheel}>
<div ref={tabsWrapper} className={styles.tabsWrapper}>
<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}>
{itemsNode}
{placeholder}
{canEdit && (
<AddPopup onCreate={onCreate}>
<Button icon="plus" className={styles.addButton} />
</AddPopup>
)}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
);
});
Boards.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
currentId: PropTypes.string,
canEdit: 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

@@ -1,104 +0,0 @@
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 { Input, Popup } from '../../lib/custom-ui';
import { useForm, useSteps } from '../../hooks';
import DeleteStep from '../DeleteStep';
import styles from './EditStep.module.scss';
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 = useCallback(() => {
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="common.deleteBoard"
content="common.areYouSureYouWantToDeleteThisBoard"
buttonContent="action.deleteBoard"
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.editBoard', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<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 EditStep;

View File

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

View File

@@ -1,260 +0,0 @@
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep';
import DueDateEditStep from '../DueDateEditStep';
import StopwatchEditStep from '../StopwatchEditStep';
import CardMoveStep from '../CardMoveStep';
import DeleteStep from '../DeleteStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
USERS: 'USERS',
LABELS: 'LABELS',
EDIT_DUE_DATE: 'EDIT_DUE_DATE',
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
MOVE: 'MOVE',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({
card,
projectsToLists,
boardMemberships,
currentUserIds,
labels,
currentLabelIds,
onNameEdit,
onUpdate,
onMove,
onTransfer,
onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
onBoardFetch,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelMove,
onLabelDelete,
onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleEditNameClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleUsersClick = useCallback(() => {
openStep(StepTypes.USERS);
}, [openStep]);
const handleLabelsClick = useCallback(() => {
openStep(StepTypes.LABELS);
}, [openStep]);
const handleEditDueDateClick = useCallback(() => {
openStep(StepTypes.EDIT_DUE_DATE);
}, [openStep]);
const handleEditStopwatchClick = useCallback(() => {
openStep(StepTypes.EDIT_STOPWATCH);
}, [openStep]);
const handleMoveClick = useCallback(() => {
openStep(StepTypes.MOVE);
}, [openStep]);
const handleDuplicateClick = useCallback(() => {
onDuplicate();
onClose();
}, [onDuplicate, onClose]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleDueDateUpdate = useCallback(
(dueDate) => {
onUpdate({
dueDate,
});
},
[onUpdate],
);
const handleStopwatchUpdate = useCallback(
(stopwatch) => {
onUpdate({
stopwatch,
});
},
[onUpdate],
);
if (step) {
switch (step.type) {
case StepTypes.USERS:
return (
<BoardMembershipsStep
items={boardMemberships}
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}
onMove={onLabelMove}
onDelete={onLabelDelete}
onBack={handleBack}
/>
);
case StepTypes.EDIT_DUE_DATE:
return (
<DueDateEditStep
defaultValue={card.dueDate}
onUpdate={handleDueDateUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_STOPWATCH:
return (
<StopwatchEditStep
defaultValue={card.stopwatch}
onUpdate={handleStopwatchUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.MOVE:
return (
<CardMoveStep
projectsToLists={projectsToLists}
defaultPath={pick(card, ['projectId', 'boardId', 'listId'])}
onMove={onMove}
onTransfer={onTransfer}
onBoardFetch={onBoardFetch}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.DELETE:
return (
<DeleteStep
title="common.deleteCard"
content="common.areYouSureYouWantToDeleteThisCard"
buttonContent="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={handleEditNameClick}>
{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={handleEditDueDateClick}>
{t('action.editDueDate', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
{t('action.editStopwatch', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
{t('action.moveCard', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
{t('action.duplicateCard', {
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,
projectsToLists: PropTypes.array.isRequired,
boardMemberships: 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,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onBoardFetch: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ActionsStep;

View File

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

View File

@@ -1,289 +0,0 @@
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 { usePopup } from '../../lib/popup';
import { startStopwatch, stopStopwatch } from '../../utils/stopwatch';
import Paths from '../../constants/Paths';
import Tasks from './Tasks';
import NameEdit from './NameEdit';
import ActionsStep from './ActionsStep';
import User from '../User';
import Label from '../Label';
import DueDate from '../DueDate';
import Stopwatch from '../Stopwatch';
import styles from './Card.module.scss';
const Card = React.memo(
({
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
coverUrl,
boardId,
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,
tasks,
allProjectsToLists,
allBoardMemberships,
allLabels,
canEdit,
onUpdate,
onMove,
onTransfer,
onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
onBoardFetch,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelMove,
onLabelDelete,
}) => {
const nameEdit = useRef(null);
const handleClick = useCallback(() => {
if (document.activeElement) {
document.activeElement.blur();
}
}, []);
const handleToggleStopwatchClick = useCallback(
(event) => {
event.preventDefault();
onUpdate({
stopwatch: stopwatch.startedAt ? stopStopwatch(stopwatch) : startStopwatch(stopwatch),
});
},
[stopwatch, onUpdate],
);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleNameEdit = useCallback(() => {
nameEdit.current.open();
}, []);
const ActionsPopup = usePopup(ActionsStep);
const contentNode = (
<>
{coverUrl && <img src={coverUrl} alt="" className={styles.cover} />}
<div className={styles.details}>
{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} />}
{(description ||
dueDate ||
stopwatch ||
attachmentsTotal > 0 ||
notificationsTotal > 0) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
className={classNames(
styles.attachment,
styles.attachmentLeft,
styles.notification,
)}
>
{notificationsTotal}
</span>
)}
{dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDate value={dueDate} isCompleted={isDueDateCompleted} size="tiny" />
</span>
)}
{stopwatch && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Stopwatch
as="span"
startedAt={stopwatch.startedAt}
total={stopwatch.total}
size="tiny"
onClick={canEdit ? handleToggleStopwatchClick : undefined}
/>
</span>
)}
{description && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="align left" />
</span>
</span>
)}
{attachmentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="attach" />
{attachmentsTotal}
</span>
</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} avatarUrl={user.avatarUrl} size="small" />
</span>
))}
</span>
)}
</div>
</>
);
return (
<Draggable draggableId={`card:${id}`} index={index} isDragDisabled={!isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} {...dragHandleProps} ref={innerRef} className={styles.wrapper}>
<NameEdit ref={nameEdit} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={styles.card}>
{isPersisted ? (
<>
<Link
to={Paths.CARDS.replace(':id', id)}
className={styles.content}
onClick={handleClick}
>
{contentNode}
</Link>
{canEdit && (
<ActionsPopup
card={{
dueDate,
stopwatch,
boardId,
listId,
projectId,
}}
projectsToLists={allProjectsToLists}
boardMemberships={allBoardMemberships}
currentUserIds={users.map((user) => user.id)}
labels={allLabels}
currentLabelIds={labels.map((label) => label.id)}
onNameEdit={handleNameEdit}
onUpdate={onUpdate}
onMove={onMove}
onTransfer={onTransfer}
onDuplicate={onDuplicate}
onDelete={onDelete}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
onBoardFetch={onBoardFetch}
onLabelAdd={onLabelAdd}
onLabelRemove={onLabelRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelMove={onLabelMove}
onLabelDelete={onLabelDelete}
>
<Button className={classNames(styles.actionsButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
)}
</>
) : (
<span className={styles.content}>{contentNode}</span>
)}
</div>
</NameEdit>
</div>
)}
</Draggable>
);
},
);
Card.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
coverUrl: PropTypes.string,
boardId: PropTypes.string.isRequired,
listId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
attachmentsTotal: PropTypes.number.isRequired,
notificationsTotal: PropTypes.number.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
allProjectsToLists: PropTypes.array.isRequired,
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onBoardFetch: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
};
Card.defaultProps = {
description: undefined,
dueDate: undefined,
isDueDateCompleted: undefined,
stopwatch: undefined,
coverUrl: undefined,
};
export default Card;

View File

@@ -1,127 +0,0 @@
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 { focusEnd } from '../../utils/element-helpers';
import styles from './NameEdit.module.scss';
const NameEdit = 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(
close,
isOpened,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
focusEnd(field.current.ref.current);
}
}, [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>
);
});
NameEdit.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(NameEdit);

View File

@@ -1,66 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Progress } from 'semantic-ui-react';
import { useToggle } from '../../lib/hooks';
import Linkify from '../Linkify';
import styles from './Tasks.module.scss';
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-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div className={styles.button} onClick={handleToggleClick}>
<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)}
>
<Linkify linkStopPropagation>{item.name}</Linkify>
</li>
))}
</ul>
)}
</>
);
});
Tasks.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default Tasks;

View File

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

View File

@@ -1,112 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Comment, Icon, Loader, Visibility } from 'semantic-ui-react';
import { ActivityTypes } from '../../../constants/Enums';
import CommentAdd from './CommentAdd';
import Item from './Item';
import styles from './Activities.module.scss';
const Activities = React.memo(
({
items,
isFetching,
isAllFetched,
isDetailsVisible,
isDetailsFetching,
canEdit,
canEditAllComments,
onFetch,
onDetailsToggle,
onCommentCreate,
onCommentUpdate,
onCommentDelete,
}) => {
const [t] = useTranslation();
const handleToggleDetailsClick = useCallback(() => {
onDetailsToggle(!isDetailsVisible);
}, [isDetailsVisible, onDetailsToggle]);
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="list ul" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>
{t('common.actions')}
<Button
content={isDetailsVisible ? t('action.hideDetails') : t('action.showDetails')}
className={styles.toggleButton}
onClick={handleToggleDetailsClick}
/>
</div>
{canEdit && <CommentAdd onCreate={onCommentCreate} />}
<div className={styles.wrapper}>
<Comment.Group>
{items.map((item) =>
item.type === ActivityTypes.COMMENT_CARD ? (
<Item.Comment
key={item.id}
data={item.data}
createdAt={item.createdAt}
isPersisted={item.isPersisted}
user={item.user}
canEdit={(item.user.isCurrent && canEdit) || canEditAllComments}
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 || isDetailsFetching ? (
<Loader active inverted inline="centered" size="small" className={styles.loader} />
) : (
!isAllFetched && <Visibility fireOnMount onOnScreen={onFetch} />
)}
</div>
</div>
);
},
);
Activities.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isFetching: PropTypes.bool.isRequired,
isAllFetched: PropTypes.bool.isRequired,
isDetailsVisible: PropTypes.bool.isRequired,
isDetailsFetching: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
canEditAllComments: PropTypes.bool.isRequired,
onFetch: PropTypes.func.isRequired,
onDetailsToggle: PropTypes.func.isRequired,
onCommentCreate: PropTypes.func.isRequired,
onCommentUpdate: PropTypes.func.isRequired,
onCommentDelete: PropTypes.func.isRequired,
};
export default Activities;

View File

@@ -1,102 +0,0 @@
import React, { useCallback, 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 { useDidUpdate, useToggle } from '../../../lib/hooks';
import { useClosableForm, useForm } from '../../../hooks';
import styles from './CommentAdd.module.scss';
const DEFAULT_DATA = {
text: '',
};
const CommentAdd = React.memo(({ onCreate }) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectTextFieldState, selectTextField] = useToggle();
const textField = useRef(null);
const close = useCallback(() => {
setIsOpened(false);
}, []);
const submit = useCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
};
if (!cleanData.text) {
textField.current.ref.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
selectTextField();
}, [onCreate, data, setData, selectTextField]);
const handleFieldFocus = useCallback(() => {
setIsOpened(true);
}, []);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.ctrlKey && event.key === 'Enter') {
submit();
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(close);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useDidUpdate(() => {
textField.current.ref.current.focus();
}, [selectTextFieldState]);
return (
<Form onSubmit={handleSubmit}>
<TextArea
ref={textField}
as={TextareaAutosize}
name="text"
value={data.text}
placeholder={t('common.writeComment')}
minRows={isOpened ? 3 : 1}
spellCheck={false}
className={styles.field}
onFocus={handleFieldFocus}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
{isOpened && (
<div className={styles.controls}>
<Button
positive
content={t('action.addComment')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
)}
</Form>
);
});
CommentAdd.propTypes = {
onCreate: PropTypes.func.isRequired,
};
export default CommentAdd;

View File

@@ -1,115 +0,0 @@
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 { useForm } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers';
import styles from './CommentEdit.module.scss';
const CommentEdit = React.forwardRef(({ defaultData, onUpdate, text, actions }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(null);
const textField = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
setData({
text: '',
...defaultData,
});
}, [defaultData, setData]);
const close = useCallback(() => {
setIsOpened(false);
setData(null);
}, [setData]);
const submit = useCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
};
if (cleanData.text && !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 = useCallback(() => {
submit();
}, [submit]);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
focusEnd(textField.current.ref.current);
}
}, [isOpened]);
if (!isOpened) {
return (
<>
{actions}
{text}
</>
);
}
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}>
<Button positive content={t('action.save')} />
</div>
</Form>
);
});
CommentEdit.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
text: PropTypes.element.isRequired,
actions: PropTypes.element.isRequired,
};
export default React.memo(CommentEdit);

View File

@@ -1,89 +0,0 @@
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 getDateFormat from '../../../utils/get-date-format';
import { ActivityTypes } from '../../../constants/Enums';
import ItemComment from './ItemComment';
import User from '../../User';
import styles from './Item.module.scss';
const Item = React.memo(({ type, data, createdAt, user }) => {
const [t] = useTranslation();
let contentNode;
switch (type) {
case ActivityTypes.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 ActivityTypes.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} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.content)}>
<div>{contentNode}</div>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
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

@@ -1,95 +0,0 @@
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 { usePopup } from '../../../lib/popup';
import { Markdown } from '../../../lib/custom-ui';
import getDateFormat from '../../../utils/get-date-format';
import CommentEdit from './CommentEdit';
import User from '../../User';
import DeleteStep from '../../DeleteStep';
import styles from './ItemComment.module.scss';
const ItemComment = React.memo(
({ data, createdAt, isPersisted, user, canEdit, onUpdate, onDelete }) => {
const [t] = useTranslation();
const commentEdit = useRef(null);
const handleEditClick = useCallback(() => {
commentEdit.current.open();
}, []);
const DeletePopup = usePopup(DeleteStep);
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.content)}>
<CommentEdit
ref={commentEdit}
defaultData={data}
onUpdate={onUpdate}
text={
<div className={styles.text}>
<Markdown linkTarget="_blank">{data.text}</Markdown>
</div>
}
actions={
<div className={styles.title}>
<span>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</span>
{canEdit && (
<Comment.Actions>
<Comment.Action
as="button"
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
<DeletePopup
title="common.deleteComment"
content="common.areYouSureYouWantToDeleteThisComment"
buttonContent="action.deleteComment"
onConfirm={onDelete}
>
<Comment.Action
as="button"
content={t('action.delete')}
disabled={!isPersisted}
/>
</DeletePopup>
</Comment.Actions>
)}
</div>
}
/>
</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
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default ItemComment;

View File

@@ -1,54 +0,0 @@
:global(#app) {
.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: #fff;
border-radius: 0px 8px 8px;
box-shadow: 0 1px 2px -1px rgba(9, 30, 66, 0.25),
0 0 0 1px rgba(9, 30, 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;
}
.title {
padding-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: -1em;
background: #f5f6f7;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}
}

View File

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

View File

@@ -1,53 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../lib/custom-ui';
import styles from './AttachmentAddStep.module.scss';
const AttachmentAddStep = React.memo(({ onCreate, onClose }) => {
const [t] = useTranslation();
const handleFileSelect = useCallback(
(file) => {
onCreate({
file,
});
onClose();
},
[onCreate, onClose],
);
return (
<>
<Popup.Header>
{t('common.addAttachment', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<FilePicker multiple onSelect={handleFileSelect}>
<Menu.Item className={styles.menuItem}>
{t('common.fromComputer', {
context: 'title',
})}
</Menu.Item>
</FilePicker>
</Menu>
<hr className={styles.divider} />
<div className={styles.tip}>
{t('common.pressPasteShortcutToAddAttachmentFromClipboard')}
</div>
</Popup.Content>
</>
);
});
AttachmentAddStep.propTypes = {
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default AttachmentAddStep;

View File

@@ -1,5 +0,0 @@
:global(#app) {
.field {
margin-bottom: 20px;
}
}

View File

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

View File

@@ -1,164 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Gallery, Item as GalleryItem } from 'react-photoswipe-gallery';
import { Button } from 'semantic-ui-react';
import { useToggle } from '../../../lib/hooks';
import Item from './Item';
import styles from './Attachments.module.scss';
const INITIALLY_VISIBLE = 4;
const Attachments = React.memo(
({ items, canEdit, onUpdate, onDelete, onCoverUpdate, onGalleryOpen, onGalleryClose }) => {
const [t] = useTranslation();
const [isAllVisible, toggleAllVisible] = useToggle();
const handleCoverSelect = useCallback(
(id) => {
onCoverUpdate(id);
},
[onCoverUpdate],
);
const handleCoverDeselect = useCallback(() => {
onCoverUpdate(null);
}, [onCoverUpdate]);
const handleUpdate = useCallback(
(id, data) => {
onUpdate(id, data);
},
[onUpdate],
);
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const handleBeforeGalleryOpen = useCallback(
(gallery) => {
onGalleryOpen();
gallery.on('destroy', () => {
onGalleryClose();
});
},
[onGalleryOpen, onGalleryClose],
);
const handleToggleAllVisibleClick = useCallback(() => {
toggleAllVisible();
}, [toggleAllVisible]);
const galleryItemsNode = items.map((item, index) => {
const isPdf = item.url && item.url.endsWith('.pdf');
let props;
if (item.image) {
props = item.image;
} else {
props = {
content: isPdf ? (
// eslint-disable-next-line jsx-a11y/alt-text
<object
data={item.url}
type="application/pdf"
className={classNames(styles.content, styles.contentPdf)}
/>
) : (
<span className={classNames(styles.content, styles.contentError)}>
{t('common.thereIsNoPreviewAvailableForThisAttachment')}
</span>
),
};
}
const isVisible = isAllVisible || index < INITIALLY_VISIBLE;
return (
<GalleryItem
{...props} // eslint-disable-line react/jsx-props-no-spreading
key={item.id}
original={item.url}
caption={item.name}
>
{({ ref, open }) =>
isVisible ? (
<Item
ref={ref}
name={item.name}
url={item.url}
coverUrl={item.coverUrl}
createdAt={item.createdAt}
isCover={item.isCover}
isPersisted={item.isPersisted}
canEdit={canEdit}
onClick={item.image || isPdf ? open : undefined}
onCoverSelect={() => handleCoverSelect(item.id)}
onCoverDeselect={handleCoverDeselect}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
/>
) : (
<span ref={ref} />
)
}
</GalleryItem>
);
});
return (
<>
<Gallery
withCaption
withDownloadButton
options={{
wheelToZoom: true,
showHideAnimationType: 'none',
closeTitle: '',
zoomTitle: '',
arrowPrevTitle: '',
arrowNextTitle: '',
errorMsg: '',
}}
onBeforeOpen={handleBeforeGalleryOpen}
>
{galleryItemsNode}
</Gallery>
{items.length > INITIALLY_VISIBLE && (
<Button
fluid
content={
isAllVisible
? t('action.showFewerAttachments')
: t('action.showAllAttachments', {
hidden: items.length - INITIALLY_VISIBLE,
})
}
className={styles.toggleButton}
onClick={handleToggleAllVisibleClick}
/>
)}
</>
);
},
);
Attachments.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onCoverUpdate: PropTypes.func.isRequired,
onGalleryOpen: PropTypes.func.isRequired,
onGalleryClose: PropTypes.func.isRequired,
};
export default Attachments;

View File

@@ -1,163 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Label, Loader } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import EditStep from './EditStep';
import styles from './Item.module.scss';
const Item = React.forwardRef(
(
{
name,
url,
coverUrl,
createdAt,
isCover,
isPersisted,
canEdit,
onCoverSelect,
onCoverDeselect,
onClick,
onUpdate,
onDelete,
},
ref,
) => {
const [t] = useTranslation();
const handleClick = useCallback(() => {
if (onClick) {
onClick();
} else {
window.open(url, '_blank');
}
}, [url, onClick]);
const handleToggleCoverClick = useCallback(
(event) => {
event.stopPropagation();
if (isCover) {
onCoverDeselect();
} else {
onCoverSelect();
}
},
[isCover, onCoverSelect, onCoverDeselect],
);
const EditPopup = usePopup(EditStep);
if (!isPersisted) {
return (
<div className={classNames(styles.wrapper, styles.wrapperSubmitting)}>
<Loader inverted />
</div>
);
}
const filename = url.split('/').pop();
const extension = filename.slice((Math.max(0, filename.lastIndexOf('.')) || Infinity) + 1);
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<div ref={ref} className={styles.wrapper} onClick={handleClick}>
<div
className={styles.thumbnail}
style={{
background: coverUrl && `url("${coverUrl}") center / cover`,
}}
>
{coverUrl ? (
isCover && (
<Label
corner="left"
size="mini"
icon={{
name: 'star',
color: 'grey',
inverted: true,
}}
className={styles.thumbnailLabel}
/>
)
) : (
<span className={styles.extension}>{extension || '-'}</span>
)}
</div>
<div className={styles.details}>
<span className={styles.name}>{name}</span>
<span className={styles.date}>
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
{coverUrl && canEdit && (
<span className={styles.options}>
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
name="window maximize outline"
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button>
</span>
)}
</div>
{canEdit && (
<EditPopup
defaultData={{
name,
}}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
);
},
);
Item.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string,
coverUrl: PropTypes.string,
createdAt: PropTypes.instanceOf(Date),
isCover: PropTypes.bool.isRequired,
isPersisted: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onClick: PropTypes.func,
onCoverSelect: PropTypes.func.isRequired,
onCoverDeselect: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
Item.defaultProps = {
url: undefined,
coverUrl: undefined,
createdAt: undefined,
onClick: undefined,
};
export default React.memo(Item);

View File

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

View File

@@ -1,642 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Grid, Icon, Modal } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import { Markdown } from '../../lib/custom-ui';
import { startStopwatch, stopStopwatch } from '../../utils/stopwatch';
import NameField from './NameField';
import DescriptionEdit from './DescriptionEdit';
import Tasks from './Tasks';
import Attachments from './Attachments';
import AttachmentAddZone from './AttachmentAddZone';
import AttachmentAddStep from './AttachmentAddStep';
import Activities from './Activities';
import User from '../User';
import Label from '../Label';
import DueDate from '../DueDate';
import Stopwatch from '../Stopwatch';
import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep';
import DueDateEditStep from '../DueDateEditStep';
import StopwatchEditStep from '../StopwatchEditStep';
import CardMoveStep from '../CardMoveStep';
import DeleteStep from '../DeleteStep';
import styles from './CardModal.module.scss';
const CardModal = React.memo(
({
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,
isAllActivitiesFetched,
isActivitiesDetailsVisible,
isActivitiesDetailsFetching,
listId,
boardId,
projectId,
users,
labels,
tasks,
attachments,
activities,
allProjectsToLists,
allBoardMemberships,
allLabels,
canEdit,
canEditCommentActivities,
canEditAllCommentActivities,
onUpdate,
onMove,
onTransfer,
onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
onBoardFetch,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelMove,
onLabelDelete,
onTaskCreate,
onTaskUpdate,
onTaskMove,
onTaskDelete,
onAttachmentCreate,
onAttachmentUpdate,
onAttachmentDelete,
onActivitiesFetch,
onActivitiesDetailsToggle,
onCommentActivityCreate,
onCommentActivityUpdate,
onCommentActivityDelete,
onClose,
}) => {
const [t] = useTranslation();
const [isLinkCopied, setIsLinkCopied] = useState(false);
const isGalleryOpened = useRef(false);
const handleToggleStopwatchClick = useCallback(() => {
onUpdate({
stopwatch: stopwatch.startedAt ? stopStopwatch(stopwatch) : startStopwatch(stopwatch),
});
}, [stopwatch, onUpdate]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleDescriptionUpdate = useCallback(
(newDescription) => {
onUpdate({
description: newDescription,
});
},
[onUpdate],
);
const handleDueDateUpdate = useCallback(
(newDueDate) => {
onUpdate({
dueDate: newDueDate,
});
},
[onUpdate],
);
const handleDueDateCompletionChange = useCallback(() => {
onUpdate({
isDueDateCompleted: !isDueDateCompleted,
});
}, [isDueDateCompleted, onUpdate]);
const handleStopwatchUpdate = useCallback(
(newStopwatch) => {
onUpdate({
stopwatch: newStopwatch,
});
},
[onUpdate],
);
const handleCoverUpdate = useCallback(
(newCoverAttachmentId) => {
onUpdate({
coverAttachmentId: newCoverAttachmentId,
});
},
[onUpdate],
);
const handleToggleSubscriptionClick = useCallback(() => {
onUpdate({
isSubscribed: !isSubscribed,
});
}, [isSubscribed, onUpdate]);
const handleDuplicateClick = useCallback(() => {
onDuplicate();
onClose();
}, [onDuplicate, onClose]);
const handleCopyLinkClick = useCallback(() => {
navigator.clipboard.writeText(window.location.href);
setIsLinkCopied(true);
setTimeout(() => {
setIsLinkCopied(false);
}, 5000);
}, []);
const handleGalleryOpen = useCallback(() => {
isGalleryOpened.current = true;
}, []);
const handleGalleryClose = useCallback(() => {
isGalleryOpened.current = false;
}, []);
const handleClose = useCallback(() => {
if (isGalleryOpened.current) {
return;
}
onClose();
}, [onClose]);
const AttachmentAddPopup = usePopup(AttachmentAddStep);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const DueDateEditPopup = usePopup(DueDateEditStep);
const StopwatchEditPopup = usePopup(StopwatchEditStep);
const CardMovePopup = usePopup(CardMoveStep);
const DeletePopup = usePopup(DeleteStep);
const userIds = users.map((user) => user.id);
const labelIds = labels.map((label) => label.id);
const contentNode = (
<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.headerTitleWrapper}>
{canEdit ? (
<NameField defaultValue={name} onUpdate={handleNameUpdate} />
) : (
<div className={styles.headerTitle}>{name}</div>
)}
</div>
</div>
</Grid.Column>
</Grid.Row>
<Grid.Row className={styles.modalPadding}>
<Grid.Column width={canEdit ? 12 : 16} className={styles.contentPadding}>
{(users.length > 0 || labels.length > 0 || dueDate || stopwatch) && (
<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}>
{canEdit ? (
<BoardMembershipsPopup
items={allBoardMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<User name={user.name} avatarUrl={user.avatarUrl} />
</BoardMembershipsPopup>
) : (
<User name={user.name} avatarUrl={user.avatarUrl} />
)}
</span>
))}
{canEdit && (
<BoardMembershipsPopup
items={allBoardMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button
type="button"
className={classNames(styles.attachment, styles.dueDate)}
>
<Icon name="add" size="small" className={styles.addAttachment} />
</button>
</BoardMembershipsPopup>
)}
</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}>
{canEdit ? (
<LabelsPopup
key={label.id}
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onMove={onLabelMove}
onDelete={onLabelDelete}
>
<Label name={label.name} color={label.color} />
</LabelsPopup>
) : (
<Label name={label.name} color={label.color} />
)}
</span>
))}
{canEdit && (
<LabelsPopup
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onMove={onLabelMove}
onDelete={onLabelDelete}
>
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button
type="button"
className={classNames(styles.attachment, styles.dueDate)}
>
<Icon name="add" size="small" className={styles.addAttachment} />
</button>
</LabelsPopup>
)}
</div>
)}
{dueDate && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.dueDate', {
context: 'title',
})}
</div>
<span className={classNames(styles.attachment, styles.attachmentDueDate)}>
{canEdit ? (
<>
<Checkbox
checked={isDueDateCompleted}
disabled={!canEdit}
onChange={handleDueDateCompletionChange}
/>
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
<DueDate
withStatusIcon
value={dueDate}
isCompleted={isDueDateCompleted}
/>
</DueDateEditPopup>
</>
) : (
<DueDate withStatusIcon value={dueDate} isCompleted={isDueDateCompleted} />
)}
</span>
</div>
)}
{stopwatch && (
<div className={styles.attachments}>
<div className={styles.text}>
{t('common.stopwatch', {
context: 'title',
})}
</div>
<span className={styles.attachment}>
{canEdit ? (
<StopwatchEditPopup
defaultValue={stopwatch}
onUpdate={handleStopwatchUpdate}
>
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
</StopwatchEditPopup>
) : (
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
)}
</span>
{canEdit && (
// eslint-disable-next-line jsx-a11y/control-has-associated-label
<button
type="button"
className={classNames(styles.attachment, styles.dueDate)}
onClick={handleToggleStopwatchClick}
>
<Icon
name={stopwatch.startedAt ? 'pause' : 'play'}
size="small"
className={styles.addAttachment}
/>
</button>
)}
</div>
)}
</div>
)}
{(description || canEdit) && (
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="align left" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.description')}</div>
{canEdit ? (
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>
{description ? (
<button
type="button"
className={classNames(styles.descriptionText, styles.cursorPointer)}
>
<Markdown linkStopPropagation linkTarget="_blank">
{description}
</Markdown>
</button>
) : (
<button type="button" className={styles.descriptionButton}>
<span className={styles.descriptionButtonText}>
{t('action.addMoreDetailedDescription')}
</span>
</button>
)}
</DescriptionEdit>
) : (
<div className={styles.descriptionText}>
<Markdown linkStopPropagation linkTarget="_blank">
{description}
</Markdown>
</div>
)}
</div>
</div>
)}
{(tasks.length > 0 || canEdit) && (
<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}
canEdit={canEdit}
onCreate={onTaskCreate}
onUpdate={onTaskUpdate}
onMove={onTaskMove}
onDelete={onTaskDelete}
/>
</div>
</div>
)}
{attachments.length > 0 && (
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="attach" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.attachments')}</div>
<Attachments
items={attachments}
canEdit={canEdit}
onUpdate={onAttachmentUpdate}
onDelete={onAttachmentDelete}
onCoverUpdate={handleCoverUpdate}
onGalleryOpen={handleGalleryOpen}
onGalleryClose={handleGalleryClose}
/>
</div>
</div>
)}
<Activities
items={activities}
isFetching={isActivitiesFetching}
isAllFetched={isAllActivitiesFetched}
isDetailsVisible={isActivitiesDetailsVisible}
isDetailsFetching={isActivitiesDetailsFetching}
canEdit={canEditCommentActivities}
canEditAllComments={canEditAllCommentActivities}
onFetch={onActivitiesFetch}
onDetailsToggle={onActivitiesDetailsToggle}
onCommentCreate={onCommentActivityCreate}
onCommentUpdate={onCommentActivityUpdate}
onCommentDelete={onCommentActivityDelete}
/>
</Grid.Column>
{canEdit && (
<Grid.Column width={4} className={styles.sidebarPadding}>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
<BoardMembershipsPopup
items={allBoardMemberships}
currentUserIds={userIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<Button fluid className={styles.actionButton}>
<Icon name="user outline" className={styles.actionIcon} />
{t('common.members')}
</Button>
</BoardMembershipsPopup>
<LabelsPopup
items={allLabels}
currentIds={labelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onMove={onLabelMove}
onDelete={onLabelDelete}
>
<Button fluid className={styles.actionButton}>
<Icon name="bookmark outline" className={styles.actionIcon} />
{t('common.labels')}
</Button>
</LabelsPopup>
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
<Button fluid className={styles.actionButton}>
<Icon name="calendar check outline" className={styles.actionIcon} />
{t('common.dueDate', {
context: 'title',
})}
</Button>
</DueDateEditPopup>
<StopwatchEditPopup defaultValue={stopwatch} onUpdate={handleStopwatchUpdate}>
<Button fluid className={styles.actionButton}>
<Icon name="clock outline" className={styles.actionIcon} />
{t('common.stopwatch')}
</Button>
</StopwatchEditPopup>
<AttachmentAddPopup onCreate={onAttachmentCreate}>
<Button fluid className={styles.actionButton}>
<Icon name="attach" className={styles.actionIcon} />
{t('common.attachment')}
</Button>
</AttachmentAddPopup>
</div>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('common.actions')}</span>
<Button
fluid
className={styles.actionButton}
onClick={handleToggleSubscriptionClick}
>
<Icon name="paper plane outline" className={styles.actionIcon} />
{isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
</Button>
<CardMovePopup
projectsToLists={allProjectsToLists}
defaultPath={{
projectId,
boardId,
listId,
}}
onMove={onMove}
onTransfer={onTransfer}
onBoardFetch={onBoardFetch}
>
<Button
fluid
className={styles.actionButton}
onClick={handleToggleSubscriptionClick}
>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</CardMovePopup>
<Button fluid className={styles.actionButton} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
{window.isSecureContext && (
<Button fluid className={styles.actionButton} onClick={handleCopyLinkClick}>
<Icon
name={isLinkCopied ? 'linkify' : 'unlink'}
className={styles.actionIcon}
/>
{isLinkCopied
? t('common.linkIsCopied')
: t('action.copyLink', {
context: 'title',
})}
</Button>
)}
<DeletePopup
title="common.deleteCard"
content="common.areYouSureYouWantToDeleteThisCard"
buttonContent="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>
);
return (
<Modal open closeIcon centered={false} onClose={handleClose} className={styles.wrapper}>
{canEdit ? (
<AttachmentAddZone onCreate={onAttachmentCreate}>{contentNode}</AttachmentAddZone>
) : (
contentNode
)}
</Modal>
);
},
);
CardModal.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired,
isActivitiesFetching: PropTypes.bool.isRequired,
isAllActivitiesFetched: PropTypes.bool.isRequired,
isActivitiesDetailsVisible: PropTypes.bool.isRequired,
isActivitiesDetailsFetching: PropTypes.bool.isRequired,
listId: PropTypes.string.isRequired,
boardId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
attachments: PropTypes.array.isRequired,
activities: PropTypes.array.isRequired,
allProjectsToLists: PropTypes.array.isRequired,
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
canEditCommentActivities: PropTypes.bool.isRequired,
canEditAllCommentActivities: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onBoardFetch: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onTaskCreate: PropTypes.func.isRequired,
onTaskUpdate: PropTypes.func.isRequired,
onTaskMove: PropTypes.func.isRequired,
onTaskDelete: PropTypes.func.isRequired,
onAttachmentCreate: PropTypes.func.isRequired,
onAttachmentUpdate: PropTypes.func.isRequired,
onAttachmentDelete: PropTypes.func.isRequired,
onActivitiesFetch: PropTypes.func.isRequired,
onActivitiesDetailsToggle: PropTypes.func.isRequired,
onCommentActivityCreate: PropTypes.func.isRequired,
onCommentActivityUpdate: PropTypes.func.isRequired,
onCommentActivityDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
CardModal.defaultProps = {
description: undefined,
dueDate: undefined,
isDueDateCompleted: false,
stopwatch: undefined,
};
export default CardModal;

View File

@@ -1,153 +0,0 @@
import React, { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import SimpleMDE from 'react-simplemde-editor';
import { useClickAwayListener } from '../../lib/hooks';
import { useNestedRef } from '../../hooks';
import styles from './DescriptionEdit.module.scss';
const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, setValue] = useState(null);
const editorWrapperRef = useRef(null);
const codemirrorRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef();
const open = useCallback(() => {
setIsOpened(true);
setValue(defaultValue || '');
}, [defaultValue, setValue]);
const close = useCallback(() => {
const cleanValue = value.trim() || null;
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
setIsOpened(false);
setValue(null);
}, [defaultValue, onUpdate, value, setValue]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
if (!window.getSelection().toString()) {
open();
}
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.ctrlKey && event.key === 'Enter') {
close();
}
},
[close],
);
const handleSubmit = useCallback(() => {
close();
}, [close]);
const handleAwayClick = useCallback(() => {
if (!isOpened) {
return;
}
close();
}, [isOpened, close]);
const handleClickAwayCancel = useCallback(() => {
codemirrorRef.current.focus();
}, []);
const clickAwayProps = useClickAwayListener(
[editorWrapperRef, buttonRef],
handleAwayClick,
handleClickAwayCancel,
);
const handleGetCodemirrorInstance = useCallback((codemirror) => {
codemirrorRef.current = codemirror;
}, []);
const mdEditorOptions = useMemo(
() => ({
autoDownloadFontAwesome: false,
autofocus: true,
spellChecker: false,
status: false,
toolbar: [
'bold',
'italic',
'heading',
'strikethrough',
'|',
'quote',
'unordered-list',
'ordered-list',
'table',
'|',
'link',
'image',
'|',
'undo',
'redo',
'|',
'guide',
],
}),
[],
);
if (!isOpened) {
return React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form onSubmit={handleSubmit}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div {...clickAwayProps} ref={editorWrapperRef}>
<SimpleMDE
value={value}
options={mdEditorOptions}
placeholder={t('common.enterDescription')}
className={styles.field}
getCodemirrorInstance={handleGetCodemirrorInstance}
onKeyDown={handleFieldKeyDown}
onChange={setValue}
/>
</div>
<div className={styles.controls}>
<Button positive ref={handleButtonRef} content={t('action.save')} />
</div>
</Form>
);
});
DescriptionEdit.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string,
onUpdate: PropTypes.func.isRequired,
};
DescriptionEdit.defaultProps = {
defaultValue: undefined,
};
export default React.memo(DescriptionEdit);

View File

@@ -1,21 +0,0 @@
:global(#app) {
.controls {
clear: both;
margin-top: 6px;
}
.field {
background: #fff;
color: #17394d;
display: block;
font-size: 14px;
line-height: 1.5;
margin-bottom: 4px;
overflow: hidden;
resize: none;
&:focus {
outline: none;
}
}
}

View File

@@ -1,68 +0,0 @@
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, usePrevious } from '../../lib/hooks';
import { useField } from '../../hooks';
import styles from './NameField.module.scss';
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

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

View File

@@ -1,144 +0,0 @@
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 { useDidUpdate, useToggle } from '../../../lib/hooks';
import { useClosableForm, useForm } from '../../../hooks';
import styles from './Add.module.scss';
const DEFAULT_DATA = {
name: '',
};
const MULTIPLE_REGEX = /\s*\r?\n\s*/;
const Add = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [focusNameFieldState, focusNameField] = useToggle();
const nameField = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
}, []);
const close = useCallback(() => {
setIsOpened(false);
}, []);
const submit = useCallback(
(isMultiple = false) => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
if (isMultiple) {
cleanData.name.split(MULTIPLE_REGEX).forEach((name) => {
onCreate({
...cleanData,
name,
});
});
} else {
onCreate(cleanData);
}
setData(DEFAULT_DATA);
focusNameField();
},
[onCreate, data, setData, focusNameField],
);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit(event.ctrlKey);
}
},
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
close,
isOpened,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
nameField.current.ref.current.focus();
}
}, [isOpened]);
useDidUpdate(() => {
nameField.current.ref.current.focus();
}, [focusNameFieldState]);
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

@@ -1,102 +0,0 @@
import React, { useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Draggable } from 'react-beautiful-dnd';
import { Button, Checkbox, Icon } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import NameEdit from './NameEdit';
import ActionsStep from './ActionsStep';
import Linkify from '../../Linkify';
import styles from './Item.module.scss';
const Item = React.memo(
({ id, index, name, isCompleted, isPersisted, canEdit, onUpdate, onDelete }) => {
const nameEdit = useRef(null);
const handleClick = useCallback(() => {
if (isPersisted && canEdit) {
nameEdit.current.open();
}
}, [isPersisted, canEdit]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleToggleChange = useCallback(() => {
onUpdate({
isCompleted: !isCompleted,
});
}, [isCompleted, onUpdate]);
const handleNameEdit = useCallback(() => {
nameEdit.current.open();
}, []);
const ActionsPopup = usePopup(ActionsStep);
return (
<Draggable draggableId={id} index={index} isDragDisabled={!isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const contentNode = (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} {...dragHandleProps} ref={innerRef} className={styles.wrapper}>
<span className={styles.checkboxWrapper}>
<Checkbox
checked={isCompleted}
disabled={!isPersisted || !canEdit}
className={styles.checkbox}
onChange={handleToggleChange}
/>
</span>
<NameEdit ref={nameEdit} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={classNames(canEdit && styles.contentHoverable)}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span
className={classNames(styles.text, canEdit && styles.textEditable)}
onClick={handleClick}
>
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
<Linkify linkStopPropagation>{name}</Linkify>
</span>
</span>
{isPersisted && canEdit && (
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
)}
</div>
</NameEdit>
</div>
);
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
}}
</Draggable>
);
},
);
Item.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
isPersisted: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Item;

View File

@@ -1,103 +0,0 @@
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 { useField } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers';
import styles from './NameEdit.module.scss';
const NameEdit = 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 && 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 = useCallback(() => {
submit();
}, [submit]);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
focusEnd(field.current.ref.current);
}
}, [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}>
<Button positive content={t('action.save')} />
</div>
</Form>
);
});
NameEdit.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(NameEdit);

View File

@@ -1,116 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { Progress } from 'semantic-ui-react';
import { closePopup } from '../../../lib/popup';
import DroppableTypes from '../../../constants/DroppableTypes';
import Item from './Item';
import Add from './Add';
import styles from './Tasks.module.scss';
import globalStyles from '../../../styles.module.scss';
const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete }) => {
const [t] = useTranslation();
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
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 completedItems = items.filter((item) => item.isCompleted);
return (
<>
{items.length > 0 && (
<>
<span className={styles.progressWrapper}>
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
</span>
<span className={styles.count}>
{completedItems.length}/{items.length}
</span>
</>
)}
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="tasks" type={DroppableTypes.TASK}>
{({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} ref={innerRef}>
{items.map((item, index) => (
<Item
key={item.id}
id={item.id}
index={index}
name={item.name}
isCompleted={item.isCompleted}
isPersisted={item.isPersisted}
canEdit={canEdit}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
/>
))}
{placeholder}
{canEdit && (
<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>
)}
</div>
)}
</Droppable>
</DragDropContext>
</>
);
});
Tasks.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired,
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Tasks;

View File

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

View File

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

View File

@@ -1,161 +0,0 @@
import React, { useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import styles from './CardMoveStep.module.scss';
const CardMoveStep = React.memo(
({ projectsToLists, defaultPath, onMove, onTransfer, onBoardFetch, onBack, onClose }) => {
const [t] = useTranslation();
const [path, handleFieldChange] = useForm(() => ({
projectId: null,
boardId: null,
listId: null,
...defaultPath,
}));
const selectedProject = useMemo(
() => projectsToLists.find((project) => project.id === path.projectId) || null,
[projectsToLists, path.projectId],
);
const selectedBoard = useMemo(
() =>
(selectedProject && selectedProject.boards.find((board) => board.id === path.boardId)) ||
null,
[selectedProject, path.boardId],
);
const selectedList = useMemo(
() => (selectedBoard && selectedBoard.lists.find((list) => list.id === path.listId)) || null,
[selectedBoard, path.listId],
);
const handleBoardIdChange = useCallback(
(event, data) => {
if (selectedProject.boards.find((board) => board.id === data.value).isFetching === null) {
onBoardFetch(data.value);
}
handleFieldChange(event, data);
},
[onBoardFetch, handleFieldChange, selectedProject],
);
const handleSubmit = useCallback(() => {
if (selectedBoard.id !== defaultPath.boardId) {
onTransfer(selectedBoard.id, selectedList.id);
} else if (selectedList.id !== defaultPath.listId) {
onMove(selectedList.id);
}
onClose();
}, [defaultPath, onMove, onTransfer, onClose, selectedBoard, selectedList]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.moveCard', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.project')}</div>
<Dropdown
fluid
selection
name="projectId"
options={projectsToLists.map((project) => ({
text: project.name,
value: project.id,
}))}
value={selectedProject && selectedProject.id}
placeholder={
projectsToLists.length === 0 ? t('common.noProjects') : t('common.selectProject')
}
disabled={projectsToLists.length === 0}
className={styles.field}
onChange={handleFieldChange}
/>
{selectedProject && (
<>
<div className={styles.text}>{t('common.board')}</div>
<Dropdown
fluid
selection
name="boardId"
options={selectedProject.boards.map((board) => ({
text: board.name,
value: board.id,
}))}
value={selectedBoard && selectedBoard.id}
placeholder={
selectedProject.boards.length === 0
? t('common.noBoards')
: t('common.selectBoard')
}
disabled={selectedProject.boards.length === 0}
className={styles.field}
onChange={handleBoardIdChange}
/>
</>
)}
{selectedBoard && (
<>
<div className={styles.text}>{t('common.list')}</div>
<Dropdown
fluid
selection
name="listId"
options={selectedBoard.lists.map((list) => ({
text: list.name,
value: list.id,
}))}
value={selectedList && selectedList.id}
placeholder={
selectedBoard.isFetching === false && selectedBoard.lists.length === 0
? t('common.noLists')
: t('common.selectList')
}
loading={selectedBoard.isFetching !== false}
disabled={selectedBoard.isFetching !== false || selectedBoard.lists.length === 0}
className={styles.field}
onChange={handleFieldChange}
/>
</>
)}
<Button
positive
content={t('action.move')}
disabled={(selectedBoard && selectedBoard.isFetching !== false) || !selectedList}
/>
</Form>
</Popup.Content>
</>
);
},
);
CardMoveStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
projectsToLists: PropTypes.array.isRequired,
defaultPath: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onBoardFetch: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
CardMoveStep.defaultProps = {
onBack: undefined,
};
export default CardMoveStep;

View File

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

View File

@@ -1,52 +0,0 @@
import classNames from 'classnames';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import PropTypes from 'prop-types';
import React from 'react';
import { Button } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import globalStyles from '../../styles.module.scss';
import styles from './ColorPicker.module.scss';
const ColorPicker = React.memo(({ current, onChange, colors, allowDeletion }) => {
const { t } = useTranslation();
return (
<>
<div className={styles.colorButtons}>
{colors.map((color) => (
<Button
key={color}
type="button"
name="color"
value={color}
className={classNames(
styles.colorButton,
color === current && styles.colorButtonActive,
globalStyles[`background${upperFirst(camelCase(color))}`],
)}
onClick={onChange}
/>
))}
</div>
{current && allowDeletion && (
<Button fluid value={undefined} onClick={onChange} content={t('action.removeColor')} />
)}
</>
);
});
ColorPicker.propTypes = {
current: PropTypes.string,
colors: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func,
allowDeletion: PropTypes.bool,
};
ColorPicker.defaultProps = {
current: undefined,
onChange: undefined,
allowDeletion: false,
};
export default ColorPicker;

View File

@@ -1,40 +0,0 @@
:global(#app) {
.colorButton {
float: left;
height: 40px;
margin: 4px;
padding: 0;
position: relative;
width: 49.6px;
&: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;
&:after {
content: '';
display: table;
clear: both;
}
}
}

View File

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

View File

@@ -1,90 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation, Trans } from 'react-i18next';
import { Loader } from 'semantic-ui-react';
import ModalTypes from '../../constants/ModalTypes';
import FixedContainer from '../../containers/FixedContainer';
import StaticContainer from '../../containers/StaticContainer';
import UsersModalContainer from '../../containers/UsersModalContainer';
import UserSettingsModalContainer from '../../containers/UserSettingsModalContainer';
import ProjectAddModalContainer from '../../containers/ProjectAddModalContainer';
import Background from '../Background';
import styles from './Core.module.scss';
const Core = React.memo(
({ isInitializing, isSocketDisconnected, currentModal, currentProject, currentBoard }) => {
const [t] = useTranslation();
const defaultTitle = useRef(document.title);
useEffect(() => {
let title;
if (currentProject) {
title = currentProject.name;
if (currentBoard) {
title += ` | ${currentBoard.name}`;
}
} else {
title = defaultTitle.current;
}
document.title = title;
}, [currentProject, currentBoard]);
return (
<>
{isInitializing ? (
<Loader active size="massive" />
) : (
<>
{currentProject && currentProject.background && (
<Background
type={currentProject.background.type}
name={currentProject.background.name}
imageUrl={currentProject.backgroundImage && currentProject.backgroundImage.url}
/>
)}
<FixedContainer />
<StaticContainer />
{currentModal === ModalTypes.USERS && <UsersModalContainer />}
{currentModal === ModalTypes.USER_SETTINGS && <UserSettingsModalContainer />}
{currentModal === ModalTypes.PROJECT_ADD && <ProjectAddModalContainer />}
</>
)}
{isSocketDisconnected && (
<div className={styles.message}>
<div className={styles.messageHeader}>{t('common.noConnectionToServer')}</div>
<div className={styles.messageContent}>
<Trans i18nKey="common.allChangesWillBeAutomaticallySavedAfterConnectionRestored">
All changes will be automatically saved
<br />
after connection restored
</Trans>
</div>
</div>
)}
</>
);
},
);
Core.propTypes = {
isInitializing: PropTypes.bool.isRequired,
isSocketDisconnected: PropTypes.bool.isRequired,
currentModal: PropTypes.oneOf(Object.values(ModalTypes)),
/* eslint-disable react/forbid-prop-types */
currentProject: PropTypes.object,
currentBoard: PropTypes.object,
/* eslint-enable react/forbid-prop-types */
};
Core.defaultProps = {
currentModal: undefined,
currentProject: undefined,
currentBoard: undefined,
};
export default Core;

View File

@@ -1,28 +0,0 @@
:global(#app) {
.message {
background: #eb5a46;
border-radius: 4px;
bottom: 20px;
box-shadow: #b04632 0 1px 0;
max-width: calc(100% - 40px);
padding: 12px 18px;
position: fixed;
right: 20px;
width: 390px;
z-index: 10001;
}
.messageContent {
color: #fff;
font-size: 16px;
line-height: 1.4;
}
.messageHeader {
color: #fff;
font-size: 24px;
font-weight: bold;
line-height: 1.2;
margin-bottom: 8px;
}
}

View File

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

View File

@@ -1,39 +0,0 @@
import React 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 styles from './DeleteStep.module.scss';
const DeleteStep = React.memo(({ title, content, buttonContent, onConfirm, onBack }) => {
const [t] = useTranslation();
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.content}>{t(content)}</div>
<Button fluid negative content={t(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

@@ -1,7 +0,0 @@
:global(#app) {
.content {
color: #212121;
padding-bottom: 6px;
padding-left: 2px;
}
}

View File

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

View File

@@ -1,154 +0,0 @@
import upperFirst from 'lodash/upperFirst';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { useForceUpdate } from '../../lib/hooks';
import getDateFormat from '../../utils/get-date-format';
import styles from './DueDate.module.scss';
const SIZES = {
TINY: 'tiny',
SMALL: 'small',
MEDIUM: 'medium',
};
const STATUSES = {
DUE_SOON: 'dueSoon',
OVERDUE: 'overdue',
COMPLETED: 'completed',
};
const LONG_DATE_FORMAT_BY_SIZE = {
tiny: 'longDate',
small: 'longDate',
medium: 'longDateTime',
};
const FULL_DATE_FORMAT_BY_SIZE = {
tiny: 'fullDate',
small: 'fullDate',
medium: 'fullDateTime',
};
const STATUS_ICON_PROPS_BY_STATUS = {
[STATUSES.DUE_SOON]: {
name: 'hourglass half',
color: 'orange',
},
[STATUSES.OVERDUE]: {
name: 'hourglass end',
color: 'red',
},
[STATUSES.COMPLETED]: {
name: 'checkmark',
color: 'green',
},
};
const getStatus = (dateTime, isCompleted) => {
if (isCompleted) {
return STATUSES.COMPLETED;
}
const secondsLeft = Math.floor((dateTime.getTime() - new Date().getTime()) / 1000);
if (secondsLeft <= 0) {
return STATUSES.OVERDUE;
}
if (secondsLeft <= 24 * 60 * 60) {
return STATUSES.DUE_SOON;
}
return null;
};
const DueDate = React.memo(({ value, size, isCompleted, isDisabled, withStatusIcon, onClick }) => {
const [t] = useTranslation();
const forceUpdate = useForceUpdate();
const statusRef = useRef(null);
statusRef.current = getStatus(value, isCompleted);
const intervalRef = useRef(null);
const dateFormat = getDateFormat(
value,
LONG_DATE_FORMAT_BY_SIZE[size],
FULL_DATE_FORMAT_BY_SIZE[size],
);
useEffect(() => {
if ([null, STATUSES.DUE_SOON].includes(statusRef.current)) {
intervalRef.current = setInterval(() => {
const status = getStatus(value, isCompleted);
if (status !== statusRef.current) {
forceUpdate();
}
if (status === STATUSES.OVERDUE) {
clearInterval(intervalRef.current);
}
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [value, isCompleted, forceUpdate]);
const contentNode = (
<span
className={classNames(
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
!withStatusIcon && statusRef.current && styles[`wrapper${upperFirst(statusRef.current)}`],
onClick && styles.wrapperHoverable,
)}
>
{t(`format:${dateFormat}`, {
value,
postProcess: 'formatDate',
})}
{withStatusIcon && statusRef.current && (
// eslint-disable-next-line react/jsx-props-no-spreading
<Icon {...STATUS_ICON_PROPS_BY_STATUS[statusRef.current]} className={styles.statusIcon} />
)}
</span>
);
return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode}
</button>
) : (
contentNode
);
});
DueDate.propTypes = {
value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(SIZES)),
isCompleted: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool,
withStatusIcon: PropTypes.bool,
onClick: PropTypes.func,
onCompletionToggle: PropTypes.func,
};
DueDate.defaultProps = {
size: SIZES.MEDIUM,
isDisabled: false,
withStatusIcon: false,
onClick: undefined,
onCompletionToggle: undefined,
};
export default DueDate;

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import HeaderContainer from '../../containers/HeaderContainer';
import ProjectContainer from '../../containers/ProjectContainer';
import BoardActionsContainer from '../../containers/BoardActionsContainer';
import styles from './Fixed.module.scss';
function Fixed({ projectId, board }) {
return (
<div className={styles.wrapper}>
<HeaderContainer />
{projectId && <ProjectContainer />}
{board && !board.isFetching && <BoardActionsContainer />}
</div>
);
}
Fixed.propTypes = {
projectId: PropTypes.string,
board: PropTypes.object, // eslint-disable-line react/forbid-prop-types
};
Fixed.defaultProps = {
projectId: undefined,
board: undefined,
};
export default Fixed;

View File

@@ -1,8 +0,0 @@
:global(#app) {
.wrapper {
max-width: 100vw;
position: fixed;
width: 100%;
z-index: 1;
}
}

View File

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

View File

@@ -1,126 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Button, Icon, Menu } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths';
import NotificationsStep from './NotificationsStep';
import User from '../User';
import UserStep from '../UserStep';
import styles from './Header.module.scss';
const POPUP_PROPS = {
position: 'bottom right',
};
const Header = React.memo(
({
project,
user,
notifications,
isLogouting,
canEditProject,
canEditUsers,
onProjectSettingsClick,
onUsersClick,
onNotificationDelete,
onUserSettingsClick,
onLogout,
}) => {
const handleProjectSettingsClick = useCallback(() => {
if (canEditProject) {
onProjectSettingsClick();
}
}, [canEditProject, onProjectSettingsClick]);
const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS);
const UserPopup = usePopup(UserStep, POPUP_PROPS);
return (
<div className={styles.wrapper}>
{!project && (
<Link to={Paths.ROOT} className={classNames(styles.logo, styles.title)}>
Planka
</Link>
)}
<Menu inverted size="large" className={styles.menu}>
{project && (
<Menu.Menu position="left">
<Menu.Item
as={Link}
to={Paths.ROOT}
className={classNames(styles.item, styles.itemHoverable)}
>
<Icon fitted name="arrow left" />
</Menu.Item>
<Menu.Item className={classNames(styles.item, styles.title)}>
{project.name}
{canEditProject && (
<Button
className={classNames(styles.editButton, styles.target)}
onClick={handleProjectSettingsClick}
>
<Icon fitted name="pencil" size="small" />
</Button>
)}
</Menu.Item>
</Menu.Menu>
)}
<Menu.Menu position="right">
{canEditUsers && (
<Menu.Item
className={classNames(styles.item, styles.itemHoverable)}
onClick={onUsersClick}
>
<Icon fitted name="users" />
</Menu.Item>
)}
<NotificationsPopup items={notifications} onDelete={onNotificationDelete}>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
<Icon fitted name="bell" />
{notifications.length > 0 && (
<span className={styles.notification}>{notifications.length}</span>
)}
</Menu.Item>
</NotificationsPopup>
<UserPopup
isLogouting={isLogouting}
onSettingsClick={onUserSettingsClick}
onLogout={onLogout}
>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
<span className={styles.userName}>{user.name}</span>
<User name={user.name} avatarUrl={user.avatarUrl} size="small" />
</Menu.Item>
</UserPopup>
</Menu.Menu>
</Menu>
</div>
);
},
);
Header.propTypes = {
/* eslint-disable react/forbid-prop-types */
project: PropTypes.object,
user: PropTypes.object.isRequired,
notifications: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isLogouting: PropTypes.bool.isRequired,
canEditProject: PropTypes.bool.isRequired,
canEditUsers: PropTypes.bool.isRequired,
onProjectSettingsClick: PropTypes.func.isRequired,
onUsersClick: PropTypes.func.isRequired,
onNotificationDelete: PropTypes.func.isRequired,
onUserSettingsClick: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
};
Header.defaultProps = {
project: undefined,
};
export default Header;

View File

@@ -1,140 +0,0 @@
import truncate from 'lodash/truncate';
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 { Popup } from '../../lib/custom-ui';
import Paths from '../../constants/Paths';
import { ActivityTypes } from '../../constants/Enums';
import User from '../User';
import styles from './NotificationsStep.module.scss';
const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
const [t] = useTranslation();
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const handleDeleteAll = useCallback(() => {
items.forEach((item) => {
onDelete(item.id);
});
}, [items, onDelete]);
const renderItemContent = useCallback(
({ activity, card }) => {
switch (activity.type) {
case ActivityTypes.MOVE_CARD:
return (
<Trans
i18nKey="common.userMovedCardFromListToList"
values={{
user: activity.user.name,
card: card.name,
fromList: activity.data.fromList.name,
toList: activity.data.toList.name,
}}
>
{activity.user.name}
{' moved '}
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
{card.name}
</Link>
{' from '}
{activity.data.fromList.name}
{' to '}
{activity.data.toList.name}
</Trans>
);
case ActivityTypes.COMMENT_CARD: {
const commentText = truncate(activity.data.text);
return (
<Trans
i18nKey="common.userLeftNewCommentToCard"
values={{
user: activity.user.name,
comment: commentText,
card: card.name,
}}
>
{activity.user.name}
{` left a new comment «${commentText}» 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', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{items.length > 0 ? (
<div className={styles.wrapper}>
{items.length > 1 && (
<Button
type="button"
icon="trash alternate outline"
content={t('action.deleteNotifications')}
onClick={handleDeleteAll}
className={styles.deleteAllButton}
/>
)}
{items.map((item) => (
<div key={item.id} className={styles.item}>
{item.card && item.activity ? (
<>
<User
name={item.activity.user.name}
avatarUrl={item.activity.user.avatarUrl}
size="large"
/>
<span className={styles.itemContent}>{renderItemContent(item)}</span>
</>
) : (
<div className={styles.itemDeleted}>{t('common.cardOrActionAreDeleted')}</div>
)}
<Button
type="button"
icon="trash alternate outline"
className={styles.itemButton}
onClick={() => handleDelete(item.id)}
/>
</div>
))}
</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 NotificationsStep;

View File

@@ -1,80 +0,0 @@
:global(#app) {
.item {
padding: 12px;
&:hover {
background: #f0f0f0;
}
}
.itemButton {
background: transparent;
box-shadow: none;
float: right;
height: 20px;
line-height: 20px;
margin: 0;
min-height: auto;
padding: 0;
transition: background 0.3s ease;
width: 20px;
&:hover {
background: #e9e9e9;
}
}
.itemContent {
display: inline-block;
font-size: 12px;
min-height: 36px;
overflow: hidden;
padding: 0 4px 0 8px;
vertical-align: top;
width: calc(100% - 56px);
word-break: break-word;
}
.itemDeleted {
display: inline-block;
line-height: 20px;
min-height: 20px;
padding: 0 4px 0 8px;
vertical-align: top;
width: calc(100% - 20px);
}
.wrapper {
margin: 0 -12px;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
.deleteAllButton {
background: transparent;
box-shadow: none;
transition: background 0.3s ease;
display: block;
margin: 0 auto;
padding: 0.5em 1em;
font-size: 0.875em;
&:hover {
background: #e9e9e9;
}
}
}

View File

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

View File

@@ -1,58 +0,0 @@
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import LabelColors from '../../constants/LabelColors';
import styles from './Label.module.scss';
import globalStyles from '../../styles.module.scss';
const SIZES = {
TINY: 'tiny',
SMALL: 'small',
MEDIUM: 'medium',
};
const Label = React.memo(({ name, color, size, isDisabled, onClick }) => {
const contentNode = (
<span
title={name}
className={classNames(
styles.wrapper,
!name && styles.wrapperNameless,
styles[`wrapper${upperFirst(size)}`],
onClick && styles.wrapperHoverable,
globalStyles[`background${upperFirst(camelCase(color))}`],
)}
>
{name || '\u00A0'}
</span>
);
return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode}
</button>
) : (
contentNode
);
});
Label.propTypes = {
name: PropTypes.string,
color: PropTypes.oneOf(LabelColors).isRequired,
size: PropTypes.oneOf(Object.values(SIZES)),
isDisabled: PropTypes.bool,
onClick: PropTypes.func,
};
Label.defaultProps = {
name: undefined,
size: SIZES.MEDIUM,
isDisabled: false,
onClick: undefined,
};
export default Label;

View File

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

View File

@@ -1,5 +0,0 @@
:global(#app) {
.submitButton {
margin-top: 12px;
}
}

View File

@@ -1,8 +0,0 @@
:global(#app) {
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
}

View File

@@ -1,42 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Input } from '../../lib/custom-ui';
import LabelColors from '../../constants/LabelColors';
import ColorPicker from '../ColorPicker';
import styles from './Editor.module.scss';
const Editor = React.memo(({ data, onFieldChange }) => {
const [t] = useTranslation();
const nameField = useRef(null);
useEffect(() => {
nameField.current.select();
}, []);
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>
<ColorPicker colors={LabelColors} current={data.color} onChange={onFieldChange} />
</>
);
});
Editor.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onFieldChange: PropTypes.func.isRequired,
};
export default Editor;

View File

@@ -1,81 +0,0 @@
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Draggable } from 'react-beautiful-dnd';
import { Button } from 'semantic-ui-react';
import styles from './Item.module.scss';
import globalStyles from '../../styles.module.scss';
const Item = React.memo(
({ id, index, name, color, isPersisted, isActive, canEdit, onSelect, onDeselect, onEdit }) => {
const handleToggleClick = useCallback(() => {
if (isPersisted) {
if (isActive) {
onDeselect();
} else {
onSelect();
}
}
}, [isPersisted, isActive, onSelect, onDeselect]);
return (
<Draggable draggableId={id} index={index} isDragDisabled={!isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const contentNode = (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} ref={innerRef} className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
className={classNames(
styles.name,
isActive && styles.nameActive,
globalStyles[`background${upperFirst(camelCase(color))}`],
)}
onClick={handleToggleClick}
>
{name}
</span>
{canEdit && (
<Button
icon="pencil"
size="small"
floated="right"
disabled={!isPersisted}
className={styles.editButton}
onClick={onEdit}
/>
)}
</div>
);
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
}}
</Draggable>
);
},
);
Item.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string,
color: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
};
Item.defaultProps = {
name: undefined,
};
export default Item;

View File

@@ -1,245 +0,0 @@
import pick from 'lodash/pick';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { Button } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import { useField, useSteps } from '../../hooks';
import DroppableTypes from '../../constants/DroppableTypes';
import AddStep from './AddStep';
import EditStep from './EditStep';
import Item from './Item';
import styles from './LabelsStep.module.scss';
import globalStyles from '../../styles.module.scss';
const StepTypes = {
ADD: 'ADD',
EDIT: 'EDIT',
};
const LabelsStep = React.memo(
({
items,
currentIds,
title,
canEdit,
onSelect,
onDeselect,
onCreate,
onUpdate,
onMove,
onDelete,
onBack,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredItems = useMemo(
() =>
items.filter(
(label) =>
(label.name && label.name.toLowerCase().includes(cleanSearch)) ||
label.color.includes(cleanSearch),
),
[items, cleanSearch],
);
const searchField = useRef(null);
const handleAddClick = useCallback(() => {
openStep(StepTypes.ADD);
}, [openStep]);
const handleEdit = useCallback(
(id) => {
openStep(StepTypes.EDIT, {
id,
});
},
[openStep],
);
const handleSelect = useCallback(
(id) => {
onSelect(id);
},
[onSelect],
);
const handleDeselect = useCallback(
(id) => {
onDeselect(id);
},
[onDeselect],
);
const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging);
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
document.body.classList.remove(globalStyles.dragging);
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],
);
useEffect(() => {
searchField.current.focus({
preventScroll: true,
});
}, []);
if (step) {
switch (step.type) {
case StepTypes.ADD:
return (
<AddStep
defaultData={{
name: search,
}}
onCreate={onCreate}
onBack={handleBack}
/>
);
case StepTypes.EDIT: {
const currentItem = items.find((item) => item.id === step.params.id);
if (currentItem) {
return (
<EditStep
defaultData={pick(currentItem, ['name', 'color'])}
onUpdate={(data) => handleUpdate(currentItem.id, data)}
onDelete={() => handleDelete(currentItem.id)}
onBack={handleBack}
/>
);
}
openStep(null);
break;
}
default:
}
}
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={searchField}
value={search}
placeholder={t('common.searchLabels')}
icon="search"
onChange={handleSearchChange}
/>
{filteredItems.length > 0 && (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="labels" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={styles.items}
>
{filteredItems.map((item, index) => (
<Item
key={item.id}
id={item.id}
index={index}
name={item.name}
color={item.color}
isPersisted={item.isPersisted}
isActive={currentIds.includes(item.id)}
canEdit={canEdit}
onSelect={() => handleSelect(item.id)}
onDeselect={() => handleDeselect(item.id)}
onEdit={() => handleEdit(item.id)}
/>
))}
{placeholder}
</div>
)}
</Droppable>
<Droppable droppableId="labels:hack" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={styles.droppableHack}
>
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
{canEdit && (
<Button
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
/>
)}
</Popup.Content>
</>
);
},
);
LabelsStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
items: PropTypes.array.isRequired,
currentIds: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
title: PropTypes.string,
canEdit: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
LabelsStep.defaultProps = {
title: 'common.labels',
canEdit: true,
onBack: undefined,
};
export default LabelsStep;

View File

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

View File

@@ -1,150 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import ListColors from '../../constants/ListColors';
import { useSteps } from '../../hooks';
import ColorPicker from '../ColorPicker';
import ListSortStep from '../ListSortStep';
import DeleteStep from '../DeleteStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
DELETE: 'DELETE',
SORT: 'SORT',
EDIT_COLOR: 'CHANGE_COLOR',
};
const ActionsStep = React.memo(
({ onNameEdit, onCardAdd, onSort, onDelete, onClose, onColorEdit, color }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleEditNameClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleAddCardClick = useCallback(() => {
onCardAdd();
onClose();
}, [onCardAdd, onClose]);
const handleSortClick = useCallback(() => {
openStep(StepTypes.SORT);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const hanndleEditColorClick = useCallback(() => {
openStep(StepTypes.EDIT_COLOR);
}, [openStep]);
const handleSortTypeSelect = useCallback(
(type) => {
onSort({
type,
});
onClose();
},
[onSort, onClose],
);
if (step && step.type) {
switch (step.type) {
case StepTypes.SORT:
return <ListSortStep onTypeSelect={handleSortTypeSelect} onBack={handleBack} />;
case StepTypes.DELETE:
return (
<DeleteStep
title="common.deleteList"
content="common.areYouSureYouWantToDeleteThisList"
buttonContent="action.deleteList"
onConfirm={onDelete}
onBack={handleBack}
/>
);
case StepTypes.EDIT_COLOR:
return (
<>
<Popup.Header onBack={handleBack}>
{t('action.editColor', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<ColorPicker
colors={ListColors}
current={color}
allowDeletion
onChange={(e) => onColorEdit(e.currentTarget.value)}
/>
</Popup.Content>
</>
);
default:
}
}
return (
<>
<Popup.Header>
{t('common.listActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={hanndleEditColorClick}>
{t('action.editColor', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleAddCardClick}>
{t('action.addCard', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSortClick}>
{t('action.sortList', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteList', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
},
);
ActionsStep.propTypes = {
onNameEdit: PropTypes.func.isRequired,
onCardAdd: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onColorEdit: PropTypes.func.isRequired,
color: PropTypes.string,
};
ActionsStep.defaultProps = {
color: undefined,
};
export default ActionsStep;

View File

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

View File

@@ -1,126 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useDidUpdate, useToggle } from '../../lib/hooks';
import { useClosableForm, useForm } from '../../hooks';
import styles from './CardAdd.module.scss';
const DEFAULT_DATA = {
name: '',
};
const CardAdd = React.memo(({ isOpened, onCreate, onClose }) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [focusNameFieldState, focusNameField] = useToggle();
const nameField = useRef(null);
const submit = useCallback(
(autoOpen) => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
onCreate(cleanData, autoOpen);
setData(DEFAULT_DATA);
if (autoOpen) {
onClose();
} else {
focusNameField();
}
},
[onCreate, onClose, data, setData, focusNameField],
);
const handleFieldKeyDown = useCallback(
(event) => {
switch (event.key) {
case 'Enter': {
event.preventDefault();
const autoOpen = event.ctrlKey;
submit(autoOpen);
break;
}
case 'Escape': {
onClose();
break;
}
default:
}
},
[onClose, submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(onClose);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
nameField.current.ref.current.focus();
}
}, [isOpened]);
useDidUpdate(() => {
nameField.current.ref.current.focus();
}, [focusNameFieldState]);
return (
<Form
className={classNames(styles.wrapper, !isOpened && styles.wrapperClosed)}
onSubmit={handleSubmit}
>
<div className={styles.fieldWrapper}>
<TextArea
ref={nameField}
as={TextareaAutosize}
name="name"
value={data.name}
placeholder={t('common.enterCardTitle')}
minRows={3}
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.addCard')}
className={styles.submitButton}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
CardAdd.propTypes = {
isOpened: PropTypes.bool.isRequired,
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default CardAdd;

View File

@@ -1,32 +0,0 @@
:global(#app) {
.field {
border: none;
margin-bottom: 4px;
outline: none;
overflow: hidden;
padding: 0;
resize: none;
width: 100%;
}
.fieldWrapper {
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 #ccc;
margin-bottom: 8px;
min-height: 20px;
padding: 6px 8px 2px;
}
.submitButton {
vertical-align: top;
}
.wrapper {
padding-bottom: 8px;
}
.wrapperClosed {
display: none;
}
}

View File

@@ -1,204 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import camelCase from 'lodash/camelCase';
import { useTranslation } from 'react-i18next';
import upperFirst from 'lodash/upperFirst';
import { Draggable, Droppable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import DroppableTypes from '../../constants/DroppableTypes';
import CardContainer from '../../containers/CardContainer';
import CardAdd from './CardAdd';
import NameEdit from './NameEdit';
import ActionsStep from './ActionsStep';
import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-icon.svg';
import styles from './List.module.scss';
import globalStyles from '../../styles.module.scss';
const List = React.memo(
({
id,
index,
name,
color,
isPersisted,
cardIds,
canEdit,
onUpdate,
onDelete,
onSort,
onCardCreate,
}) => {
const [t] = useTranslation();
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
const nameEdit = useRef(null);
const cardsWrapper = useRef(null);
const handleHeaderClick = useCallback(() => {
if (isPersisted && canEdit) {
nameEdit.current.open();
}
}, [isPersisted, canEdit]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleColorEdit = useCallback(
(newColor) => {
onUpdate({
color: newColor,
});
},
[onUpdate],
);
const handleAddCardClick = useCallback(() => {
setIsAddCardOpened(true);
}, []);
const handleAddCardClose = useCallback(() => {
setIsAddCardOpened(false);
}, []);
const handleNameEdit = useCallback(() => {
nameEdit.current.open();
}, []);
const handleCardAdd = useCallback(() => {
setIsAddCardOpened(true);
}, []);
useEffect(() => {
if (isAddCardOpened) {
cardsWrapper.current.scrollTop = cardsWrapper.current.scrollHeight;
}
}, [cardIds, isAddCardOpened]);
const ActionsPopup = usePopup(ActionsStep);
const cardsNode = (
<Droppable
droppableId={`list:${id}`}
type={DroppableTypes.CARD}
isDropDisabled={!isPersisted}
>
{({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} ref={innerRef}>
<div className={styles.cards}>
{cardIds.map((cardId, cardIndex) => (
<CardContainer key={cardId} id={cardId} index={cardIndex} />
))}
{placeholder}
{canEdit && (
<CardAdd
isOpened={isAddCardOpened}
onCreate={onCardCreate}
onClose={handleAddCardClose}
/>
)}
</div>
</div>
)}
</Droppable>
);
return (
<Draggable draggableId={`list:${id}`} index={index} isDragDisabled={!isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }) => (
<div
{...draggableProps} // eslint-disable-line react/jsx-props-no-spreading
data-drag-scroller
ref={innerRef}
className={styles.innerWrapper}
>
<div className={styles.outerWrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
className={classNames(styles.header, canEdit && styles.headerEditable)}
onClick={handleHeaderClick}
>
<NameEdit ref={nameEdit} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={styles.headerName}>
{color && (
<Icon
name="circle"
className={classNames(
styles.headerNameColor,
globalStyles[`color${upperFirst(camelCase(color))}`],
)}
/>
)}
{name}
</div>
</NameEdit>
{isPersisted && canEdit && (
<ActionsPopup
onNameEdit={handleNameEdit}
onCardAdd={handleCardAdd}
onDelete={onDelete}
onSort={onSort}
color={color}
onColorEdit={handleColorEdit}
>
<Button className={classNames(styles.headerButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
)}
</div>
<div ref={cardsWrapper} className={styles.cardsInnerWrapper}>
<div className={styles.cardsOuterWrapper}>{cardsNode}</div>
</div>
{!isAddCardOpened && canEdit && (
<button
type="button"
disabled={!isPersisted}
className={styles.addCardButton}
onClick={handleAddCardClick}
>
<PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}>
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
</span>
</button>
)}
</div>
</div>
)}
</Draggable>
);
},
);
List.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
isPersisted: PropTypes.bool.isRequired,
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onCardCreate: PropTypes.func.isRequired,
};
List.defaultProps = {
color: undefined,
};
export default List;

View File

@@ -1,104 +0,0 @@
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import TextareaAutosize from 'react-textarea-autosize';
import { TextArea } from 'semantic-ui-react';
import { useField } from '../../hooks';
import { focusEnd } from '../../utils/element-helpers';
import styles from './NameEdit.module.scss';
const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
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 && cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
close();
}, [defaultValue, onUpdate, value, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleFieldClick = useCallback((event) => {
event.stopPropagation();
}, []);
const handleFieldKeyDown = useCallback(
(event) => {
switch (event.key) {
case 'Enter':
event.preventDefault();
submit();
break;
case 'Escape':
submit();
break;
default:
}
},
[submit],
);
const handleFieldBlur = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
focusEnd(field.current.ref.current);
}
}, [isOpened]);
if (!isOpened) {
return children;
}
return (
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
spellCheck={false}
className={styles.field}
onClick={handleFieldClick}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
);
});
NameEdit.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(NameEdit);

View File

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

View File

@@ -1,61 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { ListSortTypes } from '../../constants/Enums';
import styles from './ListSortStep.module.scss';
const ListSortStep = React.memo(({ onTypeSelect, onBack }) => {
const [t] = useTranslation();
return (
<>
<Popup.Header onBack={onBack}>
{t('common.sortList', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.NAME_ASC)}
>
{t('common.title')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.DUE_DATE_ASC)}
>
{t('common.dueDate')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_ASC)}
>
{t('common.oldestFirst')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_DESC)}
>
{t('common.newestFirst')}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ListSortStep.propTypes = {
onTypeSelect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ListSortStep.defaultProps = {
onBack: undefined,
};
export default ListSortStep;

View File

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

View File

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

View File

@@ -1,269 +0,0 @@
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import { isUsername } from '../../utils/validator';
import styles from './Login.module.scss';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Invalid credentials':
return {
type: 'error',
content: 'common.invalidCredentials',
};
case 'Invalid email or username':
return {
type: 'error',
content: 'common.invalidEmailOrUsername',
};
case 'Invalid password':
return {
type: 'error',
content: 'common.invalidPassword',
};
case 'Use single sign-on':
return {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Failed to fetch':
return {
type: 'warning',
content: 'common.noInternetConnection',
};
case 'Network request failed':
return {
type: 'warning',
content: 'common.serverConnectionFailed',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const Login = React.memo(
({
defaultData,
isSubmitting,
isSubmittingUsingOidc,
error,
withOidc,
isOidcEnforced,
onAuthenticate,
onAuthenticateUsingOidc,
onMessageDismiss,
}) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm(() => ({
emailOrUsername: '',
password: '',
...defaultData,
}));
const message = useMemo(() => createMessage(error), [error]);
const [focusPasswordFieldState, focusPasswordField] = useToggle();
const emailOrUsernameField = useRef(null);
const passwordField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
emailOrUsername: data.emailOrUsername.trim(),
};
if (!isEmail(cleanData.emailOrUsername) && !isUsername(cleanData.emailOrUsername)) {
emailOrUsernameField.current.select();
return;
}
if (!cleanData.password) {
passwordField.current.focus();
return;
}
onAuthenticate(cleanData);
}, [onAuthenticate, data]);
useEffect(() => {
if (!isOidcEnforced) {
emailOrUsernameField.current.focus();
}
}, [isOidcEnforced]);
useEffect(() => {
if (wasSubmitting && !isSubmitting && error) {
switch (error.message) {
case 'Invalid credentials':
case 'Invalid email or username':
emailOrUsernameField.current.select();
break;
case 'Invalid password':
setData((prevData) => ({
...prevData,
password: '',
}));
focusPasswordField();
break;
default:
}
}
}, [isSubmitting, wasSubmitting, error, setData, focusPasswordField]);
useDidUpdate(() => {
passwordField.current.focus();
}, [focusPasswordFieldState]);
return (
<div className={classNames(styles.wrapper, styles.fullHeight)}>
<Grid verticalAlign="middle" className={styles.fullHeightPaddingFix}>
<Grid.Column widescreen={4} largeScreen={5} computer={6} tablet={16} mobile={16}>
<Grid verticalAlign="middle" className={styles.fullHeightPaddingFix}>
<Grid.Column>
<div className={styles.loginWrapper}>
<Header
as="h1"
textAlign="center"
content={t('common.logInToPlanka')}
className={styles.formTitle}
/>
<div>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}
{!isOidcEnforced && (
<Form size="large" onSubmit={handleSubmit}>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.emailOrUsername')}</div>
<Input
fluid
ref={emailOrUsernameField}
name="emailOrUsername"
value={data.emailOrUsername}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.password')}</div>
<Input.Password
fluid
ref={passwordField}
name="password"
value={data.password}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<Form.Button
primary
size="large"
icon="right arrow"
labelPosition="right"
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting || isSubmittingUsingOidc}
/>
</Form>
)}
{withOidc && (
<Button
type="button"
fluid={isOidcEnforced}
primary={isOidcEnforced}
size={isOidcEnforced ? 'large' : undefined}
icon={isOidcEnforced ? 'right arrow' : undefined}
labelPosition={isOidcEnforced ? 'right' : undefined}
content={t('action.logInWithSSO')}
loading={isSubmittingUsingOidc}
disabled={isSubmitting || isSubmittingUsingOidc}
onClick={onAuthenticateUsingOidc}
/>
)}
</div>
</div>
</Grid.Column>
</Grid>
</Grid.Column>
<Grid.Column
widescreen={12}
largeScreen={11}
computer={10}
only="computer"
className={classNames(styles.cover, styles.fullHeight)}
>
<div className={styles.descriptionWrapperOverlay} />
<div className={styles.descriptionWrapper}>
<Header inverted as="h1" content="Planka" className={styles.descriptionTitle} />
<Header
inverted
as="h2"
content={t('common.projectManagement')}
className={styles.descriptionSubtitle}
/>
</div>
</Grid.Column>
</Grid>
</div>
);
},
);
Login.propTypes = {
/* eslint-disable react/forbid-prop-types */
defaultData: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
isSubmitting: PropTypes.bool.isRequired,
isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired,
isOidcEnforced: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
};
Login.defaultProps = {
error: undefined,
};
export default Login;

View File

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

View File

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

View File

@@ -1,187 +0,0 @@
import pick from 'lodash/pick';
import React, { useCallback } 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 { useSteps } from '../../hooks';
import User from '../User';
import DeleteStep from '../DeleteStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
EDIT_PERMISSIONS: 'EDIT_PERMISSIONS',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({
membership,
permissionsSelectStep,
title,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
leaveConfirmationButtonContent,
deleteButtonContent,
deleteConfirmationTitle,
deleteConfirmationContent,
deleteConfirmationButtonContent,
canEdit,
canLeave,
onUpdate,
onDelete,
onBack,
onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleEditPermissionsClick = useCallback(() => {
openStep(StepTypes.EDIT_PERMISSIONS);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleRoleSelect = useCallback(
(data) => {
if (onUpdate) {
onUpdate(data);
}
},
[onUpdate],
);
const handleDeleteConfirm = useCallback(() => {
onDelete();
onClose();
}, [onDelete, onClose]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_PERMISSIONS: {
const PermissionsSelectStep = permissionsSelectStep;
return (
<PermissionsSelectStep
defaultData={pick(membership, ['role', 'canComment'])}
title="common.editPermissions"
buttonContent="action.save"
onSelect={handleRoleSelect}
onBack={handleBack}
onClose={onClose}
/>
);
}
case StepTypes.DELETE:
return (
<DeleteStep
title={membership.user.isCurrent ? leaveConfirmationTitle : deleteConfirmationTitle}
content={
membership.user.isCurrent ? leaveConfirmationContent : deleteConfirmationContent
}
buttonContent={
membership.user.isCurrent
? leaveConfirmationButtonContent
: deleteConfirmationButtonContent
}
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
default:
}
}
const contentNode = (
<>
<span className={styles.user}>
<User name={membership.user.name} avatarUrl={membership.user.avatarUrl} size="large" />
</span>
<span className={styles.content}>
<div className={styles.name}>{membership.user.name}</div>
<div className={styles.email}>{membership.user.email}</div>
</span>
{permissionsSelectStep && canEdit && (
<Button
fluid
content={t('action.editPermissions')}
className={styles.button}
onClick={handleEditPermissionsClick}
/>
)}
{membership.user.isCurrent
? canLeave && (
<Button
fluid
content={t(leaveButtonContent)}
className={styles.button}
onClick={handleDeleteClick}
/>
)
: canEdit && (
<Button
fluid
content={t(deleteButtonContent)}
className={styles.button}
onClick={handleDeleteClick}
/>
)}
</>
);
return onBack ? (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>{contentNode}</Popup.Content>
</>
) : (
contentNode
);
},
);
ActionsStep.propTypes = {
membership: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
leaveConfirmationButtonContent: PropTypes.string,
deleteButtonContent: PropTypes.string,
deleteConfirmationTitle: PropTypes.string,
deleteConfirmationContent: PropTypes.string,
deleteConfirmationButtonContent: PropTypes.string,
canEdit: PropTypes.bool.isRequired,
canLeave: PropTypes.bool.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
ActionsStep.defaultProps = {
permissionsSelectStep: undefined,
title: 'common.memberActions',
leaveButtonContent: 'action.leaveBoard',
leaveConfirmationTitle: 'common.leaveBoard',
leaveConfirmationContent: 'common.areYouSureYouWantToLeaveBoard',
leaveConfirmationButtonContent: 'action.leaveBoard',
deleteButtonContent: 'action.removeFromBoard',
deleteConfirmationTitle: 'common.removeMember',
deleteConfirmationContent: 'common.areYouSureYouWantToRemoveThisMemberFromBoard',
deleteConfirmationButtonContent: 'action.removeMember',
onUpdate: undefined,
onBack: undefined,
};
export default ActionsStep;

View File

@@ -1,145 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Input, Popup } from '../../../lib/custom-ui';
import { useField, useSteps } from '../../../hooks';
import UserItem from './UserItem';
import styles from './AddStep.module.scss';
const StepTypes = {
SELECT_PERMISSIONS: 'SELECT_PERMISSIONS',
};
const AddStep = React.memo(
({ users, currentUserIds, permissionsSelectStep, title, onCreate, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredUsers = useMemo(
() =>
users.filter(
(user) =>
user.email.includes(cleanSearch) ||
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[users, cleanSearch],
);
const searchField = useRef(null);
const handleUserSelect = useCallback(
(id) => {
if (permissionsSelectStep) {
openStep(StepTypes.SELECT_PERMISSIONS, {
userId: id,
});
} else {
onCreate({
userId: id,
});
onClose();
}
},
[permissionsSelectStep, onCreate, onClose, openStep],
);
const handleRoleSelect = useCallback(
(data) => {
onCreate({
userId: step.params.userId,
...data,
});
},
[onCreate, step],
);
useEffect(() => {
searchField.current.focus({
preventScroll: true,
});
}, []);
if (step) {
switch (step.type) {
case StepTypes.SELECT_PERMISSIONS: {
const currentUser = users.find((user) => user.id === step.params.userId);
if (currentUser) {
const PermissionsSelectStep = permissionsSelectStep;
return (
<PermissionsSelectStep
buttonContent="action.addMember"
onSelect={handleRoleSelect}
onBack={handleBack}
onClose={onClose}
/>
);
}
openStep(null);
break;
}
default:
}
}
return (
<>
<Popup.Header>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={searchField}
value={search}
placeholder={t('common.searchUsers')}
icon="search"
onChange={handleSearchChange}
/>
{filteredUsers.length > 0 && (
<div className={styles.users}>
{filteredUsers.map((user) => (
<UserItem
key={user.id}
name={user.name}
avatarUrl={user.avatarUrl}
isActive={currentUserIds.includes(user.id)}
onSelect={() => handleUserSelect(user.id)}
/>
))}
</div>
)}
</Popup.Content>
</>
);
},
);
AddStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
currentUserIds: PropTypes.array.isRequired,
/* eslint-disable react/forbid-prop-types */
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
AddStep.defaultProps = {
permissionsSelectStep: undefined,
title: 'common.addMember',
};
export default AddStep;

View File

@@ -1,20 +0,0 @@
:global(#app) {
.users {
margin-top: 8px;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
}

View File

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

View File

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

View File

@@ -1,156 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import AddStep from './AddStep';
import ActionsStep from './ActionsStep';
import MembershipsStep from './MembershipsStep';
import User from '../User';
import styles from './Memberships.module.scss';
const MAX_MEMBERS = 6;
const Memberships = React.memo(
({
items,
allUsers,
permissionsSelectStep,
title,
addTitle,
actionsTitle,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
leaveConfirmationButtonContent,
deleteButtonContent,
deleteConfirmationTitle,
deleteConfirmationContent,
deleteConfirmationButtonContent,
canEdit,
canLeaveIfLast,
onCreate,
onUpdate,
onDelete,
}) => {
const AddPopup = usePopup(AddStep);
const ActionsPopup = usePopup(ActionsStep);
const MembershipsPopup = usePopup(MembershipsStep);
const remainMembersCount = items.length - MAX_MEMBERS;
return (
<>
<span className={styles.users}>
{items.slice(0, MAX_MEMBERS).map((item) => (
<span key={item.id} className={styles.user}>
<ActionsPopup
membership={item}
permissionsSelectStep={permissionsSelectStep}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={items.length > 1 || canLeaveIfLast}
onUpdate={(data) => onUpdate(item.id, data)}
onDelete={() => onDelete(item.id)}
>
<User
name={item.user.name}
avatarUrl={item.user.avatarUrl}
size="large"
isDisabled={!item.isPersisted}
/>
</ActionsPopup>
</span>
))}
</span>
{remainMembersCount > 0 && (
<MembershipsPopup
items={items}
permissionsSelectStep={permissionsSelectStep}
title={title}
actionsTitle={actionsTitle}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={items.length > 1 || canLeaveIfLast}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button icon className={styles.addUser}>
+{remainMembersCount < 99 ? remainMembersCount : 99}
</Button>
</MembershipsPopup>
)}
{canEdit && (
<AddPopup
users={allUsers}
currentUserIds={items.map((item) => item.user.id)}
permissionsSelectStep={permissionsSelectStep}
title={addTitle}
onCreate={onCreate}
>
<Button icon="add user" className={styles.addUser} />
</AddPopup>
)}
</>
);
},
);
Memberships.propTypes = {
/* eslint-disable react/forbid-prop-types */
items: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
addTitle: PropTypes.string,
actionsTitle: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
leaveConfirmationButtonContent: PropTypes.string,
deleteButtonContent: PropTypes.string,
deleteConfirmationTitle: PropTypes.string,
deleteConfirmationContent: PropTypes.string,
deleteConfirmationButtonContent: PropTypes.string,
canEdit: PropTypes.bool,
canLeaveIfLast: PropTypes.bool,
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
};
Memberships.defaultProps = {
permissionsSelectStep: undefined,
title: undefined,
addTitle: undefined,
actionsTitle: undefined,
leaveButtonContent: undefined,
leaveConfirmationTitle: undefined,
leaveConfirmationContent: undefined,
leaveConfirmationButtonContent: undefined,
deleteButtonContent: undefined,
deleteConfirmationTitle: undefined,
deleteConfirmationContent: undefined,
deleteConfirmationButtonContent: undefined,
canEdit: true,
canLeaveIfLast: true,
onUpdate: undefined,
};
export default Memberships;

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