Merge 0b8e4b4b61 into 076cd214fe
This commit is contained in:
commit
6d897eccd8
|
|
@ -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"
|
||||
:transition-group="true"
|
||||
@dragStart="handleDragStart"
|
||||
@drop="saveTaskTreeDrop"
|
||||
@updateList="updateTaskTreeList"
|
||||
@taskUpdated="updateTasks"
|
||||
>
|
||||
<span
|
||||
v-if="canDragTasks && isPositionSorting"
|
||||
class="icon handle"
|
||||
>
|
||||
<Icon icon="grip-lines" />
|
||||
</span>
|
||||
</SingleTaskInProject>
|
||||
</template>
|
||||
</draggable>
|
||||
/>
|
||||
|
||||
<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) => {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -954,7 +954,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"
|
||||
<TaskTreeDraggable
|
||||
:tasks="displayedTasks"
|
||||
:all-tasks="allTasksWithSubtasks"
|
||||
:hierarchical="hierarchical"
|
||||
:can-mark-task-as-done="canMarkTaskAsDone"
|
||||
:disabled="!canDragTasks"
|
||||
:show-project="true"
|
||||
:the-task="task"
|
||||
:can-mark-as-done="(projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ"
|
||||
:dragging="drag"
|
||||
@taskUpdated="updateTasks"
|
||||
@dragStart="handleDragStart"
|
||||
@drop="handleTaskTreeDrop"
|
||||
@updateList="updateTaskTreeList"
|
||||
/>
|
||||
</div>
|
||||
</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