feat: Restore toggleable due dates (#1332)

This commit is contained in:
Steven Correia
2025-09-05 07:55:20 -04:00
committed by GitHub
parent 7da241c5a3
commit 37bd4d1349
14 changed files with 140 additions and 17 deletions

View File

@@ -187,7 +187,12 @@ const ProjectContent = React.memo(({ cardId }) => {
)} )}
{card.dueDate && ( {card.dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}> <span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDateChip value={card.dueDate} size="tiny" withStatus={!card.isClosed} /> <DueDateChip
value={card.dueDate}
size="tiny"
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed}
/>
</span> </span>
)} )}
{card.stopwatch && ( {card.stopwatch && (

View File

@@ -7,7 +7,7 @@ import React, { useCallback, useContext, useMemo, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Grid, Icon } from 'semantic-ui-react'; import { Button, Checkbox, Grid, Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../lib/hooks'; import { useDidUpdate } from '../../../lib/hooks';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@@ -175,6 +175,14 @@ const ProjectContent = React.memo(() => {
[dispatch], [dispatch],
); );
const handleDueCompletionChange = useCallback(() => {
dispatch(
entryActions.updateCurrentCard({
isDueCompleted: !card.isDueCompleted,
}),
);
}, [card.isDueCompleted, dispatch]);
const handleToggleStopwatchClick = useCallback(() => { const handleToggleStopwatchClick = useCallback(() => {
dispatch( dispatch(
entryActions.updateCurrentCard({ entryActions.updateCurrentCard({
@@ -410,19 +418,30 @@ const ProjectContent = React.memo(() => {
context: 'title', context: 'title',
})} })}
</div> </div>
<span className={styles.attachment}> <span className={classNames(styles.attachment, styles.attachmentDueDate)}>
{canEditDueDate ? ( {canEditDueDate ? (
<EditDueDatePopup cardId={card.id}> <>
<DueDateChip {!card.isClosed && (
withStatusIcon <Checkbox
value={card.dueDate} checked={card.isDueCompleted}
withStatus={!card.isClosed} disabled={!canEditDueDate}
/> onChange={handleDueCompletionChange}
</EditDueDatePopup> />
)}
<EditDueDatePopup cardId={card.id}>
<DueDateChip
withStatusIcon
value={card.dueDate}
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed}
/>
</EditDueDatePopup>
</>
) : ( ) : (
<DueDateChip <DueDateChip
withStatusIcon withStatusIcon
value={card.dueDate} value={card.dueDate}
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed} withStatus={!card.isClosed}
/> />
)} )}

View File

@@ -60,6 +60,12 @@
vertical-align: top; vertical-align: top;
} }
.attachmentDueDate {
align-items: center;
display: flex;
gap: 4px;
}
.attachments { .attachments {
display: inline-block; display: inline-block;
margin: 0 8px 8px 0; margin: 0 8px 8px 0;

View File

@@ -24,6 +24,7 @@ const Sizes = {
const Statuses = { const Statuses = {
DUE_SOON: 'dueSoon', DUE_SOON: 'dueSoon',
OVERDUE: 'overdue', OVERDUE: 'overdue',
COMPLETED: 'completed',
}; };
const LONG_DATE_FORMAT_BY_SIZE = { const LONG_DATE_FORMAT_BY_SIZE = {
@@ -47,9 +48,17 @@ const STATUS_ICON_PROPS_BY_STATUS = {
name: 'hourglass end', name: 'hourglass end',
color: 'red', color: 'red',
}, },
[Statuses.COMPLETED]: {
name: 'checkmark',
color: 'green',
},
}; };
const getStatus = (date) => { const getStatus = (date, isCompleted) => {
if (isCompleted) {
return Statuses.COMPLETED;
}
const secondsLeft = Math.floor((date.getTime() - new Date().getTime()) / 1000); const secondsLeft = Math.floor((date.getTime() - new Date().getTime()) / 1000);
if (secondsLeft <= 0) { if (secondsLeft <= 0) {
@@ -64,12 +73,12 @@ const getStatus = (date) => {
}; };
const DueDateChip = React.memo( const DueDateChip = React.memo(
({ value, size, isDisabled, withStatus, withStatusIcon, onClick }) => { ({ value, size, isCompleted, isDisabled, withStatus, withStatusIcon, onClick }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
const statusRef = useRef(null); const statusRef = useRef(null);
statusRef.current = withStatus ? getStatus(value) : null; statusRef.current = withStatus ? getStatus(value, isCompleted) : null;
const intervalRef = useRef(null); const intervalRef = useRef(null);
@@ -80,9 +89,13 @@ const DueDateChip = React.memo(
); );
useEffect(() => { useEffect(() => {
if (withStatus && statusRef.current !== Statuses.OVERDUE) { if (
withStatus &&
statusRef.current !== Statuses.OVERDUE &&
statusRef.current !== Statuses.COMPLETED
) {
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
const status = getStatus(value); const status = getStatus(value, isCompleted);
if (status !== statusRef.current) { if (status !== statusRef.current) {
forceUpdate(); forceUpdate();
@@ -99,7 +112,7 @@ const DueDateChip = React.memo(
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
}; };
}, [value, withStatus, forceUpdate]); }, [value, isCompleted, withStatus, forceUpdate]);
const contentNode = ( const contentNode = (
<span <span
@@ -134,6 +147,7 @@ const DueDateChip = React.memo(
DueDateChip.propTypes = { DueDateChip.propTypes = {
value: PropTypes.instanceOf(Date).isRequired, value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(Sizes)), size: PropTypes.oneOf(Object.values(Sizes)),
isCompleted: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,
withStatus: PropTypes.bool.isRequired, withStatus: PropTypes.bool.isRequired,
withStatusIcon: PropTypes.bool, withStatusIcon: PropTypes.bool,

View File

@@ -62,4 +62,9 @@
background: #db2828; background: #db2828;
color: #fff; color: #fff;
} }
.wrapperCompleted {
background: #21ba45;
color: #fff;
}
} }

View File

@@ -19,6 +19,7 @@ export default class extends BaseModel {
name: attr(), name: attr(),
description: attr(), description: attr(),
dueDate: attr(), dueDate: attr(),
isDueCompleted: attr(),
stopwatch: attr(), stopwatch: attr(),
isClosed: attr(), isClosed: attr(),
commentsTotal: attr({ commentsTotal: attr({
@@ -323,6 +324,16 @@ export default class extends BaseModel {
payload.data.listChangedAt = new Date(); // eslint-disable-line no-param-reassign payload.data.listChangedAt = new Date(); // eslint-disable-line no-param-reassign
} }
if (payload.data.dueDate !== undefined) {
if (payload.data.dueDate) {
if (!cardModel.dueDate) {
payload.data.isDueCompleted = false; // eslint-disable-line no-param-reassign
}
} else {
payload.data.isDueCompleted = null; // eslint-disable-line no-param-reassign
}
}
if (payload.data.isClosed !== undefined && payload.data.isClosed !== cardModel.isClosed) { if (payload.data.isClosed !== undefined && payload.data.isClosed !== cardModel.isClosed) {
cardModel.linkedTasks.update({ cardModel.linkedTasks.update({
isCompleted: payload.data.isClosed, isCompleted: payload.data.isClosed,
@@ -568,6 +579,7 @@ export default class extends BaseModel {
name: this.name, name: this.name,
description: this.description, description: this.description,
dueDate: this.dueDate, dueDate: this.dueDate,
isDueCompleted: this.isDueCompleted,
stopwatch: this.stopwatch, stopwatch: this.stopwatch,
isClosed: this.isClosed, isClosed: this.isClosed,
...data, ...data,

View File

@@ -49,6 +49,10 @@ module.exports = {
type: 'string', type: 'string',
custom: isDueDate, custom: isDueDate,
}, },
isDueCompleted: {
type: 'boolean',
allowNull: true,
},
stopwatch: { stopwatch: {
type: 'json', type: 'json',
custom: isStopwatch, custom: isStopwatch,
@@ -93,6 +97,7 @@ module.exports = {
'name', 'name',
'description', 'description',
'dueDate', 'dueDate',
'isDueCompleted',
'stopwatch', 'stopwatch',
]); ]);

View File

@@ -70,6 +70,10 @@ module.exports = {
custom: isDueDate, custom: isDueDate,
allowNull: true, allowNull: true,
}, },
isDueCompleted: {
type: 'boolean',
allowNull: true,
},
stopwatch: { stopwatch: {
type: 'json', type: 'json',
custom: isStopwatch, custom: isStopwatch,
@@ -136,6 +140,7 @@ module.exports = {
'name', 'name',
'description', 'description',
'dueDate', 'dueDate',
'isDueCompleted',
'stopwatch', 'stopwatch',
); );
} }
@@ -195,6 +200,7 @@ module.exports = {
'name', 'name',
'description', 'description',
'dueDate', 'dueDate',
'isDueCompleted',
'stopwatch', 'stopwatch',
'isSubscribed', 'isSubscribed',
]); ]);

View File

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

View File

@@ -25,6 +25,14 @@ module.exports = {
async fn(inputs) { async fn(inputs) {
const { values } = inputs; const { values } = inputs;
if (values.dueDate) {
if (_.isNil(values.isDueCompleted)) {
values.isDueCompleted = false;
}
} else {
delete values.isDueCompleted;
}
if (sails.helpers.lists.isFinite(values.list)) { if (sails.helpers.lists.isFinite(values.list)) {
if (_.isUndefined(values.position)) { if (_.isUndefined(values.position)) {
throw 'positionMustBeInValues'; throw 'positionMustBeInValues';

View File

@@ -90,6 +90,7 @@ module.exports = {
'name', 'name',
'description', 'description',
'dueDate', 'dueDate',
'isDueCompleted',
'stopwatch', 'stopwatch',
'isClosed', 'isClosed',
]), ]),

View File

@@ -101,6 +101,20 @@ module.exports = {
values.coverAttachmentId = values.coverAttachment.id; values.coverAttachmentId = values.coverAttachment.id;
} }
const dueDate = _.isUndefined(values.dueDate) ? inputs.record.dueDate : values.dueDate;
if (dueDate) {
const isDueCompleted = _.isUndefined(values.isDueCompleted)
? inputs.record.isDueCompleted
: values.isDueCompleted;
if (_.isNull(isDueCompleted)) {
values.isDueCompleted = false;
}
} else {
values.isDueCompleted = null;
}
let card; let card;
if (_.isEmpty(values)) { if (_.isEmpty(values)) {
card = inputs.record; card = inputs.record;

View File

@@ -45,6 +45,11 @@ module.exports = {
type: 'ref', type: 'ref',
columnName: 'due_date', columnName: 'due_date',
}, },
isDueCompleted: {
type: 'boolean',
allowNull: true,
columnName: 'is_due_completed',
},
stopwatch: { stopwatch: {
type: 'json', type: 'json',
}, },

View File

@@ -0,0 +1,23 @@
/*!
* 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_due_completed');
});
return knex('card')
.update({
isDueCompleted: false,
})
.whereNotNull('due_date');
};
exports.down = (knex) =>
knex.schema.table('card', (table) => {
table.dropColumn('is_due_completed');
});