Merge pull request #1719 from crmaris/fix/task-reorder-hidden-completed

fix: Fix task reordering when completed tasks are hidden

Conflicted with the icon button tooltips, which had added a Tooltip import to
the same file. Both imports stay except `useToggle`, which this branch makes
redundant by lifting the per-list eye toggle up to TaskLists so the position
calculation can see it.
This commit is contained in:
Daniel Hiller
2026-09-17 00:45:20 +02:00
6 changed files with 53 additions and 20 deletions
@@ -11,7 +11,6 @@ import { useSelector } from 'react-redux';
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Icon } from 'semantic-ui-react'; import { Button, Icon } from 'semantic-ui-react';
import { useToggle } from '../../../../lib/hooks';
import { Tooltip } from '../../../../lib/custom-ui'; import { Tooltip } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors'; import selectors from '../../../../selectors';
@@ -22,7 +21,7 @@ import TaskList from '../../../task-lists/TaskList';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
const Item = React.memo(({ id, index }) => { const Item = React.memo(({ id, index, isCompletedVisible, onCompletedVisibleToggle }) => {
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []); const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
const taskList = useSelector((state) => selectTaskListById(state, id)); const taskList = useSelector((state) => selectTaskListById(state, id));
@@ -33,11 +32,9 @@ const Item = React.memo(({ id, index }) => {
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
}); });
const [isCompletedVisible, toggleCompletedVisible] = useToggle();
const handleToggleCompletedVisibleClick = useCallback(() => { const handleToggleCompletedVisibleClick = useCallback(() => {
toggleCompletedVisible(); onCompletedVisibleToggle(id);
}, [toggleCompletedVisible]); }, [id, onCompletedVisibleToggle]);
const EditPopup = usePopupInClosableContext(EditStep); const EditPopup = usePopupInClosableContext(EditStep);
@@ -115,6 +112,8 @@ const Item = React.memo(({ id, index }) => {
Item.propTypes = { Item.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
isCompletedVisible: PropTypes.bool.isRequired,
onCompletedVisibleToggle: PropTypes.func.isRequired,
}; };
export default Item; export default Item;
@@ -3,7 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { closePopup } from '../../../../lib/popup'; import { closePopup } from '../../../../lib/popup';
@@ -20,6 +20,14 @@ const TaskLists = React.memo(() => {
const taskListIds = useSelector(selectors.selectTaskListIdsForCurrentCard); const taskListIds = useSelector(selectors.selectTaskListIdsForCurrentCard);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [completedVisibilities, setCompletedVisibilities] = useState({});
const handleCompletedVisibleToggle = useCallback((taskListId) => {
setCompletedVisibilities((prevCompletedVisibilities) => ({
...prevCompletedVisibilities,
[taskListId]: !prevCompletedVisibilities[taskListId],
}));
}, []);
const handleDragStart = useCallback(() => { const handleDragStart = useCallback(() => {
document.body.classList.add(globalStyles.dragging); document.body.classList.add(globalStyles.dragging);
@@ -45,16 +53,18 @@ const TaskLists = React.memo(() => {
dispatch(entryActions.moveTaskList(id, destination.index)); dispatch(entryActions.moveTaskList(id, destination.index));
break; break;
case DroppableTypes.TASK: case DroppableTypes.TASK: {
dispatch( const taskListId = parseDndId(destination.droppableId);
entryActions.moveTask(id, parseDndId(destination.droppableId), destination.index), const isCompletedVisible = !!completedVisibilities[taskListId];
);
dispatch(entryActions.moveTask(id, taskListId, destination.index, isCompletedVisible));
break; break;
}
default: default:
} }
}, },
[dispatch], [completedVisibilities, dispatch],
); );
return ( return (
@@ -64,7 +74,13 @@ const TaskLists = React.memo(() => {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} ref={innerRef}> <div {...droppableProps} ref={innerRef}>
{taskListIds.map((taskListId, index) => ( {taskListIds.map((taskListId, index) => (
<Item key={taskListId} id={taskListId} index={index} /> <Item
key={taskListId}
id={taskListId}
index={index}
isCompletedVisible={!!completedVisibilities[taskListId]}
onCompletedVisibleToggle={handleCompletedVisibleToggle}
/>
))} ))}
{placeholder} {placeholder}
</div> </div>
+2 -1
View File
@@ -35,12 +35,13 @@ const handleTaskUpdate = (task) => ({
}, },
}); });
const moveTask = (id, taskListId, index) => ({ const moveTask = (id, taskListId, index, isCompletedVisible) => ({
type: EntryActionTypes.TASK_MOVE, type: EntryActionTypes.TASK_MOVE,
payload: { payload: {
id, id,
taskListId, taskListId,
index, index,
isCompletedVisible,
}, },
}); });
+8 -2
View File
@@ -68,8 +68,14 @@ export function* handleTaskUpdate(task) {
yield put(actions.handleTaskUpdate(task)); yield put(actions.handleTaskUpdate(task));
} }
export function* moveTask(id, taskListId, index) { export function* moveTask(id, taskListId, index, isCompletedVisible) {
const position = yield select(selectors.selectNextTaskPosition, taskListId, index, id); const position = yield select(
selectors.selectNextTaskPosition,
taskListId,
index,
id,
isCompletedVisible,
);
yield call(updateTask, id, { yield call(updateTask, id, {
taskListId, taskListId,
+4 -2
View File
@@ -22,8 +22,10 @@ export default function* tasksWatchers() {
takeEvery(EntryActionTypes.TASK_UPDATE_HANDLE, ({ payload: { task } }) => takeEvery(EntryActionTypes.TASK_UPDATE_HANDLE, ({ payload: { task } }) =>
services.handleTaskUpdate(task), services.handleTaskUpdate(task),
), ),
takeEvery(EntryActionTypes.TASK_MOVE, ({ payload: { id, taskListId, index } }) => takeEvery(
services.moveTask(id, taskListId, index), EntryActionTypes.TASK_MOVE,
({ payload: { id, taskListId, index, isCompletedVisible } }) =>
services.moveTask(id, taskListId, index, isCompletedVisible),
), ),
takeEvery(EntryActionTypes.TASK_DELETE, ({ payload: { id } }) => services.deleteTask(id)), takeEvery(EntryActionTypes.TASK_DELETE, ({ payload: { id } }) => services.deleteTask(id)),
takeEvery(EntryActionTypes.TASK_DELETE_HANDLE, ({ payload: { task } }) => takeEvery(EntryActionTypes.TASK_DELETE_HANDLE, ({ payload: { task } }) =>
+11 -2
View File
@@ -116,14 +116,23 @@ export const selectNextTaskPosition = createSelector(
(_, taskListId) => taskListId, (_, taskListId) => taskListId,
(_, __, index) => index, (_, __, index) => index,
(_, __, ___, excludedId) => excludedId, (_, __, ___, excludedId) => excludedId,
({ TaskList }, taskListId, index, excludedId) => { (_, __, ___, ____, isCompletedVisible) => isCompletedVisible,
({ TaskList }, taskListId, index, excludedId, isCompletedVisible) => {
const taskListModel = TaskList.withId(taskListId); const taskListModel = TaskList.withId(taskListId);
if (!taskListModel) { if (!taskListModel) {
return taskListModel; return taskListModel;
} }
return nextPosition(taskListModel.getTasksQuerySet().toRefArray(), index, excludedId); let tasks = taskListModel.getTasksQuerySet().toRefArray();
// when an index is provided it refers to the rendered list, which excludes
// completed tasks if they are hidden, so the same filter must be applied here
if (!isUndefined(index) && taskListModel.hideCompletedTasks && !isCompletedVisible) {
tasks = tasks.filter((task) => !task.isCompleted);
}
return nextPosition(tasks, index, excludedId);
}, },
); );