1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 05:09:43 +02:00

feat: Colorize DueDate badge and add toggleable completion flag.

Mark overdue dates with red colour, less than 24h with yellow and completed with green. In Card edit modal, DueDate widget now allows toggling completion flag (checkbox).
This commit is contained in:
Arkadiusz Dzięgiel 2024-08-12 08:12:42 +02:00
parent f84166406f
commit 1bf25474d0
12 changed files with 200 additions and 38 deletions

View file

@ -24,6 +24,7 @@ const Card = React.memo(
index,
name,
dueDate,
dueCompleted,
stopwatch,
coverUrl,
boardId,
@ -81,6 +82,15 @@ const Card = React.memo(
[onUpdate],
);
const handleDueDateCompletionUpdate = useCallback(
(dueDateCompleted) => {
onUpdate({
dueDateCompleted,
});
},
[onUpdate],
);
const handleNameEdit = useCallback(() => {
nameEdit.current.open();
}, []);
@ -120,7 +130,12 @@ const Card = React.memo(
)}
{dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDate value={dueDate} size="tiny" />
<DueDate
value={dueDate}
completed={dueCompleted}
size="tiny"
onUpdateCompletion={handleDueDateCompletionUpdate}
/>
</span>
)}
{stopwatch && (
@ -221,6 +236,7 @@ Card.propTypes = {
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
dueDate: PropTypes.instanceOf(Date),
dueCompleted: PropTypes.bool.isRequired,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
coverUrl: PropTypes.string,
boardId: PropTypes.string.isRequired,

View file

@ -32,6 +32,7 @@ const CardModal = React.memo(
name,
description,
dueDate,
dueCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,
@ -171,6 +172,15 @@ const CardModal = React.memo(
onClose();
}, [onClose]);
const handleDueDateCompletion = useCallback(
(completion) => {
onUpdate({
dueCompleted: completion,
});
},
[onUpdate],
);
const AttachmentAddPopup = usePopup(AttachmentAddStep);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
@ -303,10 +313,14 @@ const CardModal = React.memo(
<span className={styles.attachment}>
{canEdit ? (
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
<DueDate value={dueDate} />
<DueDate
value={dueDate}
completed={dueCompleted}
onUpdateCompletion={handleDueDateCompletion}
/>
</DueDateEditPopup>
) : (
<DueDate value={dueDate} />
<DueDate value={dueDate} completed={dueCompleted} />
)}
</span>
</div>
@ -562,6 +576,7 @@ CardModal.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
dueCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired,
isActivitiesFetching: PropTypes.bool.isRequired,
@ -616,6 +631,7 @@ CardModal.propTypes = {
CardModal.defaultProps = {
description: undefined,
dueDate: undefined,
dueCompleted: false,
stopwatch: undefined,
};

View file

@ -1,8 +1,9 @@
import upperFirst from 'lodash/upperFirst';
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Checkbox } from 'semantic-ui-react';
import getDateFormat from '../../utils/get-date-format';
@ -26,50 +27,96 @@ const FULL_DATE_FORMAT_BY_SIZE = {
medium: 'fullDateTime',
};
const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
const [t] = useTranslation();
const getDueClass = (value) => {
const now = new Date();
const tomorrow = new Date(now).setDate(now.getDate() + 1);
const dateFormat = getDateFormat(
value,
LONG_DATE_FORMAT_BY_SIZE[size],
FULL_DATE_FORMAT_BY_SIZE[size],
);
if (now > value) return styles.overdue;
if (tomorrow > value) return styles.soon;
return null;
};
const contentNode = (
<span
className={classNames(
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
onClick && styles.wrapperHoverable,
)}
>
{t(`format:${dateFormat}`, {
value,
postProcess: 'formatDate',
})}
</span>
);
const DueDate = React.memo(
({ value, completed, size, isDisabled, onClick, onUpdateCompletion }) => {
const [t] = useTranslation();
return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode}
</button>
) : (
contentNode
);
});
const dateFormat = getDateFormat(
value,
LONG_DATE_FORMAT_BY_SIZE[size],
FULL_DATE_FORMAT_BY_SIZE[size],
);
const classes = [
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
onClick && styles.wrapperHoverable,
completed ? styles.completed : getDueClass(value),
];
const handleToggleChange = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
if (!isDisabled) onUpdateCompletion(!completed);
},
[onUpdateCompletion, completed, isDisabled],
);
return onClick ? (
<div className={styles.wrapperGroup}>
<button
type="button"
aria-label="Toggle completion"
className={classNames(...classes, styles.wrapperCheckbox)}
onClick={handleToggleChange}
>
<Checkbox
className={styles.checkbox}
checked={completed}
disabled={isDisabled}
onChange={handleToggleChange}
/>
</button>
<button
type="button"
disabled={isDisabled}
className={classNames(...classes, styles.wrapperButton)}
onClick={onClick}
>
<span>
{t(`format:${dateFormat}`, {
value,
postProcess: 'formatDate',
})}
</span>
</button>
</div>
) : (
<span className={classNames(...classes)}>
{t(`format:${dateFormat}`, {
value,
postProcess: 'formatDate',
})}
</span>
);
},
);
DueDate.propTypes = {
value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(SIZES)),
isDisabled: PropTypes.bool,
completed: PropTypes.bool,
onClick: PropTypes.func,
onUpdateCompletion: PropTypes.func,
};
DueDate.defaultProps = {
size: SIZES.MEDIUM,
isDisabled: false,
completed: false,
onClick: undefined,
onUpdateCompletion: undefined,
};
export default DueDate;

View file

@ -25,22 +25,75 @@
color: #17394d;
}
.wrapperGroup {
display: flex;
align-items: stretch;
justify-content: stretch;
}
.overdue {
background: #db2828;
color: #ffffff;
&.wrapperHoverable:hover {
background: #d01919;
color: #ffffff;
}
}
.soon {
background: #fbbd08;
color: #ffffff;
&.wrapperHoverable:hover {
background: #eaae00;
color: #ffffff;
}
}
.completed {
background: #21ba45;
color: #ffffff;
&.wrapperHoverable:hover {
background: #16ab39;
color: #ffffff;
}
}
/* Sizes */
.wrapperTiny {
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
&.wrapperCheckbox {
padding-right: 6px;
}
}
.wrapperSmall {
font-size: 12px;
line-height: 20px;
padding: 2px 6px;
&.wrapperCheckbox {
padding-right: 6px;
}
}
.wrapperMedium {
line-height: 20px;
padding: 6px 12px;
}
.wrapperCheckbox {
padding-right: 12px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
cursor: pointer;
}
.checkbox {
display: block;
}
.wrapperButton {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}

View file

@ -20,10 +20,8 @@ const makeMapStateToProps = () => {
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const { name, dueDate, stopwatch, coverUrl, boardId, listId, isPersisted } = selectCardById(
state,
id,
);
const { name, dueDate, dueCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
selectCardById(state, id);
const users = selectUsersByCardId(state, id);
const labels = selectLabelsByCardId(state, id);
@ -38,6 +36,7 @@ const makeMapStateToProps = () => {
index,
name,
dueDate,
dueCompleted,
stopwatch,
coverUrl,
boardId,

View file

@ -21,6 +21,7 @@ const mapStateToProps = (state) => {
name,
description,
dueDate,
dueCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,
@ -49,6 +50,7 @@ const mapStateToProps = (state) => {
name,
description,
dueDate,
dueCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,

View file

@ -20,6 +20,9 @@ export default class extends BaseModel {
relatedName: 'ownCards',
}),
dueDate: attr(),
dueCompleted: attr({
getDefault: () => false,
}),
stopwatch: attr(),
isSubscribed: attr({
getDefault: () => false,
@ -248,6 +251,7 @@ export default class extends BaseModel {
'name',
'description',
'dueDate',
'dueCompleted',
'stopwatch',
]),
...payload.card,

View file

@ -57,6 +57,9 @@ module.exports = {
type: 'string',
custom: dueDateValidator,
},
dueCompleted: {
type: 'boolean',
},
stopwatch: {
type: 'json',
custom: stopwatchValidator,
@ -95,7 +98,7 @@ module.exports = {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const values = _.pick(inputs, ['position', 'name', 'description', 'dueDate', 'stopwatch']);
const values = _.pick(inputs, ['position', 'name', 'description', 'dueDate', 'dueCompleted', 'stopwatch']);
const card = await sails.helpers.cards.createOne
.with({

View file

@ -80,6 +80,9 @@ module.exports = {
custom: dueDateValidator,
allowNull: true,
},
dueCompleted: {
type: 'boolean',
},
stopwatch: {
type: 'json',
custom: stopwatchValidator,
@ -173,6 +176,7 @@ module.exports = {
'name',
'description',
'dueDate',
'dueCompleted',
'stopwatch',
'isSubscribed',
]);

View file

@ -77,6 +77,7 @@ module.exports = {
'name',
'description',
'dueDate',
'dueCompleted',
'stopwatch',
]),
...values,

View file

@ -28,6 +28,11 @@ module.exports = {
type: 'ref',
columnName: 'due_date',
},
dueCompleted: {
type: 'boolean',
defaultsTo: false,
columnName: 'due_completed',
},
stopwatch: {
type: 'json',
},

View file

@ -0,0 +1,12 @@
module.exports.up = async (knex) => knex.schema.table('card', (table) => {
/* Columns */
table.boolean('due_completed').notNullable().defaultTo(false);
});
module.exports.down = async (knex) => {
await knex.schema.table('card', (table) => {
table.dropColumn('due_completed');
});
};