feat: Add ability to copy/cut cards with shortcut support

This commit is contained in:
Maksim Eltyshev
2025-12-09 14:58:01 +01:00
parent 52acc9de90
commit 9e6e38fcf7
113 changed files with 1616 additions and 259 deletions

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -26,6 +26,10 @@ const FiniteContent = React.memo(() => {
[dispatch],
);
const handleCardPaste = useCallback(() => {
dispatch(entryActions.pasteCardInCurrentContext());
}, [dispatch]);
let View;
switch (board.view) {
case BoardViews.GRID:
@@ -39,7 +43,13 @@ const FiniteContent = React.memo(() => {
default:
}
return <View cardIds={cardIds} onCardCreate={canAddCard ? handleCardCreate : undefined} />;
return (
<View
cardIds={cardIds}
onCardCreate={canAddCard ? handleCardCreate : undefined}
onCardPaste={canAddCard ? handleCardPaste : undefined}
/>
);
});
export default FiniteContent;

View File

@@ -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,6 +67,8 @@ const GridView = React.memo(
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
</div>
) : (
<div>
<div className={styles.addCardButtonWrapper}>
<Button
type="button"
disabled={!onCardCreate}
@@ -70,6 +80,18 @@ const GridView = React.memo(
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
</span>
</Button>
{onCardPaste && clipboard && canPasteCard && (
<Button
type="button"
disabled={!onCardCreate}
className={classNames(styles.addCardButton, styles.paste)}
onClick={onCardPaste}
>
<Icon fitted name="paste" />
</Button>
)}
</div>
</div>
))}
{cardIds.map((cardId) => (
<div key={cardId} className={styles.card}>
@@ -97,6 +119,7 @@ GridView.propTypes = {
isAllCardsFetched: PropTypes.bool,
onCardsFetch: PropTypes.func,
onCardCreate: PropTypes.func,
onCardPaste: PropTypes.func,
};
GridView.defaultProps = {
@@ -104,6 +127,7 @@ GridView.defaultProps = {
isAllCardsFetched: undefined,
onCardsFetch: undefined,
onCardCreate: undefined,
onCardPaste: undefined,
};
export default GridView;

View File

@@ -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;

View File

@@ -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,6 +61,7 @@ const ListView = React.memo(
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
</div>
) : (
<div className={styles.addCardButtonWrapper}>
<Button
type="button"
disabled={!onCardCreate}
@@ -65,6 +73,17 @@ const ListView = React.memo(
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
</span>
</Button>
{onCardPaste && clipboard && canPasteCard && (
<Button
type="button"
disabled={!onCardCreate}
className={classNames(styles.addCardButton, styles.paste)}
onClick={onCardPaste}
>
<Icon fitted name="paste" />
</Button>
)}
</div>
))}
{cardIds.length > 0 && (
<div className={classNames(styles.segment, styles.cards)}>
@@ -95,6 +114,7 @@ ListView.propTypes = {
isAllCardsFetched: PropTypes.bool,
onCardsFetch: PropTypes.func,
onCardCreate: PropTypes.func,
onCardPaste: PropTypes.func,
};
ListView.defaultProps = {
@@ -102,6 +122,7 @@ ListView.defaultProps = {
isAllCardsFetched: undefined,
onCardsFetch: undefined,
onCardCreate: undefined,
onCardPaste: undefined,
};
export default ListView;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -16,6 +16,7 @@ import { closePopup, usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import { BoardShortcutsContext } from '../../../contexts';
import Paths from '../../../constants/Paths';
import ClipboardTypes from '../../../constants/ClipboardTypes';
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent';
import StoryContent from './StoryContent';
@@ -44,14 +45,25 @@ const Card = React.memo(({ id, isInline }) => {
return selectIsCardWithIdRecent(state, id);
});
const isCut = useSelector((state) => {
const clipboard = selectors.selectClipboard(state);
return clipboard && clipboard.type === ClipboardTypes.CUT && card.id === clipboard.cardId;
});
const canUseActions = useSelector((state) => {
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
if (isManager) {
return true;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const dispatch = useDispatch();
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const [handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
const [, , handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
const actionsPopupRef = useRef(null);
@@ -139,7 +151,11 @@ const Card = React.memo(({ id, isInline }) => {
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div
className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
className={classNames(
styles.content,
card.isClosed && styles.contentDisabled,
isCut && styles.contentCut,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleCardMouseLeave}
onClick={handleClick}

View File

@@ -44,6 +44,10 @@
}
}
.contentCut {
opacity: 0.5;
}
.contentDisabled {
filter: saturate(0.5);
opacity: 0.64;

View File

@@ -60,6 +60,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canEditName,
canEditDueDate,
canEditStopwatch,
canCopy,
canCut,
canDuplicate,
canMove,
canRestore,
@@ -68,6 +70,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canUseMembers,
canUseLabels,
} = useSelector((state) => {
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
@@ -77,6 +81,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canEditName: false,
canEditDueDate: false,
canEditStopwatch: false,
canCopy: isManager || isEditor,
canCut: isEditor,
canDuplicate: false,
canMove: false,
canRestore: isEditor,
@@ -92,6 +98,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canEditName: isEditor,
canEditDueDate: isEditor,
canEditStopwatch: isEditor,
canCopy: isManager || isEditor,
canCut: isEditor,
canDuplicate: isEditor,
canMove: isEditor,
canRestore: null,
@@ -117,17 +125,20 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
[cardId, dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCard(cardId, {
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
const handleCopyClick = useCallback(() => {
dispatch(entryActions.copyCard(cardId));
onClose();
}, [cardId, onClose, card.name, dispatch, t]);
}, [cardId, onClose, dispatch]);
const handleCutClick = useCallback(() => {
dispatch(entryActions.cutCard(cardId));
onClose();
}, [cardId, onClose, dispatch]);
const handleDuplicateClick = useCallback(() => {
dispatch(entryActions.duplicateCard(cardId));
onClose();
}, [cardId, onClose, dispatch]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCard(cardId, card.prevListId, undefined, true));
@@ -344,6 +355,22 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
})}
</Menu.Item>
)}
{canCopy && (
<Menu.Item className={styles.menuItem} onClick={handleCopyClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.copyCard', {
context: 'title',
})}
</Menu.Item>
)}
{canCut && (
<Menu.Item className={styles.menuItem} onClick={handleCutClick}>
<Icon name="cut" className={styles.menuItemIcon} />
{t('action.cutCard', {
context: 'title',
})}
</Menu.Item>
)}
{canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />

View File

@@ -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);

View File

@@ -105,7 +105,7 @@ const ProjectContent = React.memo(() => {
canSubscribe: isMember,
canJoin: false,
canDuplicate: false,
canMove: false,
canMove: isEditor,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,

View File

@@ -104,7 +104,7 @@ const StoryContent = React.memo(() => {
canSubscribe: isMember,
canJoin: false,
canDuplicate: false,
canMove: false,
canMove: isEditor,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, Message } from 'semantic-ui-react';
const SourceCardNotCopyableToast = React.memo(() => {
const [t] = useTranslation();
return (
<Message visible negative size="tiny">
<Icon name="paste" />
{t('common.sourceCardIsNoLongerAvailableForCopying')}
</Message>
);
});
export default SourceCardNotCopyableToast;

View File

@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, Message } from 'semantic-ui-react';
const SourceCardNotMovableToast = React.memo(() => {
const [t] = useTranslation();
return (
<Message visible negative size="tiny">
<Icon name="paste" />
{t('common.sourceCardIsNoLongerAvailableForMoving')}
</Message>
);
});
export default SourceCardNotMovableToast;

View File

@@ -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(() => (

View File

@@ -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,11 +48,14 @@ 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 { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector(
(state) => {
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
@@ -61,14 +65,19 @@ const List = React.memo(({ id, index }) => {
canEdit: isEditModeEnabled && isEditor,
canArchiveCards: list.type === ListTypes.CLOSED && isEditor,
canAddCard: isEditor,
canPasteCard: isEditor,
canDropCard: isEditor,
};
}, shallowEqual);
},
shallowEqual,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const [addCardPosition, setAddCardPosition] = useState(null);
const [scrollBottomState, scrollBottom] = useToggle();
const [handleListMouseEnter, handleListMouseLeave] = useContext(BoardShortcutsContext);
const wrapperRef = useRef(null);
const cardsWrapperRef = useRef(null);
@@ -82,6 +91,17 @@ const List = React.memo(({ id, index }) => {
[id, dispatch, addCardPosition],
);
const handlePasteCardClick = useCallback(() => {
dispatch(entryActions.pasteCard(id));
scrollBottom();
}, [id, dispatch, scrollBottom]);
const handleMouseEnter = useCallback(() => {
handleListMouseEnter(id, () => {
scrollBottom();
});
}, [id, scrollBottom, handleListMouseEnter]);
const handleHeaderClick = useCallback(() => {
if (list.isPersisted && canEdit) {
setIsEditNameOpened(true);
@@ -123,6 +143,10 @@ const List = React.memo(({ id, index }) => {
addCardPosition === AddCardPositions.TOP ? 0 : cardsWrapperRef.current.scrollHeight;
}, [cardIds, addCardPosition]);
useDidUpdate(() => {
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
}, [scrollBottomState]);
const ActionsPopup = usePopup(ActionsStep);
const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
@@ -169,6 +193,8 @@ const List = React.memo(({ id, index }) => {
data-drag-scroller
ref={innerRef}
className={styles.innerWrapper}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleListMouseLeave}
>
<div
ref={wrapperRef}
@@ -231,6 +257,7 @@ const List = React.memo(({ id, index }) => {
<div className={styles.cardsOuterWrapper}>{cardsNode}</div>
</div>
{!addCardPosition && canAddCard && (
<div className={styles.addCardButtonWrapper}>
<button
type="button"
disabled={!list.isPersisted}
@@ -242,6 +269,17 @@ const List = React.memo(({ id, index }) => {
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
</span>
</button>
{clipboard && canPasteCard && (
<button
type="button"
disabled={!list.isPersisted}
className={classNames(styles.addCardButton, styles.paste)}
onClick={handlePasteCardClick}
>
<Icon name="paste" />
</button>
)}
</div>
)}
</div>
</div>

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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,
};

View File

@@ -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`,

View File

@@ -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,
};

View File

@@ -5,4 +5,4 @@
import { createContext } from 'react';
export default createContext([null, null]);
export default createContext([null, null, null, null]);

View File

@@ -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,

View File

@@ -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: 'حذف',

View File

@@ -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: 'Изтриване',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'Διαγραφή',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'حذف',

View File

@@ -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',

View File

@@ -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: 'Sabonner à la carte lors de la rédaction dun 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 lutilisateur',
deactivateUser_title: 'Désactiver lutilisateur',
delete: 'Supprimer',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: '削除',

View File

@@ -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: '삭제',

View File

@@ -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',

View File

@@ -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ń',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'Удалить',

View File

@@ -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ť',

View File

@@ -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: 'Обриши',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'Видалити',

View File

@@ -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",

View File

@@ -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: '删除',

View File

@@ -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: '刪除',

View File

@@ -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));

View File

@@ -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,10 +317,6 @@ 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
}
@@ -341,7 +338,6 @@ export default class extends BaseModel {
}
cardModel.update(payload.data);
}
break;
}
@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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));

View File

@@ -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);

View File

@@ -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);

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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 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,

View File

@@ -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),
),

View File

@@ -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,

View File

@@ -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 (!isProjectManager) {
if (!boardMembership) {
throw Errors.CARD_NOT_FOUND; // Forbidden
}
// TODO: allow for endless lists?
if (!sails.helpers.lists.isFinite(list)) {
if (boardMembership.role !== BoardMembership.Roles.EDITOR) {
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({
} = 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,

View File

@@ -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,
};
},
};

View File

@@ -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;

View File

@@ -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;
const project = values.project || inputs.project;
if (values.board) {
if (values.board.projectId !== project.id) {
throw 'boardInValuesMustBelongToProject';
}
} else if (typeState === List.TypeStates.CLOSED) {
values.isClosed = true;
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);
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 {

View File

@@ -98,8 +98,12 @@ module.exports = {
throw 'coverAttachmentInValuesMustContainImage';
}
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', {
sails.sockets.broadcast(
`board:${card.boardId}`,
'cardUpdate',
{
item: card,
});
},
inputs.request,
);
// TODO: add transfer action
} else {

View File

@@ -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({

View File

@@ -2,6 +2,7 @@
"Archive": "أرشيف",
"Card Created": "تم إنشاء البطاقة",
"Card Moved": "تم نقل البطاقة",
"copy": "نسخة",
"New Comment": "تعليق جديد",
"Test Title": "عنوان تجريبي",
"This is a test text message!": "هذه رسالة نصية تجريبية!",

View File

@@ -2,6 +2,7 @@
"Archive": "Архив",
"Card Created": "Картата е създадена",
"Card Moved": "Картата е преместена",
"copy": "копие",
"New Comment": "Нов коментар",
"Test Title": "Тестово заглавие",
"This is a test text message!": "Това е тестово текстово съобщение!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -2,6 +2,7 @@
"Archive": "Αρχείο",
"Card Created": "Η κάρτα δημιουργήθηκε",
"Card Moved": "Η κάρτα μετακινήθηκε",
"copy": "αντίγραφο",
"New Comment": "Νέο σχόλιο",
"Test Title": "Τίτλος δοκιμής",
"This is a test text message!": "Αυτό είναι ένα δοκιμαστικό μήνυμα!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -2,6 +2,7 @@
"Archive": "بایگانی",
"Card Created": "کارت ایجاد شد",
"Card Moved": "کارت منتقل شد",
"copy": "کپی",
"New Comment": "نظر جدید",
"Test Title": "عنوان آزمایشی",
"This is a test text message!": "این یک پیام متنی آزمایشی است!",

View File

@@ -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!",

View File

@@ -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 !",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -2,6 +2,7 @@
"Archive": "アーカイブ",
"Card Created": "カードが作成されました",
"Card Moved": "カードが移動されました",
"copy": "コピー",
"New Comment": "新しいコメント",
"Test Title": "テストタイトル",
"This is a test text message!": "これはテストテキストメッセージです!",

View File

@@ -2,6 +2,7 @@
"Archive": "보관함",
"Card Created": "카드가 생성됨",
"Card Moved": "카드가 이동됨",
"copy": "복사본",
"New Comment": "새 댓글",
"Test Title": "테스트 제목",
"This is a test text message!": "이것은 테스트 텍스트 메시지입니다!",

View File

@@ -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!",

View File

@@ -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!",

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