From 96523f1fbf5f6f9eee8b9ea481bcf24265e41292 Mon Sep 17 00:00:00 2001 From: konrad Date: Wed, 29 Sep 2021 18:31:14 +0000 Subject: [PATCH] feat: task checklist improvements (#797) Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/797 Co-authored-by: konrad Co-committed-by: konrad --- src/components/input/editor.vue | 36 +------- .../tasks/partials/checklist-summary.vue | 60 ++++++++++++ .../tasks/partials/singleTaskInList.vue | 11 ++- src/helpers/checklistFromText.test.ts | 91 +++++++++++++++++++ src/helpers/checklistFromText.ts | 53 +++++++++++ src/i18n/lang/en.json | 2 + src/styles/components/task.scss | 5 + src/styles/components/tasks.scss | 5 + src/views/tasks/TaskDetailView.vue | 6 +- 9 files changed, 232 insertions(+), 37 deletions(-) create mode 100644 src/components/tasks/partials/checklist-summary.vue create mode 100644 src/helpers/checklistFromText.test.ts create mode 100644 src/helpers/checklistFromText.ts diff --git a/src/components/input/editor.vue b/src/components/input/editor.vue index a82ef1217..4d142e323 100644 --- a/src/components/input/editor.vue +++ b/src/components/input/editor.vue @@ -53,6 +53,7 @@ import hljs from 'highlight.js/lib/common' import AttachmentModel from '../../models/attachment' import AttachmentService from '../../services/attachment' +import {findCheckboxesInText} from '../../helpers/checklistFromText' export default { name: 'editor', @@ -303,36 +304,7 @@ export default { return str.substr(0, index) + replacement + str.substr(index + replacement.length) }, findNthIndex(str, n) { - - const searchLength = 6 - const listPrefixes = ['*', '-'] - - let inChecked, inUnchecked, startIndex = 0 - // We're building an array with all checkboxes, checked or unchecked. - // I've found this to be the best way to always get the results I need. - // The difficulty without an index is that we need to get all checkboxes, checked and unchecked - // and calculate our index based off that to compare it and find the checkbox we need. - let checkboxes = [] - - // Searching in two different loops for each search term since that is way easier and more predicatble - // More "intelligent" solutions sometimes don't have all values or duplicates. - // Because we're sorting and removing duplicates of them, we can safely put everything in one giant array. - listPrefixes.forEach(pref => { - while ((inChecked = str.indexOf(`${pref} [x]`, startIndex)) > -1) { - checkboxes.push(inChecked) - startIndex = startIndex + searchLength - } - - startIndex = 0 - while ((inUnchecked = str.indexOf(`${pref} [ ]`, startIndex)) > -1) { - checkboxes.push(inUnchecked) - startIndex = startIndex + searchLength - } - }) - - checkboxes.sort((a, b) => a - b) - checkboxes = checkboxes.filter((v, i, s) => s.indexOf(v) === i && v > -1) - + const checkboxes = findCheckboxesInText(str) return checkboxes[n] }, renderPreview() { @@ -435,7 +407,7 @@ export default { console.debug(index, this.text.substr(index, 9)) const listPrefix = this.text.substr(index, 1) - + if (checked) { this.text = this.replaceAt(this.text, index, `${listPrefix} [x] `) } else { @@ -475,7 +447,7 @@ export default { input[type="checkbox"] { margin-right: .5rem; } - + &.has-checkbox { margin-left: -2em; list-style: none; diff --git a/src/components/tasks/partials/checklist-summary.vue b/src/components/tasks/partials/checklist-summary.vue new file mode 100644 index 000000000..85b6f85bb --- /dev/null +++ b/src/components/tasks/partials/checklist-summary.vue @@ -0,0 +1,60 @@ + + + + + \ No newline at end of file diff --git a/src/components/tasks/partials/singleTaskInList.vue b/src/components/tasks/partials/singleTaskInList.vue index 294e77afc..40ac483ab 100644 --- a/src/components/tasks/partials/singleTaskInList.vue +++ b/src/components/tasks/partials/singleTaskInList.vue @@ -59,6 +59,7 @@ + { this.task.done = !this.task.done diff --git a/src/helpers/checklistFromText.test.ts b/src/helpers/checklistFromText.test.ts new file mode 100644 index 000000000..3d9981396 --- /dev/null +++ b/src/helpers/checklistFromText.test.ts @@ -0,0 +1,91 @@ +import {findCheckboxesInText, getChecklistStatistics} from './checklistFromText' + +describe('Find checklists in text', () => { + it('should find no checkbox', () => { + const text: string = 'Lorem Ipsum' + const checkboxes = findCheckboxesInText(text) + + expect(checkboxes).toHaveLength(0) + }) + it('should find multiple checkboxes', () => { + const text: string = `* [ ] Lorem Ipsum +* [ ] Dolor sit amet + +Here's some text in between + +* [x] Dolor sit amet +- [ ] Dolor sit amet` + const checkboxes = findCheckboxesInText(text) + + expect(checkboxes).toHaveLength(4) + expect(checkboxes[0]).toBe(0) + expect(checkboxes[1]).toBe(18) + expect(checkboxes[2]).toBe(69) + }) + it('should find one checkbox with *', () => { + const text: string = '* [ ] Lorem Ipsum' + const checkboxes = findCheckboxesInText(text) + + expect(checkboxes).toHaveLength(1) + expect(checkboxes[0]).toBe(0) + }) + it('should find one checkbox with -', () => { + const text: string = '- [ ] Lorem Ipsum' + const checkboxes = findCheckboxesInText(text) + + expect(checkboxes).toHaveLength(1) + expect(checkboxes[0]).toBe(0) + }) + it('should find one checked checkbox with *', () => { + const text: string = '* [x] Lorem Ipsum' + const checkboxes = findCheckboxesInText(text) + + expect(checkboxes).toHaveLength(1) + expect(checkboxes[0]).toBe(0) + }) + it('should find one checked checkbox with -', () => { + const text: string = '- [x] Lorem Ipsum' + const checkboxes = findCheckboxesInText(text) + + expect(checkboxes).toHaveLength(1) + expect(checkboxes[0]).toBe(0) + }) +}) + +describe('Get Checklist Statistics in a Text', () => { + it('should find no checkbox', () => { + const text: string = 'Lorem Ipsum' + const stats = getChecklistStatistics(text) + + expect(stats.total).toBe(0) + }) + it('should find one checkbox', () => { + const text: string = '* [ ] Lorem Ipsum' + const stats = getChecklistStatistics(text) + + expect(stats.total).toBe(1) + expect(stats.checked).toBe(0) + }) + it('should find one checked checkbox', () => { + const text: string = '* [x] Lorem Ipsum' + const stats = getChecklistStatistics(text) + + expect(stats.total).toBe(1) + expect(stats.checked).toBe(1) + }) + it('should find multiple mixed and matched', () => { + const text: string = `* [ ] Lorem Ipsum +* [ ] Dolor sit amet +* [x] Dolor sit amet +- [x] Dolor sit amet + +Here's some text in between + +* [x] Dolor sit amet +- [ ] Dolor sit amet` + const stats = getChecklistStatistics(text) + + expect(stats.total).toBe(6) + expect(stats.checked).toBe(3) + }) +}) diff --git a/src/helpers/checklistFromText.ts b/src/helpers/checklistFromText.ts new file mode 100644 index 000000000..880c51670 --- /dev/null +++ b/src/helpers/checklistFromText.ts @@ -0,0 +1,53 @@ +const checked = '[x]' + +interface CheckboxStatistics { + total: number + checked: number +} + +interface MatchedCheckboxes { + checked: number[] + unchecked: number[] +} + +const getCheckboxesInText = (text: string): MatchedCheckboxes => { + const regex = /[*-] \[[ x]]/g + let match + const checkboxes: MatchedCheckboxes = { + checked: [], + unchecked: [], + } + + while ((match = regex.exec(text)) !== null) { + if (match[0].endsWith(checked)) { + checkboxes.checked.push(match.index) + } else { + checkboxes.unchecked.push(match.index) + } + } + + return checkboxes +} + +/** + * Returns the indices where checkboxes start and end in the given text. + * + * @param text + */ +export const findCheckboxesInText = (text: string): number[] => { + const checkboxes = getCheckboxesInText(text) + + return [ + ...checkboxes.checked, + ...checkboxes.unchecked, + ].sort() +} + +export const getChecklistStatistics = (text: string): CheckboxStatistics => { + const checkboxes = getCheckboxesInText(text) + + return { + total: checkboxes.checked.length + checkboxes.unchecked.length, + checked: checkboxes.checked.length, + } +} diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index aa1801b31..c4d1135fa 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -499,6 +499,8 @@ "doneSuccess": "The task was successfully marked as done.", "undoneSuccess": "The task was successfully un-marked as done.", "openDetail": "Open task detail view", + "checklistTotal": "{checked} of {total} tasks", + "checklistAllDone": "{total} tasks", "show": { "titleCurrent": "Current Tasks", "titleDates": "Tasks from {from} until {to}", diff --git a/src/styles/components/task.scss b/src/styles/components/task.scss index ecc81c59b..19d825f01 100644 --- a/src/styles/components/task.scss +++ b/src/styles/components/task.scss @@ -10,6 +10,7 @@ .subtitle { color: $grey-500; + margin-bottom: 1rem; a { color: $grey-800; @@ -178,6 +179,10 @@ color: $grey-500; text-align: right; } + + .checklist-summary { + margin-left: .25rem; + } } .link-share-container:not(.has-background) .task-view { diff --git a/src/styles/components/tasks.scss b/src/styles/components/tasks.scss index ceae69cdf..56829ec6a 100644 --- a/src/styles/components/tasks.scss +++ b/src/styles/components/tasks.scss @@ -195,6 +195,11 @@ border-bottom-color: $grey-300; } } + + .checklist-summary { + padding-left: .5rem; + font-size: .9rem; + } .progress { width: 50px; diff --git a/src/views/tasks/TaskDetailView.vue b/src/views/tasks/TaskDetailView.vue index a740a9510..653af154b 100644 --- a/src/views/tasks/TaskDetailView.vue +++ b/src/views/tasks/TaskDetailView.vue @@ -8,9 +8,11 @@ {{ getListTitle(parent.list) }} + + -
+
@@ -445,10 +447,12 @@ import TaskSubscription from '@/components/misc/subscription.vue' import {CURRENT_LIST} from '@/store/mutation-types' import {uploadFile} from '@/helpers/attachments' +import ChecklistSummary from '../../components/tasks/partials/checklist-summary' export default { name: 'TaskDetailView', components: { + ChecklistSummary, TaskSubscription, Datepicker, ColorPicker,