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

This commit is contained in:
Lars de Ridder 2026-04-02 12:18:34 +02:00 committed by GitHub
parent d73222e4a7
commit cb4f92980b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 405 additions and 1 deletions

View File

@ -0,0 +1,201 @@
<template>
<template v-if="kanbanView">
<span class="has-text-grey-light"> &gt; </span>
<template v-if="canWrite">
<Dropdown>
<template #trigger="{toggleOpen}">
<BaseButton
class="bucket-name"
@click="toggleOpen"
>
{{ currentBucketTitle }}
<Icon
icon="pencil-alt"
class="change-indicator"
/>
</BaseButton>
</template>
<DropdownItem
v-for="bucket in buckets"
:key="bucket.id"
:class="{'is-active': currentBucket?.id === bucket.id}"
@click="changeBucket(bucket)"
>
{{ bucket.title }}
</DropdownItem>
</Dropdown>
</template>
<span
v-else
class="bucket-name"
>
{{ currentBucketTitle }}
</span>
</template>
</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 {useBaseStore} from '@/stores/base'
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 baseStore = useBaseStore()
const project = computed(() => projectStore.projects[props.task.projectId])
// If the project has exactly one manual kanban view, always use it.
// If there are multiple, only show the selector when the active view is one of them.
const kanbanView = computed(() => {
if (!project.value?.views) {
return null
}
const manualKanbanViews = project.value.views.filter(
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
&& v.bucketConfigurationMode === 'manual',
)
if (manualKanbanViews.length === 1) {
return manualKanbanViews[0]
}
if (manualKanbanViews.length > 1) {
const activeViewId = baseStore.currentProjectViewId
return manualKanbanViews.find(v => v.id === activeViewId) || null
}
return null
})
const buckets = ref<IBucket[]>([])
watch(
() => kanbanView.value,
async (view) => {
if (!view) {
buckets.value = []
return
}
const bucketService = new BucketService()
try {
buckets.value = await bucketService.getAll({
projectId: props.task.projectId,
projectViewId: view.id,
} as IBucket)
} catch (e) {
console.error('Failed to load buckets:', e)
}
},
{immediate: true},
)
const currentBucket = computed(() => {
if (!kanbanView.value) {
return undefined
}
return props.task.buckets?.find(b => b.projectViewId === kanbanView.value.id)
})
const currentBucketTitle = computed(() => {
return currentBucket.value?.title || t('task.detail.noBucket')
})
async function changeBucket(bucket: IBucket) {
if (!kanbanView.value || currentBucket.value?.id === bucket.id) {
return
}
const taskBucketService = new TaskBucketService()
const updatedTaskBucket = await taskBucketService.update(new TaskBucketModel({
taskId: props.task.id,
bucketId: bucket.id,
projectViewId: kanbanView.value.id,
projectId: props.task.projectId,
}))
const updatedBuckets = (props.task.buckets || []).map(b => {
if (b.projectViewId === kanbanView.value.id) {
return {...bucket}
}
return b
})
if (!updatedBuckets.find(b => b.projectViewId === kanbanView.value.id)) {
updatedBuckets.push({...bucket})
}
kanbanStore.moveTaskToBucket(props.task, bucket.id)
// Only pick up done state from the response since moving to/from the
// done bucket can toggle it. Spreading the full response task would
// overwrite fields like maxPermission that are not part of this endpoint.
const updatedTask = {
...props.task,
done: updatedTaskBucket.task?.done ?? props.task.done,
doneAt: updatedTaskBucket.task?.doneAt ?? props.task.doneAt,
buckets: updatedBuckets,
bucketId: bucket.id,
}
emit('update:task', updatedTask)
success({message: t('task.detail.bucketChangedSuccess')})
}
</script>
<style lang="scss" scoped>
.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

@ -879,6 +879,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" />
@ -659,6 +664,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'
@ -899,7 +905,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)
taskColor.value = task.value.hexColor
setActiveFields()

View File

@ -0,0 +1,193 @@
import {test, expect} from '../../support/fixtures'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from '../../factories/project_view'
import {TaskBucketFactory} from '../../factories/task_buckets'
async function createKanbanTaskInBucket() {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(2, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
project: projects[0],
view: views[0],
buckets,
task: tasks[0],
}
}
test.describe('Task Bucket Select', () => {
test('Shows the current bucket name when opening a task from a kanban view', async ({authenticatedPage: page}) => {
const {project, view, buckets, task} = await createKanbanTaskInBucket()
await page.goto(`/projects/${project.id}/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle')).toContainText(buckets[0].title)
})
test('Can change the bucket from the task detail view', async ({authenticatedPage: page}) => {
const {project, view, buckets, task} = await createKanbanTaskInBucket()
await page.goto(`/projects/${project.id}/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
// Click the bucket name to open the dropdown
await page.locator('.task-view .subtitle .bucket-name').click()
// Select the other bucket
await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page.locator('.task-view .subtitle')).toContainText(buckets[1].title)
})
test('Does not show the bucket selector when project has no kanban view', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
// Only create a list view, no kanban view
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 0,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
await page.locator('.tasks .task').filter({hasText: tasks[0].title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible()
})
test.describe('Multiple kanban views', () => {
async function createTaskWithMultipleKanbanViews() {
const projects = await ProjectFactory.create(1)
const listView = (await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 0,
}))[0]
const kanbanView1 = (await ProjectViewFactory.create(1, {
id: 2,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
}, false))[0]
const kanbanView2 = (await ProjectViewFactory.create(1, {
id: 3,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
}, false))[0]
const bucketsView1 = await BucketFactory.create(2, {
project_view_id: kanbanView1.id,
})
const bucketsView2 = await BucketFactory.create(2, {
id: (i: number) => i + 2,
project_view_id: kanbanView2.id,
}, false)
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: bucketsView1[0].id,
project_view_id: kanbanView1.id,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: bucketsView2[0].id,
project_view_id: kanbanView2.id,
}, false)
return {
project: projects[0],
listView,
kanbanView1,
kanbanView2,
bucketsView1,
bucketsView2,
task: tasks[0],
}
}
test('Does not show the bucket selector when opening a task from the list view', async ({authenticatedPage: page}) => {
const {project, listView, task} = await createTaskWithMultipleKanbanViews()
await page.goto(`/projects/${project.id}/${listView.id}`)
await page.locator('.tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible()
})
test('Shows the correct buckets when opening a task from the first kanban view', async ({authenticatedPage: page}) => {
const {project, kanbanView1, bucketsView1, task} = await createTaskWithMultipleKanbanViews()
await page.goto(`/projects/${project.id}/${kanbanView1.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView1[0].title)
await page.locator('.task-view .subtitle .bucket-name').click()
await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView1.length)
for (const bucket of bucketsView1) {
await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible()
}
})
test('Shows the correct buckets when opening a task from the second kanban view', async ({authenticatedPage: page}) => {
const {project, kanbanView2, bucketsView2, task} = await createTaskWithMultipleKanbanViews()
await page.goto(`/projects/${project.id}/${kanbanView2.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView2[0].title)
await page.locator('.task-view .subtitle .bucket-name').click()
await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView2.length)
for (const bucket of bucketsView2) {
await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible()
}
})
})
test('Keeps action buttons visible after changing the bucket', async ({authenticatedPage: page}) => {
const {project, view, buckets, task} = await createKanbanTaskInBucket()
await page.goto(`/projects/${project.id}/${view.id}`)
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
// Change the bucket
await page.locator('.task-view .subtitle .bucket-name').click()
await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
// Action buttons should still be visible
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Done'})).toBeVisible()
})
})