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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -56,6 +56,14 @@ const TaskListEditor = React.forwardRef(({ data, onFieldChange }, ref) => {
className={styles.fieldRadio}
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',
gradients: 'Gradients',
grid: 'Grid',
hideCompletedTasks: 'Hide completed tasks',
hideFromProjectListAndFavorites: 'Hide from project list and favorites',
hours: 'Hours',
importBoard_title: 'Import Board',

View file

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

View file

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

View file

@ -33,6 +33,9 @@ module.exports = {
showOnFrontOfCard: {
type: 'boolean',
},
hideCompletedTasks: {
type: 'boolean',
},
},
exits: {
@ -64,7 +67,7 @@ module.exports = {
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({
project,

View file

@ -32,6 +32,9 @@ module.exports = {
showOnFrontOfCard: {
type: 'boolean',
},
hideCompletedTasks: {
type: 'boolean',
},
},
exits: {
@ -66,7 +69,7 @@ module.exports = {
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({
values,

View file

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

View file

@ -29,6 +29,11 @@ module.exports = {
defaultsTo: true,
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');
});