diff --git a/client/src/actions/cards.js b/client/src/actions/cards.js
index 0cfffd4c..c436ecb9 100644
--- a/client/src/actions/cards.js
+++ b/client/src/actions/cards.js
@@ -160,6 +160,72 @@ const handleCardUpdate = (
},
});
+const transferCard = (id, data) => ({
+ type: ActionTypes.CARD_TRANSFER,
+ payload: {
+ id,
+ data,
+ },
+});
+
+transferCard.success = (
+ card,
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+) => ({
+ type: ActionTypes.CARD_TRANSFER__SUCCESS,
+ payload: {
+ card,
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+ },
+});
+
+transferCard.failure = (
+ id,
+ error,
+ card,
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+) => ({
+ type: ActionTypes.CARD_TRANSFER__FAILURE,
+ payload: {
+ id,
+ error,
+ card,
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+ },
+});
+
const duplicateCard = (id, localId, data) => ({
type: ActionTypes.CARD_DUPLICATE,
payload: {
@@ -204,6 +270,25 @@ duplicateCard.failure = (localId, error) => ({
},
});
+const copyCard = (id) => ({
+ type: ActionTypes.CARD_COPY,
+ payload: {
+ id,
+ },
+});
+
+const cutCard = (id) => ({
+ type: ActionTypes.CARD_CUT,
+ payload: {
+ id,
+ },
+});
+
+const pasteCard = () => ({
+ type: ActionTypes.CARD_PASTE,
+ payload: {},
+});
+
const deleteCard = (id) => ({
type: ActionTypes.CARD_DELETE,
payload: {
@@ -240,7 +325,11 @@ export default {
handleCardCreate,
updateCard,
handleCardUpdate,
+ transferCard,
duplicateCard,
+ copyCard,
+ cutCard,
+ pasteCard,
deleteCard,
handleCardDelete,
};
diff --git a/client/src/components/boards/Board/EndlessContent.jsx b/client/src/components/boards/Board/EndlessContent.jsx
index cbdc1821..4d723eb3 100644
--- a/client/src/components/boards/Board/EndlessContent.jsx
+++ b/client/src/components/boards/Board/EndlessContent.jsx
@@ -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;
diff --git a/client/src/components/boards/Board/FiniteContent.jsx b/client/src/components/boards/Board/FiniteContent.jsx
index 6dabd0c2..1e6cc174 100644
--- a/client/src/components/boards/Board/FiniteContent.jsx
+++ b/client/src/components/boards/Board/FiniteContent.jsx
@@ -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 ;
+ return (
+
+ );
});
export default FiniteContent;
diff --git a/client/src/components/boards/Board/GridView.jsx b/client/src/components/boards/Board/GridView.jsx
index c7ddcf27..a45b1133 100755
--- a/client/src/components/boards/Board/GridView.jsx
+++ b/client/src/components/boards/Board/GridView.jsx
@@ -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(
) : (
-
+
+
+
+ {onCardPaste && clipboard && canPasteCard && (
+
+ )}
+
+
))}
{cardIds.map((cardId) => (
@@ -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;
diff --git a/client/src/components/boards/Board/GridView.module.scss b/client/src/components/boards/Board/GridView.module.scss
index 16330f6b..ba9a0f69 100644
--- a/client/src/components/boards/Board/GridView.module.scss
+++ b/client/src/components/boards/Board/GridView.module.scss
@@ -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;
diff --git a/client/src/components/boards/Board/ListView.jsx b/client/src/components/boards/Board/ListView.jsx
index 434fc19e..aa281324 100755
--- a/client/src/components/boards/Board/ListView.jsx
+++ b/client/src/components/boards/Board/ListView.jsx
@@ -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(
) : (
-
+
+
+ {onCardPaste && clipboard && canPasteCard && (
+
+ )}
+
))}
{cardIds.length > 0 && (
@@ -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;
diff --git a/client/src/components/boards/Board/ListView.module.scss b/client/src/components/boards/Board/ListView.module.scss
index 4b4ae799..cdde23bd 100644
--- a/client/src/components/boards/Board/ListView.module.scss
+++ b/client/src/components/boards/Board/ListView.module.scss
@@ -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;
}
diff --git a/client/src/components/boards/Board/ShortcutsProvider.jsx b/client/src/components/boards/Board/ShortcutsProvider.jsx
index a01b33ae..81a79a3f 100644
--- a/client/src/components/boards/Board/ShortcutsProvider.jsx
+++ b/client/src/components/boards/Board/ShortcutsProvider.jsx
@@ -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;
}
diff --git a/client/src/components/cards/Card/Card.jsx b/client/src/components/cards/Card/Card.jsx
index 21fd79a7..cddcb18d 100755
--- a/client/src/components/cards/Card/Card.jsx
+++ b/client/src/components/cards/Card/Card.jsx
@@ -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 */}
{
+ 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 }
})}
)}
+ {canCopy && (
+
+
+ {t('action.copyCard', {
+ context: 'title',
+ })}
+
+ )}
+ {canCut && (
+
+
+ {t('action.cutCard', {
+ context: 'title',
+ })}
+
+ )}
{canDuplicate && (
diff --git a/client/src/components/cards/CardModal/MoreActionsStep.jsx b/client/src/components/cards/CardModal/MoreActionsStep.jsx
index de175fde..21eae731 100644
--- a/client/src/components/cards/CardModal/MoreActionsStep.jsx
+++ b/client/src/components/cards/CardModal/MoreActionsStep.jsx
@@ -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);
diff --git a/client/src/components/cards/CardModal/ProjectContent.jsx b/client/src/components/cards/CardModal/ProjectContent.jsx
index 668a0a19..99f1f033 100644
--- a/client/src/components/cards/CardModal/ProjectContent.jsx
+++ b/client/src/components/cards/CardModal/ProjectContent.jsx
@@ -105,7 +105,7 @@ const ProjectContent = React.memo(() => {
canSubscribe: isMember,
canJoin: false,
canDuplicate: false,
- canMove: false,
+ canMove: isEditor,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,
diff --git a/client/src/components/cards/CardModal/StoryContent.jsx b/client/src/components/cards/CardModal/StoryContent.jsx
index 52aa62b3..a7150e83 100644
--- a/client/src/components/cards/CardModal/StoryContent.jsx
+++ b/client/src/components/cards/CardModal/StoryContent.jsx
@@ -104,7 +104,7 @@ const StoryContent = React.memo(() => {
canSubscribe: isMember,
canJoin: false,
canDuplicate: false,
- canMove: false,
+ canMove: isEditor,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,
diff --git a/client/src/components/common/Toaster/FileIsTooBig.jsx b/client/src/components/common/Toaster/FileIsTooBigToast.jsx
similarity index 85%
rename from client/src/components/common/Toaster/FileIsTooBig.jsx
rename to client/src/components/common/Toaster/FileIsTooBigToast.jsx
index d773be30..4bdbdf26 100644
--- a/client/src/components/common/Toaster/FileIsTooBig.jsx
+++ b/client/src/components/common/Toaster/FileIsTooBigToast.jsx
@@ -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;
diff --git a/client/src/components/common/Toaster/NotEnoughStorage.jsx b/client/src/components/common/Toaster/NotEnoughStorageToast.jsx
similarity index 84%
rename from client/src/components/common/Toaster/NotEnoughStorage.jsx
rename to client/src/components/common/Toaster/NotEnoughStorageToast.jsx
index 8c68614a..a1bf9247 100644
--- a/client/src/components/common/Toaster/NotEnoughStorage.jsx
+++ b/client/src/components/common/Toaster/NotEnoughStorageToast.jsx
@@ -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;
diff --git a/client/src/components/common/Toaster/SourceCardNotCopyableToast.jsx b/client/src/components/common/Toaster/SourceCardNotCopyableToast.jsx
new file mode 100644
index 00000000..b2319fd2
--- /dev/null
+++ b/client/src/components/common/Toaster/SourceCardNotCopyableToast.jsx
@@ -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 (
+
+
+ {t('common.sourceCardIsNoLongerAvailableForCopying')}
+
+ );
+});
+
+export default SourceCardNotCopyableToast;
diff --git a/client/src/components/common/Toaster/SourceCardNotMovableToast.jsx b/client/src/components/common/Toaster/SourceCardNotMovableToast.jsx
new file mode 100644
index 00000000..f329ab5e
--- /dev/null
+++ b/client/src/components/common/Toaster/SourceCardNotMovableToast.jsx
@@ -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 (
+
+
+ {t('common.sourceCardIsNoLongerAvailableForMoving')}
+
+ );
+});
+
+export default SourceCardNotMovableToast;
diff --git a/client/src/components/common/Toaster/Toaster.jsx b/client/src/components/common/Toaster/Toaster.jsx
index 93ccdacb..0b53c3f2 100644
--- a/client/src/components/common/Toaster/Toaster.jsx
+++ b/client/src/components/common/Toaster/Toaster.jsx
@@ -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(() => (
diff --git a/client/src/components/lists/List/List.jsx b/client/src/components/lists/List/List.jsx
index ce30c2d9..64079613 100755
--- a/client/src/components/lists/List/List.jsx
+++ b/client/src/components/lists/List/List.jsx
@@ -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}
>
{!addCardPosition && canAddCard && (
-
+
+
+ {clipboard && canPasteCard && (
+
+ )}
+
)}
diff --git a/client/src/components/lists/List/List.module.scss b/client/src/components/lists/List/List.module.scss
index 7e6dd587..d4a4b767 100644
--- a/client/src/components/lists/List/List.module.scss
+++ b/client/src/components/lists/List/List.module.scss
@@ -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;
}
diff --git a/client/src/constants/ActionTypes.js b/client/src/constants/ActionTypes.js
index f55d57f3..844130cf 100644
--- a/client/src/constants/ActionTypes.js
+++ b/client/src/constants/ActionTypes.js
@@ -290,6 +290,9 @@ export default {
CARD_DUPLICATE: 'CARD_DUPLICATE',
CARD_DUPLICATE__SUCCESS: 'CARD_DUPLICATE__SUCCESS',
CARD_DUPLICATE__FAILURE: 'CARD_DUPLICATE__FAILURE',
+ CARD_COPY: 'CARD_COPY',
+ CARD_CUT: 'CARD_CUT',
+ CARD_PASTE: 'CARD_PASTE',
CARD_DELETE: 'CARD_DELETE',
CARD_DELETE__SUCCESS: 'CARD_DELETE__SUCCESS',
CARD_DELETE__FAILURE: 'CARD_DELETE__FAILURE',
diff --git a/client/src/constants/ClipboardTypes.js b/client/src/constants/ClipboardTypes.js
new file mode 100644
index 00000000..c5cec49a
--- /dev/null
+++ b/client/src/constants/ClipboardTypes.js
@@ -0,0 +1,12 @@
+/*!
+ * Copyright (c) 2024 PLANKA Software GmbH
+ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
+ */
+
+const COPY = 'COPY';
+const CUT = 'CUT';
+
+export default {
+ COPY,
+ CUT,
+};
diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js
index 9508a6d8..0e36c226 100755
--- a/client/src/constants/EntryActionTypes.js
+++ b/client/src/constants/EntryActionTypes.js
@@ -202,6 +202,11 @@ export default {
CURRENT_CARD_TRANSFER: `${PREFIX}/CURRENT_CARD_TRANSFER`,
CARD_DUPLICATE: `${PREFIX}/CARD_DUPLICATE`,
CURRENT_CARD_DUPLICATE: `${PREFIX}/CURRENT_CARD_DUPLICATE`,
+ CARD_COPY: `${PREFIX}/CARD_COPY`,
+ CARD_CUT: `${PREFIX}/CARD_CUT`,
+ CARD_PASTE: `${PREFIX}/CARD_PASTE`,
+ CARD_IN_CURRENT_CONTEXT_PASTE: `${PREFIX}/CARD_IN_CURRENT_CONTEXT_PASTE`,
+ CARD_IN_CURRENT_LIST_PASTE: `${PREFIX}/CARD_IN_CURRENT_LIST_PASTE`,
TO_ADJACENT_CARD_GO: `${PREFIX}/TO_ADJACENT_CARD_GO`,
CARD_DELETE: `${PREFIX}/CARD_DELETE`,
CURRENT_CARD_DELETE: `${PREFIX}/CURRENT_CARD_DELETE`,
diff --git a/client/src/constants/ToastTypes.js b/client/src/constants/ToastTypes.js
index b06077ef..9af16ae5 100644
--- a/client/src/constants/ToastTypes.js
+++ b/client/src/constants/ToastTypes.js
@@ -6,9 +6,13 @@
const FILE_IS_TOO_BIG = 'FILE_IS_TOO_BIG';
const NOT_ENOUGH_STORAGE = 'NOT_ENOUGH_STORAGE';
const EMPTY_TRASH = 'EMPTY_TRASH';
+const SOURCE_CARD_NOT_COPYABLE = 'SOURCE_CARD_NOT_COPYABLE';
+const SOURCE_CARD_NOT_MOVABLE = 'SOURCE_CARD_NOT_MOVABLE';
export default {
FILE_IS_TOO_BIG,
NOT_ENOUGH_STORAGE,
EMPTY_TRASH,
+ SOURCE_CARD_NOT_COPYABLE,
+ SOURCE_CARD_NOT_MOVABLE,
};
diff --git a/client/src/contexts/BoardShortcutsContext.js b/client/src/contexts/BoardShortcutsContext.js
index 054473ec..6333c2bc 100644
--- a/client/src/contexts/BoardShortcutsContext.js
+++ b/client/src/contexts/BoardShortcutsContext.js
@@ -5,4 +5,4 @@
import { createContext } from 'react';
-export default createContext([null, null]);
+export default createContext([null, null, null, null]);
diff --git a/client/src/entry-actions/cards.js b/client/src/entry-actions/cards.js
index 7df99d2e..9be8fa8e 100755
--- a/client/src/entry-actions/cards.js
+++ b/client/src/entry-actions/cards.js
@@ -135,7 +135,7 @@ const transferCurrentCard = (boardId, listId, index = 0) => ({
},
});
-const duplicateCard = (id, data) => ({
+const duplicateCard = (id, data = {}) => ({
type: EntryActionTypes.CARD_DUPLICATE,
payload: {
id,
@@ -143,13 +143,44 @@ const duplicateCard = (id, data) => ({
},
});
-const duplicateCurrentCard = (data) => ({
+const duplicateCurrentCard = (data = {}) => ({
type: EntryActionTypes.CURRENT_CARD_DUPLICATE,
payload: {
data,
},
});
+const copyCard = (id) => ({
+ type: EntryActionTypes.CARD_COPY,
+ payload: {
+ id,
+ },
+});
+
+const cutCard = (id) => ({
+ type: EntryActionTypes.CARD_CUT,
+ payload: {
+ id,
+ },
+});
+
+const pasteCard = (listId) => ({
+ type: EntryActionTypes.CARD_PASTE,
+ payload: {
+ listId,
+ },
+});
+
+const pasteCardInCurrentContext = () => ({
+ type: EntryActionTypes.CARD_IN_CURRENT_CONTEXT_PASTE,
+ payload: {},
+});
+
+const pasteCardInCurrentList = () => ({
+ type: EntryActionTypes.CARD_IN_CURRENT_LIST_PASTE,
+ payload: {},
+});
+
const goToAdjacentCard = (direction) => ({
type: EntryActionTypes.TO_ADJACENT_CARD_GO,
payload: {
@@ -196,6 +227,11 @@ export default {
transferCurrentCard,
duplicateCard,
duplicateCurrentCard,
+ copyCard,
+ cutCard,
+ pasteCard,
+ pasteCardInCurrentContext,
+ pasteCardInCurrentList,
goToAdjacentCard,
deleteCard,
deleteCurrentCard,
diff --git a/client/src/locales/ar-YE/core.js b/client/src/locales/ar-YE/core.js
index fd718478..cc202377 100644
--- a/client/src/locales/ar-YE/core.js
+++ b/client/src/locales/ar-YE/core.js
@@ -298,6 +298,8 @@ export default {
showOnFrontOfCard: 'عرض في مقدمة البطاقة',
smtp: 'SMTP',
sortList_title: 'فرز القائمة',
+ sourceCardIsNoLongerAvailableForCopying: 'البطاقة المصدر لم تعد متاحة للنسخ.',
+ sourceCardIsNoLongerAvailableForMoving: 'البطاقة المصدر لم تعد متاحة للنقل.',
stopwatch: 'المؤقت',
story: 'القصة',
subscribeToCardWhenCommenting: 'الاشتراك في البطاقة عند التعليق',
@@ -384,6 +386,7 @@ export default {
archiveCards_title: 'أرشفة البطاقات',
assignAsOwner: 'تعيين كمالك',
cancel: 'إلغاء',
+ copyCard_title: 'نسخ البطاقة',
createApiKey: 'إنشاء مفتاح API',
createBoard: 'إنشاء لوحة',
createCustomFieldGroup: 'إنشاء مجموعة حقل مخصص',
@@ -391,6 +394,7 @@ export default {
createLabel: 'إنشاء ملصق',
createNewLabel: 'إنشاء ملصق جديد',
createProject: 'إنشاء مشروع',
+ cutCard_title: 'قص البطاقة',
deactivateUser: 'إلغاء تفعيل المستخدم',
deactivateUser_title: 'إلغاء تفعيل المستخدم',
delete: 'حذف',
diff --git a/client/src/locales/bg-BG/core.js b/client/src/locales/bg-BG/core.js
index ccc83a94..e8715a0b 100644
--- a/client/src/locales/bg-BG/core.js
+++ b/client/src/locales/bg-BG/core.js
@@ -310,6 +310,9 @@ export default {
showOnFrontOfCard: 'Показване отпред на картата',
smtp: 'SMTP',
sortList_title: 'Сортиране на списък',
+ sourceCardIsNoLongerAvailableForCopying: 'Оригиналната карта вече не е налична за копиране.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Оригиналната карта вече не е налична за преместване.',
stopwatch: 'Хронометър',
story: 'История',
subscribeToCardWhenCommenting: 'Абониране за карта при коментиране',
@@ -398,6 +401,7 @@ export default {
archiveCards_title: 'Архивиране на карти',
assignAsOwner: 'Назначаване като собственик',
cancel: 'Отказ',
+ copyCard_title: 'Копиране на карта',
createApiKey: 'Създаване на API ключ',
createBoard: 'Създаване на табло',
createCustomFieldGroup: 'Създаване на група персонализирани полета',
@@ -405,6 +409,7 @@ export default {
createLabel: 'Създаване на етикет',
createNewLabel: 'Създаване на нов етикет',
createProject: 'Създаване на проект',
+ cutCard_title: 'Изрязване на карта',
deactivateUser: 'Деактивиране на потребител',
deactivateUser_title: 'Деактивиране на потребител',
delete: 'Изтриване',
diff --git a/client/src/locales/ca-ES/core.js b/client/src/locales/ca-ES/core.js
index d4d500ea..eda9a868 100644
--- a/client/src/locales/ca-ES/core.js
+++ b/client/src/locales/ca-ES/core.js
@@ -309,6 +309,10 @@ export default {
showOnFrontOfCard: 'Mostrar a la part frontal de la targeta',
smtp: 'SMTP',
sortList_title: 'Ordenar llista',
+ sourceCardIsNoLongerAvailableForCopying:
+ "La targeta d'origen ja no està disponible per copiar.",
+ sourceCardIsNoLongerAvailableForMoving:
+ "La targeta d'origen ja no està disponible per moure.",
stopwatch: 'Cronòmetre',
story: 'Història',
subscribeToCardWhenCommenting: "Subscriure's a la targeta en comentar",
@@ -399,6 +403,7 @@ export default {
archiveCards_title: 'Arxivar targetes',
assignAsOwner: 'Assignar com a propietari',
cancel: 'Cancel·lar',
+ copyCard_title: 'Copiar targeta',
createApiKey: 'Crear clau API',
createBoard: 'Crear tauler',
createCustomFieldGroup: 'Crear grup de camps personalitzats',
@@ -406,6 +411,7 @@ export default {
createLabel: 'Crear etiqueta',
createNewLabel: 'Crear nova etiqueta',
createProject: 'Crear projecte',
+ cutCard_title: 'Tallar targeta',
deactivateUser: 'Desactivar usuari',
deactivateUser_title: 'Desactivar usuari',
delete: 'Eliminar',
diff --git a/client/src/locales/cs-CZ/core.js b/client/src/locales/cs-CZ/core.js
index e07dfb92..2e1f365b 100644
--- a/client/src/locales/cs-CZ/core.js
+++ b/client/src/locales/cs-CZ/core.js
@@ -301,6 +301,9 @@ export default {
showOnFrontOfCard: 'Zobrazit na přední straně karty',
smtp: 'SMTP',
sortList_title: 'Řadit podle',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Zdrojová karta již není k dispozici pro kopírování.',
+ sourceCardIsNoLongerAvailableForMoving: 'Zdrojová karta již není k dispozici pro přesunutí.',
stopwatch: 'Časovač',
story: 'Příběh',
subscribeToCardWhenCommenting: 'Odebírat karty při komentování',
@@ -388,6 +391,7 @@ export default {
archiveCards_title: 'Archiv karet',
assignAsOwner: 'Přiřadit jako vlastníka',
cancel: 'Zrušit',
+ copyCard_title: 'Kopírovat kartu',
createApiKey: 'Vytvořit API klíč',
createBoard: 'Vytvořit nástěnku',
createCustomFieldGroup: 'Vytvořit vlastní skupinu polí',
@@ -395,6 +399,7 @@ export default {
createLabel: 'Vytvořit štítek',
createNewLabel: 'Vytvořit nový štítek',
createProject: 'Vytvořit projekt',
+ cutCard_title: 'Vyjmout kartu',
deactivateUser: 'Deaktivace uživatele',
deactivateUser_title: 'Deaktivace uživatele',
delete: 'Smazat',
diff --git a/client/src/locales/da-DK/core.js b/client/src/locales/da-DK/core.js
index 22ed9a9d..c5f6fa23 100644
--- a/client/src/locales/da-DK/core.js
+++ b/client/src/locales/da-DK/core.js
@@ -305,6 +305,10 @@ export default {
showOnFrontOfCard: 'Vis på forsiden af kortet',
smtp: 'SMTP',
sortList_title: 'Sortér liste',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Kildekortet er ikke længere tilgængeligt til kopiering.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Kildekortet er ikke længere tilgængeligt til flytning.',
stopwatch: 'Stopur',
story: 'Story',
subscribeToCardWhenCommenting: 'Abonnér på kort ved kommentering',
@@ -393,6 +397,7 @@ export default {
archiveCards_title: 'Arkivér kort',
assignAsOwner: 'Sæt som ejer',
cancel: 'Annuller',
+ copyCard_title: 'Kopiér kort',
createApiKey: 'Opret API-nøgle',
createBoard: 'Opret tavle',
createCustomFieldGroup: 'Opret brugerdefineret feltgruppe',
@@ -400,6 +405,7 @@ export default {
createLabel: 'Opret label',
createNewLabel: 'Opret ny label',
createProject: 'Opret projekt',
+ cutCard_title: 'Klip kort',
deactivateUser: 'Deaktivér bruger',
deactivateUser_title: 'Deaktivér bruger',
delete: 'Slet',
diff --git a/client/src/locales/de-DE/core.js b/client/src/locales/de-DE/core.js
index bbe238f3..b9ed814d 100644
--- a/client/src/locales/de-DE/core.js
+++ b/client/src/locales/de-DE/core.js
@@ -321,6 +321,9 @@ export default {
showOnFrontOfCard: 'Auf der Vorderseite der Karte anzeigen',
smtp: 'SMTP',
sortList_title: 'Liste sortieren',
+ sourceCardIsNoLongerAvailableForCopying: 'Quellkarte ist nicht mehr zum Kopieren verfügbar.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Quellkarte ist nicht mehr zum Verschieben verfügbar.',
stopwatch: 'Stoppuhr',
story: 'Wissen',
subscribeToCardWhenCommenting: 'Karte beim Kommentieren abonnieren',
@@ -410,6 +413,7 @@ export default {
archiveCards_title: 'Karten archivieren',
assignAsOwner: 'Als Eigentümer zuweisen',
cancel: 'Abbrechen',
+ copyCard_title: 'Karte Kopieren',
createApiKey: 'API-Schlüssel erstellen',
createBoard: 'Arbeitsbereich erstellen',
createCustomFieldGroup: 'Feldgruppe erstellen',
@@ -417,6 +421,7 @@ export default {
createLabel: 'Label erstellen',
createNewLabel: 'Neues Label erstellen',
createProject: 'Projekt erstellen',
+ cutCard_title: 'Karte Ausschneiden',
deactivateUser: 'Benutzer deaktivieren',
deactivateUser_title: 'Benutzer deaktivieren',
delete: 'Löschen',
diff --git a/client/src/locales/el-GR/core.js b/client/src/locales/el-GR/core.js
index 417a3e6f..41bf4c0f 100644
--- a/client/src/locales/el-GR/core.js
+++ b/client/src/locales/el-GR/core.js
@@ -318,6 +318,10 @@ export default {
showOnFrontOfCard: 'Εμφάνιση στο μπροστινό μέρος της κάρτας',
smtp: 'SMTP',
sortList_title: 'Ταξινόμηση λίστας',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Η κάρτα πηγής δεν είναι πλέον διαθέσιμη για αντιγραφή.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Η κάρτα πηγής δεν είναι πλέον διαθέσιμη για μετακίνηση.',
stopwatch: 'Χρονόμετρο',
story: 'Ιστορία',
subscribeToCardWhenCommenting: 'Εγγραφή στην κάρτα κατά τη σχολιασμό',
@@ -413,6 +417,7 @@ export default {
archiveCards_title: 'Αρχειοθέτηση καρτών',
assignAsOwner: 'Ορισμός ως ιδιοκτήτης',
cancel: 'Ακύρωση',
+ copyCard_title: 'Αντιγραφή κάρτας',
createApiKey: 'Δημιουργία κλειδιού API',
createBoard: 'Δημιουργία πίνακα',
createCustomFieldGroup: 'Δημιουργία ομάδας προσαρμοσμένων πεδίων',
@@ -420,6 +425,7 @@ export default {
createLabel: 'Δημιουργία ετικέτας',
createNewLabel: 'Δημιουργία νέας ετικέτας',
createProject: 'Δημιουργία έργου',
+ cutCard_title: 'Αποκοπή κάρτας',
deactivateUser: 'Απενεργοποίηση χρήστη',
deactivateUser_title: 'Απενεργοποίηση χρήστη',
delete: 'Διαγραφή',
diff --git a/client/src/locales/en-GB/core.js b/client/src/locales/en-GB/core.js
index 46ea7738..fd1dffab 100644
--- a/client/src/locales/en-GB/core.js
+++ b/client/src/locales/en-GB/core.js
@@ -304,6 +304,8 @@ export default {
showOnFrontOfCard: 'Show on front of card',
smtp: 'SMTP',
sortList_title: 'Sort List',
+ sourceCardIsNoLongerAvailableForCopying: 'Source card is no longer available for copying.',
+ sourceCardIsNoLongerAvailableForMoving: 'Source card is no longer available for moving.',
stopwatch: 'Stopwatch',
story: 'Story',
subscribeToCardWhenCommenting: 'Subscribe to card when commenting',
@@ -391,6 +393,7 @@ export default {
archiveCards_title: 'Archive Cards',
assignAsOwner: 'Assign as owner',
cancel: 'Cancel',
+ copyCard_title: 'Copy Card',
createApiKey: 'Create API key',
createBoard: 'Create board',
createCustomFieldGroup: 'Create custom field group',
@@ -398,6 +401,7 @@ export default {
createLabel: 'Create label',
createNewLabel: 'Create new label',
createProject: 'Create project',
+ cutCard_title: 'Cut Card',
deactivateUser: 'Deactivate user',
deactivateUser_title: 'Deactivate User',
delete: 'Delete',
diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js
index dbed9600..54f2c803 100644
--- a/client/src/locales/en-US/core.js
+++ b/client/src/locales/en-US/core.js
@@ -299,6 +299,8 @@ export default {
showOnFrontOfCard: 'Show on front of card',
smtp: 'SMTP',
sortList_title: 'Sort List',
+ sourceCardIsNoLongerAvailableForCopying: 'Source card is no longer available for copying.',
+ sourceCardIsNoLongerAvailableForMoving: 'Source card is no longer available for moving.',
stopwatch: 'Stopwatch',
story: 'Story',
subscribeToCardWhenCommenting: 'Subscribe to card when commenting',
@@ -386,6 +388,7 @@ export default {
archiveCards_title: 'Archive Cards',
assignAsOwner: 'Assign as owner',
cancel: 'Cancel',
+ copyCard_title: 'Copy Card',
createApiKey: 'Create API key',
createBoard: 'Create board',
createCustomFieldGroup: 'Create custom field group',
@@ -393,6 +396,7 @@ export default {
createLabel: 'Create label',
createNewLabel: 'Create new label',
createProject: 'Create project',
+ cutCard_title: 'Cut Card',
deactivateUser: 'Deactivate user',
deactivateUser_title: 'Deactivate User',
delete: 'Delete',
diff --git a/client/src/locales/es-ES/core.js b/client/src/locales/es-ES/core.js
index 66b5a224..efd941ac 100644
--- a/client/src/locales/es-ES/core.js
+++ b/client/src/locales/es-ES/core.js
@@ -310,6 +310,10 @@ export default {
showOnFrontOfCard: 'Mostrar en el frente de la tarjeta',
smtp: 'SMTP',
sortList_title: 'Ordenar lista',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'La tarjeta de origen ya no está disponible para copiar.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'La tarjeta de origen ya no está disponible para mover.',
stopwatch: 'Cronómetro',
story: 'Historia',
subscribeToCardWhenCommenting: 'Suscribirse a la tarjeta al comentar',
@@ -399,6 +403,7 @@ export default {
archiveCards_title: 'Archivar tarjetas',
assignAsOwner: 'Asignar como propietario',
cancel: 'Cancelar',
+ copyCard_title: 'Copiar tarjeta',
createApiKey: 'Crear clave API',
createBoard: 'Crear tablero',
createCustomFieldGroup: 'Crear grupo de campos personalizados',
@@ -406,6 +411,7 @@ export default {
createLabel: 'Crear etiqueta',
createNewLabel: 'Crear nueva etiqueta',
createProject: 'Crear proyecto',
+ cutCard_title: 'Cortar tarjeta',
deactivateUser: 'Desactivar usuario',
deactivateUser_title: 'Desactivar usuario',
delete: 'Eliminar',
diff --git a/client/src/locales/et-EE/core.js b/client/src/locales/et-EE/core.js
index f33cd37f..c52f7ec0 100644
--- a/client/src/locales/et-EE/core.js
+++ b/client/src/locales/et-EE/core.js
@@ -305,6 +305,8 @@ export default {
showOnFrontOfCard: 'Kuva kaardi ees',
smtp: 'SMTP',
sortList_title: 'Nimekiri sorteerimine',
+ sourceCardIsNoLongerAvailableForCopying: 'Lähtekaart ei ole enam kopeerimiseks saadaval.',
+ sourceCardIsNoLongerAvailableForMoving: 'Lähtekaart ei ole enam liigutamiseks saadaval.',
stopwatch: 'Stopper',
story: 'Kirjeldus',
subscribeToCardWhenCommenting: 'Telli kaart, kui kommenteerida',
@@ -393,6 +395,7 @@ export default {
archiveCards_title: 'Arhiveeri kaardid',
assignAsOwner: 'Määra omanikuks',
cancel: 'Tühista',
+ copyCard_title: 'Kopeeri kaart',
createApiKey: 'Loo API võti',
createBoard: 'Loo tahvel',
createCustomFieldGroup: 'Loo kohandatud väljade grupp',
@@ -400,6 +403,7 @@ export default {
createLabel: 'Loo silt',
createNewLabel: 'Loo uus silt',
createProject: 'Loo projekt',
+ cutCard_title: 'Lõika kaart',
deactivateUser: 'Deaktiveeri kasutaja',
deactivateUser_title: 'Deaktiveeri kasutaja',
delete: 'Kustuta',
diff --git a/client/src/locales/fa-IR/core.js b/client/src/locales/fa-IR/core.js
index ed5a721d..87a1a73f 100644
--- a/client/src/locales/fa-IR/core.js
+++ b/client/src/locales/fa-IR/core.js
@@ -308,6 +308,8 @@ export default {
showOnFrontOfCard: 'نمایش در جلوی کارت',
smtp: 'SMTP',
sortList_title: 'مرتبسازی لیست',
+ sourceCardIsNoLongerAvailableForCopying: 'کارت مبدا دیگر برای کپی کردن در دسترس نیست.',
+ sourceCardIsNoLongerAvailableForMoving: 'کارت مبدا دیگر برای انتقال در دسترس نیست.',
stopwatch: 'کرنومتر',
story: 'داستان',
subscribeToCardWhenCommenting: 'هنگام نظر دادن به کارت مشترک شو',
@@ -395,6 +397,7 @@ export default {
archiveCards_title: 'آرشیو کارتها',
assignAsOwner: 'تعیین به عنوان مالک',
cancel: 'لغو',
+ copyCard_title: 'کپی کارت',
createApiKey: 'ایجاد کلید API',
createBoard: 'ایجاد برد',
createCustomFieldGroup: 'ایجاد گروه فیلد سفارشی',
@@ -402,6 +405,7 @@ export default {
createLabel: 'ایجاد برچسب',
createNewLabel: 'ایجاد برچسب جدید',
createProject: 'ایجاد پروژه',
+ cutCard_title: 'برش کارت',
deactivateUser: 'غیرفعال کردن کاربر',
deactivateUser_title: 'غیرفعال کردن کاربر',
delete: 'حذف',
diff --git a/client/src/locales/fi-FI/core.js b/client/src/locales/fi-FI/core.js
index e9fa6290..8e95fddf 100644
--- a/client/src/locales/fi-FI/core.js
+++ b/client/src/locales/fi-FI/core.js
@@ -301,6 +301,10 @@ export default {
showOnFrontOfCard: 'Näytä kortin etupuolella',
smtp: 'SMTP',
sortList_title: 'Lajittele lista',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Lähdekortti ei ole enää saatavilla kopiointia varten.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Lähdekortti ei ole enää saatavilla siirtämistä varten.',
stopwatch: 'Ajastin',
story: 'Tarina',
subscribeToCardWhenCommenting: 'Tilaa kortti kommentoidessa',
@@ -392,6 +396,7 @@ export default {
archiveCards_title: 'Arkistoi kortit',
assignAsOwner: 'Aseta omistajaksi',
cancel: 'Peruuta',
+ copyCard_title: 'Kopioi kortti',
createApiKey: 'Luo API-avain',
createBoard: 'Luo taulu',
createCustomFieldGroup: 'Luo mukautettujen kenttien ryhmä',
@@ -399,6 +404,7 @@ export default {
createLabel: 'Luo tunniste',
createNewLabel: 'Luo uusi tunniste',
createProject: 'Luo projekti',
+ cutCard_title: 'Leikkaa kortti',
deactivateUser: 'Poista käyttäjä käytöstä',
deactivateUser_title: 'Poista käyttäjä käytöstä',
delete: 'Poista',
diff --git a/client/src/locales/fr-FR/core.js b/client/src/locales/fr-FR/core.js
index 262e9aa0..9dc94255 100644
--- a/client/src/locales/fr-FR/core.js
+++ b/client/src/locales/fr-FR/core.js
@@ -309,6 +309,10 @@ export default {
showOnFrontOfCard: 'Afficher sur le devant de la carte',
smtp: 'SMTP',
sortList_title: 'Trier la liste',
+ sourceCardIsNoLongerAvailableForCopying:
+ "La carte source n'est plus disponible pour la copie.",
+ sourceCardIsNoLongerAvailableForMoving:
+ "La carte source n'est plus disponible pour le déplacement.",
stopwatch: 'Minuteur',
story: 'Story',
subscribeToCardWhenCommenting: 'S’abonner à la carte lors de la rédaction d’un commentaire',
@@ -397,6 +401,7 @@ export default {
archiveCards_title: 'Archiver les cartes',
assignAsOwner: 'Assigner comme propriétaire',
cancel: 'Annuler',
+ copyCard_title: 'Copier la carte',
createApiKey: 'Créer une clé API',
createBoard: 'Créer un tableau',
createCustomFieldGroup: 'Créer un groupe de champs personnalisés',
@@ -404,6 +409,7 @@ export default {
createLabel: 'Créer une étiquette',
createNewLabel: 'Créer une nouvelle étiquette',
createProject: 'Créer un projet',
+ cutCard_title: 'Couper la carte',
deactivateUser: 'Désactiver l’utilisateur',
deactivateUser_title: 'Désactiver l’utilisateur',
delete: 'Supprimer',
diff --git a/client/src/locales/hu-HU/core.js b/client/src/locales/hu-HU/core.js
index 3f06912c..cdae66c0 100644
--- a/client/src/locales/hu-HU/core.js
+++ b/client/src/locales/hu-HU/core.js
@@ -299,6 +299,8 @@ export default {
showOnFrontOfCard: 'Megjelenítés a kártya borítóján',
smtp: 'SMTP',
sortList_title: 'Rendezés listában',
+ sourceCardIsNoLongerAvailableForCopying: 'A forráskártya már nem érhető el másoláshoz.',
+ sourceCardIsNoLongerAvailableForMoving: 'A forráskártya már nem érhető el áthelyezéshez.',
stopwatch: 'Stopper',
story: 'Story',
subscribeToCardWhenCommenting: 'Feliratkozás a kártyára kommenteléskor',
@@ -393,6 +395,7 @@ export default {
archiveCards_title: 'Archív kártyák',
assignAsOwner: 'Hozzárendelés tulajdonosnak',
cancel: 'Mégsem',
+ copyCard_title: 'Kártya másolása',
createApiKey: 'API kulcs létrehozása',
createBoard: 'Tábla létrehozása',
createCustomFieldGroup: 'Egyedi mezőcsoport létrehozása',
@@ -400,6 +403,7 @@ export default {
createLabel: 'Címke létrehozása',
createNewLabel: 'Új címke létrehozása',
createProject: 'Projekt létrehozása',
+ cutCard_title: 'Kártya kivágása',
deactivateUser: 'Felhasználó inaktiválása',
deactivateUser_title: 'Felhasználó inaktiválása',
delete: 'Törlés',
diff --git a/client/src/locales/id-ID/core.js b/client/src/locales/id-ID/core.js
index 22f98bd3..d06822b8 100644
--- a/client/src/locales/id-ID/core.js
+++ b/client/src/locales/id-ID/core.js
@@ -306,6 +306,8 @@ export default {
showOnFrontOfCard: 'Tampilkan di depan kartu',
smtp: 'SMTP',
sortList_title: 'Urutkan daftar',
+ sourceCardIsNoLongerAvailableForCopying: 'Kartu sumber tidak lagi tersedia untuk disalin.',
+ sourceCardIsNoLongerAvailableForMoving: 'Kartu sumber tidak lagi tersedia untuk dipindahkan.',
stopwatch: 'Stopwatch',
story: 'Cerita',
subscribeToCardWhenCommenting: 'Berlangganan kartu saat berkomentar',
@@ -394,6 +396,7 @@ export default {
archiveCards_title: 'Arsipkan kartu',
assignAsOwner: 'Tetapkan sebagai pemilik',
cancel: 'Batal',
+ copyCard_title: 'Salin Kartu',
createApiKey: 'Buat kunci API',
createBoard: 'Tambah papan',
createCustomFieldGroup: 'Buat grup bidang kustom',
@@ -401,6 +404,7 @@ export default {
createLabel: 'Tambah label',
createNewLabel: 'Tambah label baru',
createProject: 'Tambah proyek',
+ cutCard_title: 'Potong Kartu',
deactivateUser: 'Nonaktifkan pengguna',
deactivateUser_title: 'Nonaktifkan pengguna',
delete: 'Hapus',
diff --git a/client/src/locales/it-IT/core.js b/client/src/locales/it-IT/core.js
index c0252038..4a62ca59 100644
--- a/client/src/locales/it-IT/core.js
+++ b/client/src/locales/it-IT/core.js
@@ -306,6 +306,10 @@ export default {
showOnFrontOfCard: 'Mostra davanti alla scheda',
smtp: 'SMTP',
sortList_title: 'Ordina',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'La scheda sorgente non è più disponibile per la copia.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'La scheda sorgente non è più disponibile per lo spostamento.',
stopwatch: 'Timer',
story: 'Storia',
subscribeToCardWhenCommenting: 'Iscrivimi alla scheda quando commento',
@@ -395,6 +399,7 @@ export default {
archiveCards_title: 'Archivia schede',
assignAsOwner: 'Assegna come proprietario',
cancel: 'Annulla',
+ copyCard_title: 'Copia scheda',
createApiKey: 'Crea chiave API',
createBoard: 'Crea bacheca',
createCustomFieldGroup: 'Crea campi personalizzati',
@@ -402,6 +407,7 @@ export default {
createLabel: 'Crea etichetta',
createNewLabel: 'Crea nuova etichetta',
createProject: 'Crea progetto',
+ cutCard_title: 'Taglia scheda',
deactivateUser: 'Disattiva utente',
deactivateUser_title: 'Disattiva utente',
delete: 'Elimina',
diff --git a/client/src/locales/ja-JP/core.js b/client/src/locales/ja-JP/core.js
index 916aaa9b..5b612b99 100644
--- a/client/src/locales/ja-JP/core.js
+++ b/client/src/locales/ja-JP/core.js
@@ -302,6 +302,8 @@ export default {
showOnFrontOfCard: 'カードの前面に表示',
smtp: 'SMTP',
sortList_title: 'リストを並び替え',
+ sourceCardIsNoLongerAvailableForCopying: 'ソースカードはコピーできなくなりました。',
+ sourceCardIsNoLongerAvailableForMoving: 'ソースカードは移動できなくなりました。',
stopwatch: 'タイマー',
story: 'ストーリー',
subscribeToCardWhenCommenting: 'コメント時にカードを購読',
@@ -391,6 +393,7 @@ export default {
archiveCards_title: 'カードをアーカイブ',
assignAsOwner: 'オーナーとして割り当て',
cancel: 'キャンセル',
+ copyCard_title: 'カードをコピー',
createApiKey: 'APIキーを作成',
createBoard: 'ボードを作成',
createCustomFieldGroup: 'カスタムフィールドグループを作成',
@@ -398,6 +401,7 @@ export default {
createLabel: 'ラベルを作成',
createNewLabel: '新しいラベルを作成',
createProject: 'プロジェクトを作成',
+ cutCard_title: 'カードを切り取り',
deactivateUser: 'ユーザーを非アクティブにする',
deactivateUser_title: 'ユーザーを非アクティブにする',
delete: '削除',
diff --git a/client/src/locales/ko-KR/core.js b/client/src/locales/ko-KR/core.js
index d90688c5..96651775 100644
--- a/client/src/locales/ko-KR/core.js
+++ b/client/src/locales/ko-KR/core.js
@@ -296,6 +296,8 @@ export default {
showOnFrontOfCard: '카드 앞면에 표시',
smtp: 'SMTP',
sortList_title: '목록 정렬',
+ sourceCardIsNoLongerAvailableForCopying: '원본 카드를 더 이상 복사할 수 없습니다.',
+ sourceCardIsNoLongerAvailableForMoving: '원본 카드를 더 이상 이동할 수 없습니다.',
stopwatch: '스톱워치',
story: '스토리',
subscribeToCardWhenCommenting: '댓글 작성 시 카드 구독',
@@ -388,6 +390,7 @@ export default {
archiveCards_title: '카드들 보관',
assignAsOwner: '소유자로 지정',
cancel: '취소',
+ copyCard_title: '카드 복사',
createApiKey: 'API 키 생성',
createBoard: '보드 생성',
createCustomFieldGroup: '사용자 정의 필드 그룹 생성',
@@ -395,6 +398,7 @@ export default {
createLabel: '라벨 생성',
createNewLabel: '새 라벨 생성',
createProject: '프로젝트 생성',
+ cutCard_title: '카드 잘라내기',
deactivateUser: '사용자 비활성화',
deactivateUser_title: '사용자 비활성화',
delete: '삭제',
diff --git a/client/src/locales/nl-NL/core.js b/client/src/locales/nl-NL/core.js
index 5a214245..306f1753 100644
--- a/client/src/locales/nl-NL/core.js
+++ b/client/src/locales/nl-NL/core.js
@@ -305,6 +305,9 @@ export default {
showOnFrontOfCard: 'Tonen op voorkant van kaart',
smtp: 'SMTP',
sortList_title: 'Lijst sorteren',
+ sourceCardIsNoLongerAvailableForCopying: 'Bronkaart is niet meer beschikbaar voor kopiëren.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Bronkaart is niet meer beschikbaar voor verplaatsen.',
stopwatch: 'Stopwatch',
story: 'Verhaal',
subscribeToCardWhenCommenting: 'Abonneren op kaart bij het plaatsen van commentaar',
@@ -396,6 +399,7 @@ export default {
archiveCards_title: 'Kaarten archiveren',
assignAsOwner: 'Toewijzen als eigenaar',
cancel: 'Annuleren',
+ copyCard_title: 'Kaart kopiëren',
createApiKey: 'API-sleutel aanmaken',
createBoard: 'Bord aanmaken',
createCustomFieldGroup: 'Aangepaste veldgroep aanmaken',
@@ -403,6 +407,7 @@ export default {
createLabel: 'Label aanmaken',
createNewLabel: 'Nieuw label aanmaken',
createProject: 'Project aanmaken',
+ cutCard_title: 'Kaart knippen',
deactivateUser: 'Gebruiker deactiveren',
deactivateUser_title: 'Gebruiker deactiveren',
delete: 'Verwijderen',
diff --git a/client/src/locales/pl-PL/core.js b/client/src/locales/pl-PL/core.js
index c326af4f..a878a9ec 100644
--- a/client/src/locales/pl-PL/core.js
+++ b/client/src/locales/pl-PL/core.js
@@ -304,6 +304,10 @@ export default {
showOnFrontOfCard: 'Pokazuj na przodzie karty',
smtp: 'SMTP',
sortList_title: 'Sortowanie listy',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Źródłowa karta nie jest już dostępna do skopiowania.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Źródłowa karta nie jest już dostępna do przeniesienia.',
stopwatch: 'Stoper',
story: 'Scenorys',
subscribeToCardWhenCommenting: 'Subskrybuj kartę przy komentowaniu',
@@ -392,6 +396,7 @@ export default {
archiveCards_title: 'Archiwizuj karty',
assignAsOwner: 'Przypisz jako właściciela',
cancel: 'Anuluj',
+ copyCard_title: 'Kopiuj kartę',
createApiKey: 'Utwórz klucz API',
createBoard: 'Utwórz tablicę',
createCustomFieldGroup: 'Utwórz grupę pól własnych',
@@ -399,6 +404,7 @@ export default {
createLabel: 'Utwórz oznaczenie',
createNewLabel: 'Utwórz nowe oznaczenie',
createProject: 'Utwórz projekt',
+ cutCard_title: 'Wytnij kartę',
deactivateUser: 'Dezaktywuj użytkownika',
deactivateUser_title: 'Dezaktywuj użytkownika',
delete: 'Usuń',
diff --git a/client/src/locales/pt-BR/core.js b/client/src/locales/pt-BR/core.js
index edac9783..a2464b68 100644
--- a/client/src/locales/pt-BR/core.js
+++ b/client/src/locales/pt-BR/core.js
@@ -307,6 +307,10 @@ export default {
showOnFrontOfCard: 'Mostrar na frente do cartão',
smtp: 'SMTP',
sortList_title: 'Ordenar lista',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'O cartão de origem não está mais disponível para cópia.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'O cartão de origem não está mais disponível para mover.',
stopwatch: 'Cronômetro',
story: 'História',
subscribeToCardWhenCommenting: 'Inscrever-se no cartão ao comentar',
@@ -395,6 +399,7 @@ export default {
archiveCards_title: 'Arquivar cartões',
assignAsOwner: 'Atribuir como proprietário',
cancel: 'Cancelar',
+ copyCard_title: 'Copiar cartão',
createApiKey: 'Criar chave API',
createBoard: 'Criar quadro',
createCustomFieldGroup: 'Criar grupo de campos personalizados',
@@ -402,6 +407,7 @@ export default {
createLabel: 'Criar rótulo',
createNewLabel: 'Criar novo rótulo',
createProject: 'Criar projeto',
+ cutCard_title: 'Cortar cartão',
deactivateUser: 'Desativar usuário',
deactivateUser_title: 'Desativar usuário',
delete: 'Excluir',
diff --git a/client/src/locales/pt-PT/core.js b/client/src/locales/pt-PT/core.js
index 1079bbab..b91c835a 100644
--- a/client/src/locales/pt-PT/core.js
+++ b/client/src/locales/pt-PT/core.js
@@ -309,6 +309,10 @@ export default {
showOnFrontOfCard: 'Mostrar na frente do cartão',
smtp: 'SMTP',
sortList_title: 'Ordenar lista',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'O cartão de origem já não está disponível para cópia.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'O cartão de origem já não está disponível para mover.',
stopwatch: 'Cronómetro',
story: 'História',
subscribeToCardWhenCommenting: 'Subscrever cartão ao comentar',
@@ -398,6 +402,7 @@ export default {
archiveCards_title: 'Arquivar cartões',
assignAsOwner: 'Atribuir como proprietário',
cancel: 'Cancelar',
+ copyCard_title: 'Copiar cartão',
createApiKey: 'Criar chave API',
createBoard: 'Criar quadro',
createCustomFieldGroup: 'Criar grupo de campos personalizados',
@@ -405,6 +410,7 @@ export default {
createLabel: 'Criar etiqueta',
createNewLabel: 'Criar nova etiqueta',
createProject: 'Criar projeto',
+ cutCard_title: 'Cortar cartão',
deactivateUser: 'Desativar utilizador',
deactivateUser_title: 'Desativar utilizador',
delete: 'Eliminar',
diff --git a/client/src/locales/ro-RO/core.js b/client/src/locales/ro-RO/core.js
index e9bc1f38..0877dcf9 100644
--- a/client/src/locales/ro-RO/core.js
+++ b/client/src/locales/ro-RO/core.js
@@ -303,6 +303,9 @@ export default {
showOnFrontOfCard: 'Afișează pe fața cardului',
smtp: 'SMTP',
sortList_title: 'Sortează lista',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Cardul sursă nu mai este disponibil pentru copiere.',
+ sourceCardIsNoLongerAvailableForMoving: 'Cardul sursă nu mai este disponibil pentru mutare.',
stopwatch: 'Cronometru',
story: 'Poveste',
subscribeToCardWhenCommenting: 'Abonează-te la card când comentezi',
@@ -393,6 +396,7 @@ export default {
archiveCards_title: 'Arhivează cardurile',
assignAsOwner: 'Atribuie ca proprietar',
cancel: 'Anulează',
+ copyCard_title: 'Copiază cardul',
createApiKey: 'Creează cheie API',
createBoard: 'Creați tablă',
createCustomFieldGroup: 'Creați grup de câmpuri personalizate',
@@ -400,6 +404,7 @@ export default {
createLabel: 'Creați eticheta',
createNewLabel: 'Creați o nouă etichetă',
createProject: 'Creați proiect',
+ cutCard_title: 'Taie cardul',
deactivateUser: 'Dezactivați utilizatorul',
deactivateUser_title: 'Dezactivați utilizatorul',
delete: 'Ștergeți',
diff --git a/client/src/locales/ru-RU/core.js b/client/src/locales/ru-RU/core.js
index 60e45bc2..c9c8b18f 100644
--- a/client/src/locales/ru-RU/core.js
+++ b/client/src/locales/ru-RU/core.js
@@ -306,6 +306,10 @@ export default {
showOnFrontOfCard: 'Показать на лицевой стороне карточки',
smtp: 'SMTP',
sortList_title: 'Сортировка списка',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Исходная карточка больше не доступна для копирования.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Исходная карточка больше не доступна для перемещения.',
stopwatch: 'Секундомер',
story: 'История',
subscribeToCardWhenCommenting: 'Подписаться на карточку при комментировании',
@@ -394,6 +398,7 @@ export default {
archiveCards_title: 'Архивировать карточки',
assignAsOwner: 'Назначить владельцем',
cancel: 'Отменить',
+ copyCard_title: 'Копировать карточку',
createApiKey: 'Создать ключ API',
createBoard: 'Создать доску',
createCustomFieldGroup: 'Создать группу настраиваемых полей',
@@ -401,6 +406,7 @@ export default {
createLabel: 'Создать метку',
createNewLabel: 'Создать новую метку',
createProject: 'Создать проект',
+ cutCard_title: 'Вырезать карточку',
deactivateUser: 'Деактивировать пользователя',
deactivateUser_title: 'Деактивировать пользователя',
delete: 'Удалить',
diff --git a/client/src/locales/sk-SK/core.js b/client/src/locales/sk-SK/core.js
index f8bfa3a0..83f204dc 100644
--- a/client/src/locales/sk-SK/core.js
+++ b/client/src/locales/sk-SK/core.js
@@ -300,6 +300,8 @@ export default {
showOnFrontOfCard: 'Zobraziť na prednej strane karty',
smtp: 'SMTP',
sortList_title: 'Zoradiť zoznam',
+ sourceCardIsNoLongerAvailableForCopying: 'Zdrojová karta už nie je dostupná na kopírovanie.',
+ sourceCardIsNoLongerAvailableForMoving: 'Zdrojová karta už nie je dostupná na presunutie.',
stopwatch: 'Časovač',
story: 'Príbeh',
subscribeToCardWhenCommenting: 'Odoberať kartu pri komentovaní',
@@ -387,6 +389,7 @@ export default {
archiveCards_title: 'Archivovať karty',
assignAsOwner: 'Prideliť ako vlastníka',
cancel: 'Zrušiť',
+ copyCard_title: 'Kopírovať kartu',
createApiKey: 'Vytvoriť API kľúč',
createBoard: 'Vytvoriť tabuľu',
createCustomFieldGroup: 'Vytvoriť skupinu vlastných polí',
@@ -394,6 +397,7 @@ export default {
createLabel: 'Vytvoriť štítok',
createNewLabel: 'Vytvoriť nový štítok',
createProject: 'Vytvoriť projekt',
+ cutCard_title: 'Vystrihnúť kartu',
deactivateUser: 'Deaktivovať používateľa',
deactivateUser_title: 'Deaktivovať používateľa',
delete: 'Zmazať',
diff --git a/client/src/locales/sr-Cyrl-RS/core.js b/client/src/locales/sr-Cyrl-RS/core.js
index 39517caf..b076b47b 100644
--- a/client/src/locales/sr-Cyrl-RS/core.js
+++ b/client/src/locales/sr-Cyrl-RS/core.js
@@ -303,6 +303,8 @@ export default {
showOnFrontOfCard: 'Прикажи на предњој страни картице',
smtp: 'SMTP',
sortList_title: 'Сложи списак',
+ sourceCardIsNoLongerAvailableForCopying: 'Изворна картица више није доступна за копирање.',
+ sourceCardIsNoLongerAvailableForMoving: 'Изворна картица више није доступна за премештање.',
stopwatch: 'Штоперица',
story: 'Прича',
subscribeToCardWhenCommenting: 'Претплати се на картицу при коментарисању',
@@ -390,6 +392,7 @@ export default {
archiveCards_title: 'Архивирај картице',
assignAsOwner: 'Додели као власника',
cancel: 'Откажи',
+ copyCard_title: 'Копирај картицу',
createApiKey: 'Креирај API кључ',
createBoard: 'Направи таблу',
createCustomFieldGroup: 'Направи групу прилагођених поља',
@@ -397,6 +400,7 @@ export default {
createLabel: 'Направи ознаку',
createNewLabel: 'Направи нову ознаку',
createProject: 'Направи пројекат',
+ cutCard_title: 'Исеци картицу',
deactivateUser: 'Деактивирај корисника',
deactivateUser_title: 'Деактивирај корисника',
delete: 'Обриши',
diff --git a/client/src/locales/sr-Latn-RS/core.js b/client/src/locales/sr-Latn-RS/core.js
index 28ccf018..64e69bdd 100644
--- a/client/src/locales/sr-Latn-RS/core.js
+++ b/client/src/locales/sr-Latn-RS/core.js
@@ -304,6 +304,8 @@ export default {
showOnFrontOfCard: 'Prikaži na prednjoj strani kartice',
smtp: 'SMTP',
sortList_title: 'Složi spisak',
+ sourceCardIsNoLongerAvailableForCopying: 'Izvorna kartica više nije dostupna za kopiranje.',
+ sourceCardIsNoLongerAvailableForMoving: 'Izvorna kartica više nije dostupna za premeštanje.',
stopwatch: 'Štoperica',
story: 'Priča',
subscribeToCardWhenCommenting: 'Pretplati se na karticu pri komentarisanju',
@@ -392,6 +394,7 @@ export default {
archiveCards_title: 'Arhiviraj kartice',
assignAsOwner: 'Dodeli kao vlasnika',
cancel: 'Otkaži',
+ copyCard_title: 'Kopiraj karticu',
createApiKey: 'Kreiraj API ključ',
createBoard: 'Napravi tablu',
createCustomFieldGroup: 'Napravi grupu prilagođenih polja',
@@ -399,6 +402,7 @@ export default {
createLabel: 'Napravi oznaku',
createNewLabel: 'Napravi novu oznaku',
createProject: 'Napravi projekat',
+ cutCard_title: 'Iseci karticu',
deactivateUser: 'Deaktiviraj korisnika',
deactivateUser_title: 'Deaktiviraj korisnika',
delete: 'Obriši',
diff --git a/client/src/locales/sv-SE/core.js b/client/src/locales/sv-SE/core.js
index bdaf7eb5..37ab8ffd 100644
--- a/client/src/locales/sv-SE/core.js
+++ b/client/src/locales/sv-SE/core.js
@@ -310,6 +310,10 @@ export default {
showOnFrontOfCard: 'Visa på framsidan av kort',
smtp: 'SMTP',
sortList_title: 'Sortera lista',
+ sourceCardIsNoLongerAvailableForCopying:
+ 'Källkortet är inte längre tillgängligt för kopiering.',
+ sourceCardIsNoLongerAvailableForMoving:
+ 'Källkortet är inte längre tillgängligt för flyttning.',
stopwatch: 'Timer',
story: 'Berättelse',
subscribeToCardWhenCommenting: 'Prenumerera på kort vid kommentering',
@@ -399,6 +403,7 @@ export default {
archiveCards_title: 'Arkivera kort',
assignAsOwner: 'Tilldela som ägare',
cancel: 'Avbryt',
+ copyCard_title: 'Kopiera kort',
createApiKey: 'Skapa API-nyckel',
createBoard: 'Skapa tavla',
createCustomFieldGroup: 'Skapa anpassad fältgrupp',
@@ -406,6 +411,7 @@ export default {
createLabel: 'Skapa etikett',
createNewLabel: 'Skapa ny etikett',
createProject: 'Skapa projekt',
+ cutCard_title: 'Klipp ut kort',
deactivateUser: 'Inaktivera användare',
deactivateUser_title: 'Inaktivera användare',
delete: 'Ta bort',
diff --git a/client/src/locales/tr-TR/core.js b/client/src/locales/tr-TR/core.js
index c4bec1d8..1f64aae3 100644
--- a/client/src/locales/tr-TR/core.js
+++ b/client/src/locales/tr-TR/core.js
@@ -306,6 +306,8 @@ export default {
showOnFrontOfCard: 'Kartın ön yüzünde göster',
smtp: 'SMTP',
sortList_title: 'Listeyi sırala',
+ sourceCardIsNoLongerAvailableForCopying: 'Kaynak kart artık kopyalama için kullanılamıyor.',
+ sourceCardIsNoLongerAvailableForMoving: 'Kaynak kart artık taşıma için kullanılamıyor.',
stopwatch: 'kronometre',
story: 'Hikaye',
subscribeToCardWhenCommenting: 'Yorum yaparken karta abone ol',
@@ -397,6 +399,7 @@ export default {
archiveCards_title: 'Kartları arşivle',
assignAsOwner: 'Sahip olarak ata',
cancel: 'İptal',
+ copyCard_title: 'Kartı kopyala',
createApiKey: 'API anahtarı oluştur',
createBoard: 'Pano oluştur',
createCustomFieldGroup: 'Özel alan grubu oluştur',
@@ -404,6 +407,7 @@ export default {
createLabel: 'Etiket oluştur',
createNewLabel: 'Yeni etiket oluştur',
createProject: 'Proje oluştur',
+ cutCard_title: 'Kartı kes',
deactivateUser: 'Kullanıcıyı devre dışı bırak',
deactivateUser_title: 'Kullanıcıyı devre dışı bırak',
delete: 'Sil',
diff --git a/client/src/locales/uk-UA/core.js b/client/src/locales/uk-UA/core.js
index 17627ded..ef743e06 100644
--- a/client/src/locales/uk-UA/core.js
+++ b/client/src/locales/uk-UA/core.js
@@ -305,6 +305,8 @@ export default {
showOnFrontOfCard: 'Показати на лицьовій стороні картки',
smtp: 'SMTP',
sortList_title: 'Сортувати список',
+ sourceCardIsNoLongerAvailableForCopying: 'Вихідна картка більше недоступна для копіювання.',
+ sourceCardIsNoLongerAvailableForMoving: 'Вихідна картка більше недоступна для переміщення.',
stopwatch: 'Секундомір',
story: 'Історія',
subscribeToCardWhenCommenting: 'Підпишіться на картку при коментуванні',
@@ -392,6 +394,7 @@ export default {
archiveCards_title: 'Архівувати картки',
assignAsOwner: 'Призначити власником',
cancel: 'Скасувати',
+ copyCard_title: 'Копіювати картку',
createApiKey: 'Створити ключ API',
createBoard: 'Створити дошку',
createCustomFieldGroup: 'Створити групу користувацьких полів',
@@ -399,6 +402,7 @@ export default {
createLabel: 'Створити мітку',
createNewLabel: 'Створити нову мітку',
createProject: 'Створити проект',
+ cutCard_title: 'Вирізати картку',
deactivateUser: 'Деактивувати користувача',
deactivateUser_title: 'Деактивувати користувача',
delete: 'Видалити',
diff --git a/client/src/locales/uz-UZ/core.js b/client/src/locales/uz-UZ/core.js
index 24121403..621bfcd5 100644
--- a/client/src/locales/uz-UZ/core.js
+++ b/client/src/locales/uz-UZ/core.js
@@ -301,6 +301,8 @@ export default {
showOnFrontOfCard: "Karta old tomonida ko'rsatish",
smtp: 'SMTP',
sortList_title: "Ro'yxatni saralash",
+ sourceCardIsNoLongerAvailableForCopying: 'Manba karta nusxalash uchun endi mavjud emas.',
+ sourceCardIsNoLongerAvailableForMoving: 'Manba karta koʻchirish uchun endi mavjud emas.',
stopwatch: 'Taymer',
story: 'Hikoya',
subscribeToCardWhenCommenting: "Izoh qoldirganida kartaga obuna bo'lish",
@@ -390,6 +392,7 @@ export default {
archiveCards_title: 'Kartalarni arxivlash',
assignAsOwner: 'Egasi sifatida tayinlash',
cancel: 'Bekor qilish',
+ copyCard_title: 'Kartani nusxalash',
createApiKey: 'API kalitini yaratish',
createBoard: 'Doska yaratish',
createCustomFieldGroup: 'Maxsus maydon guruhi yaratish',
@@ -397,6 +400,7 @@ export default {
createLabel: 'Yorliq yaratish',
createNewLabel: 'Yangi yorliq yaratish',
createProject: 'Loyiha yaratish',
+ cutCard_title: 'Kartani kesish',
deactivateUser: 'Foydalanuvchini faolsizlantirish',
deactivateUser_title: 'Foydalanuvchini faolsizlantirish',
delete: "O'chirish",
diff --git a/client/src/locales/zh-CN/core.js b/client/src/locales/zh-CN/core.js
index 6dde571a..86b07a06 100644
--- a/client/src/locales/zh-CN/core.js
+++ b/client/src/locales/zh-CN/core.js
@@ -283,6 +283,8 @@ export default {
showOnFrontOfCard: '在卡片正面显示',
smtp: 'SMTP',
sortList_title: '排序列表',
+ sourceCardIsNoLongerAvailableForCopying: '源卡片不再可供复制。',
+ sourceCardIsNoLongerAvailableForMoving: '源卡片不再可供移动。',
stopwatch: '计时器',
story: '故事',
subscribeToCardWhenCommenting: '评论时自动关注卡片',
@@ -367,6 +369,7 @@ export default {
archiveCards_title: '归档多个卡片',
assignAsOwner: '指定为所有者',
cancel: '取消',
+ copyCard_title: '复制卡片',
createApiKey: '创建API密钥',
createBoard: '创建面板',
createCustomFieldGroup: '创建自定义字段组',
@@ -374,6 +377,7 @@ export default {
createLabel: '创建标签',
createNewLabel: '创建新标签',
createProject: '创建项目',
+ cutCard_title: '剪切卡片',
deactivateUser: '停用用户',
deactivateUser_title: '停用用户',
delete: '删除',
diff --git a/client/src/locales/zh-TW/core.js b/client/src/locales/zh-TW/core.js
index 38509808..2108e79c 100644
--- a/client/src/locales/zh-TW/core.js
+++ b/client/src/locales/zh-TW/core.js
@@ -283,6 +283,8 @@ export default {
showOnFrontOfCard: '在卡片正面顯示',
smtp: 'SMTP',
sortList_title: '排序列表',
+ sourceCardIsNoLongerAvailableForCopying: '來源卡片不再可供複製。',
+ sourceCardIsNoLongerAvailableForMoving: '來源卡片不再可供移動。',
stopwatch: '碼表',
story: '故事',
subscribeToCardWhenCommenting: '評論時訂閱卡片',
@@ -367,6 +369,7 @@ export default {
archiveCards_title: '封存卡片',
assignAsOwner: '指派為擁有者',
cancel: '取消',
+ copyCard_title: '複製卡片',
createApiKey: '建立API金鑰',
createBoard: '創建看板',
createCustomFieldGroup: '創建自定義欄位群組',
@@ -374,6 +377,7 @@ export default {
createLabel: '創建標籤',
createNewLabel: '創建新標籤',
createProject: '創建專案',
+ cutCard_title: '剪下卡片',
deactivateUser: '停用使用者',
deactivateUser_title: '停用使用者',
delete: '刪除',
diff --git a/client/src/models/Attachment.js b/client/src/models/Attachment.js
index f41387de..249d22e1 100644
--- a/client/src/models/Attachment.js
+++ b/client/src/models/Attachment.js
@@ -60,6 +60,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.attachments) {
payload.attachments.forEach((attachment) => {
Attachment.upsert(prepareAttachment(attachment));
@@ -80,6 +81,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.attachments.forEach((attachment) => {
Attachment.upsert(prepareAttachment(attachment));
diff --git a/client/src/models/Card.js b/client/src/models/Card.js
index 717581d6..70f62e35 100755
--- a/client/src/models/Card.js
+++ b/client/src/models/Card.js
@@ -3,6 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
+import keyBy from 'lodash/keyBy';
import { attr, fk, many, oneToOne } from 'redux-orm';
import BaseModel from './BaseModel';
@@ -316,33 +317,28 @@ export default class extends BaseModel {
case ActionTypes.CARD_UPDATE: {
const cardModel = Card.withId(payload.id);
- // TODO: introduce separate action?
- if (payload.data.boardId && payload.data.boardId !== cardModel.boardId) {
- cardModel.deleteWithRelated();
- } else {
- if (payload.data.listId && payload.data.listId !== cardModel.listId) {
- payload.data.listChangedAt = new Date(); // eslint-disable-line no-param-reassign
- }
-
- if (payload.data.dueDate !== undefined) {
- if (payload.data.dueDate) {
- if (!cardModel.dueDate) {
- payload.data.isDueCompleted = false; // eslint-disable-line no-param-reassign
- }
- } else {
- payload.data.isDueCompleted = null; // eslint-disable-line no-param-reassign
- }
- }
-
- if (payload.data.isClosed !== undefined && payload.data.isClosed !== cardModel.isClosed) {
- cardModel.linkedTasks.update({
- isCompleted: payload.data.isClosed,
- });
- }
-
- cardModel.update(payload.data);
+ if (payload.data.listId && payload.data.listId !== cardModel.listId) {
+ payload.data.listChangedAt = new Date(); // eslint-disable-line no-param-reassign
}
+ if (payload.data.dueDate !== undefined) {
+ if (payload.data.dueDate) {
+ if (!cardModel.dueDate) {
+ payload.data.isDueCompleted = false; // eslint-disable-line no-param-reassign
+ }
+ } else {
+ payload.data.isDueCompleted = null; // eslint-disable-line no-param-reassign
+ }
+ }
+
+ if (payload.data.isClosed !== undefined && payload.data.isClosed !== cardModel.isClosed) {
+ cardModel.linkedTasks.update({
+ isCompleted: payload.data.isClosed,
+ });
+ }
+
+ cardModel.update(payload.data);
+
break;
}
case ActionTypes.CARD_UPDATE_HANDLE: {
@@ -378,14 +374,86 @@ export default class extends BaseModel {
break;
}
- case ActionTypes.CARD_DUPLICATE:
- Card.withId(payload.id).duplicate(payload.localId, payload.data);
+ case ActionTypes.CARD_TRANSFER: {
+ const cardModel = Card.withId(payload.id);
+
+ if (cardModel) {
+ cardModel.update(payload.data);
+ cardModel.syncAfterBoardChange();
+ }
break;
- case ActionTypes.CARD_DUPLICATE__SUCCESS: {
- Card.withId(payload.localId).deleteWithRelated();
+ }
+ case ActionTypes.CARD_TRANSFER__SUCCESS: {
+ const cardModel = Card.withId(payload.card.id);
- const cardModel = Card.upsert(payload.card);
+ if (cardModel) {
+ cardModel.deleteWithRelated();
+ }
+
+ Card.upsert(payload.card);
+
+ if (payload.cardMemberships) {
+ payload.cardMemberships.forEach(({ cardId, userId }) => {
+ Card.withId(cardId).users.add(userId);
+ });
+ }
+
+ if (payload.cardLabels) {
+ payload.cardLabels.forEach(({ cardId, labelId }) => {
+ Card.withId(cardId).labels.add(labelId);
+ });
+ }
+
+ break;
+ }
+ case ActionTypes.CARD_TRANSFER__FAILURE: {
+ const cardModel = Card.withId(payload.id);
+
+ if (cardModel) {
+ cardModel.deleteWithRelated();
+ }
+
+ if (payload.card) {
+ Card.upsert(payload.card);
+ }
+
+ if (payload.cardMemberships) {
+ payload.cardMemberships.forEach(({ cardId, userId }) => {
+ Card.withId(cardId).users.add(userId);
+ });
+ }
+
+ if (payload.cardLabels) {
+ payload.cardLabels.forEach(({ cardId, labelId }) => {
+ Card.withId(cardId).labels.add(labelId);
+ });
+ }
+
+ break;
+ }
+ case ActionTypes.CARD_DUPLICATE: {
+ let cardModel = Card.withId(payload.id);
+
+ if (cardModel) {
+ cardModel = cardModel.duplicate(payload.localId, {
+ ...payload.data,
+ listChangedAt: new Date(),
+ });
+
+ cardModel.syncAfterBoardChange();
+ }
+
+ break;
+ }
+ case ActionTypes.CARD_DUPLICATE__SUCCESS: {
+ let cardModel = Card.withId(payload.localId);
+
+ if (cardModel) {
+ cardModel.deleteWithRelated();
+ }
+
+ cardModel = Card.upsert(payload.card);
payload.cardMemberships.forEach(({ userId }) => {
cardModel.users.add(userId);
@@ -397,10 +465,15 @@ export default class extends BaseModel {
break;
}
- case ActionTypes.CARD_DUPLICATE__FAILURE:
- Card.withId(payload.localId).deleteWithRelated();
+ case ActionTypes.CARD_DUPLICATE__FAILURE: {
+ const cardModel = Card.withId(payload.localId);
+
+ if (cardModel) {
+ cardModel.deleteWithRelated();
+ }
break;
+ }
case ActionTypes.CARD_DELETE:
Card.withId(payload.id).deleteWithRelated();
@@ -637,6 +710,47 @@ export default class extends BaseModel {
return cardModel;
}
+ syncAfterBoardChange() {
+ if (!this.board) {
+ return;
+ }
+
+ const boardMemberships = this.board.memberships.toRefArray();
+ const userIdsSet = new Set(boardMemberships.map(({ userId }) => userId));
+
+ this.users.toRefArray().forEach((user) => {
+ if (userIdsSet.has(user.id)) {
+ return;
+ }
+
+ this.users.remove(user.id);
+ });
+
+ this.taskLists.toModelArray().forEach((taskListModel) => {
+ taskListModel.tasks.toModelArray().forEach((taskModel) => {
+ if (!taskModel.assigneeUserId || userIdsSet.has(taskModel.assigneeUserId)) {
+ return;
+ }
+
+ taskModel.update({
+ assigneeUserId: null,
+ });
+ });
+ });
+
+ const labels = this.board.labels.toRefArray();
+ const labelByName = keyBy(labels, 'name');
+
+ this.labels.toRefArray().forEach((label) => {
+ if (!labelByName[label.name]) {
+ return;
+ }
+
+ this.labels.remove(label.id);
+ this.labels.add(labelByName[label.name].id);
+ });
+ }
+
deleteClearable() {
this.users.clear();
this.labels.clear();
diff --git a/client/src/models/CustomField.js b/client/src/models/CustomField.js
index 5820bc1e..b7fb03de 100644
--- a/client/src/models/CustomField.js
+++ b/client/src/models/CustomField.js
@@ -38,6 +38,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.customFields) {
payload.customFields.forEach((customField) => {
CustomField.upsert(customField);
@@ -59,6 +60,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.customFields.forEach((customField) => {
CustomField.upsert(customField);
diff --git a/client/src/models/CustomFieldGroup.js b/client/src/models/CustomFieldGroup.js
index 910cb864..b24ea51b 100644
--- a/client/src/models/CustomFieldGroup.js
+++ b/client/src/models/CustomFieldGroup.js
@@ -42,6 +42,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.customFieldGroups) {
payload.customFieldGroups.forEach((customFieldGroup) => {
CustomFieldGroup.upsert(customFieldGroup);
@@ -62,6 +63,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.customFieldGroups.forEach((customFieldGroup) => {
CustomFieldGroup.upsert(customFieldGroup);
diff --git a/client/src/models/CustomFieldValue.js b/client/src/models/CustomFieldValue.js
index f9580907..14293053 100644
--- a/client/src/models/CustomFieldValue.js
+++ b/client/src/models/CustomFieldValue.js
@@ -53,6 +53,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.customFieldValues) {
payload.customFieldValues.forEach((customFieldValue) => {
CustomFieldValue.upsert(prepareCustomFieldValue(customFieldValue));
@@ -73,6 +74,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.customFieldValues.forEach((customFieldValue) => {
CustomFieldValue.upsert(prepareCustomFieldValue(customFieldValue));
diff --git a/client/src/models/Task.js b/client/src/models/Task.js
index 7057e4ab..73a8feb9 100755
--- a/client/src/models/Task.js
+++ b/client/src/models/Task.js
@@ -45,6 +45,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.tasks) {
payload.tasks.forEach((task) => {
Task.upsert(task);
@@ -65,6 +66,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.tasks.forEach((task) => {
Task.upsert(task);
diff --git a/client/src/models/TaskList.js b/client/src/models/TaskList.js
index 9d2a71bf..50cc7feb 100755
--- a/client/src/models/TaskList.js
+++ b/client/src/models/TaskList.js
@@ -34,6 +34,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.taskLists) {
payload.taskLists.forEach((taskList) => {
TaskList.upsert(taskList);
@@ -54,6 +55,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.taskLists.forEach((taskList) => {
TaskList.upsert(taskList);
diff --git a/client/src/models/User.js b/client/src/models/User.js
index 83ceb50c..e22c348d 100755
--- a/client/src/models/User.js
+++ b/client/src/models/User.js
@@ -105,6 +105,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.CARD_UPDATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__FAILURE:
if (payload.users) {
payload.users.forEach((user) => {
User.upsert(user);
@@ -351,6 +352,7 @@ export default class extends BaseModel {
case ActionTypes.BOARD_FETCH__SUCCESS:
case ActionTypes.CARDS_FETCH__SUCCESS:
case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_TRANSFER__SUCCESS:
case ActionTypes.COMMENTS_FETCH__SUCCESS:
case ActionTypes.COMMENT_CREATE_HANDLE:
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS:
diff --git a/client/src/reducers/core.js b/client/src/reducers/core.js
index 65d0f04a..0a4e94a5 100755
--- a/client/src/reducers/core.js
+++ b/client/src/reducers/core.js
@@ -7,6 +7,7 @@ import { LOCATION_CHANGE_HANDLE } from '../lib/redux-router';
import ActionTypes from '../constants/ActionTypes';
import ModalTypes from '../constants/ModalTypes';
+import ClipboardTypes from '../constants/ClipboardTypes';
import { HomeViews, ProjectOrders } from '../constants/Enums';
const initialState = {
@@ -15,6 +16,7 @@ const initialState = {
isFavoritesEnabled: false,
isEditModeEnabled: false,
modal: null,
+ clipboard: null,
config: null,
boardId: null,
cardId: null,
@@ -175,6 +177,45 @@ export default (state = initialState, { type, payload }) => {
}
return state;
+ case ActionTypes.CARD_DELETE:
+ if (payload.clipboard && payload.id === payload.clipboard.cardId) {
+ return {
+ ...state,
+ clipboard: null,
+ };
+ }
+
+ return state;
+ case ActionTypes.CARD_DELETE_HANDLE:
+ if (payload.clipboard && payload.card.id === payload.clipboard.cardId) {
+ return {
+ ...state,
+ clipboard: null,
+ };
+ }
+
+ return state;
+ case ActionTypes.CARD_COPY:
+ return {
+ ...state,
+ clipboard: {
+ type: ClipboardTypes.COPY,
+ cardId: payload.id,
+ },
+ };
+ case ActionTypes.CARD_CUT:
+ return {
+ ...state,
+ clipboard: {
+ type: ClipboardTypes.CUT,
+ cardId: payload.id,
+ },
+ };
+ case ActionTypes.CARD_PASTE:
+ return {
+ ...state,
+ clipboard: null,
+ };
default:
return state;
}
diff --git a/client/src/sagas/core/services/cards.js b/client/src/sagas/core/services/cards.js
index 63342d3c..42e54c40 100644
--- a/client/src/sagas/core/services/cards.js
+++ b/client/src/sagas/core/services/cards.js
@@ -4,6 +4,7 @@
*/
import { call, fork, join, put, race, select, take } from 'redux-saga/effects';
+import toast from 'react-hot-toast';
import { LOCATION_CHANGE_HANDLE } from '../../../lib/redux-router';
import { goToBoard, goToCard } from './router';
@@ -11,9 +12,12 @@ import request from '../request';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
+import i18n from '../../../i18n';
import { createLocalId } from '../../../utils/local-id';
import { isListArchiveOrTrash, isListFinite } from '../../../utils/record-helpers';
import ActionTypes from '../../../constants/ActionTypes';
+import ClipboardTypes from '../../../constants/ClipboardTypes';
+import ToastTypes from '../../../constants/ToastTypes';
import { BoardViews, ListTypes, ListTypeStates } from '../../../constants/Enums';
import LIST_TYPE_STATE_BY_TYPE from '../../../constants/ListTypeStateByType';
@@ -419,7 +423,92 @@ export function* transferCard(id, boardId, listId, index) {
data.position = yield select(selectors.selectNextCardPosition, listId, index, id);
}
- yield call(updateCard, id, data);
+ const typeState = LIST_TYPE_STATE_BY_TYPE[list.type];
+
+ yield put(
+ actions.transferCard(id, {
+ ...data,
+ isClosed: typeState === ListTypeStates.CLOSED,
+ }),
+ );
+
+ let card;
+ let updateError;
+
+ try {
+ ({ item: card } = yield call(request, api.updateCard, id, data));
+ } catch (error) {
+ updateError = error;
+ }
+
+ let users;
+ let cardMemberships;
+ let cardLabels;
+ let taskLists;
+ let tasks;
+ let attachments;
+ let customFieldGroups;
+ let customFields;
+ let customFieldValues;
+
+ try {
+ ({
+ item: card,
+ included: {
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+ },
+ } = yield call(request, api.getCard, id));
+ } catch (error) {
+ yield put(actions.transferCard.failure(id, error));
+ }
+
+ if (updateError) {
+ yield put(
+ actions.transferCard.failure(
+ id,
+ updateError,
+ card,
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+ ),
+ );
+
+ yield call(toast, {
+ type: ToastTypes.SOURCE_CARD_NOT_MOVABLE,
+ });
+
+ return;
+ }
+
+ yield put(
+ actions.transferCard.success(
+ card,
+ users,
+ cardMemberships,
+ cardLabels,
+ taskLists,
+ tasks,
+ attachments,
+ customFieldGroups,
+ customFields,
+ customFieldValues,
+ ),
+ );
}
export function* transferCurrentCard(boardId, listId, index) {
@@ -431,23 +520,38 @@ export function* transferCurrentCard(boardId, listId, index) {
export function* duplicateCard(id, data) {
const localId = yield call(createLocalId);
const { cardId: currentCardId } = yield select(selectors.selectPath);
- const { boardId, listId } = yield select(selectors.selectCardById, id);
- const index = yield select(selectors.selectCardIndexById, id);
+ const sourceCard = yield select(selectors.selectCardById, id);
+
+ const boardId = data.boardId || sourceCard.boardId;
+ const listId = data.listId || sourceCard.listId;
+
+ const list = yield select(selectors.selectListById, listId);
+ const typeState = LIST_TYPE_STATE_BY_TYPE[list.type];
+
+ const nextData = {
+ ...data,
+ };
+
+ if (!nextData.position && isListFinite(list)) {
+ const index = yield select(selectors.selectCardIndexById, id);
+ nextData.position = yield select(selectors.selectNextCardPosition, listId, index + 1);
+ }
const currentUserMembership = yield select(
selectors.selectCurrentUserMembershipByBoardId,
boardId,
);
- const nextData = {
- ...data,
- position: yield select(selectors.selectNextCardPosition, listId, index + 1),
- };
-
yield put(
actions.duplicateCard(id, localId, {
...nextData,
creatorUserId: currentUserMembership.userId,
+ isClosed: typeState === ListTypeStates.CLOSED,
+ ...(sourceCard && {
+ name: `${sourceCard.name} (${i18n.t('common.copy', {
+ context: 'inline',
+ })})`,
+ }),
}),
);
@@ -481,6 +585,11 @@ export function* duplicateCard(id, data) {
} = yield call(request, api.duplicateCard, id, nextData));
} catch (error) {
yield put(actions.duplicateCard.failure(localId, error));
+
+ yield call(toast, {
+ type: ToastTypes.SOURCE_CARD_NOT_COPYABLE,
+ });
+
return;
}
@@ -516,6 +625,54 @@ export function* duplicateCurrentCard(data) {
yield call(duplicateCard, cardId, data);
}
+export function* copyCard(id) {
+ yield put(actions.copyCard(id));
+}
+
+export function* cutCard(id) {
+ yield put(actions.cutCard(id));
+}
+
+export function* pasteCard(listId) {
+ const list = yield select(selectors.selectListById, listId);
+ const clipboard = yield select(selectors.selectClipboard);
+ const sourceCard = yield select(selectors.selectCardById, clipboard.cardId);
+
+ yield put(actions.pasteCard());
+
+ if (clipboard.type === ClipboardTypes.COPY) {
+ const data = {
+ listId,
+ };
+ if (!sourceCard || list.boardId !== sourceCard.boardId) {
+ data.boardId = list.boardId;
+ }
+ if (isListFinite(list)) {
+ data.position = yield select(selectors.selectNextCardPosition, listId);
+ }
+
+ yield call(duplicateCard, clipboard.cardId, data);
+ } else if (clipboard.type === ClipboardTypes.CUT) {
+ if (sourceCard && listId === sourceCard.listId) {
+ return;
+ }
+
+ yield call(transferCard, clipboard.cardId, list.boardId, list.id);
+ }
+}
+
+export function* pasteCardInCurrentContext() {
+ const listId = yield select(selectors.selectFirstKanbanListId);
+
+ yield call(pasteCard, listId);
+}
+
+export function* pasteCardInCurrentList() {
+ const currentListId = yield select(selectors.selectCurrentListId);
+
+ yield call(pasteCard, currentListId);
+}
+
export function* goToAdjacentCard(direction) {
const card = yield select(selectors.selectCurrentCard);
const list = yield select(selectors.selectListById, card.listId);
@@ -617,6 +774,11 @@ export default {
transferCurrentCard,
duplicateCard,
duplicateCurrentCard,
+ copyCard,
+ cutCard,
+ pasteCard,
+ pasteCardInCurrentContext,
+ pasteCardInCurrentList,
goToAdjacentCard,
deleteCard,
deleteCurrentCard,
diff --git a/client/src/sagas/core/watchers/cards.js b/client/src/sagas/core/watchers/cards.js
index 4cbc438c..06315e18 100644
--- a/client/src/sagas/core/watchers/cards.js
+++ b/client/src/sagas/core/watchers/cards.js
@@ -67,6 +67,13 @@ export default function* cardsWatchers() {
takeEvery(EntryActionTypes.CURRENT_CARD_DUPLICATE, ({ payload: { data } }) =>
services.duplicateCurrentCard(data),
),
+ takeEvery(EntryActionTypes.CARD_COPY, ({ payload: { id } }) => services.copyCard(id)),
+ takeEvery(EntryActionTypes.CARD_CUT, ({ payload: { id } }) => services.cutCard(id)),
+ takeEvery(EntryActionTypes.CARD_PASTE, ({ payload: { listId } }) => services.pasteCard(listId)),
+ takeEvery(EntryActionTypes.CARD_IN_CURRENT_CONTEXT_PASTE, () =>
+ services.pasteCardInCurrentContext(),
+ ),
+ takeEvery(EntryActionTypes.CARD_IN_CURRENT_LIST_PASTE, () => services.pasteCardInCurrentList()),
takeEvery(EntryActionTypes.TO_ADJACENT_CARD_GO, ({ payload: { direction } }) =>
services.goToAdjacentCard(direction),
),
diff --git a/client/src/selectors/core.js b/client/src/selectors/core.js
index 90b4f994..53b5e03d 100644
--- a/client/src/selectors/core.js
+++ b/client/src/selectors/core.js
@@ -11,6 +11,8 @@ export const selectIsFavoritesEnabled = ({ core: { isFavoritesEnabled } }) => is
export const selectIsEditModeEnabled = ({ core: { isEditModeEnabled } }) => isEditModeEnabled;
+export const selectClipboard = ({ core: { clipboard } }) => clipboard;
+
export const selectConfig = ({ core: { config } }) => config;
export const selectRecentCardId = ({ core: { recentCardId } }) => recentCardId;
@@ -31,6 +33,7 @@ export default {
selectIsLogouting,
selectIsFavoritesEnabled,
selectIsEditModeEnabled,
+ selectClipboard,
selectConfig,
selectRecentCardId,
selectPrevCardId,
diff --git a/server/api/controllers/cards/duplicate.js b/server/api/controllers/cards/duplicate.js
index 9de23702..07c26154 100644
--- a/server/api/controllers/cards/duplicate.js
+++ b/server/api/controllers/cards/duplicate.js
@@ -26,18 +26,25 @@
* application/json:
* schema:
* type: object
- * required:
- * - position
- * - name
* properties:
+ * boardId:
+ * type: string
+ * description: ID of the board to duplicate the card to
+ * example: "1357158568008091265"
+ * listId:
+ * type: string
+ * description: ID of the list to duplicate the card to
+ * example: "1357158568008091266"
* position:
* type: number
* minimum: 0
+ * nullable: true
* description: Position for the duplicated card within the list
* example: 65536
* name:
* type: string
* maxLength: 1024
+ * nullable: true
* description: Name/title for the duplicated card
* example: Implement user authentication (copy)
* responses:
@@ -113,6 +120,8 @@
* $ref: '#/components/responses/Forbidden'
* 404:
* $ref: '#/components/responses/NotFound'
+ * 422:
+ * $ref: '#/components/responses/UnprocessableEntity'
*/
const { idInput } = require('../../../utils/inputs');
@@ -124,6 +133,18 @@ const Errors = {
CARD_NOT_FOUND: {
cardNotFound: 'Card not found',
},
+ BOARD_NOT_FOUND: {
+ boardNotFound: 'Board not found',
+ },
+ LIST_NOT_FOUND: {
+ listNotFound: 'List not found',
+ },
+ LIST_MUST_BE_PRESENT: {
+ listMustBePresent: 'List must be present',
+ },
+ POSITION_MUST_BE_PRESENT: {
+ positionMustBePresent: 'Position must be present',
+ },
};
module.exports = {
@@ -132,15 +153,17 @@ module.exports = {
...idInput,
required: true,
},
+ boardId: idInput,
+ listId: idInput,
position: {
type: 'number',
min: 0,
- required: true,
+ allowNull: true,
},
name: {
type: 'string',
maxLength: 1024,
- required: true,
+ allowNull: true,
},
},
@@ -151,6 +174,18 @@ module.exports = {
cardNotFound: {
responseType: 'notFound',
},
+ boardNotFound: {
+ responseType: 'notFound',
+ },
+ listNotFound: {
+ responseType: 'notFound',
+ },
+ listMustBePresent: {
+ responseType: 'unprocessableEntity',
+ },
+ positionMustBePresent: {
+ responseType: 'unprocessableEntity',
+ },
},
async fn(inputs) {
@@ -160,24 +195,60 @@ module.exports = {
.getPathToProjectById(inputs.id)
.intercept('pathNotFound', () => Errors.CARD_NOT_FOUND);
- const boardMembership = await BoardMembership.qm.getOneByBoardIdAndUserId(
+ const isProjectManager = await sails.helpers.users.isProjectManager(currentUser.id, project.id);
+
+ let boardMembership = await BoardMembership.qm.getOneByBoardIdAndUserId(
board.id,
currentUser.id,
);
- if (!boardMembership) {
- throw Errors.CARD_NOT_FOUND; // Forbidden
+ if (!isProjectManager) {
+ if (!boardMembership) {
+ throw Errors.CARD_NOT_FOUND; // Forbidden
+ }
+
+ if (boardMembership.role !== BoardMembership.Roles.EDITOR) {
+ throw Errors.NOT_ENOUGH_RIGHTS;
+ }
}
- // TODO: allow for endless lists?
- if (!sails.helpers.lists.isFinite(list)) {
- throw Errors.NOT_ENOUGH_RIGHTS;
+ let nextProject;
+ let nextBoard;
+
+ if (!_.isUndefined(inputs.boardId)) {
+ ({ board: nextBoard, project: nextProject } = await sails.helpers.boards
+ .getPathToProjectById(inputs.boardId)
+ .intercept('pathNotFound', () => Errors.BOARD_NOT_FOUND));
+
+ boardMembership = await BoardMembership.qm.getOneByBoardIdAndUserId(
+ nextBoard.id,
+ currentUser.id,
+ );
+
+ if (!boardMembership) {
+ throw Errors.BOARD_NOT_FOUND; // Forbidden
+ }
+ }
+
+ if (!boardMembership) {
+ throw Errors.LIST_NOT_FOUND; // Forbidden
}
if (boardMembership.role !== BoardMembership.Roles.EDITOR) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
+ let nextList;
+ if (!_.isUndefined(inputs.listId)) {
+ nextList = await List.qm.getOneById(inputs.listId, {
+ boardId: (nextBoard || board).id,
+ });
+
+ if (!nextList) {
+ throw Errors.LIST_NOT_FOUND;
+ }
+ }
+
const values = _.pick(inputs, ['position', 'name']);
const {
@@ -190,17 +261,23 @@ module.exports = {
customFieldGroups,
customFields,
customFieldValues,
- } = await sails.helpers.cards.duplicateOne.with({
- project,
- board,
- list,
- record: card,
- values: {
- ...values,
- creatorUser: currentUser,
- },
- request: this.req,
- });
+ } = await sails.helpers.cards.duplicateOne
+ .with({
+ project,
+ board,
+ list,
+ record: card,
+ values: {
+ ...values,
+ project: nextProject,
+ board: nextBoard,
+ list: nextList,
+ creatorUser: currentUser,
+ },
+ request: this.req,
+ })
+ .intercept('positionMustBeInValues', () => Errors.POSITION_MUST_BE_PRESENT)
+ .intercept('listMustBeInValues', () => Errors.LIST_MUST_BE_PRESENT);
return {
item: nextCard,
diff --git a/server/api/helpers/cards/copy-custom-fields.js b/server/api/helpers/cards/copy-custom-fields.js
new file mode 100644
index 00000000..b65d7ebc
--- /dev/null
+++ b/server/api/helpers/cards/copy-custom-fields.js
@@ -0,0 +1,185 @@
+/*!
+ * Copyright (c) 2024 PLANKA Software GmbH
+ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
+ */
+
+const { POSITION_GAP } = require('../../../constants');
+
+module.exports = {
+ inputs: {
+ fromRecord: {
+ type: 'ref',
+ required: true,
+ },
+ toRecord: {
+ type: 'ref',
+ required: true,
+ },
+ detachBoardCustomFieldGroups: {
+ type: 'boolean',
+ defaultsTo: true,
+ },
+ detachBaseCustomFieldGroups: {
+ type: 'boolean',
+ defaultsTo: true,
+ },
+ },
+
+ async fn(inputs) {
+ const boardCustomFieldGroups = inputs.detachBoardCustomFieldGroups
+ ? await CustomFieldGroup.qm.getByBoardId(inputs.fromRecord.boardId)
+ : [];
+
+ const cardCustomFieldGroups = await CustomFieldGroup.qm.getByCardId(inputs.fromRecord.id);
+
+ const customFieldGroups = [...boardCustomFieldGroups, ...cardCustomFieldGroups];
+ const customFieldGroupIds = sails.helpers.utils.mapRecords(customFieldGroups);
+
+ const customFields = await CustomField.qm.getByCustomFieldGroupIds(customFieldGroupIds);
+
+ let customFieldGroupsByBaseCustomFieldGroupId;
+ let baseCustomFieldGroupById;
+ let customFieldsByBaseCustomFieldGroupId;
+ let nextCustomFieldsTotal = customFields.length;
+
+ if (inputs.detachBaseCustomFieldGroups) {
+ customFieldGroupsByBaseCustomFieldGroupId = _.groupBy(
+ customFieldGroups.filter(({ baseCustomFieldGroupId }) => baseCustomFieldGroupId),
+ 'baseCustomFieldGroupId',
+ );
+
+ const baseCustomFieldGroupIds = Object.keys(customFieldGroupsByBaseCustomFieldGroupId);
+
+ if (baseCustomFieldGroupIds.length > 0) {
+ const baseCustomFieldGroups =
+ await BaseCustomFieldGroup.qm.getByIds(baseCustomFieldGroupIds);
+
+ baseCustomFieldGroupById = _.keyBy(baseCustomFieldGroups, 'id');
+
+ const baseCustomFields = await CustomField.qm.getByBaseCustomFieldGroupIds(
+ Object.keys(baseCustomFieldGroupById),
+ );
+
+ customFieldsByBaseCustomFieldGroupId = _.groupBy(
+ baseCustomFields,
+ 'baseCustomFieldGroupId',
+ );
+
+ nextCustomFieldsTotal += Object.entries(customFieldGroupsByBaseCustomFieldGroupId).reduce(
+ (result, [baseCustomFieldGroupId, customFieldGroupsItem]) => {
+ const customFieldsItem = customFieldsByBaseCustomFieldGroupId[baseCustomFieldGroupId];
+
+ if (!customFieldsItem) {
+ return result;
+ }
+
+ return result + customFieldsItem.length * customFieldGroupsItem.length;
+ },
+ 0,
+ );
+ }
+ }
+
+ const customFieldValues = await CustomFieldValue.qm.getByCardId(inputs.fromRecord.id);
+
+ const ids = await sails.helpers.utils.generateIds(
+ customFieldGroups.length + nextCustomFieldsTotal,
+ );
+
+ const nextCustomFieldGroupIdByCustomFieldGroupId = {};
+ const nextCustomFieldGroupsValues = customFieldGroups.map((customFieldGroup, index) => {
+ const id = ids.shift();
+ nextCustomFieldGroupIdByCustomFieldGroupId[customFieldGroup.id] = id;
+
+ const values = {
+ ..._.pick(customFieldGroup, ['position']),
+ id,
+ cardId: inputs.toRecord.id,
+ position: customFieldGroup.boardId
+ ? POSITION_GAP * (index + 1)
+ : customFieldGroup.position + POSITION_GAP * boardCustomFieldGroups.length,
+ };
+
+ if (inputs.detachBaseCustomFieldGroups) {
+ values.name =
+ customFieldGroup.name ||
+ baseCustomFieldGroupById[customFieldGroup.baseCustomFieldGroupId].name;
+ } else {
+ Object.assign(values, {
+ name: customFieldGroup.name,
+ baseCustomFieldGroupId: customFieldGroup.baseCustomFieldGroupId,
+ });
+ }
+
+ return values;
+ });
+
+ const nextCustomFieldGroups = await CustomFieldGroup.qm.create(nextCustomFieldGroupsValues);
+
+ const nextCustomFieldIdByCustomFieldId = {};
+ const nextCustomFieldsValues = customFields.map((customField) => {
+ const id = ids.shift();
+ nextCustomFieldIdByCustomFieldId[customField.id] = id;
+
+ return {
+ ..._.pick(customField, ['position', 'name', 'showOnFrontOfCard']),
+ id,
+ customFieldGroupId:
+ nextCustomFieldGroupIdByCustomFieldGroupId[customField.customFieldGroupId],
+ };
+ });
+
+ if (inputs.detachBaseCustomFieldGroups) {
+ Object.entries(customFieldGroupsByBaseCustomFieldGroupId).forEach(
+ ([baseCustomFieldGroupId, customFieldGroupsItem]) => {
+ const customFieldsItem = customFieldsByBaseCustomFieldGroupId[baseCustomFieldGroupId];
+
+ if (!customFieldsItem) {
+ return;
+ }
+
+ customFieldGroupsItem.forEach((customFieldGroup) => {
+ customFieldsItem.forEach((customField) => {
+ const groupedId = `${customFieldGroup.id}:${customField.id}`;
+ const id = ids.shift();
+
+ nextCustomFieldIdByCustomFieldId[groupedId] = id;
+
+ nextCustomFieldsValues.push({
+ ..._.pick(customField, ['position', 'name', 'showOnFrontOfCard']),
+ id,
+ customFieldGroupId: nextCustomFieldGroupIdByCustomFieldGroupId[customFieldGroup.id],
+ });
+ });
+ });
+ },
+ );
+ }
+
+ const nextCustomFields = await CustomField.qm.create(nextCustomFieldsValues);
+
+ const nextCustomFieldValuesValues = customFieldValues.map((customFieldValue) => {
+ const groupedId = `${customFieldValue.customFieldGroupId}:${customFieldValue.customFieldId}`;
+
+ return {
+ ..._.pick(customFieldValue, ['content']),
+ cardId: inputs.toRecord.id,
+ customFieldGroupId:
+ nextCustomFieldGroupIdByCustomFieldGroupId[customFieldValue.customFieldGroupId] ||
+ customFieldValue.customFieldGroupId,
+ customFieldId:
+ nextCustomFieldIdByCustomFieldId[groupedId] ||
+ nextCustomFieldIdByCustomFieldId[customFieldValue.customFieldId] ||
+ customFieldValue.customFieldId,
+ };
+ });
+
+ const nextCustomFieldValues = await CustomFieldValue.qm.create(nextCustomFieldValuesValues);
+
+ return {
+ customFieldGroups: nextCustomFieldGroups,
+ customFields: nextCustomFields,
+ customFieldValues: nextCustomFieldValues,
+ };
+ },
+};
diff --git a/server/api/helpers/cards/detach-custom-fields.js b/server/api/helpers/cards/detach-custom-fields.js
index 794c7335..c24e2ad2 100644
--- a/server/api/helpers/cards/detach-custom-fields.js
+++ b/server/api/helpers/cards/detach-custom-fields.js
@@ -198,11 +198,10 @@ module.exports = {
}
customFieldsItem.forEach((customField) => {
+ const groupedId = `${customFieldGroup.id}:${customField.id}`;
const id = ids.shift();
- nextCustomFieldIdByCustomFieldIdByCardId[cardId][
- `${customFieldGroup.id}:${customField.id}`
- ] = id;
+ nextCustomFieldIdByCustomFieldIdByCardId[cardId][groupedId] = id;
nextCustomFieldsValues.push({
..._.pick(customField, ['name', 'showOnFrontOfCard', 'position']),
@@ -223,11 +222,10 @@ module.exports = {
}
customFieldsItem.forEach((customField) => {
+ const groupedId = `${customFieldGroup.id}:${customField.id}`;
const id = ids.shift();
- nextCustomFieldIdByCustomFieldIdByCardId[customFieldGroup.cardId][
- `${customFieldGroup.id}:${customField.id}`
- ] = id;
+ nextCustomFieldIdByCustomFieldIdByCardId[customFieldGroup.cardId][groupedId] = id;
nextCustomFieldsValues.push({
..._.pick(customField, ['name', 'showOnFrontOfCard', 'position']),
@@ -262,10 +260,11 @@ module.exports = {
nextCustomFieldIdByCustomFieldIdByCardId[customFieldValue.cardId];
if (nextCustomFieldIdByCustomFieldId) {
+ const groupedId = `${customFieldValue.customFieldGroupId}:${customFieldValue.customFieldId}`;
+
const nextCustomFieldId =
- nextCustomFieldIdByCustomFieldId[
- `${customFieldValue.customFieldGroupId}:${customFieldValue.customFieldId}`
- ] || nextCustomFieldIdByCustomFieldId[customFieldValue.customFieldId];
+ nextCustomFieldIdByCustomFieldId[groupedId] ||
+ nextCustomFieldIdByCustomFieldId[customFieldValue.customFieldId];
if (nextCustomFieldId) {
updateValues.customFieldId = nextCustomFieldId;
diff --git a/server/api/helpers/cards/duplicate-one.js b/server/api/helpers/cards/duplicate-one.js
index 1fe6bb9f..164841dc 100644
--- a/server/api/helpers/cards/duplicate-one.js
+++ b/server/api/helpers/cards/duplicate-one.js
@@ -25,10 +25,6 @@ module.exports = {
type: 'ref',
required: true,
},
- join: {
- type: 'boolean',
- defaultsTo: false,
- },
request: {
type: 'ref',
},
@@ -36,25 +32,58 @@ module.exports = {
exits: {
positionMustBeInValues: {},
+ boardInValuesMustBelongToProject: {},
+ listMustBeInValues: {},
+ listInValuesMustBelongToBoard: {},
},
async fn(inputs) {
const { values } = inputs;
- if (values.list) {
- const typeState = List.TYPE_STATE_BY_TYPE[values.list.type];
+ if (values.project && values.project.id === inputs.project.id) {
+ delete values.project;
+ }
- if (inputs.record.isClosed) {
- if (typeState === List.TypeStates.OPENED) {
- values.isClosed = false;
- }
- } else if (typeState === List.TypeStates.CLOSED) {
- values.isClosed = true;
+ const project = values.project || inputs.project;
+
+ if (values.board) {
+ if (values.board.projectId !== project.id) {
+ throw 'boardInValuesMustBelongToProject';
+ }
+
+ if (values.board.id === inputs.board.id) {
+ delete values.board;
+ } else {
+ values.boardId = values.board.id;
}
}
+ const board = values.board || inputs.board;
+
+ if (values.list) {
+ if (values.list.boardId !== board.id) {
+ throw 'listInValuesMustBelongToBoard';
+ }
+
+ if (values.list.id === inputs.list.id) {
+ delete values.list;
+ } else {
+ values.listId = values.list.id;
+ }
+ } else if (values.board) {
+ throw 'listMustBeInValues';
+ }
+
const list = values.list || inputs.list;
+ if (sails.helpers.lists.isFinite(list)) {
+ if (values.list && _.isUndefined(values.position)) {
+ throw 'positionMustBeInValues';
+ }
+ } else {
+ values.position = null;
+ }
+
if (sails.helpers.lists.isFinite(list)) {
if (_.isUndefined(values.position)) {
throw 'positionMustBeInValues';
@@ -95,9 +124,54 @@ module.exports = {
}
}
+ let labelIds;
+ if (values.board) {
+ const prevLabels = await sails.helpers.cards.getLabels(inputs.record.id);
+
+ const labels = await Label.qm.getByBoardId(values.board.id);
+ const labelByName = _.keyBy(labels, 'name');
+
+ labelIds = await Promise.all(
+ prevLabels.map(async (label) => {
+ if (labelByName[label.name]) {
+ return labelByName[label.name].id;
+ }
+
+ const { id } = await sails.helpers.labels.createOne.with({
+ project,
+ values: {
+ ..._.omit(label, ['id', 'boardId', 'createdAt', 'updatedAt']),
+ board,
+ },
+ actorUser: values.creatorUser,
+ });
+
+ return id;
+ }),
+ );
+ }
+
+ if (values.list) {
+ const typeState = List.TYPE_STATE_BY_TYPE[values.list.type];
+
+ if (inputs.record.isClosed) {
+ if (typeState === List.TypeStates.OPENED) {
+ values.isClosed = false;
+ }
+ } else if (typeState === List.TypeStates.CLOSED) {
+ values.isClosed = true;
+ }
+ }
+
+ if (!values.name) {
+ const t = sails.helpers.utils.makeTranslator(values.creatorUser.language);
+ values.name = `${inputs.record.name} (${t('copy')})`;
+ }
+
let card = await Card.qm.createOne({
..._.pick(inputs.record, [
'boardId',
+ 'listId',
'prevListId',
'type',
'name',
@@ -108,12 +182,16 @@ module.exports = {
'isClosed',
]),
...values,
- listId: list.id,
creatorUserId: values.creatorUser.id,
listChangedAt: new Date().toISOString(),
});
- const cardMemberships = await CardMembership.qm.getByCardId(inputs.record.id);
+ const boardMemberUserIds = await sails.helpers.boards.getMemberUserIds(card.boardId);
+ const boardMemberUserIdsSet = new Set(boardMemberUserIds);
+
+ const cardMemberships = await CardMembership.qm.getByCardId(inputs.record.id, {
+ userIdOrIds: boardMemberUserIds,
+ });
const cardMembershipsValues = cardMemberships.map((cardMembership) => ({
..._.pick(cardMembership, ['userId']),
@@ -122,10 +200,13 @@ module.exports = {
const nextCardMemberships = await CardMembership.qm.create(cardMembershipsValues);
- const cardLabels = await CardLabel.qm.getByCardId(inputs.record.id);
+ if (!values.board) {
+ const cardLabels = await CardLabel.qm.getByCardId(inputs.record.id);
+ labelIds = sails.helpers.utils.mapRecords(cardLabels, 'labelId');
+ }
- const cardLabelsValues = cardLabels.map((cardLabel) => ({
- ..._.pick(cardLabel, ['labelId']),
+ const cardLabelsValues = labelIds.map((labelId) => ({
+ labelId,
cardId: card.id,
}));
@@ -137,15 +218,7 @@ module.exports = {
const tasks = await Task.qm.getByTaskListIds(taskListIds);
const attachments = await Attachment.qm.getByCardId(inputs.record.id);
- const customFieldGroups = await CustomFieldGroup.qm.getByCardId(inputs.record.id);
- const customFieldGroupIds = sails.helpers.utils.mapRecords(customFieldGroups);
-
- const customFields = await CustomField.qm.getByCustomFieldGroupIds(customFieldGroupIds);
- const customFieldValues = await CustomFieldValue.qm.getByCardId(inputs.record.id);
-
- const ids = await sails.helpers.utils.generateIds(
- taskLists.length + attachments.length + customFieldGroups.length + customFields.length,
- );
+ const ids = await sails.helpers.utils.generateIds(taskLists.length + attachments.length);
const nextTaskListIdByTaskListId = {};
const nextTaskListsValues = await taskLists.map((taskList) => {
@@ -162,8 +235,9 @@ module.exports = {
const nextTaskLists = await TaskList.qm.create(nextTaskListsValues);
const nextTasksValues = tasks.map((task) => ({
- ..._.pick(task, ['linkedCardId', 'assigneeUserId', 'position', 'name', 'isCompleted']),
+ ..._.pick(task, ['linkedCardId', 'position', 'name', 'isCompleted']),
taskListId: nextTaskListIdByTaskListId[task.taskListId],
+ assigneeUserId: boardMemberUserIdsSet.has(task.assigneeUserId) ? task.assigneeUserId : null,
}));
const nextTasks = await Task.qm.create(nextTasksValues);
@@ -193,47 +267,16 @@ module.exports = {
}
}
- const nextCustomFieldGroupIdByCustomFieldGroupId = {};
- const nextCustomFieldGroupsValues = customFieldGroups.map((customFieldGroup) => {
- const id = ids.shift();
- nextCustomFieldGroupIdByCustomFieldGroupId[customFieldGroup.id] = id;
-
- return {
- ..._.pick(customFieldGroup, ['baseCustomFieldGroupId', 'position', 'name']),
- id,
- cardId: card.id,
- };
- });
-
- const nextCustomFieldGroups = await CustomFieldGroup.qm.create(nextCustomFieldGroupsValues);
-
- const nextCustomFieldIdByCustomFieldId = {};
- const nextCustomFieldsValues = customFields.map((customField) => {
- const id = ids.shift();
- nextCustomFieldIdByCustomFieldId[customField.id] = id;
-
- return {
- ..._.pick(customField, ['position', 'name', 'showOnFrontOfCard']),
- id,
- customFieldGroupId:
- nextCustomFieldGroupIdByCustomFieldGroupId[customField.customFieldGroupId],
- };
- });
-
- const nextCustomFields = await CustomField.qm.create(nextCustomFieldsValues);
-
- const nextCustomFieldValuesValues = customFieldValues.map((customFieldValue) => ({
- ..._.pick(customFieldValue, ['content']),
- cardId: card.id,
- customFieldGroupId:
- nextCustomFieldGroupIdByCustomFieldGroupId[customFieldValue.customFieldGroupId] ||
- customFieldValue.customFieldGroupId,
- customFieldId:
- nextCustomFieldIdByCustomFieldId[customFieldValue.customFieldId] ||
- customFieldValue.customFieldId,
- }));
-
- const nextCustomFieldValues = await CustomFieldValue.qm.create(nextCustomFieldValuesValues);
+ const {
+ customFieldGroups: nextCustomFieldGroups,
+ customFields: nextCustomFields,
+ customFieldValues: nextCustomFieldValues,
+ } = await sails.helpers.cards.copyCustomFields(
+ inputs.record,
+ card,
+ !!values.board,
+ !!values.project,
+ );
sails.sockets.broadcast(
`board:${card.boardId}`,
@@ -252,8 +295,8 @@ module.exports = {
buildData: () => ({
item: card,
included: {
- projects: [inputs.project],
- boards: [inputs.board],
+ projects: [project],
+ boards: [board],
lists: [list],
cardMemberships: nextCardMemberships,
cardLabels: nextCardLabels,
@@ -291,6 +334,8 @@ module.exports = {
}
await sails.helpers.actions.createOne.with({
+ project,
+ board,
list,
webhooks,
values: {
@@ -302,8 +347,6 @@ module.exports = {
},
user: values.creatorUser,
},
- project: inputs.project,
- board: inputs.board,
});
return {
diff --git a/server/api/helpers/cards/update-one.js b/server/api/helpers/cards/update-one.js
index 5dee4f28..969a974d 100644
--- a/server/api/helpers/cards/update-one.js
+++ b/server/api/helpers/cards/update-one.js
@@ -98,7 +98,11 @@ module.exports = {
throw 'coverAttachmentInValuesMustContainImage';
}
- values.coverAttachmentId = values.coverAttachment.id;
+ if (values.coverAttachment.id === inputs.record.coverAttachmentId) {
+ delete values.coverAttachment;
+ } else {
+ values.coverAttachmentId = values.coverAttachment.id;
+ }
}
const dueDate = _.isUndefined(values.dueDate) ? inputs.record.dueDate : values.dueDate;
@@ -289,9 +293,14 @@ module.exports = {
inputs.request,
);
- sails.sockets.broadcast(`board:${card.boardId}`, 'cardUpdate', {
- item: card,
- });
+ sails.sockets.broadcast(
+ `board:${card.boardId}`,
+ 'cardUpdate',
+ {
+ item: card,
+ },
+ inputs.request,
+ );
// TODO: add transfer action
} else {
diff --git a/server/api/hooks/query-methods/models/CardMembership.js b/server/api/hooks/query-methods/models/CardMembership.js
index af74b355..5e5d2406 100644
--- a/server/api/hooks/query-methods/models/CardMembership.js
+++ b/server/api/hooks/query-methods/models/CardMembership.js
@@ -13,10 +13,16 @@ const createOne = (values) => CardMembership.create({ ...values }).fetch();
const getByIds = (ids) => defaultFind(ids);
-const getByCardId = (cardId) =>
- defaultFind({
+const getByCardId = (cardId, { userIdOrIds } = {}) => {
+ const criteria = {
cardId,
- });
+ };
+ if (userIdOrIds) {
+ criteria.userId = userIdOrIds;
+ }
+
+ return defaultFind(criteria);
+};
const getByCardIds = (cardIds) =>
defaultFind({
diff --git a/server/config/locales/ar-YE.json b/server/config/locales/ar-YE.json
index f20dd4df..636ce605 100644
--- a/server/config/locales/ar-YE.json
+++ b/server/config/locales/ar-YE.json
@@ -2,6 +2,7 @@
"Archive": "أرشيف",
"Card Created": "تم إنشاء البطاقة",
"Card Moved": "تم نقل البطاقة",
+ "copy": "نسخة",
"New Comment": "تعليق جديد",
"Test Title": "عنوان تجريبي",
"This is a test text message!": "هذه رسالة نصية تجريبية!",
diff --git a/server/config/locales/bg-BG.json b/server/config/locales/bg-BG.json
index 27f8ebe9..3b47652c 100644
--- a/server/config/locales/bg-BG.json
+++ b/server/config/locales/bg-BG.json
@@ -2,6 +2,7 @@
"Archive": "Архив",
"Card Created": "Картата е създадена",
"Card Moved": "Картата е преместена",
+ "copy": "копие",
"New Comment": "Нов коментар",
"Test Title": "Тестово заглавие",
"This is a test text message!": "Това е тестово текстово съобщение!",
diff --git a/server/config/locales/ca-ES.json b/server/config/locales/ca-ES.json
index 5f17959d..d554b3f2 100644
--- a/server/config/locales/ca-ES.json
+++ b/server/config/locales/ca-ES.json
@@ -2,6 +2,7 @@
"Archive": "Arxivar",
"Card Created": "Targeta creada",
"Card Moved": "Targeta moguda",
+ "copy": "còpia",
"New Comment": "Comentari nou",
"Test Title": "Títol de prova",
"This is a test text message!": "Aquest és un missatge de text de prova!",
diff --git a/server/config/locales/cs-CZ.json b/server/config/locales/cs-CZ.json
index b869dab4..1ba9f5a0 100644
--- a/server/config/locales/cs-CZ.json
+++ b/server/config/locales/cs-CZ.json
@@ -2,6 +2,7 @@
"Archive": "Archiv",
"Card Created": "Karta vytvořena",
"Card Moved": "Karta přesunuta",
+ "copy": "kopie",
"New Comment": "Nový komentář",
"Test Title": "Testovací název",
"This is a test text message!": "Toto je testovací textová zpráva!",
diff --git a/server/config/locales/da-DK.json b/server/config/locales/da-DK.json
index 71dc0629..c6274ea8 100644
--- a/server/config/locales/da-DK.json
+++ b/server/config/locales/da-DK.json
@@ -2,6 +2,7 @@
"Archive": "Arkiv",
"Card Created": "Kort oprettet",
"Card Moved": "Kort flyttet",
+ "copy": "kopi",
"New Comment": "Ny kommentar",
"Test Title": "Test titel",
"This is a test text message!": "Dette er en test tekstbesked!",
diff --git a/server/config/locales/de-DE.json b/server/config/locales/de-DE.json
index 82f120a5..5873715a 100644
--- a/server/config/locales/de-DE.json
+++ b/server/config/locales/de-DE.json
@@ -2,6 +2,7 @@
"Archive": "Archiv",
"Card Created": "Karte erstellt",
"Card Moved": "Karte verschoben",
+ "copy": "Kopie",
"New Comment": "Neuer Kommentar",
"Test Title": "Testtitel",
"This is a test text message!": "Dies ist eine Test-Textnachricht!",
diff --git a/server/config/locales/el-GR.json b/server/config/locales/el-GR.json
index e81cc220..afc51232 100644
--- a/server/config/locales/el-GR.json
+++ b/server/config/locales/el-GR.json
@@ -2,6 +2,7 @@
"Archive": "Αρχείο",
"Card Created": "Η κάρτα δημιουργήθηκε",
"Card Moved": "Η κάρτα μετακινήθηκε",
+ "copy": "αντίγραφο",
"New Comment": "Νέο σχόλιο",
"Test Title": "Τίτλος δοκιμής",
"This is a test text message!": "Αυτό είναι ένα δοκιμαστικό μήνυμα!",
diff --git a/server/config/locales/en-GB.json b/server/config/locales/en-GB.json
index 0434cc38..e2f5d00e 100644
--- a/server/config/locales/en-GB.json
+++ b/server/config/locales/en-GB.json
@@ -2,6 +2,7 @@
"Archive": "Archive",
"Card Created": "Card Created",
"Card Moved": "Card Moved",
+ "copy": "copy",
"New Comment": "New Comment",
"Test Title": "Test Title",
"This is a test text message!": "This is a test text message!",
diff --git a/server/config/locales/en-US.json b/server/config/locales/en-US.json
index 6b43f324..e079b77f 100644
--- a/server/config/locales/en-US.json
+++ b/server/config/locales/en-US.json
@@ -2,6 +2,7 @@
"Archive": "Archive",
"Card Created": "Card Created",
"Card Moved": "Card Moved",
+ "copy": "copy",
"New Comment": "New Comment",
"Test Title": "Test Title",
"This is a test text message!": "This is a test text message!",
diff --git a/server/config/locales/es-ES.json b/server/config/locales/es-ES.json
index 16417dc4..02daae89 100644
--- a/server/config/locales/es-ES.json
+++ b/server/config/locales/es-ES.json
@@ -2,6 +2,7 @@
"Archive": "Archivo",
"Card Created": "Tarjeta creada",
"Card Moved": "Tarjeta movida",
+ "copy": "copia",
"New Comment": "Nuevo comentario",
"Test Title": "Título de prueba",
"This is a test text message!": "¡Este es un mensaje de texto de prueba!",
diff --git a/server/config/locales/et-EE.json b/server/config/locales/et-EE.json
index 7f3060f4..cc804de2 100644
--- a/server/config/locales/et-EE.json
+++ b/server/config/locales/et-EE.json
@@ -2,6 +2,7 @@
"Archive": "Arhiiv",
"Card Created": "Kaart loodud",
"Card Moved": "Kaart liigutatud",
+ "copy": "koopia",
"New Comment": "Uus kommentaar",
"Test Title": "Testi pealkiri",
"This is a test text message!": "See on testi tekstisõnum!",
diff --git a/server/config/locales/fa-IR.json b/server/config/locales/fa-IR.json
index 484837d9..11daaca1 100644
--- a/server/config/locales/fa-IR.json
+++ b/server/config/locales/fa-IR.json
@@ -2,6 +2,7 @@
"Archive": "بایگانی",
"Card Created": "کارت ایجاد شد",
"Card Moved": "کارت منتقل شد",
+ "copy": "کپی",
"New Comment": "نظر جدید",
"Test Title": "عنوان آزمایشی",
"This is a test text message!": "این یک پیام متنی آزمایشی است!",
diff --git a/server/config/locales/fi-FI.json b/server/config/locales/fi-FI.json
index 62cbecb5..efdfe0a7 100644
--- a/server/config/locales/fi-FI.json
+++ b/server/config/locales/fi-FI.json
@@ -2,6 +2,7 @@
"Archive": "Arkisto",
"Card Created": "Kortti luotu",
"Card Moved": "Kortti siirretty",
+ "copy": "kopio",
"New Comment": "Uusi kommentti",
"Test Title": "Testin otsikko",
"This is a test text message!": "Tämä on testiviesti!",
diff --git a/server/config/locales/fr-FR.json b/server/config/locales/fr-FR.json
index d9996530..88424f77 100644
--- a/server/config/locales/fr-FR.json
+++ b/server/config/locales/fr-FR.json
@@ -2,6 +2,7 @@
"Archive": "Archive",
"Card Created": "Carte créée",
"Card Moved": "Carte déplacée",
+ "copy": "copie",
"New Comment": "Nouveau commentaire",
"Test Title": "Titre de test",
"This is a test text message!": "Ceci est un message texte de test !",
diff --git a/server/config/locales/hu-HU.json b/server/config/locales/hu-HU.json
index 25e7c784..0be45df2 100644
--- a/server/config/locales/hu-HU.json
+++ b/server/config/locales/hu-HU.json
@@ -2,6 +2,7 @@
"Archive": "Archívum",
"Card Created": "Kártya létrehozva",
"Card Moved": "Kártya áthelyezve",
+ "copy": "másolat",
"New Comment": "Új hozzászólás",
"Test Title": "Teszt cím",
"This is a test text message!": "Ez itt egy szöveges teszt üzenet!",
diff --git a/server/config/locales/id-ID.json b/server/config/locales/id-ID.json
index ec719a42..0fad02e5 100644
--- a/server/config/locales/id-ID.json
+++ b/server/config/locales/id-ID.json
@@ -2,6 +2,7 @@
"Archive": "Arsip",
"Card Created": "Kartu dibuat",
"Card Moved": "Kartu dipindahkan",
+ "copy": "salinan",
"New Comment": "Komentar baru",
"Test Title": "Judul tes",
"This is a test text message!": "Ini adalah pesan teks tes!",
diff --git a/server/config/locales/it-IT.json b/server/config/locales/it-IT.json
index 113c3ea9..3ccf93a0 100644
--- a/server/config/locales/it-IT.json
+++ b/server/config/locales/it-IT.json
@@ -2,6 +2,7 @@
"Archive": "Archivio",
"Card Created": "Nuova task creata",
"Card Moved": "Task spostata",
+ "copy": "copia",
"New Comment": "Nuovo commento",
"Test Title": "Titolo di test",
"This is a test text message!": "Questo è un messaggio di testo di test!",
diff --git a/server/config/locales/ja-JP.json b/server/config/locales/ja-JP.json
index 810ca4e3..fee9e214 100644
--- a/server/config/locales/ja-JP.json
+++ b/server/config/locales/ja-JP.json
@@ -2,6 +2,7 @@
"Archive": "アーカイブ",
"Card Created": "カードが作成されました",
"Card Moved": "カードが移動されました",
+ "copy": "コピー",
"New Comment": "新しいコメント",
"Test Title": "テストタイトル",
"This is a test text message!": "これはテストテキストメッセージです!",
diff --git a/server/config/locales/ko-KR.json b/server/config/locales/ko-KR.json
index 144be51d..0746a800 100644
--- a/server/config/locales/ko-KR.json
+++ b/server/config/locales/ko-KR.json
@@ -2,6 +2,7 @@
"Archive": "보관함",
"Card Created": "카드가 생성됨",
"Card Moved": "카드가 이동됨",
+ "copy": "복사본",
"New Comment": "새 댓글",
"Test Title": "테스트 제목",
"This is a test text message!": "이것은 테스트 텍스트 메시지입니다!",
diff --git a/server/config/locales/nl-NL.json b/server/config/locales/nl-NL.json
index 70753dda..ba2b9f46 100644
--- a/server/config/locales/nl-NL.json
+++ b/server/config/locales/nl-NL.json
@@ -2,6 +2,7 @@
"Archive": "Archief",
"Card Created": "Kaart aangemaakt",
"Card Moved": "Kaart verplaatst",
+ "copy": "kopie",
"New Comment": "Nieuwe reactie",
"Test Title": "Test titel",
"This is a test text message!": "Dit is een test tekstbericht!",
diff --git a/server/config/locales/pl-PL.json b/server/config/locales/pl-PL.json
index 6eaff471..289e6395 100644
--- a/server/config/locales/pl-PL.json
+++ b/server/config/locales/pl-PL.json
@@ -2,6 +2,7 @@
"Archive": "Archiwum",
"Card Created": "Karta utworzona",
"Card Moved": "Karta przeniesiona",
+ "copy": "kopia",
"New Comment": "Nowy komentarz",
"Test Title": "Tytuł testowy",
"This is a test text message!": "To jest testowa wiadomość tekstowa!",
diff --git a/server/config/locales/pt-BR.json b/server/config/locales/pt-BR.json
index 49745515..f2bb7cc8 100644
--- a/server/config/locales/pt-BR.json
+++ b/server/config/locales/pt-BR.json
@@ -2,6 +2,7 @@
"Archive": "Arquivo",
"Card Created": "Cartão criado",
"Card Moved": "Cartão movido",
+ "copy": "cópia",
"New Comment": "Novo comentário",
"Test Title": "Título de teste",
"This is a test text message!": "Esta é uma mensagem de texto de teste!",
diff --git a/server/config/locales/pt-PT.json b/server/config/locales/pt-PT.json
index 7379cf5a..5807dad5 100644
--- a/server/config/locales/pt-PT.json
+++ b/server/config/locales/pt-PT.json
@@ -2,6 +2,7 @@
"Archive": "Arquivo",
"Card Created": "Cartão criado",
"Card Moved": "Cartão movido",
+ "copy": "cópia",
"New Comment": "Novo comentário",
"Test Title": "Título de teste",
"This is a test text message!": "Esta é uma mensagem de texto de teste!",
diff --git a/server/config/locales/ro-RO.json b/server/config/locales/ro-RO.json
index d6885dd8..3a2c9ec0 100644
--- a/server/config/locales/ro-RO.json
+++ b/server/config/locales/ro-RO.json
@@ -2,6 +2,7 @@
"Archive": "Arhivă",
"Card Created": "Card creat",
"Card Moved": "Card mutat",
+ "copy": "copie",
"New Comment": "Comentariu nou",
"Test Title": "Titlu de test",
"This is a test text message!": "Acesta este un mesaj text de test!",
diff --git a/server/config/locales/ru-RU.json b/server/config/locales/ru-RU.json
index c94db4d8..abc0684c 100644
--- a/server/config/locales/ru-RU.json
+++ b/server/config/locales/ru-RU.json
@@ -2,6 +2,7 @@
"Archive": "Архив",
"Card Created": "Карточка создана",
"Card Moved": "Карточка перемещена",
+ "copy": "копия",
"New Comment": "Новый комментарий",
"Test Title": "Тестовый заголовок",
"This is a test text message!": "Это тестовое сообщение!",
diff --git a/server/config/locales/sk-SK.json b/server/config/locales/sk-SK.json
index a0bc5c7f..f6335ef4 100644
--- a/server/config/locales/sk-SK.json
+++ b/server/config/locales/sk-SK.json
@@ -2,6 +2,7 @@
"Archive": "Archív",
"Card Created": "Karta vytvorená",
"Card Moved": "Karta presunutá",
+ "copy": "kópia",
"New Comment": "Nový komentár",
"Test Title": "Testovací názov",
"This is a test text message!": "Toto je testovacia textová správa!",
diff --git a/server/config/locales/sr-Cyrl-RS.json b/server/config/locales/sr-Cyrl-RS.json
index e09cc2d5..3c74c619 100644
--- a/server/config/locales/sr-Cyrl-RS.json
+++ b/server/config/locales/sr-Cyrl-RS.json
@@ -2,6 +2,7 @@
"Archive": "Архива",
"Card Created": "Картица креирана",
"Card Moved": "Картица премештена",
+ "copy": "копија",
"New Comment": "Нови коментар",
"Test Title": "Тест наслов",
"This is a test text message!": "Ово је тест текстуална порука!",
diff --git a/server/config/locales/sr-Latn-RS.json b/server/config/locales/sr-Latn-RS.json
index b9ea7368..f1ff8fe1 100644
--- a/server/config/locales/sr-Latn-RS.json
+++ b/server/config/locales/sr-Latn-RS.json
@@ -2,6 +2,7 @@
"Archive": "Arhiva",
"Card Created": "Kartica kreirana",
"Card Moved": "Kartica premeštena",
+ "copy": "kopija",
"New Comment": "Novi komentar",
"Test Title": "Test naslov",
"This is a test text message!": "Ovo je test tekstualna poruka!",
diff --git a/server/config/locales/sv-SE.json b/server/config/locales/sv-SE.json
index 568d57d9..044e41a5 100644
--- a/server/config/locales/sv-SE.json
+++ b/server/config/locales/sv-SE.json
@@ -2,6 +2,7 @@
"Archive": "Arkiv",
"Card Created": "Kort skapat",
"Card Moved": "Kort flyttat",
+ "copy": "kopia",
"New Comment": "Ny kommentar",
"Test Title": "Test titel",
"This is a test text message!": "Detta är ett test textmeddelande!",
diff --git a/server/config/locales/tr-TR.json b/server/config/locales/tr-TR.json
index 9045bfee..44d4122e 100644
--- a/server/config/locales/tr-TR.json
+++ b/server/config/locales/tr-TR.json
@@ -2,6 +2,7 @@
"Archive": "Arşiv",
"Card Created": "Kart oluşturuldu",
"Card Moved": "Kart taşındı",
+ "copy": "kopya",
"New Comment": "Yeni yorum",
"Test Title": "Test başlığı",
"This is a test text message!": "Bu bir test metin mesajıdır!",
diff --git a/server/config/locales/uk-UA.json b/server/config/locales/uk-UA.json
index 919806c9..cdae462d 100644
--- a/server/config/locales/uk-UA.json
+++ b/server/config/locales/uk-UA.json
@@ -2,6 +2,7 @@
"Archive": "Архів",
"Card Created": "Картку створено",
"Card Moved": "Картку переміщено",
+ "copy": "копія",
"New Comment": "Новий коментар",
"Test Title": "Тестовий заголовок",
"This is a test text message!": "Це нове повідомлення!",
diff --git a/server/config/locales/uz-UZ.json b/server/config/locales/uz-UZ.json
index 8c5ccbf8..5880e914 100644
--- a/server/config/locales/uz-UZ.json
+++ b/server/config/locales/uz-UZ.json
@@ -2,6 +2,7 @@
"Archive": "Arxiv",
"Card Created": "Karta yaratildi",
"Card Moved": "Karta ko'chirildi",
+ "copy": "nusxa",
"New Comment": "Yangi izoh",
"Test Title": "Test sarlavha",
"This is a test text message!": "Bu test matn xabari!",
diff --git a/server/config/locales/zh-CN.json b/server/config/locales/zh-CN.json
index 87ca06c3..9a02d3ac 100644
--- a/server/config/locales/zh-CN.json
+++ b/server/config/locales/zh-CN.json
@@ -2,6 +2,7 @@
"Archive": "归档",
"Card Created": "卡片已创建",
"Card Moved": "卡片已移动",
+ "copy": "副本",
"New Comment": "新评论",
"Test Title": "测试标题",
"This is a test text message!": "这是一条测试文本消息!",
diff --git a/server/config/locales/zh-TW.json b/server/config/locales/zh-TW.json
index c93f150f..29685db8 100644
--- a/server/config/locales/zh-TW.json
+++ b/server/config/locales/zh-TW.json
@@ -2,6 +2,7 @@
"Archive": "封存",
"Card Created": "卡片已建立",
"Card Moved": "卡片已移動",
+ "copy": "副本",
"New Comment": "新留言",
"Test Title": "測試標題",
"This is a test text message!": "這是一則測試文字訊息!",