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:
parent
d911030831
commit
bec23795c2
19 changed files with 259 additions and 0 deletions
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 */
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
91
server/api/controllers/cards/duplicate.js
Executable file
91
server/api/controllers/cards/duplicate.js
Executable 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue