1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-24 15:49:46 +02:00

feat: Add ability to hide completed tasks (#1210)

This commit is contained in:
Symon Baikov 2025-07-21 19:33:02 +03:00 committed by GitHub
parent fc9c94b3b6
commit d8fbf2f909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 158 additions and 55 deletions

View file

@ -16,12 +16,19 @@ import Task from './Task';
import styles from './TaskList.module.scss'; import styles from './TaskList.module.scss';
const TaskList = React.memo(({ id }) => { const TaskList = React.memo(({ id }) => {
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []); const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
const taskLists = useSelector((state) => selectTaskListById(state, id));
const tasks = useSelector((state) => selectTasksByTaskListId(state, id)); const tasks = useSelector((state) => selectTasksByTaskListId(state, id));
const [isOpened, toggleOpened] = useToggle(); const [isOpened, toggleOpened] = useToggle();
const filteredTasks = useMemo(
() => (taskLists.hideCompletedTasks ? tasks.filter((task) => !task.isCompleted) : tasks),
[taskLists.hideCompletedTasks, tasks],
);
// TODO: move to selector? // TODO: move to selector?
const completedTasksTotal = useMemo( const completedTasksTotal = useMemo(
() => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0), () => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0),
@ -30,10 +37,14 @@ const TaskList = React.memo(({ id }) => {
const handleToggleClick = useCallback( const handleToggleClick = useCallback(
(event) => { (event) => {
if (filteredTasks.length === 0) {
return;
}
event.stopPropagation(); event.stopPropagation();
toggleOpened(); toggleOpened();
}, },
[toggleOpened], [toggleOpened, filteredTasks.length],
); );
if (tasks.length === 0) { if (tasks.length === 0) {
@ -44,7 +55,7 @@ const TaskList = React.memo(({ id }) => {
<> <>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */} jsx-a11y/no-static-element-interactions */}
<div className={styles.button} onClick={handleToggleClick}> <div className={styles.progressRow} onClick={handleToggleClick}>
<span className={styles.progressWrapper}> <span className={styles.progressWrapper}>
<Progress <Progress
autoSuccess autoSuccess
@ -56,14 +67,18 @@ const TaskList = React.memo(({ id }) => {
/> />
</span> </span>
<span <span
className={classNames(styles.count, isOpened ? styles.countOpened : styles.countClosed)} className={classNames(
styles.count,
filteredTasks.length > 0 && styles.countOpenable,
filteredTasks.length > 0 && (isOpened ? styles.countOpened : styles.countClosed),
)}
> >
{completedTasksTotal}/{tasks.length} {completedTasksTotal}/{tasks.length}
</span> </span>
</div> </div>
{isOpened && ( {isOpened && filteredTasks.length > 0 && (
<ul className={styles.tasks}> <ul className={styles.tasks}>
{tasks.map((task) => ( {filteredTasks.map((task) => (
<Task key={task.id} id={task.id} /> <Task key={task.id} id={task.id} />
))} ))}
</ul> </ul>

View file

@ -4,29 +4,21 @@
*/ */
:global(#app) { :global(#app) {
.button {
background: transparent;
border: none;
line-height: 0;
margin: 0 -8px;
outline: none;
padding: 0px 8px 8px;
width: calc(100% + 16px);
}
.count { .count {
color: #888; color: #888;
display: inline-block;
font-size: 12px; font-size: 12px;
line-height: 12px; line-height: 12px;
text-align: right;
vertical-align: top;
width: 50px;
&:after { &:after {
content: ""; content: "";
opacity: 0.4; opacity: 0.4;
} }
}
.countOpenable {
&:after {
margin-left: 2px;
}
&:hover { &:hover {
opacity: 0.75; opacity: 0.75;
@ -35,13 +27,11 @@
.countOpened:after { .countOpened:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIPnI+py+0/hJzz0IruwjsVADs=") no-repeat center right; background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIPnI+py+0/hJzz0IruwjsVADs=") no-repeat center right;
margin-left: 2px;
padding: 6px 6px 0px; padding: 6px 6px 0px;
} }
.countClosed:after { .countClosed:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIRnC2nKLnT4or00Puy3rx7VQAAOw==") no-repeat center right; background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIRnC2nKLnT4or00Puy3rx7VQAAOw==") no-repeat center right;
margin-left: 2px;
padding: 0 6px 6px; padding: 0 6px 6px;
} }
@ -49,11 +39,17 @@
margin: 0; margin: 0;
} }
.progressRow {
display: flex;
gap: 8px;
justify-content: space-between;
margin: 0 -8px;
padding: 0px 8px 8px;
}
.progressWrapper { .progressWrapper {
display: inline-block;
padding: 3px 0; padding: 3px 0;
vertical-align: top; width: 100%;
width: calc(100% - 50px);
} }
.tasks { .tasks {

View file

@ -35,8 +35,9 @@ const EditStep = React.memo(({ taskListId, onClose }) => {
() => ({ () => ({
name: taskList.name, name: taskList.name,
showOnFrontOfCard: taskList.showOnFrontOfCard, showOnFrontOfCard: taskList.showOnFrontOfCard,
hideCompletedTasks: taskList.hideCompletedTasks,
}), }),
[taskList.name, taskList.showOnFrontOfCard], [taskList.name, taskList.showOnFrontOfCard, taskList.hideCompletedTasks],
); );
const [data, handleFieldChange] = useForm(() => ({ const [data, handleFieldChange] = useForm(() => ({
@ -44,6 +45,7 @@ const EditStep = React.memo(({ taskListId, onClose }) => {
context: 'title', context: 'title',
}), }),
showOnFrontOfCard: true, showOnFrontOfCard: true,
hideCompletedTasks: false,
...defaultData, ...defaultData,
})); }));

View file

@ -3,13 +3,14 @@
* 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, { useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react'; import { Button, Icon } from 'semantic-ui-react';
import { useToggle } from '../../../../lib/hooks';
import selectors from '../../../../selectors'; import selectors from '../../../../selectors';
import { usePopupInClosableContext } from '../../../../hooks'; import { usePopupInClosableContext } from '../../../../hooks';
@ -29,8 +30,16 @@ 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(() => {
toggleCompletedVisible();
}, [toggleCompletedVisible]);
const EditPopup = usePopupInClosableContext(EditStep); const EditPopup = usePopupInClosableContext(EditStep);
const withActions = taskList.hideCompletedTasks || canEdit;
return ( return (
<Draggable <Draggable
draggableId={`task-list:${id}`} draggableId={`task-list:${id}`}
@ -51,20 +60,37 @@ const Item = React.memo(({ id, index }) => {
<div <div
className={classNames( className={classNames(
styles.moduleHeader, styles.moduleHeader,
canEdit && styles.moduleHeaderEditable, withActions && styles.moduleHeaderWithActions,
taskList.hideCompletedTasks && canEdit && styles.both,
)} )}
> >
{taskList.isPersisted && canEdit && ( {taskList.isPersisted && withActions && (
<EditPopup taskListId={taskList.id}> <div className={classNames(styles.actions)}>
<Button className={styles.editButton}> {taskList.hideCompletedTasks && (
<Icon fitted name="pencil" size="small" /> <Button
</Button> className={styles.button}
</EditPopup> onClick={handleToggleCompletedVisibleClick}
>
<Icon
fitted
name={isCompletedVisible ? 'eye slash' : 'eye'}
size="small"
/>
</Button>
)}
{canEdit && (
<EditPopup taskListId={taskList.id}>
<Button className={styles.button}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
)} )}
<span className={styles.moduleHeaderTitle}>{taskList.name}</span> <span className={styles.moduleHeaderTitle}>{taskList.name}</span>
</div> </div>
</div> </div>
<TaskList id={id} /> <TaskList id={id} isCompletedVisible={isCompletedVisible} />
</div> </div>
</div> </div>
); );

View file

@ -4,7 +4,15 @@
*/ */
:global(#app) { :global(#app) {
.editButton { .actions {
display: flex;
gap: 2px;
position: absolute;
right: 0;
top: 4px;
}
.button {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
line-height: 28px; line-height: 28px;
@ -12,9 +20,6 @@
min-height: auto; min-height: auto;
opacity: 0; opacity: 0;
padding: 0; padding: 0;
position: absolute;
right: 0;
top: 4px;
width: 28px; width: 28px;
&:hover { &:hover {
@ -32,14 +37,18 @@
padding: 6px 0; padding: 6px 0;
} }
.moduleHeaderEditable { .moduleHeaderWithActions {
padding-right: 32px; padding-right: 32px;
&:hover { &:hover {
.editButton { .button {
opacity: 1; opacity: 1;
} }
} }
&.both {
padding-right: 62px;
}
} }
.moduleHeaderTitle { .moduleHeaderTitle {

View file

@ -23,6 +23,7 @@ const AddTaskListStep = React.memo(({ onClose }) => {
context: 'title', context: 'title',
}), }),
showOnFrontOfCard: true, showOnFrontOfCard: true,
hideCompletedTasks: false,
}); });
const taskListEditorRef = useRef(null); const taskListEditorRef = useRef(null);

View file

@ -21,7 +21,7 @@ import AddTask from './AddTask';
import styles from './TaskList.module.scss'; import styles from './TaskList.module.scss';
const TaskList = React.memo(({ id }) => { const TaskList = React.memo(({ id, isCompletedVisible }) => {
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []); const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []); const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []); const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
@ -45,6 +45,14 @@ const TaskList = React.memo(({ id }) => {
const [isAddOpened, setIsAddOpened] = useState(false); const [isAddOpened, setIsAddOpened] = useState(false);
const [, , setIsClosableActive] = useContext(ClosableContext); const [, , setIsClosableActive] = useContext(ClosableContext);
const filteredTasks = useMemo(
() =>
!isCompletedVisible && taskList.hideCompletedTasks
? tasks.filter((task) => !task.isCompleted)
: tasks,
[isCompletedVisible, taskList.hideCompletedTasks, tasks],
);
// TODO: move to selector? // TODO: move to selector?
const completedTasksTotal = useMemo( const completedTasksTotal = useMemo(
() => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0), () => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0),
@ -66,7 +74,7 @@ const TaskList = React.memo(({ id }) => {
return ( return (
<> <>
{tasks.length > 0 && ( {tasks.length > 0 && (
<> <div className={styles.progressRow}>
<span className={styles.progressWrapper}> <span className={styles.progressWrapper}>
<Progress <Progress
autoSuccess autoSuccess
@ -80,7 +88,7 @@ const TaskList = React.memo(({ id }) => {
<span className={styles.count}> <span className={styles.count}>
{completedTasksTotal}/{tasks.length} {completedTasksTotal}/{tasks.length}
</span> </span>
</> </div>
)} )}
<Droppable <Droppable
droppableId={`task-list:${id}`} droppableId={`task-list:${id}`}
@ -90,7 +98,7 @@ const TaskList = React.memo(({ id }) => {
{({ innerRef, droppableProps, placeholder }) => ( {({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} ref={innerRef} className={styles.tasks}> <div {...droppableProps} ref={innerRef} className={styles.tasks}>
{tasks.map((task, index) => ( {filteredTasks.map((task, index) => (
<Task key={task.id} id={task.id} index={index} /> <Task key={task.id} id={task.id} index={index} />
))} ))}
{placeholder} {placeholder}
@ -117,6 +125,7 @@ const TaskList = React.memo(({ id }) => {
TaskList.propTypes = { TaskList.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
isCompletedVisible: PropTypes.bool.isRequired,
}; };
export default TaskList; export default TaskList;

View file

@ -6,23 +6,24 @@
:global(#app) { :global(#app) {
.count { .count {
color: #8c8c8c; color: #8c8c8c;
display: inline-block;
font-size: 14px; font-size: 14px;
line-height: 14px; line-height: 14px;
text-align: right;
vertical-align: top;
width: 50px;
} }
.progress { .progress {
margin: 0 0 16px; margin: 0;
}
.progressRow {
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 16px;
} }
.progressWrapper { .progressWrapper {
display: inline-block;
padding: 3px 0; padding: 3px 0;
vertical-align: top; width: 100%;
width: calc(100% - 50px);
} }
.tasks { .tasks {

View file

@ -56,6 +56,14 @@ const TaskListEditor = React.forwardRef(({ data, onFieldChange }, ref) => {
className={styles.fieldRadio} className={styles.fieldRadio}
onChange={onFieldChange} onChange={onFieldChange}
/> />
<Radio
toggle
name="hideCompletedTasks"
checked={data.hideCompletedTasks}
label={t('common.hideCompletedTasks')}
className={styles.fieldRadio}
onChange={onFieldChange}
/>
</> </>
); );
}); });

View file

@ -196,6 +196,7 @@ export default {
general: 'General', general: 'General',
gradients: 'Gradients', gradients: 'Gradients',
grid: 'Grid', grid: 'Grid',
hideCompletedTasks: 'Hide completed tasks',
hideFromProjectListAndFavorites: 'Hide from project list and favorites', hideFromProjectListAndFavorites: 'Hide from project list and favorites',
hours: 'Hours', hours: 'Hours',
importBoard_title: 'Import Board', importBoard_title: 'Import Board',

View file

@ -191,6 +191,7 @@ export default {
general: 'General', general: 'General',
gradients: 'Gradients', gradients: 'Gradients',
grid: 'Grid', grid: 'Grid',
hideCompletedTasks: 'Hide completed tasks',
hideFromProjectListAndFavorites: 'Hide from project list and favorites', hideFromProjectListAndFavorites: 'Hide from project list and favorites',
hours: 'Hours', hours: 'Hours',
importBoard_title: 'Import Board', importBoard_title: 'Import Board',

View file

@ -16,6 +16,7 @@ export default class extends BaseModel {
position: attr(), position: attr(),
name: attr(), name: attr(),
showOnFrontOfCard: attr(), showOnFrontOfCard: attr(),
hideCompletedTasks: attr(),
cardId: fk({ cardId: fk({
to: 'Card', to: 'Card',
as: 'card', as: 'card',
@ -111,6 +112,7 @@ export default class extends BaseModel {
position: this.position, position: this.position,
name: this.name, name: this.name,
showOnFrontOfCard: this.showOnFrontOfCard, showOnFrontOfCard: this.showOnFrontOfCard,
hideCompletedTasks: this.hideCompletedTasks,
...data, ...data,
}); });

View file

@ -33,6 +33,9 @@ module.exports = {
showOnFrontOfCard: { showOnFrontOfCard: {
type: 'boolean', type: 'boolean',
}, },
hideCompletedTasks: {
type: 'boolean',
},
}, },
exits: { exits: {
@ -64,7 +67,7 @@ module.exports = {
throw Errors.NOT_ENOUGH_RIGHTS; throw Errors.NOT_ENOUGH_RIGHTS;
} }
const values = _.pick(inputs, ['position', 'name', 'showOnFrontOfCard']); const values = _.pick(inputs, ['position', 'name', 'showOnFrontOfCard', 'hideCompletedTasks']);
const taskList = await sails.helpers.taskLists.createOne.with({ const taskList = await sails.helpers.taskLists.createOne.with({
project, project,

View file

@ -32,6 +32,9 @@ module.exports = {
showOnFrontOfCard: { showOnFrontOfCard: {
type: 'boolean', type: 'boolean',
}, },
hideCompletedTasks: {
type: 'boolean',
},
}, },
exits: { exits: {
@ -66,7 +69,7 @@ module.exports = {
throw Errors.NOT_ENOUGH_RIGHTS; throw Errors.NOT_ENOUGH_RIGHTS;
} }
const values = _.pick(inputs, ['position', 'name', 'showOnFrontOfCard']); const values = _.pick(inputs, ['position', 'name', 'showOnFrontOfCard', 'hideCompletedTasks']);
taskList = await sails.helpers.taskLists.updateOne.with({ taskList = await sails.helpers.taskLists.updateOne.with({
values, values,

View file

@ -138,7 +138,7 @@ module.exports = {
nextTaskListIdByTaskListId[taskList.id] = id; nextTaskListIdByTaskListId[taskList.id] = id;
return { return {
..._.pick(taskList, ['position', 'name', 'showOnFrontOfCard']), ..._.pick(taskList, ['position', 'name', 'showOnFrontOfCard', 'hideCompletedTasks']),
id, id,
cardId: card.id, cardId: card.id,
}; };

View file

@ -29,6 +29,11 @@ module.exports = {
defaultsTo: true, defaultsTo: true,
columnName: 'show_on_front_of_card', columnName: 'show_on_front_of_card',
}, },
hideCompletedTasks: {
type: 'boolean',
defaultsTo: false,
columnName: 'hide_completed_tasks',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗

View file

@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('task_list', (table) => {
/* Columns */
table.boolean('hide_completed_tasks').notNullable().defaultTo(false);
});
return knex.schema.alterTable('task_list', (table) => {
table.boolean('hide_completed_tasks').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.table('task_list', (table) => {
table.dropColumn('hide_completed_tasks');
});