From bec23795c2c6a4813ce9e94d0ede0dd244640946 Mon Sep 17 00:00:00 2001 From: Matthieu Bollot Date: Tue, 2 Apr 2024 16:29:45 +0200 Subject: [PATCH] feat: Add ability to duplicate a card --- client/src/actions/cards.js | 33 +++++++ client/src/actions/tasks.js | 10 ++ client/src/api/cards.js | 6 ++ client/src/components/Card/ActionsStep.jsx | 13 +++ client/src/components/Card/Card.jsx | 3 + client/src/components/CardModal/CardModal.jsx | 11 +++ client/src/constants/ActionTypes.js | 5 + client/src/constants/EntryActionTypes.js | 3 + client/src/containers/CardContainer.js | 1 + client/src/containers/CardModalContainer.js | 1 + client/src/entry-actions/cards.js | 22 +++++ client/src/locales/en/core.js | 1 + client/src/locales/fr/core.js | 1 + client/src/models/Card.js | 15 +++ client/src/models/Task.js | 6 ++ client/src/sagas/core/services/cards.js | 31 +++++++ client/src/sagas/core/watchers/cards.js | 5 + server/api/controllers/cards/duplicate.js | 91 +++++++++++++++++++ server/config/routes.js | 1 + 19 files changed, 259 insertions(+) create mode 100755 server/api/controllers/cards/duplicate.js 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',