mirror of
https://github.com/plankanban/planka.git
synced 2025-07-24 07:39:44 +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:
parent
f84166406f
commit
1bf25474d0
12 changed files with 200 additions and 38 deletions
|
@ -24,6 +24,7 @@ const Card = React.memo(
|
||||||
index,
|
index,
|
||||||
name,
|
name,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
dueCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
boardId,
|
boardId,
|
||||||
|
@ -81,6 +82,15 @@ const Card = React.memo(
|
||||||
[onUpdate],
|
[onUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDueDateCompletionUpdate = useCallback(
|
||||||
|
(dueDateCompleted) => {
|
||||||
|
onUpdate({
|
||||||
|
dueDateCompleted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
const handleNameEdit = useCallback(() => {
|
const handleNameEdit = useCallback(() => {
|
||||||
nameEdit.current.open();
|
nameEdit.current.open();
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -120,7 +130,12 @@ const Card = React.memo(
|
||||||
)}
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
||||||
<DueDate value={dueDate} size="tiny" />
|
<DueDate
|
||||||
|
value={dueDate}
|
||||||
|
completed={dueCompleted}
|
||||||
|
size="tiny"
|
||||||
|
onUpdateCompletion={handleDueDateCompletionUpdate}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{stopwatch && (
|
{stopwatch && (
|
||||||
|
@ -221,6 +236,7 @@ Card.propTypes = {
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
dueDate: PropTypes.instanceOf(Date),
|
dueDate: PropTypes.instanceOf(Date),
|
||||||
|
dueCompleted: PropTypes.bool.isRequired,
|
||||||
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||||
coverUrl: PropTypes.string,
|
coverUrl: PropTypes.string,
|
||||||
boardId: PropTypes.string.isRequired,
|
boardId: PropTypes.string.isRequired,
|
||||||
|
|
|
@ -32,6 +32,7 @@ const CardModal = React.memo(
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
dueCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
isActivitiesFetching,
|
isActivitiesFetching,
|
||||||
|
@ -171,6 +172,15 @@ const CardModal = React.memo(
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleDueDateCompletion = useCallback(
|
||||||
|
(completion) => {
|
||||||
|
onUpdate({
|
||||||
|
dueCompleted: completion,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
const AttachmentAddPopup = usePopup(AttachmentAddStep);
|
const AttachmentAddPopup = usePopup(AttachmentAddStep);
|
||||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||||
const LabelsPopup = usePopup(LabelsStep);
|
const LabelsPopup = usePopup(LabelsStep);
|
||||||
|
@ -303,10 +313,14 @@ const CardModal = React.memo(
|
||||||
<span className={styles.attachment}>
|
<span className={styles.attachment}>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
|
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
|
||||||
<DueDate value={dueDate} />
|
<DueDate
|
||||||
|
value={dueDate}
|
||||||
|
completed={dueCompleted}
|
||||||
|
onUpdateCompletion={handleDueDateCompletion}
|
||||||
|
/>
|
||||||
</DueDateEditPopup>
|
</DueDateEditPopup>
|
||||||
) : (
|
) : (
|
||||||
<DueDate value={dueDate} />
|
<DueDate value={dueDate} completed={dueCompleted} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -562,6 +576,7 @@ CardModal.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
description: PropTypes.string,
|
description: PropTypes.string,
|
||||||
dueDate: PropTypes.instanceOf(Date),
|
dueDate: PropTypes.instanceOf(Date),
|
||||||
|
dueCompleted: PropTypes.bool,
|
||||||
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||||
isSubscribed: PropTypes.bool.isRequired,
|
isSubscribed: PropTypes.bool.isRequired,
|
||||||
isActivitiesFetching: PropTypes.bool.isRequired,
|
isActivitiesFetching: PropTypes.bool.isRequired,
|
||||||
|
@ -616,6 +631,7 @@ CardModal.propTypes = {
|
||||||
CardModal.defaultProps = {
|
CardModal.defaultProps = {
|
||||||
description: undefined,
|
description: undefined,
|
||||||
dueDate: undefined,
|
dueDate: undefined,
|
||||||
|
dueCompleted: false,
|
||||||
stopwatch: undefined,
|
stopwatch: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import upperFirst from 'lodash/upperFirst';
|
import upperFirst from 'lodash/upperFirst';
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Checkbox } from 'semantic-ui-react';
|
||||||
|
|
||||||
import getDateFormat from '../../utils/get-date-format';
|
import getDateFormat from '../../utils/get-date-format';
|
||||||
|
|
||||||
|
@ -26,50 +27,96 @@ const FULL_DATE_FORMAT_BY_SIZE = {
|
||||||
medium: 'fullDateTime',
|
medium: 'fullDateTime',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
|
const getDueClass = (value) => {
|
||||||
const [t] = useTranslation();
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now).setDate(now.getDate() + 1);
|
||||||
|
|
||||||
const dateFormat = getDateFormat(
|
if (now > value) return styles.overdue;
|
||||||
value,
|
if (tomorrow > value) return styles.soon;
|
||||||
LONG_DATE_FORMAT_BY_SIZE[size],
|
return null;
|
||||||
FULL_DATE_FORMAT_BY_SIZE[size],
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const contentNode = (
|
const DueDate = React.memo(
|
||||||
<span
|
({ value, completed, size, isDisabled, onClick, onUpdateCompletion }) => {
|
||||||
className={classNames(
|
const [t] = useTranslation();
|
||||||
styles.wrapper,
|
|
||||||
styles[`wrapper${upperFirst(size)}`],
|
|
||||||
onClick && styles.wrapperHoverable,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`format:${dateFormat}`, {
|
|
||||||
value,
|
|
||||||
postProcess: 'formatDate',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return onClick ? (
|
const dateFormat = getDateFormat(
|
||||||
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
|
value,
|
||||||
{contentNode}
|
LONG_DATE_FORMAT_BY_SIZE[size],
|
||||||
</button>
|
FULL_DATE_FORMAT_BY_SIZE[size],
|
||||||
) : (
|
);
|
||||||
contentNode
|
|
||||||
);
|
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 = {
|
DueDate.propTypes = {
|
||||||
value: PropTypes.instanceOf(Date).isRequired,
|
value: PropTypes.instanceOf(Date).isRequired,
|
||||||
size: PropTypes.oneOf(Object.values(SIZES)),
|
size: PropTypes.oneOf(Object.values(SIZES)),
|
||||||
isDisabled: PropTypes.bool,
|
isDisabled: PropTypes.bool,
|
||||||
|
completed: PropTypes.bool,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
|
onUpdateCompletion: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
DueDate.defaultProps = {
|
DueDate.defaultProps = {
|
||||||
size: SIZES.MEDIUM,
|
size: SIZES.MEDIUM,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
completed: false,
|
||||||
onClick: undefined,
|
onClick: undefined,
|
||||||
|
onUpdateCompletion: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DueDate;
|
export default DueDate;
|
||||||
|
|
|
@ -25,22 +25,75 @@
|
||||||
color: #17394d;
|
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 */
|
/* Sizes */
|
||||||
|
|
||||||
.wrapperTiny {
|
.wrapperTiny {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
padding: 0px 6px;
|
padding: 0px 6px;
|
||||||
|
&.wrapperCheckbox {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapperSmall {
|
.wrapperSmall {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
&.wrapperCheckbox {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapperMedium {
|
.wrapperMedium {
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
padding: 6px 12px;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,10 +20,8 @@ const makeMapStateToProps = () => {
|
||||||
const allLabels = selectors.selectLabelsForCurrentBoard(state);
|
const allLabels = selectors.selectLabelsForCurrentBoard(state);
|
||||||
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||||
|
|
||||||
const { name, dueDate, stopwatch, coverUrl, boardId, listId, isPersisted } = selectCardById(
|
const { name, dueDate, dueCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
|
||||||
state,
|
selectCardById(state, id);
|
||||||
id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const users = selectUsersByCardId(state, id);
|
const users = selectUsersByCardId(state, id);
|
||||||
const labels = selectLabelsByCardId(state, id);
|
const labels = selectLabelsByCardId(state, id);
|
||||||
|
@ -38,6 +36,7 @@ const makeMapStateToProps = () => {
|
||||||
index,
|
index,
|
||||||
name,
|
name,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
dueCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
boardId,
|
boardId,
|
||||||
|
|
|
@ -21,6 +21,7 @@ const mapStateToProps = (state) => {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
dueCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
isActivitiesFetching,
|
isActivitiesFetching,
|
||||||
|
@ -49,6 +50,7 @@ const mapStateToProps = (state) => {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
dueCompleted,
|
||||||
stopwatch,
|
stopwatch,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
isActivitiesFetching,
|
isActivitiesFetching,
|
||||||
|
|
|
@ -20,6 +20,9 @@ export default class extends BaseModel {
|
||||||
relatedName: 'ownCards',
|
relatedName: 'ownCards',
|
||||||
}),
|
}),
|
||||||
dueDate: attr(),
|
dueDate: attr(),
|
||||||
|
dueCompleted: attr({
|
||||||
|
getDefault: () => false,
|
||||||
|
}),
|
||||||
stopwatch: attr(),
|
stopwatch: attr(),
|
||||||
isSubscribed: attr({
|
isSubscribed: attr({
|
||||||
getDefault: () => false,
|
getDefault: () => false,
|
||||||
|
@ -248,6 +251,7 @@ export default class extends BaseModel {
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'dueDate',
|
'dueDate',
|
||||||
|
'dueCompleted',
|
||||||
'stopwatch',
|
'stopwatch',
|
||||||
]),
|
]),
|
||||||
...payload.card,
|
...payload.card,
|
||||||
|
|
|
@ -57,6 +57,9 @@ module.exports = {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
custom: dueDateValidator,
|
custom: dueDateValidator,
|
||||||
},
|
},
|
||||||
|
dueCompleted: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
stopwatch: {
|
stopwatch: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
custom: stopwatchValidator,
|
custom: stopwatchValidator,
|
||||||
|
@ -95,7 +98,7 @@ module.exports = {
|
||||||
throw Errors.NOT_ENOUGH_RIGHTS;
|
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
|
const card = await sails.helpers.cards.createOne
|
||||||
.with({
|
.with({
|
||||||
|
|
|
@ -80,6 +80,9 @@ module.exports = {
|
||||||
custom: dueDateValidator,
|
custom: dueDateValidator,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
dueCompleted: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
stopwatch: {
|
stopwatch: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
custom: stopwatchValidator,
|
custom: stopwatchValidator,
|
||||||
|
@ -173,6 +176,7 @@ module.exports = {
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'dueDate',
|
'dueDate',
|
||||||
|
'dueCompleted',
|
||||||
'stopwatch',
|
'stopwatch',
|
||||||
'isSubscribed',
|
'isSubscribed',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -77,6 +77,7 @@ module.exports = {
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'dueDate',
|
'dueDate',
|
||||||
|
'dueCompleted',
|
||||||
'stopwatch',
|
'stopwatch',
|
||||||
]),
|
]),
|
||||||
...values,
|
...values,
|
||||||
|
|
|
@ -28,6 +28,11 @@ module.exports = {
|
||||||
type: 'ref',
|
type: 'ref',
|
||||||
columnName: 'due_date',
|
columnName: 'due_date',
|
||||||
},
|
},
|
||||||
|
dueCompleted: {
|
||||||
|
type: 'boolean',
|
||||||
|
defaultsTo: false,
|
||||||
|
columnName: 'due_completed',
|
||||||
|
},
|
||||||
stopwatch: {
|
stopwatch: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
},
|
},
|
||||||
|
|
12
server/db/migrations/20240812065305_add_due_completion.js.js
Normal file
12
server/db/migrations/20240812065305_add_due_completion.js.js
Normal 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');
|
||||||
|
});
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue