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 @@ + + + + + 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() + }) +})