From cb4f92980b193e07ccfa574d15b43a94551e3e20 Mon Sep 17 00:00:00 2001
From: Lars de Ridder <1681068+larsderidder@users.noreply.github.com>
Date: Thu, 2 Apr 2026 12:18:34 +0200
Subject: [PATCH] feat(task): allow changing bucket from task detail view
(#2233)
---
.../tasks/partials/BucketSelect.vue | 201 ++++++++++++++++++
frontend/src/i18n/lang/en.json | 2 +
frontend/src/modelTypes/ITask.ts | 1 +
frontend/src/models/task.ts | 1 +
frontend/src/views/tasks/TaskDetailView.vue | 8 +-
frontend/tests/e2e/task/bucket-select.spec.ts | 193 +++++++++++++++++
6 files changed, 405 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/tasks/partials/BucketSelect.vue
create mode 100644 frontend/tests/e2e/task/bucket-select.spec.ts
diff --git a/frontend/src/components/tasks/partials/BucketSelect.vue b/frontend/src/components/tasks/partials/BucketSelect.vue
new file mode 100644
index 000000000..72e33c58a
--- /dev/null
+++ b/frontend/src/components/tasks/partials/BucketSelect.vue
@@ -0,0 +1,201 @@
+
+
+ >
+
+
+
+
+ {{ currentBucketTitle }}
+
+
+
+
+ {{ bucket.title }}
+
+
+
+
+ {{ currentBucketTitle }}
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json
index 67a4060ea..6a19fa43a 100644
--- a/frontend/src/i18n/lang/en.json
+++ b/frontend/src/i18n/lang/en.json
@@ -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}",
diff --git a/frontend/src/modelTypes/ITask.ts b/frontend/src/modelTypes/ITask.ts
index dc5c60e03..f8504c30a 100644
--- a/frontend/src/modelTypes/ITask.ts
+++ b/frontend/src/modelTypes/ITask.ts
@@ -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
diff --git a/frontend/src/models/task.ts b/frontend/src/models/task.ts
index 0464103d9..ecec237b6 100644
--- a/frontend/src/models/task.ts
+++ b/frontend/src/models/task.ts
@@ -96,6 +96,7 @@ export default class TaskModel extends AbstractModel implements ITask {
projectId: IProject['id'] = 0
bucketId: IBucket['id'] = 0
+ buckets: IBucket[] = []
constructor(data: Partial = {}) {
super()
diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue
index 11eaf0900..d99d3f5ec 100644
--- a/frontend/src/views/tasks/TaskDetailView.vue
+++ b/frontend/src/views/tasks/TaskDetailView.vue
@@ -55,6 +55,11 @@
class="has-text-grey-light"
> >
+
@@ -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()
diff --git a/frontend/tests/e2e/task/bucket-select.spec.ts b/frontend/tests/e2e/task/bucket-select.spec.ts
new file mode 100644
index 000000000..1f8d286d5
--- /dev/null
+++ b/frontend/tests/e2e/task/bucket-select.spec.ts
@@ -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()
+ })
+})