diff --git a/client/src/actions/cards.js b/client/src/actions/cards.js
index 0969910..136a81d 100644
--- a/client/src/actions/cards.js
+++ b/client/src/actions/cards.js
@@ -89,6 +89,37 @@ const handleCardDelete = (card) => ({
},
});
+const duplicateCard = (id, localId) => ({
+ type: ActionTypes.CARD_DUPLICATE,
+ payload: {
+ id,
+ localId,
+ },
+});
+
+duplicateCard.success = (localId, card) => ({
+ type: ActionTypes.CARD_DUPLICATE__SUCCESS,
+ payload: {
+ localId,
+ card,
+ },
+});
+
+duplicateCard.failure = (id, error) => ({
+ type: ActionTypes.CARD_DUPLICATE__FAILURE,
+ payload: {
+ id,
+ error,
+ },
+});
+
+const handleCardDuplicate = (card) => ({
+ type: ActionTypes.CARD_DUPLICATE_HANDLE,
+ payload: {
+ card,
+ },
+});
+
export default {
createCard,
handleCardCreate,
@@ -96,4 +127,6 @@ export default {
handleCardUpdate,
deleteCard,
handleCardDelete,
+ duplicateCard,
+ handleCardDuplicate,
};
diff --git a/client/src/actions/tasks.js b/client/src/actions/tasks.js
index 679ade0..9279018 100644
--- a/client/src/actions/tasks.js
+++ b/client/src/actions/tasks.js
@@ -7,6 +7,15 @@ const createTask = (task) => ({
},
});
+const createTasks = () => ({});
+
+createTasks.success = (tasks) => ({
+ type: ActionTypes.TASKS_CREATE__SUCCESS,
+ payload: {
+ tasks,
+ },
+});
+
createTask.success = (localId, task) => ({
type: ActionTypes.TASK_CREATE__SUCCESS,
payload: {
@@ -90,6 +99,7 @@ const handleTaskDelete = (task) => ({
});
export default {
+ createTasks,
createTask,
handleTaskCreate,
updateTask,
diff --git a/client/src/api/cards.js b/client/src/api/cards.js
index 5240038..a9c59cf 100755
--- a/client/src/api/cards.js
+++ b/client/src/api/cards.js
@@ -63,6 +63,11 @@ const deleteCard = (id, headers) =>
item: transformCard(body.item),
}));
+const duplicateCard = (id, headers) =>
+ socket.post(`/cards/${id}/duplicate`, undefined, headers).then((body) => ({
+ ...body,
+ item: transformCard(body.item),
+ }));
/* Event handlers */
const makeHandleCardCreate = (next) => (body) => {
@@ -81,6 +86,7 @@ export default {
getCard,
updateCard,
deleteCard,
+ duplicateCard,
makeHandleCardCreate,
makeHandleCardUpdate,
makeHandleCardDelete,
diff --git a/client/src/components/Card/ActionsStep.jsx b/client/src/components/Card/ActionsStep.jsx
index dbd349e..1165caa 100644
--- a/client/src/components/Card/ActionsStep.jsx
+++ b/client/src/components/Card/ActionsStep.jsx
@@ -21,6 +21,7 @@ const StepTypes = {
EDIT_DUE_DATE: 'EDIT_DUE_DATE',
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
MOVE: 'MOVE',
+ DUPLICATE: 'DUPLICATE',
DELETE: 'DELETE',
};
@@ -37,6 +38,7 @@ const ActionsStep = React.memo(
onMove,
onTransfer,
onDelete,
+ onDuplicate,
onUserAdd,
onUserRemove,
onBoardFetch,
@@ -76,6 +78,11 @@ const ActionsStep = React.memo(
openStep(StepTypes.MOVE);
}, [openStep]);
+ const handleDuplicateClick = useCallback(() => {
+ onDuplicate();
+ onClose();
+ }, [onDuplicate, onClose]);
+
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
@@ -207,6 +214,11 @@ const ActionsStep = React.memo(
context: 'title',
})}
+
+ {t('action.duplicate', {
+ context: 'title',
+ })}
+
{t('action.deleteCard', {
context: 'title',
@@ -233,6 +245,7 @@ ActionsStep.propTypes = {
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
+ onDuplicate: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onBoardFetch: PropTypes.func.isRequired,
diff --git a/client/src/components/Card/Card.jsx b/client/src/components/Card/Card.jsx
index 37521a0..7e69fcb 100755
--- a/client/src/components/Card/Card.jsx
+++ b/client/src/components/Card/Card.jsx
@@ -42,6 +42,7 @@ const Card = React.memo(
onMove,
onTransfer,
onDelete,
+ onDuplicate,
onUserAdd,
onUserRemove,
onBoardFetch,
@@ -185,6 +186,7 @@ const Card = React.memo(
onUpdate={onUpdate}
onMove={onMove}
onTransfer={onTransfer}
+ onDuplicate={onDuplicate}
onDelete={onDelete}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
@@ -239,6 +241,7 @@ Card.propTypes = {
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
+ onDuplicate: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onBoardFetch: PropTypes.func.isRequired,
diff --git a/client/src/components/CardModal/CardModal.jsx b/client/src/components/CardModal/CardModal.jsx
index 76fa5aa..a7f2d63 100755
--- a/client/src/components/CardModal/CardModal.jsx
+++ b/client/src/components/CardModal/CardModal.jsx
@@ -56,6 +56,7 @@ const CardModal = React.memo(
onMove,
onTransfer,
onDelete,
+ onDuplicate,
onUserAdd,
onUserRemove,
onBoardFetch,
@@ -140,6 +141,11 @@ const CardModal = React.memo(
});
}, [isSubscribed, onUpdate]);
+ const handleDuplicateClick = useCallback(() => {
+ onDuplicate();
+ onClose();
+ }, [onDuplicate, onClose]);
+
const handleGalleryOpen = useCallback(() => {
isGalleryOpened.current = true;
}, []);
@@ -496,6 +502,10 @@ const CardModal = React.memo(
{t('action.move')}
+
onMove: (listId, index) => entryActions.moveCard(id, listId, index),
onTransfer: (boardId, listId) => entryActions.transferCard(id, boardId, listId),
onDelete: () => entryActions.deleteCard(id),
+ onDuplicate: () => entryActions.duplicateCard(id),
onUserAdd: (userId) => entryActions.addUserToCard(userId, id),
onUserRemove: (userId) => entryActions.removeUserFromCard(userId, id),
onBoardFetch: entryActions.fetchBoard,
diff --git a/client/src/containers/CardModalContainer.js b/client/src/containers/CardModalContainer.js
index ceae984..f5142cc 100755
--- a/client/src/containers/CardModalContainer.js
+++ b/client/src/containers/CardModalContainer.js
@@ -79,6 +79,7 @@ const mapDispatchToProps = (dispatch) =>
onMove: entryActions.moveCurrentCard,
onTransfer: entryActions.transferCurrentCard,
onDelete: entryActions.deleteCurrentCard,
+ onDuplicate: entryActions.duplicateCurrentCard,
onUserAdd: entryActions.addUserToCurrentCard,
onUserRemove: entryActions.removeUserFromCurrentCard,
onBoardFetch: entryActions.fetchBoard,
diff --git a/client/src/entry-actions/cards.js b/client/src/entry-actions/cards.js
index d73b247..6f70671 100755
--- a/client/src/entry-actions/cards.js
+++ b/client/src/entry-actions/cards.js
@@ -93,6 +93,25 @@ const handleCardDelete = (card) => ({
},
});
+const duplicateCard = (id) => ({
+ type: EntryActionTypes.CARD_DUPLICATE,
+ payload: {
+ id,
+ },
+});
+
+const duplicateCurrentCard = () => ({
+ type: EntryActionTypes.CURRENT_CARD_DUPLICATE,
+ payload: {},
+});
+
+const handleCardDuplicate = (card) => ({
+ type: EntryActionTypes.CARD_DUPLICATE_HANDLE,
+ payload: {
+ card,
+ },
+});
+
export default {
createCard,
handleCardCreate,
@@ -106,4 +125,7 @@ export default {
deleteCard,
deleteCurrentCard,
handleCardDelete,
+ duplicateCard,
+ duplicateCurrentCard,
+ handleCardDuplicate,
};
diff --git a/client/src/locales/en/core.js b/client/src/locales/en/core.js
index 83b0116..160453c 100644
--- a/client/src/locales/en/core.js
+++ b/client/src/locales/en/core.js
@@ -196,6 +196,7 @@ export default {
deleteTask: 'Delete task',
deleteTask_title: 'Delete Task',
deleteUser: 'Delete user',
+ duplicate: 'Duplicate',
edit: 'Edit',
editDueDate_title: 'Edit Due Date',
editDescription_title: 'Edit Description',
diff --git a/client/src/locales/fr/core.js b/client/src/locales/fr/core.js
index e20e291..1b9296f 100644
--- a/client/src/locales/fr/core.js
+++ b/client/src/locales/fr/core.js
@@ -169,6 +169,7 @@ export default {
deleteTask: 'Supprimer la tâche',
deleteTask_title: 'Supprimer la tâche',
deleteUser: "Supprimer l'utilisateur",
+ duplicate: 'Dupliquer',
edit: 'Modifier',
editDueDate_title: "Modifier la date d'échéance",
editDescription_title: 'Éditer la description',
diff --git a/client/src/models/Card.js b/client/src/models/Card.js
index 78c682c..f1d5c76 100755
--- a/client/src/models/Card.js
+++ b/client/src/models/Card.js
@@ -194,6 +194,21 @@ export default class extends BaseModel {
break;
}
+ case ActionTypes.CARD_DUPLICATE: {
+ const cardModel = Card.withId(payload.id);
+ Card.upsert({
+ ...cardModel.fields,
+ name: `${cardModel.name} (copy)`,
+ id: payload.localId,
+ });
+ break;
+ }
+ case ActionTypes.CARD_DUPLICATE__SUCCESS:
+ case ActionTypes.CARD_DUPLICATE_HANDLE: {
+ Card.withId(payload.localId).delete();
+ Card.upsert(payload.card);
+ break;
+ }
case ActionTypes.ACTIVITIES_FETCH:
Card.withId(payload.cardId).update({
isActivitiesFetching: true,
diff --git a/client/src/models/Task.js b/client/src/models/Task.js
index 1a47271..08ba5c7 100755
--- a/client/src/models/Task.js
+++ b/client/src/models/Task.js
@@ -49,6 +49,12 @@ export default class extends BaseModel {
});
break;
+ case ActionTypes.TASKS_CREATE__SUCCESS: {
+ payload.tasks.forEach((task) => {
+ Task.upsert(task);
+ });
+ break;
+ }
case ActionTypes.TASK_CREATE:
case ActionTypes.TASK_CREATE_HANDLE:
case ActionTypes.TASK_UPDATE__SUCCESS:
diff --git a/client/src/sagas/core/services/cards.js b/client/src/sagas/core/services/cards.js
index 40c3db8..29c1e35 100644
--- a/client/src/sagas/core/services/cards.js
+++ b/client/src/sagas/core/services/cards.js
@@ -142,6 +142,34 @@ export function* handleCardDelete(card) {
yield put(actions.handleCardDelete(card));
}
+export function* duplicateCard(id) {
+ const localId = yield call(createLocalId);
+
+ yield put(actions.duplicateCard(id, localId));
+
+ let card;
+ let included;
+ try {
+ ({ item: card, included } = yield call(request, api.duplicateCard, id));
+ } catch (error) {
+ yield put(actions.duplicateCard.failure(localId, error));
+ return;
+ }
+
+ yield put(actions.duplicateCard.success(localId, card));
+ yield put(actions.createTasks.success(included.tasks));
+}
+
+export function* duplicateCurrentCard() {
+ const { cardId } = yield select(selectors.selectPath);
+
+ yield call(duplicateCard, cardId);
+}
+
+export function* handleCardDuplicate(card) {
+ yield put(actions.handleCardDuplicate(card));
+}
+
export default {
createCard,
handleCardCreate,
@@ -155,4 +183,7 @@ export default {
deleteCard,
deleteCurrentCard,
handleCardDelete,
+ duplicateCard,
+ duplicateCurrentCard,
+ handleCardDuplicate,
};
diff --git a/client/src/sagas/core/watchers/cards.js b/client/src/sagas/core/watchers/cards.js
index 0290515..32dced4 100644
--- a/client/src/sagas/core/watchers/cards.js
+++ b/client/src/sagas/core/watchers/cards.js
@@ -37,5 +37,10 @@ export default function* cardsWatchers() {
takeEvery(EntryActionTypes.CARD_DELETE_HANDLE, ({ payload: { card } }) =>
services.handleCardDelete(card),
),
+ takeEvery(EntryActionTypes.CARD_DUPLICATE, ({ payload: { id } }) => services.duplicateCard(id)),
+ takeEvery(EntryActionTypes.CURRENT_CARD_DUPLICATE, () => services.duplicateCurrentCard()),
+ takeEvery(EntryActionTypes.CARD_DUPLICATE_HANDLE, ({ payload: { card } }) =>
+ services.handleCardDuplicate(card),
+ ),
]);
}
diff --git a/server/api/controllers/cards/duplicate.js b/server/api/controllers/cards/duplicate.js
new file mode 100755
index 0000000..0060a4e
--- /dev/null
+++ b/server/api/controllers/cards/duplicate.js
@@ -0,0 +1,91 @@
+const Errors = {
+ NOT_ENOUGH_RIGHTS: {
+ notEnoughRights: 'Not enough rights',
+ },
+ CARD_NOT_FOUND: {
+ cardNotFound: 'Card not found',
+ },
+};
+
+module.exports = {
+ inputs: {
+ id: {
+ type: 'string',
+ regex: /^[0-9]+$/,
+ required: true,
+ },
+ },
+
+ exits: {
+ notEnoughRights: {
+ responseType: 'forbidden',
+ },
+ cardNotFound: {
+ responseType: 'notFound',
+ },
+ },
+
+ async fn(inputs) {
+ const { currentUser } = this.req;
+
+ const { card } = await sails.helpers.cards
+ .getProjectPath(inputs.id)
+ .intercept('pathNotFound', () => Errors.CARD_NOT_FOUND);
+
+ const boardMembership = await BoardMembership.findOne({
+ boardId: card.boardId,
+ userId: currentUser.id,
+ });
+
+ if (!boardMembership) {
+ throw Errors.CARD_NOT_FOUND; // Forbidden
+ }
+
+ if (boardMembership.role !== BoardMembership.Roles.EDITOR) {
+ throw Errors.NOT_ENOUGH_RIGHTS;
+ }
+
+ const { board, list } = await sails.helpers.lists
+ .getProjectPath(card.listId)
+ .intercept('pathNotFound', () => Errors.LIST_NOT_FOUND);
+
+ const values = _.pick(card, ['position', 'name', 'description', 'dueDate', 'stopwatch']);
+
+ const newCard = await sails.helpers.cards.createOne
+ .with({
+ board,
+ values: {
+ ...values,
+ name: `${card.name} (copy)`,
+ list,
+ creatorUser: currentUser,
+ },
+ request: this.req,
+ })
+ .intercept('positionMustBeInValues', () => Errors.POSITION_MUST_BE_PRESENT);
+
+ const tasks = await sails.helpers.cards.getTasks(inputs.id);
+
+ const newTasks = await Promise.all(
+ tasks.map(async (task) => {
+ const taskValues = _.pick(task, ['position', 'name', 'isCompleted']);
+ const newTask = await sails.helpers.tasks.createOne.with({
+ values: {
+ ...taskValues,
+ card: newCard,
+ },
+ request: this.req,
+ });
+ return newTask;
+ }),
+ );
+
+ // TODO: add labels
+ return {
+ item: newCard,
+ included: {
+ tasks: newTasks,
+ },
+ };
+ },
+};
diff --git a/server/config/routes.js b/server/config/routes.js
index 77f5c43..37e8f5d 100644
--- a/server/config/routes.js
+++ b/server/config/routes.js
@@ -54,6 +54,7 @@ module.exports.routes = {
'POST /api/lists/:listId/cards': 'cards/create',
'GET /api/cards/:id': 'cards/show',
+ 'POST /api/cards/:id/duplicate': 'cards/duplicate',
'PATCH /api/cards/:id': 'cards/update',
'DELETE /api/cards/:id': 'cards/delete',
'POST /api/cards/:cardId/memberships': 'card-memberships/create',