1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-08-10 07:55:27 +02:00

feat: Add ability to duplicate a card

This commit is contained in:
Matthieu Bollot 2024-04-02 16:29:45 +02:00
parent d911030831
commit bec23795c2
No known key found for this signature in database
GPG key ID: 426E24F35CBB5BC2
19 changed files with 259 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
{t('action.duplicate', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{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,

View file

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

View file

@ -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')}
</Button>
</CardMovePopup>
<Button fluid className={styles.actionButton} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
<DeletePopup
title="common.deleteCard"
content="common.areYouSureYouWantToDeleteThisCard"
@ -556,6 +566,7 @@ CardModal.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,

View file

@ -198,6 +198,10 @@ export default {
CARD_DELETE__SUCCESS: 'CARD_DELETE__SUCCESS',
CARD_DELETE__FAILURE: 'CARD_DELETE__FAILURE',
CARD_DELETE_HANDLE: 'CARD_DELETE_HANDLE',
CARD_DUPLICATE: 'CARD_DUPLICATE',
CARD_DUPLICATE__SUCCESS: 'CARD_DUPLICATE__SUCCESS',
CARD_DUPLICATE__FAILURE: 'CARD_DUPLICATE__FAILURE',
CARD_DUPLICATE_HANDLE: 'CARD_DUPLICATE_HANDLE',
/* Tasks */
@ -205,6 +209,7 @@ export default {
TASK_CREATE__SUCCESS: 'TASK_CREATE__SUCCESS',
TASK_CREATE__FAILURE: 'TASK_CREATE__FAILURE',
TASK_CREATE_HANDLE: 'TASK_CREATE_HANDLE',
TASKS_CREATE__SUCCESS: 'TASKS_CREATE__SUCCESS',
TASK_UPDATE: 'TASK_UPDATE',
TASK_UPDATE__SUCCESS: 'TASK_UPDATE__SUCCESS',
TASK_UPDATE__FAILURE: 'TASK_UPDATE__FAILURE',

View file

@ -135,6 +135,9 @@ export default {
CARD_DELETE: `${PREFIX}/CARD_DELETE`,
CURRENT_CARD_DELETE: `${PREFIX}/CURRENT_CARD_DELETE`,
CARD_DELETE_HANDLE: `${PREFIX}/CARD_DELETE_HANDLE`,
CARD_DUPLICATE: `${PREFIX}/CARD_DUPLICATE`,
CURRENT_CARD_DUPLICATE: `${PREFIX}/CURRENT_CARD_DUPLICATE`,
CARD_DUPLICATE_HANDLE: `${PREFIX}/CARD_DUPLICATE_HANDLE`,
/* Tasks */

View file

@ -63,6 +63,7 @@ const mapDispatchToProps = (dispatch, { id }) =>
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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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