feat: automatically create subtask relations based on indention (#2443)
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2443 Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
commit
ec227a6872
3 changed files with 199 additions and 9 deletions
|
@ -41,18 +41,19 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, unref, computed} from 'vue'
|
||||
import {computed, ref, unref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {tryOnMounted, debouncedWatch, useWindowSize, type MaybeRef} from '@vueuse/core'
|
||||
import {debouncedWatch, type MaybeRef, tryOnMounted, useWindowSize} from '@vueuse/core'
|
||||
|
||||
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
|
||||
import TaskRelationService from '@/services/taskRelation'
|
||||
import TaskRelationModel from '@/models/taskRelation'
|
||||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
function cleanupTitle(title: string) {
|
||||
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
|
||||
}
|
||||
|
||||
function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLInputElement>()
|
||||
const minHeight = ref(0)
|
||||
|
@ -161,8 +162,9 @@ async function addTask() {
|
|||
}
|
||||
|
||||
const taskTitleBackup = newTaskTitle.value
|
||||
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
|
||||
const title = cleanupTitle(uncleanedTitle)
|
||||
const createdTasks: ITask[] = []
|
||||
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value)
|
||||
const newTasks = tasksToCreate.map(async ({title}) => {
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
|
@ -172,13 +174,44 @@ async function addTask() {
|
|||
listId: authStore.settings.defaultListId,
|
||||
position: props.defaultPosition,
|
||||
})
|
||||
emit('taskAdded', task)
|
||||
createdTasks.push(task)
|
||||
return task
|
||||
})
|
||||
|
||||
try {
|
||||
newTaskTitle.value = ''
|
||||
await Promise.all(newTasks)
|
||||
|
||||
const taskRelationService = new TaskRelationService()
|
||||
const relations = tasksToCreate.map(async t => {
|
||||
const createdTask = createdTasks.find(ct => ct.title === t.title)
|
||||
if (typeof createdTask === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (t.parent === null) {
|
||||
emit('taskAdded', createdTask)
|
||||
return
|
||||
}
|
||||
|
||||
const createdParentTask = createdTasks.find(ct => ct.title === t.parent)
|
||||
if (typeof createdTask === 'undefined' || typeof createdParentTask === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const rel = await taskRelationService.create(new TaskRelationModel({
|
||||
taskId: createdTask.id,
|
||||
otherTaskId: createdParentTask.id,
|
||||
relationKind: RELATION_KIND.PARENTTASK,
|
||||
}))
|
||||
|
||||
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
|
||||
// we're only emitting here so that the relation shows up in the task list
|
||||
emit('taskAdded', createdTask)
|
||||
|
||||
return rel
|
||||
})
|
||||
await Promise.all(relations)
|
||||
} catch (e: any) {
|
||||
newTaskTitle.value = taskTitleBackup
|
||||
if (e?.message === 'NO_LIST') {
|
||||
|
|
109
src/helpers/parseSubtasksViaIndention.test.ts
Normal file
109
src/helpers/parseSubtasksViaIndention.test.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
|
||||
|
||||
describe('Parse Subtasks via Relation', () => {
|
||||
it('Should not return a parent for a single task', () => {
|
||||
const tasks = parseSubtasksViaIndention('single task')
|
||||
|
||||
expect(tasks).to.have.length(1)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
})
|
||||
it('Should not return a parent for multiple tasks without indention', () => {
|
||||
const tasks = parseSubtasksViaIndention(`task one
|
||||
task two`)
|
||||
|
||||
expect(tasks).to.have.length(2)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[1].parent).toBeNull()
|
||||
})
|
||||
it('Should return a parent for two tasks with indention', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task`)
|
||||
|
||||
expect(tasks).to.have.length(2)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
})
|
||||
it('Should return a parent for multiple subtasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task one
|
||||
sub task two`)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub task two')
|
||||
expect(tasks[2].parent).to.eq('parent task')
|
||||
})
|
||||
it('Should work with multiple indention levels', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task
|
||||
sub sub task`)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub sub task')
|
||||
expect(tasks[2].parent).to.eq('sub task')
|
||||
})
|
||||
it('Should work with multiple indention levels and multiple tasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task
|
||||
sub sub task one
|
||||
sub sub task two`)
|
||||
|
||||
expect(tasks).to.have.length(4)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub sub task one')
|
||||
expect(tasks[2].parent).to.eq('sub task')
|
||||
expect(tasks[3].title).to.eq('sub sub task two')
|
||||
expect(tasks[3].parent).to.eq('sub task')
|
||||
})
|
||||
it('Should work with multiple indention levels and multiple tasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task
|
||||
sub sub task one
|
||||
sub sub sub task
|
||||
sub sub task two`)
|
||||
|
||||
expect(tasks).to.have.length(5)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub sub task one')
|
||||
expect(tasks[2].parent).to.eq('sub task')
|
||||
expect(tasks[3].title).to.eq('sub sub sub task')
|
||||
expect(tasks[3].parent).to.eq('sub sub task one')
|
||||
expect(tasks[4].title).to.eq('sub sub task two')
|
||||
expect(tasks[4].parent).to.eq('sub task')
|
||||
})
|
||||
it('Should return a parent for multiple subtasks with special stuff', () => {
|
||||
const tasks = parseSubtasksViaIndention(`* parent task
|
||||
* sub task one
|
||||
sub task two`)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub task two')
|
||||
expect(tasks[2].parent).to.eq('parent task')
|
||||
})
|
||||
it('Should not break when the first line is indented', () => {
|
||||
const tasks = parseSubtasksViaIndention(' single task')
|
||||
|
||||
expect(tasks).to.have.length(1)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
})
|
||||
})
|
48
src/helpers/parseSubtasksViaIndention.ts
Normal file
48
src/helpers/parseSubtasksViaIndention.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
export interface TaskWithParent {
|
||||
title: string,
|
||||
parent: string | null,
|
||||
}
|
||||
|
||||
function cleanupTitle(title: string) {
|
||||
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
|
||||
}
|
||||
|
||||
const spaceRegex = /^ */
|
||||
|
||||
/**
|
||||
* @param taskTitles should be multiple lines of task tiles with indention to declare their parent/subtask
|
||||
* relation between each other.
|
||||
*/
|
||||
export function parseSubtasksViaIndention(taskTitles: string): TaskWithParent[] {
|
||||
const titles = taskTitles.split(/[\r\n]+/)
|
||||
|
||||
return titles.map((title, index) => {
|
||||
const task: TaskWithParent = {
|
||||
title: cleanupTitle(title),
|
||||
parent: null,
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return task
|
||||
}
|
||||
|
||||
const matched = spaceRegex.exec(title)
|
||||
const matchedSpaces = matched ? matched[0].length : 0
|
||||
|
||||
if (matchedSpaces > 0) {
|
||||
// Go up the tree to find the first task with less indention than the current one
|
||||
let pi = 1
|
||||
let parentSpaces = 0
|
||||
do {
|
||||
task.parent = cleanupTitle(titles[index - pi])
|
||||
pi++
|
||||
const parentMatched = spaceRegex.exec(task.parent)
|
||||
parentSpaces = parentMatched ? parentMatched[0].length : 0
|
||||
} while (parentSpaces >= matchedSpaces)
|
||||
task.title = cleanupTitle(title.replace(spaceRegex, ''))
|
||||
task.parent = task.parent.replace(spaceRegex, '')
|
||||
}
|
||||
|
||||
return task
|
||||
})
|
||||
}
|
Loading…
Reference in a new issue