feat: render list view with collapsible bucket sections

This commit is contained in:
kolaente 2026-04-12 18:30:53 +02:00
parent 46676289e0
commit f4883ef7c4
1 changed files with 529 additions and 58 deletions

View File

@ -22,7 +22,7 @@
<template #default>
<div
:class="{ 'is-loading': loading }"
:class="{ 'is-loading': hasBuckets ? bucketStore.isLoading : loading }"
class="loader-container is-max-width-desktop list-view"
>
<Card
@ -30,69 +30,252 @@
: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"
/>
<!-- Sectioned mode (list view with manual bucket configuration) -->
<template v-if="hasBuckets">
<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()"
<div
v-for="bucket in buckets"
:key="bucket.id"
class="bucket-section"
>
{{ $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"
:can-mark-as-done="canWrite || isPseudoProject"
:the-task="t"
:all-tasks="allTasks"
@taskUpdated="updateTasks"
<div
class="bucket-section__header"
@click="toggleBucketCollapse(bucket.id)"
>
<span
v-if="canDragTasks && isPositionSorting"
class="icon handle"
class="icon bucket-section__collapse-icon"
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
>
<Icon icon="grip-lines" />
<Icon icon="chevron-down" />
</span>
</SingleTaskInProject>
</template>
</draggable>
<h2
class="bucket-section__title"
:contenteditable="(canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
:spellcheck="false"
@keydown.enter.prevent.stop="!$event.isComposing && ($event.target as HTMLElement).blur()"
@keydown.esc.prevent.stop="!$event.isComposing && ($event.target as HTMLElement).blur()"
@blur="saveBucketTitle(bucket.id, ($event.target as HTMLElement).textContent as string)"
@click.stop
>
{{ bucket.title }}
</h2>
<span
v-if="bucket.limit > 0 || bucket.count > 0"
:class="{'is-max': bucket.limit > 0 && bucket.count >= bucket.limit}"
class="bucket-section__count"
>
{{ bucket.limit > 0 ? `${bucket.count}/${bucket.limit}` : bucket.count }}
</span>
<Dropdown
v-if="canWrite && !collapsedBuckets[bucket.id]"
class="is-right bucket-section__options"
trigger-icon="ellipsis-v"
@click.stop
>
<DropdownItem
v-tooltip="$t('project.kanban.defaultBucketHint')"
:icon-class="{'has-text-primary': bucket.id === currentView?.defaultBucketId}"
icon="th"
@click.stop="toggleDefaultBucket(bucket)"
>
{{ $t('project.kanban.defaultBucket') }}
</DropdownItem>
<DropdownItem
icon="angles-up"
@click.stop="toggleBucketCollapse(bucket.id)"
>
{{ $t('project.list.collapseSection') }}
</DropdownItem>
<DropdownItem
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
class="has-text-danger"
:class="{'is-disabled': buckets.length <= 1}"
icon-class="has-text-danger"
icon="trash-alt"
@click.stop="() => deleteBucketModal(bucket.id)"
>
{{ $t('misc.delete') }}
</DropdownItem>
</Dropdown>
</div>
<Pagination
:total-pages="totalPages"
:current-page="currentPage"
/>
<draggable
v-if="!collapsedBuckets[bucket.id]"
:model-value="bucket.tasks"
group="tasks"
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="(e) => saveTaskPositionInBucket(e, bucket)"
>
<template #item="{element: t}">
<SingleTaskInProject
:show-list-color="false"
:can-mark-as-done="canWrite || isPseudoProject"
:the-task="t"
:all-tasks="getAllTasksFromBuckets()"
@taskUpdated="updateTaskInBuckets"
>
<span
v-if="canDragTasks && isPositionSorting"
class="icon handle"
>
<Icon icon="grip-lines" />
</span>
</SingleTaskInProject>
</template>
</draggable>
<!-- Load more -->
<div
v-if="!collapsedBuckets[bucket.id] && bucket.tasks.length < bucket.count"
class="bucket-section__load-more"
>
<ButtonLink @click="loadMoreForBucket(bucket.id)">
{{ $t('project.list.loadMore', {remaining: bucket.count - bucket.tasks.length}) }}
</ButtonLink>
</div>
</div>
<!-- Add section button -->
<div
v-if="canWrite"
class="bucket-section__add"
>
<div
v-if="showNewSectionInput"
class="field has-addons bucket-section__add-input"
>
<div class="control is-expanded">
<input
v-model="newSectionTitle"
v-focus.always
class="input"
:placeholder="$t('project.list.addSectionPlaceholder')"
type="text"
@keyup.enter="createNewSection"
@keyup.esc="showNewSectionInput = false"
>
</div>
<div class="control">
<XButton
:shadow="false"
@click="createNewSection"
>
{{ $t('project.list.add') }}
</XButton>
</div>
</div>
<ButtonLink
v-else
@click="showNewSectionInput = true"
>
<Icon icon="plus" />
{{ $t('project.list.addSection') }}
</ButtonLink>
</div>
<!-- Delete section confirmation modal -->
<Modal
v-if="showSectionDeleteModal"
@close="showSectionDeleteModal = false"
@submit="deleteSection"
>
<template #header>
{{ $t('project.list.deleteSection') }}
</template>
<template #text>
<p>{{ $t('project.list.deleteSectionText1') }}</p>
<p>{{ $t('project.list.deleteSectionText2') }}</p>
</template>
</Modal>
</template>
<!-- Flat mode (existing) -->
<template v-else>
<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"
: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"
/>
</template>
</Card>
</div>
</template>
@ -102,6 +285,7 @@
<script setup lang="ts">
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch, toRef} from 'vue'
import {useI18n} from 'vue-i18n'
import draggable from 'zhyswan-vuedraggable'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
@ -112,22 +296,39 @@ 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 Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue'
import Modal from '@/components/misc/Modal.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 CollapsedBuckets,
getCollapsedBucketState,
saveCollapsedBucketState,
} from '@/helpers/saveCollapsedBucketState'
import type {ITask} from '@/modelTypes/ITask'
import type {IBucket} from '@/modelTypes/IBucket'
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useBucketStore} from '@/stores/buckets'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
import TaskBucketService from '@/services/taskBucket'
import TaskBucketModel from '@/models/taskBucket'
import BucketModel from '@/models/bucket'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
const props = defineProps<{
isLoadingProject: boolean,
@ -139,6 +340,8 @@ const projectId = toRef(props, 'projectId')
defineOptions({name: 'List'})
const {t} = useI18n({useScope: 'global'})
const ctaVisible = ref(false)
const drag = ref(false)
@ -177,6 +380,14 @@ watch(
const isPositionSorting = computed(() => 'position' in sortByParam.value)
const firstNewPosition = computed(() => {
if (hasBuckets.value) {
const defaultBucket = buckets.value.find(b => b.id === currentView.value?.defaultBucketId) || buckets.value[0]
if (defaultBucket?.tasks?.length > 0) {
return calculateItemPosition(null, defaultBucket.tasks[0].position)
}
return 0
}
if (tasks.value.length === 0) {
return 0
}
@ -186,6 +397,8 @@ const firstNewPosition = computed(() => {
const baseStore = useBaseStore()
const taskStore = useTaskStore()
const bucketStore = useBucketStore()
const projectStore = useProjectStore()
const {handleTaskDropToProject} = useTaskDragToProject()
const project = computed(() => baseStore.currentProject)
@ -214,7 +427,184 @@ function focusNewTaskInput() {
addTaskRef.value?.focusTaskInput()
}
// ==========================================
// Bucket/Section mode
// ==========================================
const currentView = computed(() => {
return project.value?.views?.find(v => v.id === props.viewId) as IProjectView || null
})
const hasBuckets = computed(() => {
return currentView.value?.bucketConfigurationMode !== 'none'
&& currentView.value?.bucketConfigurationMode !== undefined
})
const buckets = computed(() => bucketStore.buckets)
const collapsedBuckets = ref<CollapsedBuckets>({})
watch(
[() => props.projectId, () => props.viewId, hasBuckets],
async ([pId, vId, bucketed]) => {
if (!bucketed) return
await bucketStore.loadBucketsForProject(pId, vId, params.value)
collapsedBuckets.value = getCollapsedBucketState(pId)
},
{immediate: true},
)
function toggleBucketCollapse(bucketId: IBucket['id']) {
collapsedBuckets.value = {
...collapsedBuckets.value,
[bucketId]: !collapsedBuckets.value[bucketId],
}
saveCollapsedBucketState(props.projectId, collapsedBuckets.value)
}
function getAllTasksFromBuckets(): ITask[] {
return buckets.value.flatMap(b => b.tasks)
}
function updateTaskInBuckets(updatedTask: ITask) {
bucketStore.setTaskInBucket(updatedTask)
}
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
const bucket = bucketStore.getBucketById(bucketId)
if (bucket?.title === bucketTitle) {
return
}
await bucketStore.updateBucket({
id: bucketId,
title: bucketTitle,
projectId: projectId.value,
})
success({message: t('project.list.sectionTitleSavedSuccess')})
}
const sectionToDelete = ref<IBucket['id']>(0)
const showSectionDeleteModal = ref(false)
function deleteBucketModal(bucketId: IBucket['id']) {
if (buckets.value.length <= 1) {
return
}
sectionToDelete.value = bucketId
showSectionDeleteModal.value = true
}
async function deleteSection() {
try {
await bucketStore.deleteBucket({
bucket: new BucketModel({
id: sectionToDelete.value,
projectId: projectId.value,
projectViewId: props.viewId,
}),
params: params.value,
})
success({message: t('project.list.deleteSectionSuccess')})
} finally {
showSectionDeleteModal.value = false
}
}
const newSectionTitle = ref('')
const showNewSectionInput = ref(false)
async function createNewSection() {
if (newSectionTitle.value === '') {
return
}
await bucketStore.createBucket(new BucketModel({
title: newSectionTitle.value,
projectId: projectId.value,
projectViewId: props.viewId,
}))
newSectionTitle.value = ''
showNewSectionInput.value = false
}
async function toggleDefaultBucket(bucket: IBucket) {
const defaultBucketId = currentView.value?.defaultBucketId === bucket.id
? 0
: bucket.id
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...currentView.value,
defaultBucketId,
}))
const views = project.value.views.map(v => v.id === currentView.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.defaultBucketSavedSuccess')})
}
async function loadMoreForBucket(bucketId: IBucket['id']) {
await bucketStore.loadNextTasksForBucket(
props.projectId,
props.viewId,
params.value,
bucketId,
)
}
async function saveTaskPositionInBucket(
e: {originalEvent?: MouseEvent, to: HTMLElement, from: HTMLElement, newIndex: number, oldIndex: number},
targetBucket: IBucket,
) {
drag.value = false
const task = targetBucket.tasks[e.newIndex]
if (!task) return
const taskBefore = targetBucket.tasks[e.newIndex - 1] ?? null
const taskAfter = targetBucket.tasks[e.newIndex + 1] ?? null
const position = calculateItemPosition(
taskBefore?.position ?? null,
taskAfter?.position ?? null,
)
await taskPositionService.value.update(new TaskPositionModel({
position,
projectViewId: props.viewId,
taskId: task.id,
}))
// If bucket changed, update bucket assignment
if (e.to !== e.from) {
const taskBucketService = new TaskBucketService()
await taskBucketService.update(new TaskBucketModel({
taskId: task.id,
bucketId: targetBucket.id,
projectViewId: props.viewId,
}))
}
}
// ==========================================
// Flat mode (existing logic)
// ==========================================
function updateTaskList(task: ITask) {
if (hasBuckets.value) {
bucketStore.addTaskToBucket(task)
baseStore.setHasTasks(true)
return
}
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
@ -246,7 +636,8 @@ 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 allAvailableTasks = hasBuckets.value ? getAllTasksFromBuckets() : tasks.value
const task = allAvailableTasks.find(t => t.id === taskId)
if (task) {
taskStore.setDraggedTask(task)
@ -421,4 +812,84 @@ onBeforeUnmount(() => {
margin-block-end: 0;
}
}
// Bucket section styles
.bucket-section {
&:not(:first-child) {
border-block-start: 1px solid var(--grey-200);
}
}
.bucket-section__header {
display: flex;
align-items: center;
gap: .5rem;
padding: .75rem 1rem;
cursor: pointer;
user-select: none;
background: var(--grey-50);
border-block-end: 1px solid var(--grey-100);
&:hover {
background: var(--grey-100);
}
}
.bucket-section__collapse-icon {
transition: transform 150ms ease;
color: var(--grey-500);
&.is-collapsed {
transform: rotate(-90deg);
}
}
.bucket-section__title {
flex: 1;
font-size: 1rem;
font-weight: 600;
margin: 0;
padding: .125rem .25rem;
border-radius: $radius;
min-inline-size: 0;
&[contenteditable='true'] {
cursor: text;
&:focus {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
}
}
.bucket-section__count {
font-size: .85rem;
color: var(--grey-500);
font-weight: 500;
&.is-max {
color: var(--danger);
}
}
.bucket-section__options {
margin-inline-start: auto;
}
.bucket-section__load-more {
padding: .5rem 1rem;
text-align: center;
color: var(--grey-500);
}
.bucket-section__add {
padding: 1rem;
text-align: center;
}
.bucket-section__add-input {
max-inline-size: 400px;
margin-inline: auto;
}
</style>