feat(hierarchical): Add option to view hierarchical presentation
Task and subbstask could be showed in hierachical manner. This modification allows to drag and drop tasks and subtask to add easily tasks relation
This commit is contained in:
parent
57a0b8fee4
commit
5e00d78ebd
|
|
@ -48,46 +48,25 @@
|
|||
</ButtonLink>
|
||||
</Nothing>
|
||||
|
||||
<draggable
|
||||
<TaskTreeDraggable
|
||||
v-if="tasks && tasks.length > 0"
|
||||
v-model="tasks"
|
||||
:group="{name: 'tasks', put: false}"
|
||||
:disabled="!canDragTasks || !isPositionSorting"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
:component-data="{
|
||||
class: {
|
||||
tasks: true,
|
||||
'dragging-disabled': !canDragTasks || !isPositionSorting
|
||||
},
|
||||
type: 'transition-group'
|
||||
}"
|
||||
:animation="100"
|
||||
:tasks="tasks"
|
||||
:all-tasks="allTasksWithSubtasks"
|
||||
:hierarchical="true"
|
||||
:disabled="!canDragTasks"
|
||||
:can-mark-task-as-done="canMarkTaskAsDone"
|
||||
:show-drag-handle="canDragTasks"
|
||||
:dragging="drag"
|
||||
:task-ref-setter="setTaskTreeRef"
|
||||
:handle="dragHandle"
|
||||
:delay-on-touch-only="!isTouchDevice"
|
||||
:delay="isTouchDevice ? 0 : 1000"
|
||||
ghost-class="task-ghost"
|
||||
@start="handleDragStart"
|
||||
@end="saveTaskPosition"
|
||||
>
|
||||
<template #item="{element: t, index}">
|
||||
<SingleTaskInProject
|
||||
:ref="(el) => setTaskRef(el, index)"
|
||||
:show-list-color="false"
|
||||
:can-mark-as-done="canWrite || isPseudoProject"
|
||||
:the-task="t"
|
||||
:all-tasks="allTasks"
|
||||
@taskUpdated="updateTasks"
|
||||
>
|
||||
<span
|
||||
v-if="canDragTasks && isPositionSorting"
|
||||
class="icon handle"
|
||||
>
|
||||
<Icon icon="grip-lines" />
|
||||
</span>
|
||||
</SingleTaskInProject>
|
||||
</template>
|
||||
</draggable>
|
||||
:transition-group="true"
|
||||
@dragStart="handleDragStart"
|
||||
@drop="saveTaskTreeDrop"
|
||||
@updateList="updateTaskTreeList"
|
||||
@taskUpdated="updateTasks"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
:total-pages="totalPages"
|
||||
|
|
@ -102,12 +81,15 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch, toRef} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
import AddTask from '@/components/tasks/AddTask.vue'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
|
||||
import TaskTreeDraggable, {
|
||||
type TaskTreeDropEvent,
|
||||
type TaskTreeListUpdateEvent,
|
||||
} from '@/components/tasks/partials/TaskTreeDraggable.vue'
|
||||
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
||||
import Nothing from '@/components/misc/Nothing.vue'
|
||||
import Pagination from '@/components/misc/Pagination.vue'
|
||||
|
|
@ -128,6 +110,10 @@ import type {IProject} from '@/modelTypes/IProject'
|
|||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import TaskPositionService from '@/services/taskPosition'
|
||||
import TaskPositionModel from '@/models/taskPosition'
|
||||
import TaskRelationService from '@/services/taskRelation'
|
||||
import TaskRelationModel from '@/models/taskRelation'
|
||||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
import {error} from '@/message'
|
||||
|
||||
const props = defineProps<{
|
||||
isLoadingProject: boolean,
|
||||
|
|
@ -161,6 +147,7 @@ const {
|
|||
)
|
||||
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
const taskRelationService = ref(new TaskRelationService())
|
||||
|
||||
// Saved filter composable for accessing filter data
|
||||
const _savedFilter = useSavedFilter(() => isSavedFilter({id: projectId.value}) ? projectId.value : undefined).filter
|
||||
|
|
@ -176,6 +163,24 @@ watch(
|
|||
|
||||
const isPositionSorting = computed(() => 'position' in sortByParam.value)
|
||||
|
||||
const allTasksWithSubtasks = computed((): ITask[] => {
|
||||
const map = new Map<number, ITask>()
|
||||
allTasks.value.forEach(t => addEmbeddedSubtasks(t, map))
|
||||
allTasks.value.forEach(t => map.set(t.id, t))
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
function addEmbeddedSubtasks(task: ITask, map: Map<number, ITask>) {
|
||||
(task.relatedTasks?.subtask ?? []).forEach(subtask => {
|
||||
if (map.has(subtask.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
map.set(subtask.id, subtask)
|
||||
addEmbeddedSubtasks(subtask, map)
|
||||
})
|
||||
}
|
||||
|
||||
const firstNewPosition = computed(() => {
|
||||
if (tasks.value.length === 0) {
|
||||
return 0
|
||||
|
|
@ -195,6 +200,10 @@ const canWrite = computed(() => {
|
|||
|
||||
const isPseudoProject = computed(() => (project.value && isSavedFilter(project.value)) || project.value?.id === -1)
|
||||
|
||||
function canMarkTaskAsDone() {
|
||||
return canWrite.value || Boolean(isPseudoProject.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
ctaVisible.value = true
|
||||
|
|
@ -246,44 +255,120 @@ function updateTasks(updatedTask: ITask) {
|
|||
function handleDragStart(e: { item: HTMLElement }) {
|
||||
drag.value = true
|
||||
const taskId = parseInt(e.item.dataset.taskId ?? '', 10)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
const task = findTaskById(taskId)
|
||||
|
||||
if (task) {
|
||||
taskStore.setDraggedTask(task)
|
||||
} else {
|
||||
taskStore.setDraggedTask(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement, from: HTMLElement, newIndex: number }) {
|
||||
drag.value = false
|
||||
function findTaskById(taskId: ITask['id'], taskList: ITask[] = allTasksWithSubtasks.value): ITask | undefined {
|
||||
for (const task of taskList) {
|
||||
if (task.id === taskId) {
|
||||
return task
|
||||
}
|
||||
|
||||
// Check if dropped on a sidebar project
|
||||
const {moved} = await handleTaskDropToProject(e, (task) => {
|
||||
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||
const found = findTaskById(taskId, task.relatedTasks?.subtask ?? [])
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTaskTreeList({parentTaskId, tasks: updatedTasks}: TaskTreeListUpdateEvent) {
|
||||
if (parentTaskId === null) {
|
||||
tasks.value = updatedTasks
|
||||
return
|
||||
}
|
||||
|
||||
const parent = findTaskById(parentTaskId)
|
||||
if (parent) {
|
||||
parent.relatedTasks.subtask = updatedTasks
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskTreeSiblings(parentTaskId: ITask['id'] | null): ITask[] {
|
||||
if (parentTaskId === null) {
|
||||
return tasks.value
|
||||
}
|
||||
|
||||
return findTaskById(parentTaskId)?.relatedTasks?.subtask ?? []
|
||||
}
|
||||
|
||||
function removeTaskFromTree(task: ITask) {
|
||||
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||
allTasksWithSubtasks.value.forEach(t => {
|
||||
if (typeof t.relatedTasks?.subtask !== 'undefined') {
|
||||
t.relatedTasks.subtask = t.relatedTasks.subtask.filter(subtask => subtask.id !== task.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
async function updateTaskParent(task: ITask, oldParentTaskId: ITask['id'] | null, newParentTaskId: ITask['id'] | null) {
|
||||
if (oldParentTaskId === newParentTaskId) {
|
||||
return
|
||||
}
|
||||
|
||||
// If dropped outside this list
|
||||
if (e.to !== e.from) {
|
||||
return
|
||||
if (oldParentTaskId !== null) {
|
||||
await taskRelationService.value.delete(new TaskRelationModel({
|
||||
taskId: oldParentTaskId,
|
||||
otherTaskId: task.id,
|
||||
relationKind: RELATION_KIND.SUBTASK,
|
||||
}))
|
||||
}
|
||||
|
||||
const task = tasks.value[e.newIndex]
|
||||
const taskBefore = tasks.value[e.newIndex - 1] ?? null
|
||||
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
||||
if (newParentTaskId !== null) {
|
||||
await taskRelationService.value.create(new TaskRelationModel({
|
||||
taskId: newParentTaskId,
|
||||
otherTaskId: task.id,
|
||||
relationKind: RELATION_KIND.SUBTASK,
|
||||
}))
|
||||
task.relatedTasks.parenttask = [findTaskById(newParentTaskId)].filter((task): task is ITask => Boolean(task))
|
||||
} else {
|
||||
task.relatedTasks.parenttask = []
|
||||
}
|
||||
}
|
||||
|
||||
const position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
|
||||
async function updateTaskPosition(task: ITask, siblings: ITask[], index: number) {
|
||||
const taskBefore = siblings[index - 1] ?? null
|
||||
const taskAfter = siblings[index + 1] ?? null
|
||||
const position = calculateItemPosition(taskBefore?.position ?? null, taskAfter?.position ?? null)
|
||||
|
||||
await taskPositionService.value.update(new TaskPositionModel({
|
||||
position,
|
||||
projectViewId: props.viewId,
|
||||
taskId: task.id,
|
||||
}))
|
||||
tasks.value[e.newIndex] = {
|
||||
...task,
|
||||
position,
|
||||
task.position = position
|
||||
}
|
||||
|
||||
async function saveTaskTreeDrop(e: TaskTreeDropEvent) {
|
||||
drag.value = false
|
||||
|
||||
// Check if dropped on a sidebar project
|
||||
const {moved} = await handleTaskDropToProject(e, removeTaskFromTree)
|
||||
|
||||
if (moved) {
|
||||
return
|
||||
}
|
||||
|
||||
const task = findTaskById(e.taskId)
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTaskParent(task, e.oldParentTaskId, e.newParentTaskId)
|
||||
await updateTaskPosition(task, getTaskTreeSiblings(e.newParentTaskId), e.newIndex)
|
||||
|
||||
if (!isPositionSorting.value) {
|
||||
sortByParam.value = {position: 'asc'}
|
||||
}
|
||||
} catch (e) {
|
||||
error(e)
|
||||
await loadTasks(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +383,17 @@ function setTaskRef(el: InstanceType<typeof SingleTaskInProject> | null, index:
|
|||
}
|
||||
}
|
||||
|
||||
function isSingleTaskComponent(el: unknown): el is InstanceType<typeof SingleTaskInProject> {
|
||||
return el !== null &&
|
||||
typeof el === 'object' &&
|
||||
'focus' in el &&
|
||||
'click' in el
|
||||
}
|
||||
|
||||
function setTaskTreeRef(el: unknown, index: number) {
|
||||
setTaskRef(isSingleTaskComponent(el) ? el : null, index)
|
||||
}
|
||||
|
||||
function focusTask(index: number) {
|
||||
if (index < 0 || index >= tasks.value.length) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
<template>
|
||||
<draggable
|
||||
:model-value="tasks"
|
||||
:group="{name: 'tasks', put: !disabled}"
|
||||
:disabled="disabled"
|
||||
:sort="!disabled"
|
||||
:item-key="(task: ITask) => task.id"
|
||||
tag="ul"
|
||||
:component-data="{
|
||||
class: {
|
||||
tasks: true,
|
||||
'task-tree': true,
|
||||
'task-tree--nested': nested,
|
||||
'task-tree--empty': dragging && tasks.length === 0,
|
||||
'dragging-disabled': disabled,
|
||||
},
|
||||
'data-parent-task-id': parentTaskId ?? '',
|
||||
type: transitionGroup ? 'transition-group' : undefined,
|
||||
}"
|
||||
:animation="100"
|
||||
:handle="handle"
|
||||
:delay-on-touch-only="delayOnTouchOnly"
|
||||
:delay="delay"
|
||||
ghost-class="task-ghost"
|
||||
:move="canMoveTask"
|
||||
@update:modelValue="updateList"
|
||||
@start="$emit('dragStart', $event)"
|
||||
@end="emitDrop"
|
||||
>
|
||||
<template #item="{element: task, index}">
|
||||
<div
|
||||
class="task-tree-item"
|
||||
:data-task-id="task.id"
|
||||
>
|
||||
<SingleTaskInProject
|
||||
:ref="(el) => taskRefSetter?.(el, index)"
|
||||
:show-list-color="showListColor"
|
||||
:show-project="showProject && !nested"
|
||||
:can-mark-as-done="canMarkTaskAsDone(task)"
|
||||
:the-task="task"
|
||||
:all-tasks="[]"
|
||||
@taskUpdated="$emit('taskUpdated', $event)"
|
||||
>
|
||||
<span
|
||||
v-if="showDragHandle && !disabled"
|
||||
class="icon handle"
|
||||
>
|
||||
<Icon icon="grip-lines" />
|
||||
</span>
|
||||
</SingleTaskInProject>
|
||||
|
||||
<TaskTreeDraggable
|
||||
v-if="hierarchical"
|
||||
:tasks="getSubtasks(task)"
|
||||
:all-tasks="allTasks"
|
||||
:hierarchical="hierarchical"
|
||||
:parent-task-id="task.id"
|
||||
:nested="true"
|
||||
:disabled="disabled"
|
||||
:show-project="showProject"
|
||||
:show-list-color="showListColor"
|
||||
:show-drag-handle="showDragHandle"
|
||||
:dragging="dragging"
|
||||
:handle="handle"
|
||||
:delay="delay"
|
||||
:delay-on-touch-only="delayOnTouchOnly"
|
||||
:can-mark-task-as-done="canMarkTaskAsDone"
|
||||
@taskUpdated="$emit('taskUpdated', $event)"
|
||||
@dragStart="$emit('dragStart', $event)"
|
||||
@drop="$emit('drop', $event)"
|
||||
@updateList="$emit('updateList', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
|
||||
import Icon from '@/components/misc/Icon'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tasks: ITask[],
|
||||
allTasks: ITask[],
|
||||
hierarchical: boolean,
|
||||
parentTaskId?: ITask['id'] | null,
|
||||
nested?: boolean,
|
||||
disabled?: boolean,
|
||||
showProject?: boolean,
|
||||
showListColor?: boolean,
|
||||
showDragHandle?: boolean,
|
||||
dragging?: boolean,
|
||||
handle?: string,
|
||||
delay?: number,
|
||||
delayOnTouchOnly?: boolean,
|
||||
transitionGroup?: boolean,
|
||||
canMarkTaskAsDone: (task: ITask) => boolean,
|
||||
taskRefSetter?: (el: unknown, index: number) => void,
|
||||
}>(), {
|
||||
parentTaskId: null,
|
||||
nested: false,
|
||||
disabled: false,
|
||||
showProject: false,
|
||||
showListColor: false,
|
||||
showDragHandle: false,
|
||||
dragging: false,
|
||||
handle: undefined,
|
||||
delay: 0,
|
||||
delayOnTouchOnly: false,
|
||||
transitionGroup: false,
|
||||
taskRefSetter: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
taskUpdated: [task: ITask],
|
||||
dragStart: [event: { item: HTMLElement }],
|
||||
drop: [event: TaskTreeDropEvent],
|
||||
updateList: [event: TaskTreeListUpdateEvent],
|
||||
}>()
|
||||
|
||||
defineOptions({name: 'TaskTreeDraggable'})
|
||||
|
||||
export interface TaskTreeDropEvent {
|
||||
taskId: ITask['id']
|
||||
oldParentTaskId: ITask['id'] | null
|
||||
newParentTaskId: ITask['id'] | null
|
||||
oldIndex: number
|
||||
newIndex: number
|
||||
originalEvent?: MouseEvent
|
||||
from: HTMLElement
|
||||
to: HTMLElement
|
||||
}
|
||||
|
||||
export interface TaskTreeListUpdateEvent {
|
||||
parentTaskId: ITask['id'] | null
|
||||
tasks: ITask[]
|
||||
}
|
||||
|
||||
function parseParentTaskId(element: HTMLElement): ITask['id'] | null {
|
||||
const value = element.dataset.parentTaskId
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
function getSubtasks(task: ITask): ITask[] {
|
||||
if (!props.hierarchical) {
|
||||
return []
|
||||
}
|
||||
|
||||
return (task.relatedTasks?.subtask ?? [])
|
||||
.map(subtask => props.allTasks.find(t => t.id === subtask.id) ?? subtask)
|
||||
}
|
||||
|
||||
function isDescendant(task: ITask, possibleDescendantId: ITask['id']): boolean {
|
||||
return getSubtasks(task).some(subtask => (
|
||||
subtask.id === possibleDescendantId || isDescendant(subtask, possibleDescendantId)
|
||||
))
|
||||
}
|
||||
|
||||
function canMoveTask(event: { draggedContext?: { element?: ITask }, to: HTMLElement }) {
|
||||
const draggedTask = event.draggedContext?.element
|
||||
if (!draggedTask) {
|
||||
return true
|
||||
}
|
||||
|
||||
const targetParentId = parseParentTaskId(event.to)
|
||||
return targetParentId === null ||
|
||||
(targetParentId !== draggedTask.id && !isDescendant(draggedTask, targetParentId))
|
||||
}
|
||||
|
||||
function updateList(updatedTasks: ITask[]) {
|
||||
emit('updateList', {
|
||||
parentTaskId: props.parentTaskId,
|
||||
tasks: updatedTasks,
|
||||
})
|
||||
}
|
||||
|
||||
function emitDrop(event: {
|
||||
item: HTMLElement,
|
||||
from: HTMLElement,
|
||||
to: HTMLElement,
|
||||
oldIndex: number,
|
||||
newIndex: number,
|
||||
originalEvent?: MouseEvent,
|
||||
}) {
|
||||
const taskId = parseInt(event.item.dataset.taskId ?? '', 10)
|
||||
if (Number.isNaN(taskId)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('drop', {
|
||||
taskId,
|
||||
oldParentTaskId: parseParentTaskId(event.from),
|
||||
newParentTaskId: parseParentTaskId(event.to),
|
||||
oldIndex: event.oldIndex,
|
||||
newIndex: event.newIndex,
|
||||
originalEvent: event.originalEvent,
|
||||
from: event.from,
|
||||
to: event.to,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-tree {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.task-tree--nested {
|
||||
margin-inline-start: 1.75rem;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.task-tree--empty {
|
||||
min-block-size: .5rem;
|
||||
padding-block: .25rem;
|
||||
}
|
||||
|
||||
.task-ghost {
|
||||
border-radius: $radius;
|
||||
background: var(--grey-100);
|
||||
border: 2px dashed var(--grey-300);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -23,10 +23,7 @@ function findProjectIdAtPosition(mouseX: number, mouseY: number): number | null
|
|||
continue
|
||||
}
|
||||
|
||||
const withProjectId =
|
||||
el.dataset?.projectId != null
|
||||
? el
|
||||
: el.closest('[data-project-id]') as HTMLElement | null
|
||||
const withProjectId = el.closest('.menu-list [data-project-id]') as HTMLElement | null
|
||||
|
||||
const projectId = withProjectId?.dataset.projectId
|
||||
if (projectId) {
|
||||
|
|
|
|||
|
|
@ -942,7 +942,8 @@
|
|||
"noTasks": "Nothing to do — Have a nice day!",
|
||||
"filterByLabel": "Filtering by label {label}",
|
||||
"clearLabelFilter": "Clear label filter",
|
||||
"savedFilterIgnored": "Your saved homepage filter is not applied while viewing tasks by label."
|
||||
"savedFilterIgnored": "Your saved homepage filter is not applied while viewing tasks by label.",
|
||||
"hierarchical": "Hierarchical view"
|
||||
},
|
||||
"detail": {
|
||||
"chooseDueDate": "Click here to set a due date",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@
|
|||
{{ $t('task.show.overdue') }}
|
||||
</FancyCheckbox>
|
||||
</p>
|
||||
<p class="show-tasks-options">
|
||||
<FancyCheckbox
|
||||
v-model="hierarchical"
|
||||
@update:modelValue="saveHierarchical"
|
||||
>
|
||||
{{ $t('task.show.hierarchical') }}
|
||||
</FancyCheckbox>
|
||||
</p>
|
||||
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
|
||||
<h3 class="has-text-centered mbs-6">
|
||||
{{ $t('task.show.noTasks') }}
|
||||
|
|
@ -81,16 +89,19 @@
|
|||
:has-content="false"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="p-2">
|
||||
<SingleTaskInProject
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:show-project="true"
|
||||
:the-task="task"
|
||||
:can-mark-as-done="(projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ"
|
||||
@taskUpdated="updateTasks"
|
||||
/>
|
||||
</div>
|
||||
<TaskTreeDraggable
|
||||
:tasks="displayedTasks"
|
||||
:all-tasks="allTasksWithSubtasks"
|
||||
:hierarchical="hierarchical"
|
||||
:can-mark-task-as-done="canMarkTaskAsDone"
|
||||
:disabled="!canDragTasks"
|
||||
:show-project="true"
|
||||
:dragging="drag"
|
||||
@taskUpdated="updateTasks"
|
||||
@dragStart="handleDragStart"
|
||||
@drop="handleTaskTreeDrop"
|
||||
@updateList="updateTaskTreeList"
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
v-else
|
||||
|
|
@ -112,12 +123,16 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
import Icon from '@/components/misc/Icon'
|
||||
import Message from '@/components/misc/Message.vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
|
||||
import DatepickerWithRange from '@/components/date/DatepickerWithRange.vue'
|
||||
import XLabel from '@/components/tasks/partials/Label.vue'
|
||||
import TaskTreeDraggable, {
|
||||
type TaskTreeDropEvent,
|
||||
type TaskTreeListUpdateEvent,
|
||||
} from '@/components/tasks/partials/TaskTreeDraggable.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import LlamaCool from '@/assets/llama-cool.svg?component'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
|
@ -125,6 +140,14 @@ import {useLabelStore} from '@/stores/labels'
|
|||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import {PERMISSIONS} from '@/constants/permissions'
|
||||
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import TaskPositionService from '@/services/taskPosition'
|
||||
import TaskPositionModel from '@/models/taskPosition'
|
||||
import TaskRelationService from '@/services/taskRelation'
|
||||
import TaskRelationModel from '@/models/taskRelation'
|
||||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
import {error} from '@/message'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
dateFrom?: Date | string,
|
||||
|
|
@ -149,6 +172,7 @@ const authStore = useAuthStore()
|
|||
const taskStore = useTaskStore()
|
||||
const projectStore = useProjectStore()
|
||||
const labelStore = useLabelStore()
|
||||
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -157,6 +181,10 @@ const {t} = useI18n({useScope: 'global'})
|
|||
const tasks = ref<ITask[]>([])
|
||||
const showNothingToDo = ref<boolean>(false)
|
||||
const taskCollectionService = ref(new TaskCollectionService())
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
const taskRelationService = ref(new TaskRelationService())
|
||||
const hierarchical = ref(localStorage.getItem('showTasksHierarchical') === 'true')
|
||||
const drag = ref(false)
|
||||
|
||||
setTimeout(() => showNothingToDo.value = true, 100)
|
||||
|
||||
|
|
@ -194,9 +222,47 @@ const pageTitle = computed(() => {
|
|||
})
|
||||
})
|
||||
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
|
||||
|
||||
// Build a flat list including embedded subtasks for hierarchical rendering.
|
||||
// Tasks from tasks.value take priority over embedded stubs (full data wins).
|
||||
const allTasksWithSubtasks = computed((): ITask[] => {
|
||||
if (!hierarchical.value) return tasks.value
|
||||
const map = new Map<number, ITask>()
|
||||
tasks.value.forEach(t => addEmbeddedSubtasks(t, map))
|
||||
tasks.value.forEach(t => map.set(t.id, t))
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
function addEmbeddedSubtasks(task: ITask, map: Map<number, ITask>) {
|
||||
(task.relatedTasks?.subtask ?? []).forEach(subtask => {
|
||||
if (map.has(subtask.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
map.set(subtask.id, subtask)
|
||||
addEmbeddedSubtasks(subtask, map)
|
||||
})
|
||||
}
|
||||
|
||||
// Top-level tasks: a task is hidden if any of its parents exists in the full task tree.
|
||||
// relatedTasks.parenttask is always populated by the API for subtasks.
|
||||
// We check against allTasksWithSubtasks (not just tasks.value) to cover all levels.
|
||||
const displayedTasks = computed((): ITask[] => {
|
||||
if (!hierarchical.value) return tasks.value
|
||||
const allTaskIds = new Set(allTasksWithSubtasks.value.map(t => t.id))
|
||||
return tasks.value.filter(t => {
|
||||
const parentIds = (t.relatedTasks?.parenttask ?? []).map((p: ITask) => p.id)
|
||||
return parentIds.length === 0 || !parentIds.some(pid => allTaskIds.has(pid))
|
||||
})
|
||||
})
|
||||
const userAuthenticated = computed(() => authStore.authenticated)
|
||||
const loading = computed(() => taskStore.isLoading || taskCollectionService.value.loading)
|
||||
const filterIdUsedOnOverview = computed(() => authStore.settings?.frontendSettings?.filterIdUsedOnOverview)
|
||||
const canDragTasks = computed(() => tasks.value.some(canMarkTaskAsDone))
|
||||
|
||||
function canMarkTaskAsDone(task: ITask) {
|
||||
return (projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ
|
||||
}
|
||||
|
||||
interface dateStrings {
|
||||
dateFrom: string,
|
||||
|
|
@ -235,10 +301,141 @@ function setShowNulls(show: boolean) {
|
|||
})
|
||||
}
|
||||
|
||||
function saveHierarchical(value: boolean) {
|
||||
localStorage.setItem('showTasksHierarchical', String(value))
|
||||
}
|
||||
|
||||
function clearLabelFilter() {
|
||||
emit('clearLabelFilter')
|
||||
}
|
||||
|
||||
function handleDragStart(e: { item: HTMLElement }) {
|
||||
drag.value = true
|
||||
const taskId = parseInt(e.item.dataset.taskId ?? '', 10)
|
||||
const task = allTasksWithSubtasks.value.find(t => t.id === taskId)
|
||||
|
||||
if (task) {
|
||||
taskStore.setDraggedTask(task)
|
||||
} else {
|
||||
taskStore.setDraggedTask(null)
|
||||
drag.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeTaskFromOverview(task: ITask) {
|
||||
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||
tasks.value.forEach(t => {
|
||||
if (typeof t.relatedTasks?.subtask !== 'undefined') {
|
||||
t.relatedTasks.subtask = t.relatedTasks.subtask.filter(subtask => subtask.id !== task.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function findTaskById(taskId: ITask['id'], taskList: ITask[] = allTasksWithSubtasks.value): ITask | undefined {
|
||||
for (const task of taskList) {
|
||||
if (task.id === taskId) {
|
||||
return task
|
||||
}
|
||||
|
||||
const found = findTaskById(taskId, task.relatedTasks?.subtask ?? [])
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTaskTreeList({parentTaskId, tasks: updatedTasks}: TaskTreeListUpdateEvent) {
|
||||
if (parentTaskId === null) {
|
||||
const updatedTaskIds = new Set(updatedTasks.map(({id}) => id))
|
||||
tasks.value = [
|
||||
...updatedTasks,
|
||||
...tasks.value.filter(({id}) => !updatedTaskIds.has(id)),
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
const parent = findTaskById(parentTaskId)
|
||||
if (parent) {
|
||||
parent.relatedTasks.subtask = updatedTasks
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskTreeSiblings(parentTaskId: ITask['id'] | null): ITask[] {
|
||||
if (parentTaskId === null) {
|
||||
return displayedTasks.value
|
||||
}
|
||||
|
||||
return findTaskById(parentTaskId)?.relatedTasks?.subtask ?? []
|
||||
}
|
||||
|
||||
function resolveListViewId(task: ITask) {
|
||||
return projectStore.projects[task.projectId]?.views.find(({viewKind}) => viewKind === PROJECT_VIEW_KINDS.LIST)?.id
|
||||
}
|
||||
|
||||
async function updateTaskPosition(task: ITask, siblings: ITask[], index: number) {
|
||||
const projectViewId = resolveListViewId(task)
|
||||
if (typeof projectViewId === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const taskBefore = siblings[index - 1] ?? null
|
||||
const taskAfter = siblings[index + 1] ?? null
|
||||
const position = calculateItemPosition(taskBefore?.position ?? null, taskAfter?.position ?? null)
|
||||
|
||||
await taskPositionService.value.update(new TaskPositionModel({
|
||||
position,
|
||||
projectViewId,
|
||||
taskId: task.id,
|
||||
}))
|
||||
task.position = position
|
||||
}
|
||||
|
||||
async function updateTaskParent(task: ITask, oldParentTaskId: ITask['id'] | null, newParentTaskId: ITask['id'] | null) {
|
||||
if (oldParentTaskId === newParentTaskId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (oldParentTaskId !== null) {
|
||||
await taskRelationService.value.delete(new TaskRelationModel({
|
||||
taskId: oldParentTaskId,
|
||||
otherTaskId: task.id,
|
||||
relationKind: RELATION_KIND.SUBTASK,
|
||||
}))
|
||||
}
|
||||
|
||||
if (newParentTaskId !== null) {
|
||||
await taskRelationService.value.create(new TaskRelationModel({
|
||||
taskId: newParentTaskId,
|
||||
otherTaskId: task.id,
|
||||
relationKind: RELATION_KIND.SUBTASK,
|
||||
}))
|
||||
task.relatedTasks.parenttask = [findTaskById(newParentTaskId)].filter((task): task is ITask => Boolean(task))
|
||||
} else {
|
||||
task.relatedTasks.parenttask = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskTreeDrop(e: TaskTreeDropEvent) {
|
||||
drag.value = false
|
||||
const {moved} = await handleTaskDropToProject(e, removeTaskFromOverview)
|
||||
if (moved) {
|
||||
return
|
||||
}
|
||||
|
||||
const task = findTaskById(e.taskId)
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTaskParent(task, e.oldParentTaskId, e.newParentTaskId)
|
||||
await updateTaskPosition(task, getTaskTreeSiblings(e.newParentTaskId), e.newIndex)
|
||||
} catch (e) {
|
||||
error(e)
|
||||
await loadPendingTasks(props.dateFrom, props.dateTo, filterIdUsedOnOverview.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingTasks(from: Date|string, to: Date|string, filterId: number | null | undefined) {
|
||||
// FIXME: HACK! This should never happen.
|
||||
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
|
||||
|
|
@ -254,7 +451,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string, filterId: nu
|
|||
filter: 'done = false',
|
||||
filter_include_nulls: props.showNulls,
|
||||
s: '',
|
||||
expand: ['comment_count', 'is_unread'],
|
||||
expand: ['subtasks', 'comment_count', 'is_unread'],
|
||||
}
|
||||
|
||||
if (!showAll.value) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue