fix: Show tasks completion on document list items (#2342)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
74
app/components/CircularProgressBar.js
Normal file
74
app/components/CircularProgressBar.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// @flow
|
||||||
|
import React from "react";
|
||||||
|
import { useTheme } from "styled-components";
|
||||||
|
|
||||||
|
const cleanPercentage = (percentage) => {
|
||||||
|
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
|
||||||
|
const tooHigh = percentage > 100;
|
||||||
|
return tooLow ? 0 : tooHigh ? 100 : +percentage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Circle = ({
|
||||||
|
color,
|
||||||
|
percentage,
|
||||||
|
offset,
|
||||||
|
}: {
|
||||||
|
color: string,
|
||||||
|
percentage?: number,
|
||||||
|
offset: number,
|
||||||
|
}) => {
|
||||||
|
const radius = offset * 0.7;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
let strokePercentage;
|
||||||
|
if (percentage) {
|
||||||
|
// because the circle is so small, anything greater than 85% appears like 100%
|
||||||
|
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
|
||||||
|
strokePercentage = percentage
|
||||||
|
? ((100 - percentage) * circumference) / 100
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
r={radius}
|
||||||
|
cx={offset}
|
||||||
|
cy={offset}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokePercentage !== circumference ? color : ""}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={percentage ? strokePercentage : 0}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ transition: "stroke-dashoffset 0.6s ease 0s" }}
|
||||||
|
></circle>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CircularProgressBar = ({
|
||||||
|
percentage,
|
||||||
|
size = 16,
|
||||||
|
}: {
|
||||||
|
percentage: number,
|
||||||
|
size?: number,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
percentage = cleanPercentage(percentage);
|
||||||
|
const offset = Math.floor(size / 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<g transform={`rotate(-90 ${offset} ${offset})`}>
|
||||||
|
<Circle color={theme.progressBarBackground} offset={offset} />
|
||||||
|
{percentage > 0 && (
|
||||||
|
<Circle
|
||||||
|
color={theme.primary}
|
||||||
|
percentage={percentage}
|
||||||
|
offset={offset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CircularProgressBar;
|
@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||||
|
import DocumentTasks from "components/DocumentTasks";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import Time from "components/Time";
|
import Time from "components/Time";
|
||||||
import useCurrentUser from "hooks/useCurrentUser";
|
import useCurrentUser from "hooks/useCurrentUser";
|
||||||
@ -64,6 +65,8 @@ function DocumentMeta({
|
|||||||
deletedAt,
|
deletedAt,
|
||||||
isDraft,
|
isDraft,
|
||||||
lastViewedAt,
|
lastViewedAt,
|
||||||
|
isTasks,
|
||||||
|
isTemplate,
|
||||||
} = document;
|
} = document;
|
||||||
|
|
||||||
// Prevent meta information from displaying if updatedBy is not available.
|
// Prevent meta information from displaying if updatedBy is not available.
|
||||||
@ -114,6 +117,11 @@ function DocumentMeta({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nestedDocumentsCount = collection
|
||||||
|
? collection.getDocumentChildren(document.id).length
|
||||||
|
: 0;
|
||||||
|
const canShowProgressBar = isTasks && !isTemplate;
|
||||||
|
|
||||||
const timeSinceNow = () => {
|
const timeSinceNow = () => {
|
||||||
if (isDraft || !showLastViewed) {
|
if (isDraft || !showLastViewed) {
|
||||||
return null;
|
return null;
|
||||||
@ -133,10 +141,6 @@ function DocumentMeta({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nestedDocumentsCount = collection
|
|
||||||
? collection.getDocumentChildren(document.id).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||||
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}
|
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}
|
||||||
@ -156,6 +160,12 @@ function DocumentMeta({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{timeSinceNow()}
|
{timeSinceNow()}
|
||||||
|
{canShowProgressBar && (
|
||||||
|
<>
|
||||||
|
•
|
||||||
|
<DocumentTasks document={document} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
33
app/components/DocumentTasks.js
Normal file
33
app/components/DocumentTasks.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import CircularProgressBar from "components/CircularProgressBar";
|
||||||
|
import Document from "../models/Document";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
document: Document,
|
||||||
|
|};
|
||||||
|
|
||||||
|
function DocumentTasks({ document }: Props) {
|
||||||
|
const { tasks, tasksPercentage } = document;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { completed, total } = tasks;
|
||||||
|
const message =
|
||||||
|
completed === 0
|
||||||
|
? t(`{{ total }} tasks`, { total })
|
||||||
|
: completed === total
|
||||||
|
? t(`{{ completed }} tasks done`, { completed })
|
||||||
|
: t(`{{ completed }} of {{ total }} tasks`, {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CircularProgressBar percentage={tasksPercentage} />
|
||||||
|
{message}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocumentTasks;
|
@ -1,6 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { addDays, differenceInDays } from "date-fns";
|
import { addDays, differenceInDays } from "date-fns";
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
|
import { floor } from "lodash";
|
||||||
import { action, computed, observable, set } from "mobx";
|
import { action, computed, observable, set } from "mobx";
|
||||||
import parseTitle from "shared/utils/parseTitle";
|
import parseTitle from "shared/utils/parseTitle";
|
||||||
import unescape from "shared/utils/unescape";
|
import unescape from "shared/utils/unescape";
|
||||||
@ -43,6 +44,7 @@ export default class Document extends BaseModel {
|
|||||||
deletedAt: ?string;
|
deletedAt: ?string;
|
||||||
url: string;
|
url: string;
|
||||||
urlId: string;
|
urlId: string;
|
||||||
|
tasks: { completed: number, total: number };
|
||||||
revision: number;
|
revision: number;
|
||||||
|
|
||||||
constructor(fields: Object, store: DocumentsStore) {
|
constructor(fields: Object, store: DocumentsStore) {
|
||||||
@ -149,6 +151,20 @@ export default class Document extends BaseModel {
|
|||||||
get isFromTemplate(): boolean {
|
get isFromTemplate(): boolean {
|
||||||
return !!this.templateId;
|
return !!this.templateId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isTasks(): boolean {
|
||||||
|
return !!this.tasks.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get tasksPercentage(): number {
|
||||||
|
if (!this.isTasks) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return floor((this.tasks.completed / this.tasks.total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
share = async () => {
|
share = async () => {
|
||||||
return this.store.rootStore.shares.create({ documentId: this.id });
|
return this.store.rootStore.shares.create({ documentId: this.id });
|
||||||
|
@ -10,6 +10,7 @@ import { Prompt, Route, withRouter } from "react-router-dom";
|
|||||||
import type { RouterHistory, Match } from "react-router-dom";
|
import type { RouterHistory, Match } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import getTasks from "shared/utils/getTasks";
|
||||||
import AuthStore from "stores/AuthStore";
|
import AuthStore from "stores/AuthStore";
|
||||||
import ToastsStore from "stores/ToastsStore";
|
import ToastsStore from "stores/ToastsStore";
|
||||||
import UiStore from "stores/UiStore";
|
import UiStore from "stores/UiStore";
|
||||||
@ -223,6 +224,8 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.isPublishing = !!options.publish;
|
this.isPublishing = !!options.publish;
|
||||||
|
|
||||||
|
document.tasks = getTasks(document.text);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedDocument = await document.save({
|
const savedDocument = await document.save({
|
||||||
...options,
|
...options,
|
||||||
|
@ -6,6 +6,7 @@ import Sequelize, { Transaction } from "sequelize";
|
|||||||
import MarkdownSerializer from "slate-md-serializer";
|
import MarkdownSerializer from "slate-md-serializer";
|
||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { MAX_TITLE_LENGTH } from "../../shared/constants";
|
import { MAX_TITLE_LENGTH } from "../../shared/constants";
|
||||||
|
import getTasks from "../../shared/utils/getTasks";
|
||||||
import parseTitle from "../../shared/utils/parseTitle";
|
import parseTitle from "../../shared/utils/parseTitle";
|
||||||
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
||||||
import unescape from "../../shared/utils/unescape";
|
import unescape from "../../shared/utils/unescape";
|
||||||
@ -106,6 +107,9 @@ const Document = sequelize.define(
|
|||||||
const slugifiedTitle = slugify(this.title);
|
const slugifiedTitle = slugify(this.title);
|
||||||
return `/doc/${slugifiedTitle}-${this.urlId}`;
|
return `/doc/${slugifiedTitle}-${this.urlId}`;
|
||||||
},
|
},
|
||||||
|
tasks: function () {
|
||||||
|
return getTasks(this.text || "");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -430,3 +430,79 @@ describe("#findByPk", () => {
|
|||||||
expect(response.id).toBe(document.id);
|
expect(response.id).toBe(document.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tasks", () => {
|
||||||
|
test("should consider all the possible checkTtems", async () => {
|
||||||
|
const document = await buildDocument({
|
||||||
|
text: `- [x] test
|
||||||
|
- [X] test
|
||||||
|
- [ ] test
|
||||||
|
- [-] test
|
||||||
|
- [_] test`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = document.tasks;
|
||||||
|
|
||||||
|
expect(tasks.completed).toBe(4);
|
||||||
|
expect(tasks.total).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return tasks keys set to 0 if checkItems isn't present", async () => {
|
||||||
|
const document = await buildDocument({
|
||||||
|
text: `text`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = document.tasks;
|
||||||
|
|
||||||
|
expect(tasks.completed).toBe(0);
|
||||||
|
expect(tasks.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return tasks keys set to 0 if the text contains broken checkItems", async () => {
|
||||||
|
const document = await buildDocument({
|
||||||
|
text: `- [x ] test
|
||||||
|
- [ x ] test
|
||||||
|
- [ ] test`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = document.tasks;
|
||||||
|
|
||||||
|
expect(tasks.completed).toBe(0);
|
||||||
|
expect(tasks.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return tasks", async () => {
|
||||||
|
const document = await buildDocument({
|
||||||
|
text: `- [x] list item
|
||||||
|
- [ ] list item`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = document.tasks;
|
||||||
|
|
||||||
|
expect(tasks.completed).toBe(1);
|
||||||
|
expect(tasks.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update tasks on save", async () => {
|
||||||
|
const document = await buildDocument({
|
||||||
|
text: `- [x] list item
|
||||||
|
- [ ] list item`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = document.tasks;
|
||||||
|
|
||||||
|
expect(tasks.completed).toBe(1);
|
||||||
|
expect(tasks.total).toBe(2);
|
||||||
|
|
||||||
|
document.text = `- [x] list item
|
||||||
|
- [ ] list item
|
||||||
|
- [ ] list item`;
|
||||||
|
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
const newTasks = document.tasks;
|
||||||
|
|
||||||
|
expect(newTasks.completed).toBe(1);
|
||||||
|
expect(newTasks.total).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -44,6 +44,7 @@ export default async function present(document: Document, options: ?Options) {
|
|||||||
title: document.title,
|
title: document.title,
|
||||||
text,
|
text,
|
||||||
emoji: document.emoji,
|
emoji: document.emoji,
|
||||||
|
tasks: document.tasks,
|
||||||
createdAt: document.createdAt,
|
createdAt: document.createdAt,
|
||||||
createdBy: undefined,
|
createdBy: undefined,
|
||||||
updatedAt: document.updatedAt,
|
updatedAt: document.updatedAt,
|
||||||
|
@ -34,6 +34,9 @@
|
|||||||
"only you": "only you",
|
"only you": "only you",
|
||||||
"person": "person",
|
"person": "person",
|
||||||
"people": "people",
|
"people": "people",
|
||||||
|
"{{ total }} tasks": "{{ total }} tasks",
|
||||||
|
"{{ completed }} tasks done": "{{ completed }} tasks done",
|
||||||
|
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||||
"Currently editing": "Currently editing",
|
"Currently editing": "Currently editing",
|
||||||
"Currently viewing": "Currently viewing",
|
"Currently viewing": "Currently viewing",
|
||||||
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
|
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
|
||||||
|
@ -179,6 +179,7 @@ export const light = {
|
|||||||
|
|
||||||
noticeInfoBackground: colors.warmGrey,
|
noticeInfoBackground: colors.warmGrey,
|
||||||
noticeInfoText: colors.almostBlack,
|
noticeInfoText: colors.almostBlack,
|
||||||
|
progressBarBackground: colors.slateLight,
|
||||||
|
|
||||||
scrollbarBackground: colors.smoke,
|
scrollbarBackground: colors.smoke,
|
||||||
scrollbarThumb: darken(0.15, colors.smokeDark),
|
scrollbarThumb: darken(0.15, colors.smokeDark),
|
||||||
@ -241,6 +242,7 @@ export const dark = {
|
|||||||
|
|
||||||
noticeInfoBackground: colors.white10,
|
noticeInfoBackground: colors.white10,
|
||||||
noticeInfoText: colors.almostWhite,
|
noticeInfoText: colors.almostWhite,
|
||||||
|
progressBarBackground: colors.slate,
|
||||||
|
|
||||||
scrollbarBackground: colors.black,
|
scrollbarBackground: colors.black,
|
||||||
scrollbarThumb: colors.lightBlack,
|
scrollbarThumb: colors.lightBlack,
|
||||||
|
21
shared/utils/getTasks.js
Normal file
21
shared/utils/getTasks.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// @flow
|
||||||
|
|
||||||
|
const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/gi;
|
||||||
|
|
||||||
|
export default function getTasks(text: string) {
|
||||||
|
const matches = [...text.matchAll(CHECKBOX_REGEX)];
|
||||||
|
let total = matches.length;
|
||||||
|
if (!total) {
|
||||||
|
return {
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const notCompleted = matches.reduce(
|
||||||
|
(accumulator, match) =>
|
||||||
|
match[1] === " " ? accumulator + 1 : accumulator,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return { completed: total - notCompleted, total };
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user