feat(task): allow changing bucket from task detail view

Show the current kanban bucket in the task detail subtitle after the
project name. Clicking it opens a dropdown to move the task to a
different bucket without leaving the detail view.

Closes #2167
This commit is contained in:
Lars de Ridder 2026-02-13 22:52:32 +01:00
parent 74aec78701
commit 9e1f97da80
5 changed files with 217 additions and 1 deletions

View File

@ -0,0 +1,206 @@
<template>
<div
v-for="kanbanView in kanbanViews"
:key="kanbanView.id"
class="bucket-select"
>
<span class="has-text-grey-light"> &gt; </span>
<template v-if="canWrite">
<Dropdown>
<template #trigger="{toggleOpen}">
<BaseButton
class="bucket-name"
@click="toggleOpen"
>
<span
v-if="kanbanViews.length > 1"
class="view-title"
>{{ kanbanView.title }}:</span>
{{ currentBucketTitle(kanbanView) }}
<Icon
icon="pencil-alt"
class="change-indicator"
/>
</BaseButton>
</template>
<DropdownItem
v-for="bucket in viewBuckets[kanbanView.id] || []"
:key="bucket.id"
:class="{'is-active': isCurrentBucket(kanbanView, bucket)}"
@click="changeBucket(kanbanView, bucket)"
>
{{ bucket.title }}
</DropdownItem>
</Dropdown>
</template>
<span
v-else
class="bucket-name is-readonly"
>
<span
v-if="kanbanViews.length > 1"
class="view-title"
>{{ kanbanView.title }}:</span>
{{ currentBucketTitle(kanbanView) }}
</span>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import type {ITask} from '@/modelTypes/ITask'
import type {IBucket} from '@/modelTypes/IBucket'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue'
import BucketService from '@/services/bucket'
import TaskBucketService from '@/services/taskBucket'
import TaskBucketModel from '@/models/taskBucket'
import {success} from '@/message'
const props = defineProps<{
task: ITask
canWrite: boolean
}>()
const emit = defineEmits<{
'update:task': [task: ITask]
}>()
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const kanbanStore = useKanbanStore()
const project = computed(() => projectStore.projects[props.task.projectId])
const kanbanViews = computed(() => {
if (!project.value?.views) {
return []
}
return project.value.views.filter(
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
&& v.bucketConfigurationMode === 'manual',
)
})
const viewBuckets = ref<Record<number, IBucket[]>>({})
watch(
() => kanbanViews.value,
async (views) => {
const bucketService = new BucketService()
for (const view of views) {
if (viewBuckets.value[view.id]) {
continue
}
try {
const buckets = await bucketService.getAll({
projectId: props.task.projectId,
projectViewId: view.id,
} as IBucket)
viewBuckets.value[view.id] = buckets
} catch {
// silently ignore if we cannot load buckets
}
}
},
{immediate: true},
)
function currentBucketForView(view: {id: number}): IBucket | undefined {
return props.task.buckets?.find(b => b.projectViewId === view.id)
}
function currentBucketTitle(view: {id: number}): string {
const bucket = currentBucketForView(view)
return bucket?.title || t('task.detail.noBucket')
}
function isCurrentBucket(view: {id: number}, bucket: IBucket): boolean {
const current = currentBucketForView(view)
return current?.id === bucket.id
}
async function changeBucket(view: {id: number}, bucket: IBucket) {
const current = currentBucketForView(view)
if (current?.id === bucket.id) {
return
}
const taskBucketService = new TaskBucketService()
try {
await taskBucketService.update(new TaskBucketModel({
taskId: props.task.id,
bucketId: bucket.id,
projectViewId: view.id,
projectId: props.task.projectId,
}))
const updatedBuckets = (props.task.buckets || []).map(b => {
if (b.projectViewId === view.id) {
return {...bucket}
}
return b
})
// If the task was not yet in this view, add the bucket
if (!updatedBuckets.find(b => b.projectViewId === view.id)) {
updatedBuckets.push({...bucket})
}
// Update the kanban store if the board is loaded
kanbanStore.moveTaskToBucket(props.task, bucket.id)
emit('update:task', {
...props.task,
buckets: updatedBuckets,
bucketId: bucket.id,
})
success({message: t('task.detail.bucketChangedSuccess')})
} catch {
// error is handled by the service layer
}
}
</script>
<style lang="scss" scoped>
.bucket-select {
display: inline;
}
.bucket-name {
color: var(--grey-800);
&:hover {
color: var(--primary);
}
}
.change-indicator {
font-size: .75em;
margin-inline-start: .25rem;
color: var(--grey-400);
}
:deep(.dropdown) {
display: inline;
}
:deep(.dropdown-trigger) {
display: inline;
padding: 0;
}
</style>

View File

@ -852,6 +852,8 @@
"updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.",
"duplicateSuccess": "The task was duplicated successfully.",
"noBucket": "No bucket",
"bucketChangedSuccess": "The task bucket has been changed successfully.",
"belongsToProject": "This task belongs to project '{project}'",
"back": "Back to project",
"due": "Due {at}",

View File

@ -58,6 +58,7 @@ export interface ITask extends IAbstract {
projectId: IProject['id'] // Meta, only used when creating a new task
bucketId: IBucket['id']
buckets: IBucket[]
}
export type ITaskPartialWithId = PartialWithId<ITask>

View File

@ -96,6 +96,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
projectId: IProject['id'] = 0
bucketId: IBucket['id'] = 0
buckets: IBucket[] = []
constructor(data: Partial<ITask> = {}) {
super()

View File

@ -55,6 +55,11 @@
class="has-text-grey-light"
> &gt; </span>
</template>
<BucketSelect
:task="task"
:can-write="canWrite"
@update:task="Object.assign(task, $event)"
/>
</h6>
<ChecklistSummary :task="task" />
@ -658,6 +663,7 @@ import RepeatAfter from '@/components/tasks/partials/RepeatAfter.vue'
import TaskSubscription from '@/components/misc/Subscription.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
import BucketSelect from '@/components/tasks/partials/BucketSelect.vue'
import Reactions from '@/components/input/Reactions.vue'
import {uploadFile} from '@/helpers/attachments'
@ -888,7 +894,7 @@ watch(
}
try {
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread']})
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
Object.assign(task.value, loaded)
attachmentStore.set(task.value.attachments)
taskColor.value = task.value.hexColor