1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 12:49:43 +02:00

feat: Persist closed state per card

This commit is contained in:
Maksim Eltyshev 2025-07-09 17:45:47 +02:00
parent 69c75a03b1
commit 709a0d1758
19 changed files with 163 additions and 71 deletions

View file

@ -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 */}
<div
className={classNames(
styles.content,
list.type === ListTypes.CLOSED && styles.contentDisabled,
)}
className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
onClick={handleClick}
>
<Content cardId={id} />
@ -126,12 +123,7 @@ const Card = React.memo(({ id, isInline }) => {
)}
</>
) : (
<span
className={classNames(
styles.content,
list.type === ListTypes.CLOSED && styles.contentDisabled,
)}
>
<span className={classNames(styles.content, card.isClosed && styles.contentDisabled)}>
<Content cardId={id} />
{colorLineNode}
</span>

View file

@ -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 (
<div className={styles.wrapper}>
<span className={styles.attachments}>
@ -90,7 +88,7 @@ const InlineContent = React.memo(({ cardId }) => {
</span>
)}
<span
className={classNames(styles.attachments, styles.name, isInClosedList && styles.nameClosed)}
className={classNames(styles.attachments, styles.name, card.isClosed && styles.nameClosed)}
>
<div className={styles.hidable}>{card.name}</div>
</span>

View file

@ -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 (
<div className={styles.wrapper}>
<div className={classNames(styles.name, isInClosedList && styles.nameClosed)}>
{card.name}
</div>
<div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>{card.name}</div>
{coverUrl && (
<div className={styles.coverWrapper}>
<img src={coverUrl} alt="" className={styles.cover} />
@ -191,11 +187,7 @@ const ProjectContent = React.memo(({ cardId }) => {
)}
{card.dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDateChip
value={card.dueDate}
size="tiny"
withStatus={!isInClosedList && !isListArchiveOrTrash(list)}
/>
<DueDateChip value={card.dueDate} size="tiny" withStatus={!card.isClosed} />
</span>
)}
{card.stopwatch && (

View file

@ -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 }) => {
))}
</span>
)}
<div className={classNames(styles.name, isInClosedList && styles.nameClosed)}>
<div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>
{card.name}
</div>
{card.description && <div className={styles.descriptionText}>{descriptionText}</div>}

View file

@ -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;
});

View file

@ -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;
}
}

View file

@ -442,18 +442,14 @@ const ProjectContent = React.memo(({ onClose }) => {
<DueDateChip
withStatusIcon
value={card.dueDate}
withStatus={
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
withStatus={!card.isClosed}
/>
</EditDueDatePopup>
) : (
<DueDateChip
withStatusIcon
value={card.dueDate}
withStatus={
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
withStatus={!card.isClosed}
/>
)}
</span>

View file

@ -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;
});

View file

@ -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;
}
}

View file

@ -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,
});

View file

@ -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);

View file

@ -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,
}),
}),
);

View file

@ -67,6 +67,7 @@ module.exports = {
name: trelloCard.name,
description: trelloCard.desc || null,
dueDate: trelloCard.due,
isClosed: trelloCard.dueComplete,
listChangedAt: new Date().toISOString(),
};

View file

@ -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,

View file

@ -91,6 +91,7 @@ module.exports = {
'description',
'dueDate',
'stopwatch',
'isClosed',
]),
...values,
creatorUserId: values.creatorUser.id,

View file

@ -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);

View file

@ -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,

View file

@ -48,6 +48,11 @@ module.exports = {
stopwatch: {
type: 'json',
},
isClosed: {
type: 'boolean',
defaultsTo: false,
columnName: 'is_closed',
},
commentsTotal: {
type: 'number',
defaultsTo: 0,

View file

@ -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');
});