diff --git a/client/src/components/cards/Card/TaskList/TaskList.jsx b/client/src/components/cards/Card/TaskList/TaskList.jsx index e9497848..f39a94fb 100644 --- a/client/src/components/cards/Card/TaskList/TaskList.jsx +++ b/client/src/components/cards/Card/TaskList/TaskList.jsx @@ -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 */} -
+
{ /> 0 && styles.countOpenable, + filteredTasks.length > 0 && (isOpened ? styles.countOpened : styles.countClosed), + )} > {completedTasksTotal}/{tasks.length}
- {isOpened && ( + {isOpened && filteredTasks.length > 0 && ( diff --git a/client/src/components/cards/Card/TaskList/TaskList.module.scss b/client/src/components/cards/Card/TaskList/TaskList.module.scss index 903ea65e..306fb10e 100644 --- a/client/src/components/cards/Card/TaskList/TaskList.module.scss +++ b/client/src/components/cards/Card/TaskList/TaskList.module.scss @@ -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 { diff --git a/client/src/components/cards/CardModal/TaskLists/EditStep.jsx b/client/src/components/cards/CardModal/TaskLists/EditStep.jsx index a88aa924..46ef072e 100644 --- a/client/src/components/cards/CardModal/TaskLists/EditStep.jsx +++ b/client/src/components/cards/CardModal/TaskLists/EditStep.jsx @@ -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, })); diff --git a/client/src/components/cards/CardModal/TaskLists/Item.jsx b/client/src/components/cards/CardModal/TaskLists/Item.jsx index 5e85f953..a93be777 100644 --- a/client/src/components/cards/CardModal/TaskLists/Item.jsx +++ b/client/src/components/cards/CardModal/TaskLists/Item.jsx @@ -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 ( {
- {taskList.isPersisted && canEdit && ( - - - + {taskList.isPersisted && withActions && ( +
+ {taskList.hideCompletedTasks && ( + + )} + {canEdit && ( + + + + )} +
)} {taskList.name}
- + ); diff --git a/client/src/components/cards/CardModal/TaskLists/Item.module.scss b/client/src/components/cards/CardModal/TaskLists/Item.module.scss index f2098135..74038f8e 100644 --- a/client/src/components/cards/CardModal/TaskLists/Item.module.scss +++ b/client/src/components/cards/CardModal/TaskLists/Item.module.scss @@ -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 { diff --git a/client/src/components/task-lists/AddTaskListStep.jsx b/client/src/components/task-lists/AddTaskListStep.jsx index c358f1b9..731b8054 100644 --- a/client/src/components/task-lists/AddTaskListStep.jsx +++ b/client/src/components/task-lists/AddTaskListStep.jsx @@ -23,6 +23,7 @@ const AddTaskListStep = React.memo(({ onClose }) => { context: 'title', }), showOnFrontOfCard: true, + hideCompletedTasks: false, }); const taskListEditorRef = useRef(null); diff --git a/client/src/components/task-lists/TaskList/TaskList.jsx b/client/src/components/task-lists/TaskList/TaskList.jsx index 25e7be7d..29713b4c 100755 --- a/client/src/components/task-lists/TaskList/TaskList.jsx +++ b/client/src/components/task-lists/TaskList/TaskList.jsx @@ -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 && ( - <> +
{ {completedTasksTotal}/{tasks.length} - +
)} { {({ innerRef, droppableProps, placeholder }) => ( // eslint-disable-next-line react/jsx-props-no-spreading
- {tasks.map((task, index) => ( + {filteredTasks.map((task, index) => ( ))} {placeholder} @@ -117,6 +125,7 @@ const TaskList = React.memo(({ id }) => { TaskList.propTypes = { id: PropTypes.string.isRequired, + isCompletedVisible: PropTypes.bool.isRequired, }; export default TaskList; diff --git a/client/src/components/task-lists/TaskList/TaskList.module.scss b/client/src/components/task-lists/TaskList/TaskList.module.scss index 2a8db18c..b229e241 100644 --- a/client/src/components/task-lists/TaskList/TaskList.module.scss +++ b/client/src/components/task-lists/TaskList/TaskList.module.scss @@ -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 { diff --git a/client/src/components/task-lists/TaskListEditor/TaskListEditor.jsx b/client/src/components/task-lists/TaskListEditor/TaskListEditor.jsx index 80346330..df9f4030 100644 --- a/client/src/components/task-lists/TaskListEditor/TaskListEditor.jsx +++ b/client/src/components/task-lists/TaskListEditor/TaskListEditor.jsx @@ -56,6 +56,14 @@ const TaskListEditor = React.forwardRef(({ data, onFieldChange }, ref) => { className={styles.fieldRadio} onChange={onFieldChange} /> + ); }); diff --git a/client/src/locales/en-GB/core.js b/client/src/locales/en-GB/core.js index 92f7f85b..7564578b 100644 --- a/client/src/locales/en-GB/core.js +++ b/client/src/locales/en-GB/core.js @@ -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', diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index f7db079d..fb804d73 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -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', diff --git a/client/src/models/TaskList.js b/client/src/models/TaskList.js index 56e02a8c..97ce9fe4 100755 --- a/client/src/models/TaskList.js +++ b/client/src/models/TaskList.js @@ -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, }); diff --git a/server/api/controllers/task-lists/create.js b/server/api/controllers/task-lists/create.js index 9f8e1611..288f8481 100755 --- a/server/api/controllers/task-lists/create.js +++ b/server/api/controllers/task-lists/create.js @@ -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, diff --git a/server/api/controllers/task-lists/update.js b/server/api/controllers/task-lists/update.js index 96b5192d..46ec794e 100755 --- a/server/api/controllers/task-lists/update.js +++ b/server/api/controllers/task-lists/update.js @@ -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, diff --git a/server/api/helpers/cards/duplicate-one.js b/server/api/helpers/cards/duplicate-one.js index ebf1a36d..0777cf22 100644 --- a/server/api/helpers/cards/duplicate-one.js +++ b/server/api/helpers/cards/duplicate-one.js @@ -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, }; diff --git a/server/api/models/TaskList.js b/server/api/models/TaskList.js index f713881a..f810d7d5 100644 --- a/server/api/models/TaskList.js +++ b/server/api/models/TaskList.js @@ -29,6 +29,11 @@ module.exports = { defaultsTo: true, columnName: 'show_on_front_of_card', }, + hideCompletedTasks: { + type: 'boolean', + defaultsTo: false, + columnName: 'hide_completed_tasks', + }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/server/db/migrations/20250721132312_add_ability_to_hide_completed_tasks.js b/server/db/migrations/20250721132312_add_ability_to_hide_completed_tasks.js new file mode 100644 index 00000000..e373a686 --- /dev/null +++ b/server/db/migrations/20250721132312_add_ability_to_hide_completed_tasks.js @@ -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'); + });