From 709a0d1758e1c46ee39b5040bc285987744a1dac Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Wed, 9 Jul 2025 17:45:47 +0200 Subject: [PATCH] feat: Persist closed state per card --- client/src/components/cards/Card/Card.jsx | 14 +---- .../components/cards/Card/InlineContent.jsx | 6 +- .../components/cards/Card/ProjectContent.jsx | 14 +---- .../components/cards/Card/StoryContent.jsx | 6 +- .../components/cards/Card/TaskList/Task.jsx | 11 +--- .../cards/Card/TaskList/TaskList.jsx | 10 +--- .../cards/CardModal/ProjectContent.jsx | 8 +-- .../task-lists/TaskList/Task/Task.jsx | 11 ++-- .../task-lists/TaskList/TaskList.jsx | 10 +--- client/src/models/Card.js | 2 + client/src/models/List.js | 55 ++++++++++++++++++- client/src/sagas/core/services/cards.js | 14 +++++ .../api/helpers/boards/import-from-trello.js | 1 + server/api/helpers/cards/create-one.js | 4 ++ server/api/helpers/cards/duplicate-one.js | 1 + server/api/helpers/cards/update-one.js | 12 +++- server/api/helpers/lists/update-one.js | 22 ++++++++ server/api/models/Card.js | 5 ++ ...708200908_persist_closed_state_per_card.js | 28 ++++++++++ 19 files changed, 163 insertions(+), 71 deletions(-) create mode 100644 server/db/migrations/20250708200908_persist_closed_state_per_card.js diff --git a/client/src/components/cards/Card/Card.jsx b/client/src/components/cards/Card/Card.jsx index 00dced39..c4d12b1a 100755 --- a/client/src/components/cards/Card/Card.jsx +++ b/client/src/components/cards/Card/Card.jsx @@ -15,7 +15,7 @@ import { usePopup } from '../../../lib/popup'; import selectors from '../../../selectors'; import Paths from '../../../constants/Paths'; -import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; +import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums'; import ProjectContent from './ProjectContent'; import StoryContent from './StoryContent'; import InlineContent from './InlineContent'; @@ -108,10 +108,7 @@ const Card = React.memo(({ id, isInline }) => { {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
@@ -126,12 +123,7 @@ const Card = React.memo(({ id, isInline }) => { )} ) : ( - + {colorLineNode} diff --git a/client/src/components/cards/Card/InlineContent.jsx b/client/src/components/cards/Card/InlineContent.jsx index 7fdea984..15452140 100644 --- a/client/src/components/cards/Card/InlineContent.jsx +++ b/client/src/components/cards/Card/InlineContent.jsx @@ -11,7 +11,7 @@ import { Icon } from 'semantic-ui-react'; import selectors from '../../../selectors'; import markdownToText from '../../../utils/markdown-to-text'; -import { BoardViews, ListTypes } from '../../../constants/Enums'; +import { BoardViews } from '../../../constants/Enums'; import UserAvatar from '../../users/UserAvatar'; import LabelChip from '../../labels/LabelChip'; @@ -54,8 +54,6 @@ const InlineContent = React.memo(({ cardId }) => { [card.description], ); - const isInClosedList = list.type === ListTypes.CLOSED; - return (
@@ -90,7 +88,7 @@ const InlineContent = React.memo(({ cardId }) => { )}
{card.name}
diff --git a/client/src/components/cards/Card/ProjectContent.jsx b/client/src/components/cards/Card/ProjectContent.jsx index 8074d923..952acc86 100644 --- a/client/src/components/cards/Card/ProjectContent.jsx +++ b/client/src/components/cards/Card/ProjectContent.jsx @@ -13,7 +13,7 @@ import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch'; import { isListArchiveOrTrash } from '../../../utils/record-helpers'; -import { BoardMembershipRoles, BoardViews, ListTypes } from '../../../constants/Enums'; +import { BoardMembershipRoles, BoardViews } from '../../../constants/Enums'; import TaskList from './TaskList'; import DueDateChip from '../DueDateChip'; import StopwatchChip from '../StopwatchChip'; @@ -110,8 +110,6 @@ const ProjectContent = React.memo(({ cardId }) => { [cardId, card.stopwatch, dispatch], ); - const isInClosedList = list.type === ListTypes.CLOSED; - const hasInformation = card.description || card.dueDate || @@ -147,9 +145,7 @@ const ProjectContent = React.memo(({ cardId }) => { return (
-
- {card.name} -
+
{card.name}
{coverUrl && (
@@ -191,11 +187,7 @@ const ProjectContent = React.memo(({ cardId }) => { )} {card.dueDate && ( - + )} {card.stopwatch && ( diff --git a/client/src/components/cards/Card/StoryContent.jsx b/client/src/components/cards/Card/StoryContent.jsx index 2911b2b2..17eaa3f1 100644 --- a/client/src/components/cards/Card/StoryContent.jsx +++ b/client/src/components/cards/Card/StoryContent.jsx @@ -11,7 +11,7 @@ import { Icon } from 'semantic-ui-react'; import selectors from '../../../selectors'; import markdownToText from '../../../utils/markdown-to-text'; -import { BoardViews, ListTypes } from '../../../constants/Enums'; +import { BoardViews } from '../../../constants/Enums'; import LabelChip from '../../labels/LabelChip'; import CustomFieldValueChip from '../../custom-field-values/CustomFieldValueChip'; @@ -76,8 +76,6 @@ const StoryContent = React.memo(({ cardId }) => { [card.description], ); - const isInClosedList = list.type === ListTypes.CLOSED; - return ( <> {coverUrl && ( @@ -107,7 +105,7 @@ const StoryContent = React.memo(({ cardId }) => { ))} )} -
+
{card.name}
{card.description &&
{descriptionText}
} diff --git a/client/src/components/cards/Card/TaskList/Task.jsx b/client/src/components/cards/Card/TaskList/Task.jsx index 731aa7ea..f2cb52bd 100644 --- a/client/src/components/cards/Card/TaskList/Task.jsx +++ b/client/src/components/cards/Card/TaskList/Task.jsx @@ -9,7 +9,6 @@ import classNames from 'classnames'; import { useSelector } from 'react-redux'; import selectors from '../../../../selectors'; -import { ListTypes } from '../../../../constants/Enums'; import Linkify from '../../../common/Linkify'; import styles from './Task.module.scss'; @@ -17,7 +16,6 @@ import styles from './Task.module.scss'; const Task = React.memo(({ id }) => { const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []); const selectCardById = useMemo(() => selectors.makeSelectCardById(), []); - const selectListById = useMemo(() => selectors.makeSelectListById(), []); const task = useSelector((state) => selectTaskById(state, id)); @@ -33,14 +31,11 @@ const Task = React.memo(({ id }) => { for (const [, cardId] of matches) { const card = selectCardById(state, cardId); - if (card) { - const list = selectListById(state, card.listId); - - if (list && list.type === ListTypes.CLOSED) { - return true; - } + if (card && card.isClosed) { + return true; } } + return false; }); diff --git a/client/src/components/cards/Card/TaskList/TaskList.jsx b/client/src/components/cards/Card/TaskList/TaskList.jsx index 368b73c8..078aca60 100644 --- a/client/src/components/cards/Card/TaskList/TaskList.jsx +++ b/client/src/components/cards/Card/TaskList/TaskList.jsx @@ -9,7 +9,6 @@ import classNames from 'classnames'; import { useSelector } from 'react-redux'; import { Progress } from 'semantic-ui-react'; import { useToggle } from '../../../../lib/hooks'; -import { ListTypes } from '../../../../constants/Enums'; import selectors from '../../../../selectors'; import Task from './Task'; @@ -18,7 +17,6 @@ import styles from './TaskList.module.scss'; const TaskList = React.memo(({ id }) => { const selectCardById = useMemo(() => selectors.makeSelectCardById(), []); - const selectListById = useMemo(() => selectors.makeSelectListById(), []); const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []); const tasks = useSelector((state) => selectTasksByTaskListId(state, id)); @@ -37,12 +35,8 @@ const TaskList = React.memo(({ id }) => { for (const [, cardId] of matches) { const card = selectCardById(state, cardId); - if (card) { - const list = selectListById(state, card.listId); - - if (list && list.type === ListTypes.CLOSED) { - return result + 1; - } + if (card && card.isClosed) { + return result + 1; } } diff --git a/client/src/components/cards/CardModal/ProjectContent.jsx b/client/src/components/cards/CardModal/ProjectContent.jsx index 1a434cb7..158e5671 100644 --- a/client/src/components/cards/CardModal/ProjectContent.jsx +++ b/client/src/components/cards/CardModal/ProjectContent.jsx @@ -442,18 +442,14 @@ const ProjectContent = React.memo(({ onClose }) => { ) : ( )} diff --git a/client/src/components/task-lists/TaskList/Task/Task.jsx b/client/src/components/task-lists/TaskList/Task/Task.jsx index 9458afea..b1d5d6d1 100755 --- a/client/src/components/task-lists/TaskList/Task/Task.jsx +++ b/client/src/components/task-lists/TaskList/Task/Task.jsx @@ -16,7 +16,7 @@ import selectors from '../../../../selectors'; import entryActions from '../../../../entry-actions'; import { usePopupInClosableContext } from '../../../../hooks'; import { isListArchiveOrTrash } from '../../../../utils/record-helpers'; -import { BoardMembershipRoles, ListTypes } from '../../../../constants/Enums'; +import { BoardMembershipRoles } from '../../../../constants/Enums'; import { ClosableContext } from '../../../../contexts'; import EditName from './EditName'; import SelectAssigneeStep from './SelectAssigneeStep'; @@ -41,14 +41,11 @@ const Task = React.memo(({ id, index }) => { for (const [, cardId] of matches) { const card = selectCardById(state, cardId); - if (card) { - const list = selectListById(state, card.listId); - - if (list && list.type === ListTypes.CLOSED) { - return true; - } + if (card && card.isClosed) { + return true; } } + return false; }); diff --git a/client/src/components/task-lists/TaskList/TaskList.jsx b/client/src/components/task-lists/TaskList/TaskList.jsx index 8ff3d8c4..84522227 100755 --- a/client/src/components/task-lists/TaskList/TaskList.jsx +++ b/client/src/components/task-lists/TaskList/TaskList.jsx @@ -14,7 +14,7 @@ import { useDidUpdate } from '../../../lib/hooks'; import selectors from '../../../selectors'; import { isListArchiveOrTrash } from '../../../utils/record-helpers'; import DroppableTypes from '../../../constants/DroppableTypes'; -import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums'; +import { BoardMembershipRoles } from '../../../constants/Enums'; import { ClosableContext } from '../../../contexts'; import Task from './Task'; import AddTask from './AddTask'; @@ -44,12 +44,8 @@ const TaskList = React.memo(({ id }) => { for (const [, cardId] of matches) { const card = selectCardById(state, cardId); - if (card) { - const list = selectListById(state, card.listId); - - if (list && list.type === ListTypes.CLOSED) { - return result + 1; - } + if (card && card.isClosed) { + return result + 1; } } diff --git a/client/src/models/Card.js b/client/src/models/Card.js index 669d6255..fb021152 100755 --- a/client/src/models/Card.js +++ b/client/src/models/Card.js @@ -20,6 +20,7 @@ export default class extends BaseModel { description: attr(), dueDate: attr(), stopwatch: attr(), + isClosed: attr(), commentsTotal: attr({ getDefault: () => 0, }), @@ -554,6 +555,7 @@ export default class extends BaseModel { description: this.description, dueDate: this.dueDate, stopwatch: this.stopwatch, + isClosed: this.isClosed, ...data, }); diff --git a/client/src/models/List.js b/client/src/models/List.js index 79255cd3..407646e6 100755 --- a/client/src/models/List.js +++ b/client/src/models/List.js @@ -101,7 +101,6 @@ export default class extends BaseModel { case ActionTypes.LIST_CREATE: case ActionTypes.LIST_CREATE_HANDLE: case ActionTypes.LIST_UPDATE__SUCCESS: - case ActionTypes.LIST_UPDATE_HANDLE: case ActionTypes.LIST_SORT__SUCCESS: case ActionTypes.LIST_CARDS_MOVE__SUCCESS: case ActionTypes.LIST_CLEAR__SUCCESS: @@ -117,10 +116,60 @@ export default class extends BaseModel { List.withId(payload.localId).delete(); break; - case ActionTypes.LIST_UPDATE: - List.withId(payload.id).update(payload.data); + case ActionTypes.LIST_UPDATE: { + const listModel = List.withId(payload.id); + + let isClosed; + if (payload.data.type) { + if (payload.data.type === ListTypes.CLOSED) { + if (listModel.type === ListTypes.ACTIVE) { + isClosed = true; + } + } else if (listModel.type === ListTypes.CLOSED) { + isClosed = false; + } + } + + listModel.update(payload.data); + + if (isClosed !== undefined) { + listModel.cards.toModelArray().forEach((cardModel) => { + cardModel.update({ + isClosed, + }); + }); + } break; + } + case ActionTypes.LIST_UPDATE_HANDLE: { + const listModel = List.withId(payload.list.id); + + if (listModel) { + let isClosed; + if (payload.list.type === ListTypes.CLOSED) { + if (listModel.type === ListTypes.ACTIVE) { + isClosed = true; + } + } else if (listModel.type === ListTypes.CLOSED) { + isClosed = false; + } + + listModel.update(prepareList(payload.list)); + + if (isClosed !== undefined) { + listModel.cards.toModelArray().forEach((cardModel) => { + cardModel.update({ + isClosed, + }); + }); + } + } else { + List.upsert(prepareList(payload.list)); + } + + break; + } case ActionTypes.LIST_SORT: List.withId(payload.id).sortCards(payload.data); diff --git a/client/src/sagas/core/services/cards.js b/client/src/sagas/core/services/cards.js index b54666b7..788295e4 100644 --- a/client/src/sagas/core/services/cards.js +++ b/client/src/sagas/core/services/cards.js @@ -137,6 +137,7 @@ export function* createCard(listId, data, autoOpen) { id: localId, boardId: list.boardId, creatorUserId: currentUserMembership.userId, + isClosed: list.type === ListTypes.CLOSED, }, autoOpen, ), @@ -225,6 +226,8 @@ export function* handleCardCreate(card) { export function* updateCard(id, data) { let prevListId; + let isClosed; + if (data.listId) { const list = yield select(selectors.selectListById, data.listId); @@ -238,6 +241,14 @@ export function* updateCard(id, data) { } else if (prevList.type === ListTypes.ARCHIVE) { prevListId = null; } + + if (card.isClosed) { + if (list.type === ListTypes.ACTIVE) { + isClosed = false; + } + } else if (list.type === ListTypes.CLOSED) { + isClosed = true; + } } yield put( @@ -246,6 +257,9 @@ export function* updateCard(id, data) { ...(prevListId !== undefined && { prevListId, }), + ...(isClosed !== undefined && { + isClosed, + }), }), ); diff --git a/server/api/helpers/boards/import-from-trello.js b/server/api/helpers/boards/import-from-trello.js index 6f426ad3..155c1457 100644 --- a/server/api/helpers/boards/import-from-trello.js +++ b/server/api/helpers/boards/import-from-trello.js @@ -67,6 +67,7 @@ module.exports = { name: trelloCard.name, description: trelloCard.desc || null, dueDate: trelloCard.due, + isClosed: trelloCard.dueComplete, listChangedAt: new Date().toISOString(), }; diff --git a/server/api/helpers/cards/create-one.js b/server/api/helpers/cards/create-one.js index 9040774a..b997231f 100644 --- a/server/api/helpers/cards/create-one.js +++ b/server/api/helpers/cards/create-one.js @@ -67,6 +67,10 @@ module.exports = { delete values.position; } + if (values.list.type === List.Types.CLOSED) { + values.isClosed = true; + } + const card = await Card.qm.createOne({ ...values, boardId: values.board.id, diff --git a/server/api/helpers/cards/duplicate-one.js b/server/api/helpers/cards/duplicate-one.js index 3c81b0dd..5414d17c 100644 --- a/server/api/helpers/cards/duplicate-one.js +++ b/server/api/helpers/cards/duplicate-one.js @@ -91,6 +91,7 @@ module.exports = { 'description', 'dueDate', 'stopwatch', + 'isClosed', ]), ...values, creatorUserId: values.creatorUser.id, diff --git a/server/api/helpers/cards/update-one.js b/server/api/helpers/cards/update-one.js index 049e4512..f9f85123 100644 --- a/server/api/helpers/cards/update-one.js +++ b/server/api/helpers/cards/update-one.js @@ -378,8 +378,6 @@ module.exports = { } if (values.list) { - values.listChangedAt = new Date().toISOString(); - if (values.board || inputs.list.type === List.Types.TRASH) { values.prevListId = null; } else if (sails.helpers.lists.isArchiveOrTrash(values.list)) { @@ -387,6 +385,16 @@ module.exports = { } else if (inputs.list.type === List.Types.ARCHIVE) { values.prevListId = null; } + + if (inputs.record.isClosed) { + if (values.list.type === List.Types.ACTIVE) { + values.isClosed = false; + } + } else if (values.list.type === List.Types.CLOSED) { + values.isClosed = true; + } + + values.listChangedAt = new Date().toISOString(); } card = await Card.qm.updateOne(inputs.record.id, values); diff --git a/server/api/helpers/lists/update-one.js b/server/api/helpers/lists/update-one.js index 72a67eb3..47c2daa2 100644 --- a/server/api/helpers/lists/update-one.js +++ b/server/api/helpers/lists/update-one.js @@ -33,6 +33,28 @@ module.exports = { async fn(inputs) { const { values } = inputs; + if (values.type) { + let isClosed; + if (values.type === List.Types.CLOSED) { + if (inputs.record.type === List.Types.ACTIVE) { + isClosed = true; + } + } else if (inputs.record.type === List.Types.CLOSED) { + isClosed = false; + } + + if (!_.isUndefined(isClosed)) { + await Card.qm.update( + { + listId: inputs.record.id, + }, + { + isClosed, + }, + ); + } + } + if (!_.isUndefined(values.position)) { const lists = await sails.helpers.boards.getFiniteListsById( inputs.board.id, diff --git a/server/api/models/Card.js b/server/api/models/Card.js index dc3073c2..3ac61c65 100755 --- a/server/api/models/Card.js +++ b/server/api/models/Card.js @@ -48,6 +48,11 @@ module.exports = { stopwatch: { type: 'json', }, + isClosed: { + type: 'boolean', + defaultsTo: false, + columnName: 'is_closed', + }, commentsTotal: { type: 'number', defaultsTo: 0, diff --git a/server/db/migrations/20250708200908_persist_closed_state_per_card.js b/server/db/migrations/20250708200908_persist_closed_state_per_card.js new file mode 100644 index 00000000..f03a7959 --- /dev/null +++ b/server/db/migrations/20250708200908_persist_closed_state_per_card.js @@ -0,0 +1,28 @@ +/*! + * 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('card', (table) => { + /* Columns */ + + table.boolean('is_closed').notNullable().default(false); + }); + + await knex.raw(` + UPDATE card + SET is_closed = TRUE + FROM list + WHERE card.list_id = list.id AND list.type = 'closed'; + `); + + return knex.schema.alterTable('card', (table) => { + table.boolean('is_closed').notNullable().alter(); + }); +}; + +exports.down = (knex) => + knex.schema.table('card', (table) => { + table.dropColumn('is_closed'); + });