mirror of
https://github.com/plankanban/planka.git
synced 2025-12-25 09:15:00 +03:00
@@ -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;
|
||||
@@ -1,10 +0,0 @@
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
max-width: 100vw;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Background from './Background';
|
||||
|
||||
export default Background;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Board from './Board';
|
||||
|
||||
export default Board;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import BoardActions from './BoardActions';
|
||||
|
||||
export default BoardActions;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import BoardMembershipPermissionsSelectStep from './BoardMembershipPermissionsSelectStep';
|
||||
|
||||
export default BoardMembershipPermissionsSelectStep;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import BoardMembershipsStep from './BoardMembershipsStep';
|
||||
|
||||
export default BoardMembershipsStep;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AddStep from './AddStep';
|
||||
|
||||
export default AddStep;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Boards from './Boards';
|
||||
|
||||
export default Boards;
|
||||
@@ -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;
|
||||
@@ -1,11 +0,0 @@
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Card from './Card';
|
||||
|
||||
export default Card;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Activities from './Activities';
|
||||
|
||||
export default Activities;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import AttachmentAddZone from './AttachmentAddZone';
|
||||
|
||||
export default AttachmentAddZone;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,3 +0,0 @@
|
||||
import Attachments from './Attachments';
|
||||
|
||||
export default Attachments;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,11 +0,0 @@
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Task from './Tasks';
|
||||
|
||||
export default Task;
|
||||
@@ -1,3 +0,0 @@
|
||||
import CardModal from './CardModal';
|
||||
|
||||
export default CardModal;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import CardMoveStep from './CardMoveStep';
|
||||
|
||||
export default CardMoveStep;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import ColorPicker from './ColorPicker';
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Core from './Core';
|
||||
|
||||
export default Core;
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
:global(#app) {
|
||||
.content {
|
||||
color: #212121;
|
||||
padding-bottom: 6px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import DeleteStep from './DeleteStep';
|
||||
|
||||
export default DeleteStep;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DueDate from './DueDate';
|
||||
|
||||
export default DueDate;
|
||||
@@ -1,3 +0,0 @@
|
||||
import DueDateEditStep from './DueDateEditStep';
|
||||
|
||||
export default DueDateEditStep;
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
max-width: 100vw;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Fixed from './Fixed';
|
||||
|
||||
export default Fixed;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Header from './Header';
|
||||
|
||||
export default Header;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Label from './Label';
|
||||
|
||||
export default Label;
|
||||
@@ -1,5 +0,0 @@
|
||||
:global(#app) {
|
||||
.submitButton {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
:global(#app) {
|
||||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import LabelsStep from './LabelsStep';
|
||||
|
||||
export default LabelsStep;
|
||||
@@ -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;
|
||||
@@ -1,11 +0,0 @@
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,3 +0,0 @@
|
||||
import List from './List';
|
||||
|
||||
export default List;
|
||||
@@ -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;
|
||||
@@ -1,11 +0,0 @@
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import ListSortStep from './ListSortStep';
|
||||
|
||||
export default ListSortStep;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Login from './Login';
|
||||
|
||||
export default Login;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AddStep from './AddStep';
|
||||
|
||||
export default AddStep;
|
||||
@@ -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
Reference in New Issue
Block a user