From 869d9c1d118cc118b8f555ff2b4f9f2d10ac0a69 Mon Sep 17 00:00:00 2001 From: symonbaikov Date: Fri, 4 Jul 2025 00:30:07 +0300 Subject: [PATCH] feat: fully rework 'move list to board' feature to match review requirements - All async logic for moving lists between boards is now handled via Redux sagas, not in React components. - Removed direct API calls and sessionStorage usage from UI. - Added a unified action creator for moving lists between boards. - Saga watcher now uses the correct action type constant. - On the backend, the move-to-board helper now: - Detaches card members who are not present on the target board. - Converts board-wide custom fields to per-card fields when moving lists. - Added selector memoization in BoardSelectStep to prevent unnecessary rerenders. - All business logic is now outside of UI components. - The feature now fully handles: - Users (members/assignees) who do not exist on the target board. - Board-wide custom fields, which are now either copied or converted to per-card fields. - All review comments are addressed: no business logic in components, no sessionStorage, all edge cases handled, only sagas and request used for async actions. --- .../src/components/lists/List/ActionsStep.jsx | 21 ++------ .../components/lists/List/BoardSelectStep.jsx | 13 ++++- client/src/constants/EntryActionTypes.js | 4 ++ client/src/entry-actions/lists.js | 9 ++++ client/src/sagas/core/services/lists.js | 20 +++++++- client/src/sagas/core/watchers/lists.js | 3 +- server/api/helpers/lists/move-to-board.js | 51 +++++++++++++++++++ 7 files changed, 99 insertions(+), 22 deletions(-) diff --git a/client/src/components/lists/List/ActionsStep.jsx b/client/src/components/lists/List/ActionsStep.jsx index 88b6bf39..1714b8fb 100755 --- a/client/src/components/lists/List/ActionsStep.jsx +++ b/client/src/components/lists/List/ActionsStep.jsx @@ -8,7 +8,6 @@ import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { Menu } from 'semantic-ui-react'; -import { useNavigate } from 'react-router-dom'; import { Popup } from '../../../lib/custom-ui'; import selectors from '../../../selectors'; @@ -21,7 +20,6 @@ import SelectListTypeStep from '../SelectListTypeStep'; import ConfirmationStep from '../../common/ConfirmationStep'; import ArchiveCardsStep from '../../cards/ArchiveCardsStep'; import BoardSelectStep from './BoardSelectStep'; -import api from '../../../api/lists'; import styles from './ActionsStep.module.scss'; @@ -42,7 +40,6 @@ const ActionsStep = React.memo(({ listId, onNameEdit, onCardAdd, onClose }) => { const dispatch = useDispatch(); const [t] = useTranslation(); const [step, openStep, handleBack] = useSteps(); - const navigate = useNavigate(); const handleTypeSelect = useCallback( (type) => { @@ -90,22 +87,10 @@ const ActionsStep = React.memo(({ listId, onNameEdit, onCardAdd, onClose }) => { }, [openStep]); const handleMoveToBoard = useCallback( - async (targetBoardId) => { - try { - const { item: updatedList, included } = await api.moveToBoard(listId, { targetBoardId }); - dispatch(entryActions.handleListUpdate(updatedList)); - if (included && included.cards) { - dispatch(entryActions.handleCardsUpdate(included.cards, [])); - } - sessionStorage.setItem('movedListId', listId); - onClose(); - navigate(`/boards/${targetBoardId}`); - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - } + (targetBoardId) => { + dispatch(entryActions.moveListToBoardRequest(listId, targetBoardId)); }, - [listId, onClose, dispatch, navigate], + [listId, dispatch], ); if (step) { diff --git a/client/src/components/lists/List/BoardSelectStep.jsx b/client/src/components/lists/List/BoardSelectStep.jsx index 40c99dc5..40705a5e 100644 --- a/client/src/components/lists/List/BoardSelectStep.jsx +++ b/client/src/components/lists/List/BoardSelectStep.jsx @@ -1,16 +1,25 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; +import { createSelector } from 'reselect'; import PopupHeader from '../../../lib/custom-ui/components/Popup/PopupHeader'; import styles from './ActionsStep.module.scss'; import selectors from '../../../selectors'; +const makeSelectBoardsByIds = () => + createSelector( + (state, boardIds) => boardIds, + (state) => state, + (boardIds, state) => boardIds.map((id) => selectors.selectBoardById(state, id)), + ); + function BoardSelectStep({ currentBoardId, onSelect, onBack, onClose }) { const [t] = useTranslation(); const projectId = useSelector((state) => selectors.selectPath(state).projectId); const boardIds = useSelector((state) => selectors.selectBoardIdsByProjectId(state, projectId)); - const boards = useSelector((state) => boardIds.map((id) => selectors.selectBoardById(state, id))); + const selectBoardsByIds = useMemo(makeSelectBoardsByIds, []); + const boards = useSelector((state) => selectBoardsByIds(state, boardIds)); return (
diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js index 9195eac1..232e4768 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -276,4 +276,8 @@ export default { NOTIFICATION_SERVICE_TEST: `${PREFIX}/NOTIFICATION_SERVICE_TEST`, NOTIFICATION_SERVICE_DELETE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE`, NOTIFICATION_SERVICE_DELETE_HANDLE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE_HANDLE`, + + /* Move List to Board Request */ + + MOVE_LIST_TO_BOARD_REQUEST: `${PREFIX}/MOVE_LIST_TO_BOARD_REQUEST`, }; diff --git a/client/src/entry-actions/lists.js b/client/src/entry-actions/lists.js index 2f245d3b..e7a06df6 100755 --- a/client/src/entry-actions/lists.js +++ b/client/src/entry-actions/lists.js @@ -86,6 +86,14 @@ const handleListDelete = (list, cards) => ({ }, }); +const moveListToBoardRequest = (listId, targetBoardId) => ({ + type: EntryActionTypes.MOVE_LIST_TO_BOARD_REQUEST, + payload: { + listId, + targetBoardId, + }, +}); + export default { createListInCurrentBoard, handleListCreate, @@ -98,4 +106,5 @@ export default { handleListClear, deleteList, handleListDelete, + moveListToBoardRequest, }; diff --git a/client/src/sagas/core/services/lists.js b/client/src/sagas/core/services/lists.js index acd6c3e2..2430cfac 100644 --- a/client/src/sagas/core/services/lists.js +++ b/client/src/sagas/core/services/lists.js @@ -5,13 +5,13 @@ import { call, put, select } from 'redux-saga/effects'; import toast from 'react-hot-toast'; - import request from '../request'; import selectors from '../../../selectors'; import actions from '../../../actions'; import api from '../../../api'; import { createLocalId } from '../../../utils/local-id'; import ToastTypes from '../../../constants/ToastTypes'; +import modalActions from '../../../actions/modals'; export function* createList(boardId, data) { const localId = yield call(createLocalId); @@ -183,6 +183,23 @@ export function* handleListDelete(list, cards) { yield put(actions.handleListDelete(list, cards)); } +export function* moveListToBoardSaga(action) { + const { listId, targetBoardId } = action.payload; + try { + const { item: updatedList, included } = yield call(request, api.moveToBoard, listId, { + targetBoardId, + }); + yield put(actions.handleListUpdate(updatedList)); + if (included && included.cards) { + yield put(actions.handleCardsUpdate(included.cards, [])); + } + yield put(modalActions.closeModal()); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } +} + export default { createList, createListInCurrentBoard, @@ -196,4 +213,5 @@ export default { handleListClear, deleteList, handleListDelete, + moveListToBoardSaga, }; diff --git a/client/src/sagas/core/watchers/lists.js b/client/src/sagas/core/watchers/lists.js index 59f1241a..8ab33e54 100644 --- a/client/src/sagas/core/watchers/lists.js +++ b/client/src/sagas/core/watchers/lists.js @@ -3,7 +3,7 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import { all, takeEvery } from 'redux-saga/effects'; +import { all, takeEvery, takeLatest } from 'redux-saga/effects'; import services from '../services'; import EntryActionTypes from '../../../constants/EntryActionTypes'; @@ -41,5 +41,6 @@ export default function* listsWatchers() { takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list, cards } }) => services.handleListDelete(list, cards), ), + takeLatest(EntryActionTypes.MOVE_LIST_TO_BOARD_REQUEST, services.moveListToBoardSaga), ]); } diff --git a/server/api/helpers/lists/move-to-board.js b/server/api/helpers/lists/move-to-board.js index 434c235a..708d0337 100644 --- a/server/api/helpers/lists/move-to-board.js +++ b/server/api/helpers/lists/move-to-board.js @@ -58,6 +58,57 @@ module.exports = { }); await Promise.all(migrateLabelsPromises); + await Promise.all( + updatedCards.map(async (card) => { + const cardMemberships = await CardMembership.find({ cardId: card.id }); + await Promise.all( + cardMemberships.map(async (membership) => { + const userMembership = await BoardMembership.findOne({ + boardId: inputs.targetBoard.id, + userId: membership.userId, + }); + if (!userMembership) { + await CardMembership.destroy({ id: membership.id }); + } + }), + ); + }), + ); + + await Promise.all( + updatedCards.map(async (card) => { + const customFieldValues = await CustomFieldValue.find({ cardId: card.id }); + await Promise.all( + customFieldValues.map(async (value) => { + const group = await CustomFieldGroup.findOne({ id: value.customFieldGroupId }); + if (group && group.boardId && group.boardId !== inputs.targetBoard.id) { + const newGroup = await CustomFieldGroup.create({ + name: group.name, + position: group.position, + cardId: card.id, + baseCustomFieldGroupId: group.baseCustomFieldGroupId, + }).fetch(); + const field = await CustomField.findOne({ id: value.customFieldId }); + const newField = await CustomField.create({ + name: field.name, + position: field.position, + showOnFrontOfCard: field.showOnFrontOfCard, + customFieldGroupId: newGroup.id, + baseCustomFieldGroupId: field.baseCustomFieldGroupId, + }).fetch(); + await CustomFieldValue.updateOne( + { id: value.id }, + { + customFieldGroupId: newGroup.id, + customFieldId: newField.id, + }, + ); + } + }), + ); + }), + ); + return { updatedList, updatedCards,