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} >
{
{cardsNode}
{!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!": "這是一則測試文字訊息!",