Compare commits

...

6 Commits

Author SHA1 Message Date
kolaente 433c68a756 test: add e2e tests for bucket selector with multiple kanban views
Verify that the bucket selector in the task detail view correctly
handles projects with multiple kanban views: hidden from list view,
and showing the right buckets for each kanban view.
2026-04-02 10:52:48 +02:00
Lars de Ridder 2da4f97bf1
Merge branch 'main' into feat/bucket-select-task-detail 2026-03-23 12:06:42 +01:00
Lars de Ridder 7a870149ba test(task): add Playwright tests for bucket select in task detail 2026-03-12 15:57:24 +01:00
Lars de Ridder 0604f8d81a fix(task): only pick up done state from bucket change response
Spreading the full response task from the bucket change endpoint
overwrote fields like maxPermission with null, causing the action
buttons and edit icon to disappear after changing buckets.
2026-03-12 15:57:21 +01:00
Lars de Ridder 57a339b8c9 fix(task): address review feedback on bucket selector
- Only show bucket selector when active view is a manual kanban view
- Log errors when loading buckets fails
- Use API response task to reflect done state changes
- Remove swallowed catch in changeBucket
2026-03-05 09:45:37 +01:00
Lars de Ridder 9e1f97da80 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
2026-03-05 09:44:09 +01:00
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

@ -864,6 +864,8 @@
"updateSuccess": "The task was saved successfully.", "updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.", "deleteSuccess": "The task has been deleted successfully.",
"duplicateSuccess": "The task was duplicated 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}'", "belongsToProject": "This task belongs to project '{project}'",
"back": "Back to project", "back": "Back to project",
"due": "Due {at}", "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 projectId: IProject['id'] // Meta, only used when creating a new task
bucketId: IBucket['id'] bucketId: IBucket['id']
buckets: IBucket[]
} }
export type ITaskPartialWithId = PartialWithId<ITask> export type ITaskPartialWithId = PartialWithId<ITask>

View File

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

View File

@ -55,6 +55,11 @@
class="has-text-grey-light" class="has-text-grey-light"
> &gt; </span> > &gt; </span>
</template> </template>
<BucketSelect
:task="task"
:can-write="canWrite"
@update:task="Object.assign(task, $event)"
/>
</h6> </h6>
<ChecklistSummary :task="task" /> <ChecklistSummary :task="task" />
@ -659,6 +664,7 @@ import RepeatAfter from '@/components/tasks/partials/RepeatAfter.vue'
import TaskSubscription from '@/components/misc/Subscription.vue' import TaskSubscription from '@/components/misc/Subscription.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue' import CustomTransition from '@/components/misc/CustomTransition.vue'
import AssigneeList from '@/components/tasks/partials/AssigneeList.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 Reactions from '@/components/input/Reactions.vue'
import {uploadFile} from '@/helpers/attachments' import {uploadFile} from '@/helpers/attachments'
@ -899,7 +905,7 @@ watch(
} }
try { 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) Object.assign(task.value, loaded)
taskColor.value = task.value.hexColor taskColor.value = task.value.hexColor
setActiveFields() 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()
})
})