Files
vikunja/frontend/src/components/project/views/ProjectList.vue

425 lines
10 KiB
Vue

<template>
<ProjectWrapper
class="project-list"
:is-loading-project="isLoadingProject"
:project-id="projectId"
:view-id
>
<template #header>
<div class="filter-container">
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
:view-id="viewId"
:project-id="projectId"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
<SortPopup
v-model="sortByParam"
/>
</div>
</template>
<template #default>
<div
:class="{ 'is-loading': loading }"
class="loader-container is-max-width-desktop list-view"
>
<Card
:padding="false"
:has-content="false"
class="has-overflow"
>
<AddTask
v-if="!project?.isArchived && canWrite"
ref="addTaskRef"
class="list-view__add-task d-print-none"
:default-position="firstNewPosition"
@taskAdded="updateTaskList"
/>
<Nothing v-if="ctaVisible && tasks.length === 0 && !loading">
{{ $t('project.list.empty') }}
<ButtonLink
v-if="project?.id > 0 && canWrite"
@click="focusNewTaskInput()"
>
{{ $t('project.list.newTaskCta') }}
</ButtonLink>
</Nothing>
<draggable
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"
: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"
:disabled="!canDragTasks || !isPositionSorting"
: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>
<Pagination
:total-pages="totalPages"
:current-page="currentPage"
/>
</Card>
</div>
</template>
</ProjectWrapper>
</template>
<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 FilterPopup from '@/components/project/partials/FilterPopup.vue'
import Nothing from '@/components/misc/Nothing.vue'
import Pagination from '@/components/misc/Pagination.vue'
import SortPopup from '@/components/project/partials/SortPopup.vue'
import {useTaskList} from '@/composables/useTaskList'
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
import {PERMISSIONS as Permissions} from '@/constants/permissions'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import type {ITask} from '@/modelTypes/ITask'
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
const props = defineProps<{
isLoadingProject: boolean,
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const projectId = toRef(props, 'projectId')
defineOptions({name: 'List'})
const ctaVisible = ref(false)
const drag = ref(false)
const {
tasks: allTasks,
loading,
totalPages,
currentPage,
loadTasks,
params,
sortByParam,
} = useTaskList(
() => projectId.value,
() => props.viewId,
{position: 'asc'},
() => projectId.value === -1
? ['comment_count', 'is_unread']
: ['subtasks', 'comment_count', 'is_unread'],
)
const taskPositionService = ref(new TaskPositionService())
// Saved filter composable for accessing filter data
const _savedFilter = useSavedFilter(() => isSavedFilter({id: projectId.value}) ? projectId.value : undefined).filter
const tasks = ref<ITask[]>([])
watch(
allTasks,
() => {
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value))
},
)
const isPositionSorting = computed(() => {
return Object.keys(sortByParam.value).length === 0 || (Object.keys(sortByParam.value).length === 1 && typeof sortByParam.value.position !== 'undefined')
})
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
return 0
}
return calculateItemPosition(null, tasks.value[0].position)
})
const baseStore = useBaseStore()
const taskStore = useTaskStore()
const {handleTaskDropToProject} = useTaskDragToProject()
const project = computed(() => baseStore.currentProject)
const canWrite = computed(() => {
return project.value?.maxPermission > Permissions.READ && project.value?.id > 0
})
const isPseudoProject = computed(() => (project.value && isSavedFilter(project.value)) || project.value?.id === -1)
onMounted(async () => {
await nextTick()
ctaVisible.value = true
})
const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value))
const isTouchDevice = ref(false)
if (typeof window !== 'undefined') {
isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches
}
const dragHandle = computed(() => isTouchDevice.value ? '.handle' : undefined)
const addTaskRef = ref<typeof AddTask | null>(null)
function focusNewTaskInput() {
addTaskRef.value?.focusTaskInput()
}
function updateTaskList(task: ITask) {
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
} else {
allTasks.value = [
task,
...allTasks.value,
]
}
baseStore.setHasTasks(true)
}
function updateTasks(updatedTask: ITask) {
if (projectId.value < 0) {
// Reload tasks to keep saved filter results in sync
loadTasks(false)
return
}
for (const t in tasks.value) {
if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask
break
}
}
}
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)
if (task) {
taskStore.setDraggedTask(task)
}
}
async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement, from: HTMLElement, newIndex: number }) {
drag.value = false
// Check if dropped on a sidebar project
const {moved} = await handleTaskDropToProject(e, (task) => {
tasks.value = tasks.value.filter(t => t.id !== task.id)
})
if (moved) {
return
}
// If dropped outside this list
if (e.to !== e.from) {
return
}
const task = tasks.value[e.newIndex]
const taskBefore = tasks.value[e.newIndex - 1] ?? null
const taskAfter = tasks.value[e.newIndex + 1] ?? null
const position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
await taskPositionService.value.update(new TaskPositionModel({
position,
projectViewId: props.viewId,
taskId: task.id,
}))
tasks.value[e.newIndex] = {
...task,
position,
}
}
function prepareFiltersAndLoadTasks() {
loadTasks()
}
const taskRefs = ref<(InstanceType<typeof SingleTaskInProject> | null)[]>([])
const focusedIndex = ref(-1)
function setTaskRef(el: InstanceType<typeof SingleTaskInProject> | null, index: number) {
if (el === null) {
delete taskRefs.value[index]
} else {
taskRefs.value[index] = el
}
}
function focusTask(index: number) {
if (index < 0 || index >= tasks.value.length) {
return
}
const taskRef = taskRefs.value[index]
focusedIndex.value = index
taskRef?.focus()
}
function handleListNavigation(e: KeyboardEvent) {
if (e.target instanceof HTMLElement && (e.target.closest('input, textarea, select, [contenteditable="true"]'))) {
return
}
if (e.key === 'j') {
e.preventDefault()
focusTask(Math.min(focusedIndex.value + 1, tasks.value.length - 1))
return
}
if (e.key === 'k') {
e.preventDefault()
if (focusedIndex.value === -1) {
focusTask(tasks.value.length - 1)
return
}
if (focusedIndex.value === 0) {
addTaskRef.value?.focusTaskInput()
focusedIndex.value = -1
return
}
focusTask(Math.max(focusedIndex.value - 1, 0))
return
}
if (e.key === 'Enter') {
if (e.isComposing) {
return
}
e.preventDefault()
taskRefs.value[focusedIndex.value]?.click(e)
}
}
onMounted(() => {
document.addEventListener('keydown', handleListNavigation)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleListNavigation)
})
</script>
<style lang="scss" scoped>
.filter-container {
display: flex;
align-items: center;
gap: .5rem;
}
.tasks {
padding: .5rem;
}
.task-ghost {
border-radius: $radius;
background: var(--grey-100);
border: 2px dashed var(--grey-300);
* {
opacity: 0;
}
}
.list-view__add-task {
padding: 1rem 1rem 0;
}
.link-share-view .card {
border: none;
box-shadow: none;
}
:deep(.single-task .handle) {
cursor: grab;
margin-inline-end: .25rem;
color: var(--grey-400);
}
@media (hover: hover) and (pointer: fine) {
:deep(.single-task .handle) {
display: none;
}
}
:deep(.tasks:not(.dragging-disabled) .single-task) {
cursor: grab;
-webkit-touch-callout: none;
user-select: none;
touch-action: manipulation;
&:active {
cursor: grabbing;
}
}
.list-view {
padding-block-end: 1rem;
:deep(.card) {
margin-block-end: 0;
}
}
</style>