feat: task checklist improvements (#797)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/797 Co-authored-by: konrad <k@knt.li> Co-committed-by: konrad <k@knt.li>
This commit is contained in:
parent
d47b13647e
commit
96523f1fbf
9 changed files with 232 additions and 37 deletions
|
@ -53,6 +53,7 @@ import hljs from 'highlight.js/lib/common'
|
||||||
|
|
||||||
import AttachmentModel from '../../models/attachment'
|
import AttachmentModel from '../../models/attachment'
|
||||||
import AttachmentService from '../../services/attachment'
|
import AttachmentService from '../../services/attachment'
|
||||||
|
import {findCheckboxesInText} from '../../helpers/checklistFromText'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'editor',
|
name: 'editor',
|
||||||
|
@ -303,36 +304,7 @@ export default {
|
||||||
return str.substr(0, index) + replacement + str.substr(index + replacement.length)
|
return str.substr(0, index) + replacement + str.substr(index + replacement.length)
|
||||||
},
|
},
|
||||||
findNthIndex(str, n) {
|
findNthIndex(str, n) {
|
||||||
|
const checkboxes = findCheckboxesInText(str)
|
||||||
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)
|
|
||||||
|
|
||||||
return checkboxes[n]
|
return checkboxes[n]
|
||||||
},
|
},
|
||||||
renderPreview() {
|
renderPreview() {
|
||||||
|
|
60
src/components/tasks/partials/checklist-summary.vue
Normal file
60
src/components/tasks/partials/checklist-summary.vue
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<span v-if="checklist.total > 0" class="checklist-summary">
|
||||||
|
<svg width="12" height="12">
|
||||||
|
<circle stroke-width="2" fill="transparent" cx="50%" cy="50%" r="5"></circle>
|
||||||
|
<circle stroke-width="2" stroke-dasharray="31" :stroke-dashoffset="checklistCircleDone"
|
||||||
|
stroke-linecap="round" fill="transparent" cx="50%" cy="50%" r="5"></circle>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{{ $t(checklist.total === checklist.checked ? 'task.checklistAllDone' : 'task.checklistTotal', checklist) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {getChecklistStatistics} from '../../../helpers/checklistFromText'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'checklist-summary',
|
||||||
|
props: {
|
||||||
|
task: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
checklist() {
|
||||||
|
return getChecklistStatistics(this.task.description)
|
||||||
|
},
|
||||||
|
checklistCircleDone() {
|
||||||
|
const r = 5
|
||||||
|
const c = Math.PI * (r * 2)
|
||||||
|
|
||||||
|
const progress = this.checklist.checked / this.checklist.total * 100
|
||||||
|
|
||||||
|
return ((100 - progress) / 100) * c
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checklist-summary {
|
||||||
|
color: $grey-500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transition: stroke-dashoffset 0.35s;
|
||||||
|
margin-right: .25rem;
|
||||||
|
|
||||||
|
circle {
|
||||||
|
stroke: $grey-400;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
stroke: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -59,6 +59,7 @@
|
||||||
<icon icon="align-left"/>
|
<icon icon="align-left"/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<checklist-summary :task="task"/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<progress
|
<progress
|
||||||
class="progress is-small"
|
class="progress is-small"
|
||||||
|
@ -94,6 +95,7 @@ import Fancycheckbox from '../../input/fancycheckbox'
|
||||||
import DeferTask from './defer-task'
|
import DeferTask from './defer-task'
|
||||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||||
import {playPop} from '@/helpers/playPop'
|
import {playPop} from '@/helpers/playPop'
|
||||||
|
import ChecklistSummary from './checklist-summary'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'singleTaskInList',
|
name: 'singleTaskInList',
|
||||||
|
@ -105,6 +107,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
ChecklistSummary,
|
||||||
DeferTask,
|
DeferTask,
|
||||||
Fancycheckbox,
|
Fancycheckbox,
|
||||||
User,
|
User,
|
||||||
|
|
91
src/helpers/checklistFromText.test.ts
Normal file
91
src/helpers/checklistFromText.test.ts
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
53
src/helpers/checklistFromText.ts
Normal file
53
src/helpers/checklistFromText.ts
Normal file
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -499,6 +499,8 @@
|
||||||
"doneSuccess": "The task was successfully marked as done.",
|
"doneSuccess": "The task was successfully marked as done.",
|
||||||
"undoneSuccess": "The task was successfully un-marked as done.",
|
"undoneSuccess": "The task was successfully un-marked as done.",
|
||||||
"openDetail": "Open task detail view",
|
"openDetail": "Open task detail view",
|
||||||
|
"checklistTotal": "{checked} of {total} tasks",
|
||||||
|
"checklistAllDone": "{total} tasks",
|
||||||
"show": {
|
"show": {
|
||||||
"titleCurrent": "Current Tasks",
|
"titleCurrent": "Current Tasks",
|
||||||
"titleDates": "Tasks from {from} until {to}",
|
"titleDates": "Tasks from {from} until {to}",
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: $grey-500;
|
color: $grey-500;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: $grey-800;
|
color: $grey-800;
|
||||||
|
@ -178,6 +179,10 @@
|
||||||
color: $grey-500;
|
color: $grey-500;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-summary {
|
||||||
|
margin-left: .25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-share-container:not(.has-background) .task-view {
|
.link-share-container:not(.has-background) .task-view {
|
||||||
|
|
|
@ -196,6 +196,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-summary {
|
||||||
|
padding-left: .5rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
margin: 0 0.5rem 0 0;
|
margin: 0 0.5rem 0 0;
|
||||||
|
|
|
@ -9,8 +9,10 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
|
<checklist-summary :task="task"/>
|
||||||
|
|
||||||
<!-- Content and buttons -->
|
<!-- Content and buttons -->
|
||||||
<div class="columns">
|
<div class="columns mt-2">
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div :class="{'is-two-thirds': canWrite}" class="column">
|
<div :class="{'is-two-thirds': canWrite}" class="column">
|
||||||
<div class="columns details">
|
<div class="columns details">
|
||||||
|
@ -445,10 +447,12 @@ import TaskSubscription from '@/components/misc/subscription.vue'
|
||||||
import {CURRENT_LIST} from '@/store/mutation-types'
|
import {CURRENT_LIST} from '@/store/mutation-types'
|
||||||
|
|
||||||
import {uploadFile} from '@/helpers/attachments'
|
import {uploadFile} from '@/helpers/attachments'
|
||||||
|
import ChecklistSummary from '../../components/tasks/partials/checklist-summary'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TaskDetailView',
|
name: 'TaskDetailView',
|
||||||
components: {
|
components: {
|
||||||
|
ChecklistSummary,
|
||||||
TaskSubscription,
|
TaskSubscription,
|
||||||
Datepicker,
|
Datepicker,
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
|
|
Loading…
Reference in a new issue