feat: Add ability to copy/cut cards with shortcut support
This commit is contained in:
@@ -30,12 +30,17 @@ const EndlessContent = React.memo(() => {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCardPaste = useCallback(() => {
|
||||
dispatch(entryActions.pasteCardInCurrentList());
|
||||
}, [dispatch]);
|
||||
|
||||
const viewProps = {
|
||||
cardIds,
|
||||
isCardsFetching,
|
||||
isAllCardsFetched,
|
||||
onCardsFetch: handleCardsFetch,
|
||||
onCardCreate: handleCardCreate,
|
||||
onCardPaste: handleCardPaste,
|
||||
};
|
||||
|
||||
let View;
|
||||
|
||||
@@ -26,6 +26,10 @@ const FiniteContent = React.memo(() => {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCardPaste = useCallback(() => {
|
||||
dispatch(entryActions.pasteCardInCurrentContext());
|
||||
}, [dispatch]);
|
||||
|
||||
let View;
|
||||
switch (board.view) {
|
||||
case BoardViews.GRID:
|
||||
@@ -39,7 +43,13 @@ const FiniteContent = React.memo(() => {
|
||||
default:
|
||||
}
|
||||
|
||||
return <View cardIds={cardIds} onCardCreate={canAddCard ? handleCardCreate : undefined} />;
|
||||
return (
|
||||
<View
|
||||
cardIds={cardIds}
|
||||
onCardCreate={canAddCard ? handleCardCreate : undefined}
|
||||
onCardPaste={canAddCard ? handleCardPaste : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default FiniteContent;
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Loader } from 'semantic-ui-react';
|
||||
import { Button, Icon, Loader } from 'semantic-ui-react';
|
||||
import { useWindowWidth } from '../../../lib/hooks';
|
||||
import { Masonry } from '../../../lib/custom-ui';
|
||||
|
||||
@@ -21,11 +22,18 @@ import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
|
||||
import styles from './GridView.module.scss';
|
||||
|
||||
const GridView = React.memo(
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate }) => {
|
||||
const canAddCard = useSelector((state) => {
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate, onCardPaste }) => {
|
||||
const clipboard = useSelector(selectors.selectClipboard);
|
||||
|
||||
const { canAddCard, canPasteCard } = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
return {
|
||||
canAddCard: isEditor,
|
||||
canPasteCard: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
|
||||
@@ -59,17 +67,31 @@ const GridView = React.memo(
|
||||
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={styles.addCardButton}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
|
||||
</span>
|
||||
</Button>
|
||||
<div>
|
||||
<div className={styles.addCardButtonWrapper}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={styles.addCardButton}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
|
||||
</span>
|
||||
</Button>
|
||||
{onCardPaste && clipboard && canPasteCard && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={classNames(styles.addCardButton, styles.paste)}
|
||||
onClick={onCardPaste}
|
||||
>
|
||||
<Icon fitted name="paste" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{cardIds.map((cardId) => (
|
||||
<div key={cardId} className={styles.card}>
|
||||
@@ -97,6 +119,7 @@ GridView.propTypes = {
|
||||
isAllCardsFetched: PropTypes.bool,
|
||||
onCardsFetch: PropTypes.func,
|
||||
onCardCreate: PropTypes.func,
|
||||
onCardPaste: PropTypes.func,
|
||||
};
|
||||
|
||||
GridView.defaultProps = {
|
||||
@@ -104,6 +127,7 @@ GridView.defaultProps = {
|
||||
isAllCardsFetched: undefined,
|
||||
onCardsFetch: undefined,
|
||||
onCardCreate: undefined,
|
||||
onCardPaste: undefined,
|
||||
};
|
||||
|
||||
export default GridView;
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
fill: rgba(255, 255, 255, 0.72);
|
||||
flex: 1;
|
||||
font-weight: normal;
|
||||
height: 42px;
|
||||
margin: 0;
|
||||
min-height: 42px;
|
||||
padding: 11px;
|
||||
text-align: left;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in,
|
||||
border-color 85ms ease-in;
|
||||
width: 100%;
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
@@ -28,6 +28,10 @@
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
&.paste {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.addCardButtonIcon {
|
||||
@@ -43,6 +47,11 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.addCardButtonWrapper {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(223, 227, 230, 0.8);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Loader } from 'semantic-ui-react';
|
||||
import { Button, Icon, Loader } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardMembershipRoles } from '../../../constants/Enums';
|
||||
@@ -20,11 +20,18 @@ import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
const ListView = React.memo(
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate }) => {
|
||||
const canAddCard = useSelector((state) => {
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate, onCardPaste }) => {
|
||||
const clipboard = useSelector(selectors.selectClipboard);
|
||||
|
||||
const { canAddCard, canPasteCard } = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
return {
|
||||
canAddCard: isEditor,
|
||||
canPasteCard: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
|
||||
@@ -54,17 +61,29 @@ const ListView = React.memo(
|
||||
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={styles.addCardButton}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
|
||||
</span>
|
||||
</Button>
|
||||
<div className={styles.addCardButtonWrapper}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={styles.addCardButton}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
|
||||
</span>
|
||||
</Button>
|
||||
{onCardPaste && clipboard && canPasteCard && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={classNames(styles.addCardButton, styles.paste)}
|
||||
onClick={onCardPaste}
|
||||
>
|
||||
<Icon fitted name="paste" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{cardIds.length > 0 && (
|
||||
<div className={classNames(styles.segment, styles.cards)}>
|
||||
@@ -95,6 +114,7 @@ ListView.propTypes = {
|
||||
isAllCardsFetched: PropTypes.bool,
|
||||
onCardsFetch: PropTypes.func,
|
||||
onCardCreate: PropTypes.func,
|
||||
onCardPaste: PropTypes.func,
|
||||
};
|
||||
|
||||
ListView.defaultProps = {
|
||||
@@ -102,6 +122,7 @@ ListView.defaultProps = {
|
||||
isAllCardsFetched: undefined,
|
||||
onCardsFetch: undefined,
|
||||
onCardCreate: undefined,
|
||||
onCardPaste: undefined,
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
fill: rgba(255, 255, 255, 0.72);
|
||||
flex: 1;
|
||||
font-weight: normal;
|
||||
height: 42px;
|
||||
margin-bottom: 12px;
|
||||
margin: 0;
|
||||
padding: 11px;
|
||||
text-align: left;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in,
|
||||
border-color 85ms ease-in;
|
||||
width: 100%;
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
@@ -28,6 +28,10 @@
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
&.paste {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.addCardButtonIcon {
|
||||
@@ -43,6 +47,12 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.addCardButtonWrapper {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -18,15 +18,34 @@ import { isActiveTextElement } from '../../../utils/element-helpers';
|
||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||
import { BoardShortcutsContext } from '../../../contexts';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums';
|
||||
import {
|
||||
BoardContexts,
|
||||
BoardMembershipRoles,
|
||||
BoardViews,
|
||||
ListTypes,
|
||||
} from '../../../constants/Enums';
|
||||
import CardActionsStep from '../../cards/CardActionsStep';
|
||||
|
||||
const canCopyCard = (isManager, boardMembership) => {
|
||||
if (isManager) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canCutCard = (boardMembership) =>
|
||||
!!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
const canPasteCard = (boardMembership) =>
|
||||
!!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
const canEditCardName = (boardMembership, list) => {
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canArchiveCard = (boardMembership, list) => {
|
||||
@@ -34,7 +53,7 @@ const canArchiveCard = (boardMembership, list) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canUseCardMembers = (boardMembership, list) => {
|
||||
@@ -42,7 +61,7 @@ const canUseCardMembers = (boardMembership, list) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canUseCardLabels = (boardMembership, list) => {
|
||||
@@ -50,7 +69,7 @@ const canUseCardLabels = (boardMembership, list) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const ShortcutsProvider = React.memo(({ children }) => {
|
||||
@@ -58,8 +77,20 @@ const ShortcutsProvider = React.memo(({ children }) => {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const selectedListRef = useRef(null);
|
||||
const selectedCardRef = useRef(null);
|
||||
|
||||
const handleListMouseEnter = useCallback((id, onPaste) => {
|
||||
selectedListRef.current = {
|
||||
id,
|
||||
onPaste,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleListMouseLeave = useCallback(() => {
|
||||
selectedListRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleCardMouseEnter = useCallback((id, editName, openActions) => {
|
||||
selectedCardRef.current = {
|
||||
id,
|
||||
@@ -73,15 +104,106 @@ const ShortcutsProvider = React.memo(({ children }) => {
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => [handleCardMouseEnter, handleCardMouseLeave],
|
||||
[handleCardMouseEnter, handleCardMouseLeave],
|
||||
() => [handleListMouseEnter, handleListMouseLeave, handleCardMouseEnter, handleCardMouseLeave],
|
||||
[handleListMouseEnter, handleListMouseLeave, handleCardMouseEnter, handleCardMouseLeave],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
selectedListRef.current = null;
|
||||
selectedCardRef.current = null;
|
||||
}, [cardId, boardId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCardCopy = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
if (!canCopyCard(isManager, boardMembership)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(entryActions.copyCard(card.id));
|
||||
};
|
||||
|
||||
const handleCardCut = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
if (!canCutCard(boardMembership)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(entryActions.cutCard(card.id));
|
||||
};
|
||||
|
||||
const handleCardPaste = (event) => {
|
||||
const state = store.getState();
|
||||
const clipboard = selectors.selectClipboard(state);
|
||||
|
||||
if (!clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const board = selectors.selectCurrentBoard(state);
|
||||
|
||||
let listId;
|
||||
if (board.context === BoardContexts.BOARD) {
|
||||
if (board.view === BoardViews.KANBAN) {
|
||||
listId = selectedListRef.current?.id;
|
||||
} else {
|
||||
listId = selectors.selectFirstKanbanListId(state);
|
||||
}
|
||||
} else {
|
||||
listId = selectors.selectCurrentListId(state);
|
||||
}
|
||||
|
||||
if (!listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = selectors.selectListById(state, listId);
|
||||
|
||||
if (!list || !list.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
if (!canPasteCard(boardMembership)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(entryActions.pasteCard(list.id));
|
||||
|
||||
if (selectedListRef.current) {
|
||||
selectedListRef.current.onPaste();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardOpen = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
@@ -234,6 +356,22 @@ const ShortcutsProvider = React.memo(({ children }) => {
|
||||
}
|
||||
|
||||
if (isModifierKeyPressed(event)) {
|
||||
switch (event.key) {
|
||||
case 'c':
|
||||
handleCardCopy(event);
|
||||
|
||||
break;
|
||||
case 'x':
|
||||
handleCardCut(event);
|
||||
|
||||
break;
|
||||
case 'v':
|
||||
handleCardPaste(event);
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { closePopup, usePopup } from '../../../lib/popup';
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardShortcutsContext } from '../../../contexts';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import ClipboardTypes from '../../../constants/ClipboardTypes';
|
||||
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
|
||||
import ProjectContent from './ProjectContent';
|
||||
import StoryContent from './StoryContent';
|
||||
@@ -44,14 +45,25 @@ const Card = React.memo(({ id, isInline }) => {
|
||||
return selectIsCardWithIdRecent(state, id);
|
||||
});
|
||||
|
||||
const isCut = useSelector((state) => {
|
||||
const clipboard = selectors.selectClipboard(state);
|
||||
return clipboard && clipboard.type === ClipboardTypes.CUT && card.id === clipboard.cardId;
|
||||
});
|
||||
|
||||
const canUseActions = useSelector((state) => {
|
||||
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
|
||||
if (isManager) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
|
||||
const [handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
|
||||
const [, , handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
|
||||
|
||||
const actionsPopupRef = useRef(null);
|
||||
|
||||
@@ -139,7 +151,11 @@ const Card = React.memo(({ id, isInline }) => {
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
|
||||
className={classNames(
|
||||
styles.content,
|
||||
card.isClosed && styles.contentDisabled,
|
||||
isCut && styles.contentCut,
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleCardMouseLeave}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.contentCut {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.contentDisabled {
|
||||
filter: saturate(0.5);
|
||||
opacity: 0.64;
|
||||
|
||||
@@ -60,6 +60,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
|
||||
canEditName,
|
||||
canEditDueDate,
|
||||
canEditStopwatch,
|
||||
canCopy,
|
||||
canCut,
|
||||
canDuplicate,
|
||||
canMove,
|
||||
canRestore,
|
||||
@@ -68,6 +70,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
|
||||
canUseMembers,
|
||||
canUseLabels,
|
||||
} = useSelector((state) => {
|
||||
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
@@ -77,6 +81,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
|
||||
canEditName: false,
|
||||
canEditDueDate: false,
|
||||
canEditStopwatch: false,
|
||||
canCopy: isManager || isEditor,
|
||||
canCut: isEditor,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canRestore: isEditor,
|
||||
@@ -92,6 +98,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
|
||||
canEditName: isEditor,
|
||||
canEditDueDate: isEditor,
|
||||
canEditStopwatch: isEditor,
|
||||
canCopy: isManager || isEditor,
|
||||
canCut: isEditor,
|
||||
canDuplicate: isEditor,
|
||||
canMove: isEditor,
|
||||
canRestore: null,
|
||||
@@ -117,17 +125,20 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
|
||||
[cardId, dispatch],
|
||||
);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.duplicateCard(cardId, {
|
||||
name: `${card.name} (${t('common.copy', {
|
||||
context: 'inline',
|
||||
})})`,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
dispatch(entryActions.copyCard(cardId));
|
||||
onClose();
|
||||
}, [cardId, onClose, card.name, dispatch, t]);
|
||||
}, [cardId, onClose, dispatch]);
|
||||
|
||||
const handleCutClick = useCallback(() => {
|
||||
dispatch(entryActions.cutCard(cardId));
|
||||
onClose();
|
||||
}, [cardId, onClose, dispatch]);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
dispatch(entryActions.duplicateCard(cardId));
|
||||
onClose();
|
||||
}, [cardId, onClose, dispatch]);
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
dispatch(entryActions.moveCard(cardId, card.prevListId, undefined, true));
|
||||
@@ -344,6 +355,22 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCopy && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleCopyClick}>
|
||||
<Icon name="copy outline" className={styles.menuItemIcon} />
|
||||
{t('action.copyCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCut && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleCutClick}>
|
||||
<Icon name="cut" className={styles.menuItemIcon} />
|
||||
{t('action.cutCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canDuplicate && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
|
||||
<Icon name="copy outline" className={styles.menuItemIcon} />
|
||||
|
||||
@@ -34,17 +34,17 @@ const MoreActionsStep = React.memo(({ onClose }) => {
|
||||
const { canEditType, canDuplicate, canMove } = useSelector((state) => {
|
||||
const list = selectListById(state, card.listId);
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return {
|
||||
canEditType: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canMove: isEditor,
|
||||
};
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
return {
|
||||
canEditType: isEditor,
|
||||
canDuplicate: isEditor,
|
||||
@@ -68,14 +68,8 @@ const MoreActionsStep = React.memo(({ onClose }) => {
|
||||
);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.duplicateCurrentCard({
|
||||
name: `${card.name} (${t('common.copy', {
|
||||
context: 'inline',
|
||||
})})`,
|
||||
}),
|
||||
);
|
||||
}, [card.name, dispatch, t]);
|
||||
dispatch(entryActions.duplicateCurrentCard());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleEditTypeClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_TYPE);
|
||||
|
||||
@@ -105,7 +105,7 @@ const ProjectContent = React.memo(() => {
|
||||
canSubscribe: isMember,
|
||||
canJoin: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canMove: isEditor,
|
||||
canRestore: isEditor,
|
||||
canArchive: isEditor,
|
||||
canDelete: isEditor,
|
||||
|
||||
@@ -104,7 +104,7 @@ const StoryContent = React.memo(() => {
|
||||
canSubscribe: isMember,
|
||||
canJoin: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canMove: isEditor,
|
||||
canRestore: isEditor,
|
||||
canArchive: isEditor,
|
||||
canDelete: isEditor,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Message } from 'semantic-ui-react';
|
||||
|
||||
const FileIsTooBig = React.memo(() => {
|
||||
const FileIsTooBigToast = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -18,4 +18,4 @@ const FileIsTooBig = React.memo(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export default FileIsTooBig;
|
||||
export default FileIsTooBigToast;
|
||||
+2
-2
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Message } from 'semantic-ui-react';
|
||||
|
||||
const NotEnoughStorage = React.memo(() => {
|
||||
const NotEnoughStorageToast = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -18,4 +18,4 @@ const NotEnoughStorage = React.memo(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export default NotEnoughStorage;
|
||||
export default NotEnoughStorageToast;
|
||||
@@ -0,0 +1,21 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Message } from 'semantic-ui-react';
|
||||
|
||||
const SourceCardNotCopyableToast = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
<Message visible negative size="tiny">
|
||||
<Icon name="paste" />
|
||||
{t('common.sourceCardIsNoLongerAvailableForCopying')}
|
||||
</Message>
|
||||
);
|
||||
});
|
||||
|
||||
export default SourceCardNotCopyableToast;
|
||||
@@ -0,0 +1,21 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Message } from 'semantic-ui-react';
|
||||
|
||||
const SourceCardNotMovableToast = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
<Message visible negative size="tiny">
|
||||
<Icon name="paste" />
|
||||
{t('common.sourceCardIsNoLongerAvailableForMoving')}
|
||||
</Message>
|
||||
);
|
||||
});
|
||||
|
||||
export default SourceCardNotMovableToast;
|
||||
@@ -7,14 +7,18 @@ import React from 'react';
|
||||
import { Toaster as HotToaster, ToastBar as HotToastBar } from 'react-hot-toast';
|
||||
|
||||
import ToastTypes from '../../../constants/ToastTypes';
|
||||
import FileIsTooBig from './FileIsTooBig';
|
||||
import NotEnoughStorage from './NotEnoughStorage';
|
||||
import FileIsTooBigToast from './FileIsTooBigToast';
|
||||
import NotEnoughStorageToast from './NotEnoughStorageToast';
|
||||
import EmptyTrashToast from './EmptyTrashToast';
|
||||
import SourceCardNotCopyableToast from './SourceCardNotCopyableToast';
|
||||
import SourceCardNotMovableToast from './SourceCardNotMovableToast';
|
||||
|
||||
const TOAST_BY_TYPE = {
|
||||
[ToastTypes.FILE_IS_TOO_BIG]: FileIsTooBig,
|
||||
[ToastTypes.NOT_ENOUGH_STORAGE]: NotEnoughStorage,
|
||||
[ToastTypes.FILE_IS_TOO_BIG]: FileIsTooBigToast,
|
||||
[ToastTypes.NOT_ENOUGH_STORAGE]: NotEnoughStorageToast,
|
||||
[ToastTypes.EMPTY_TRASH]: EmptyTrashToast,
|
||||
[ToastTypes.SOURCE_CARD_NOT_COPYABLE]: SourceCardNotCopyableToast,
|
||||
[ToastTypes.SOURCE_CARD_NOT_MOVABLE]: SourceCardNotMovableToast,
|
||||
};
|
||||
|
||||
const Toaster = React.memo(() => (
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate, useTransitioning } from '../../../lib/hooks';
|
||||
import { useDidUpdate, useToggle, useTransitioning } from '../../../lib/hooks';
|
||||
import { usePopup } from '../../../lib/popup';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { BoardShortcutsContext } from '../../../contexts';
|
||||
import DroppableTypes from '../../../constants/DroppableTypes';
|
||||
import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums';
|
||||
import { ListTypeIcons } from '../../../constants/Icons';
|
||||
@@ -47,28 +48,36 @@ const List = React.memo(({ id, index }) => {
|
||||
[],
|
||||
);
|
||||
|
||||
const clipboard = useSelector(selectors.selectClipboard);
|
||||
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
|
||||
|
||||
const list = useSelector((state) => selectListById(state, id));
|
||||
const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id));
|
||||
|
||||
const { canEdit, canArchiveCards, canAddCard, canDropCard } = useSelector((state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector(
|
||||
(state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
return {
|
||||
canEdit: isEditModeEnabled && isEditor,
|
||||
canArchiveCards: list.type === ListTypes.CLOSED && isEditor,
|
||||
canAddCard: isEditor,
|
||||
canDropCard: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
return {
|
||||
canEdit: isEditModeEnabled && isEditor,
|
||||
canArchiveCards: list.type === ListTypes.CLOSED && isEditor,
|
||||
canAddCard: isEditor,
|
||||
canPasteCard: isEditor,
|
||||
canDropCard: isEditor,
|
||||
};
|
||||
},
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
|
||||
const [addCardPosition, setAddCardPosition] = useState(null);
|
||||
const [scrollBottomState, scrollBottom] = useToggle();
|
||||
const [handleListMouseEnter, handleListMouseLeave] = useContext(BoardShortcutsContext);
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const cardsWrapperRef = useRef(null);
|
||||
@@ -82,6 +91,17 @@ const List = React.memo(({ id, index }) => {
|
||||
[id, dispatch, addCardPosition],
|
||||
);
|
||||
|
||||
const handlePasteCardClick = useCallback(() => {
|
||||
dispatch(entryActions.pasteCard(id));
|
||||
scrollBottom();
|
||||
}, [id, dispatch, scrollBottom]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
handleListMouseEnter(id, () => {
|
||||
scrollBottom();
|
||||
});
|
||||
}, [id, scrollBottom, handleListMouseEnter]);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
if (list.isPersisted && canEdit) {
|
||||
setIsEditNameOpened(true);
|
||||
@@ -123,6 +143,10 @@ const List = React.memo(({ id, index }) => {
|
||||
addCardPosition === AddCardPositions.TOP ? 0 : cardsWrapperRef.current.scrollHeight;
|
||||
}, [cardIds, addCardPosition]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
|
||||
}, [scrollBottomState]);
|
||||
|
||||
const ActionsPopup = usePopup(ActionsStep);
|
||||
const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
|
||||
|
||||
@@ -169,6 +193,8 @@ const List = React.memo(({ id, index }) => {
|
||||
data-drag-scroller
|
||||
ref={innerRef}
|
||||
className={styles.innerWrapper}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleListMouseLeave}
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
@@ -231,17 +257,29 @@ const List = React.memo(({ id, index }) => {
|
||||
<div className={styles.cardsOuterWrapper}>{cardsNode}</div>
|
||||
</div>
|
||||
{!addCardPosition && canAddCard && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!list.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 className={styles.addCardButtonWrapper}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!list.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>
|
||||
{clipboard && canPasteCard && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!list.isPersisted}
|
||||
className={classNames(styles.addCardButton, styles.paste)}
|
||||
onClick={handlePasteCardClick}
|
||||
>
|
||||
<Icon name="paste" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,19 +15,22 @@
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
fill: #6b808c;
|
||||
flex: 0 0 auto;
|
||||
flex: 1;
|
||||
font-weight: normal;
|
||||
height: 36px;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: #c3cbd0;
|
||||
color: #17394d;
|
||||
fill: #17394d;
|
||||
}
|
||||
|
||||
&.paste {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.addCardButtonIcon {
|
||||
@@ -44,6 +47,10 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.addCardButtonWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user