feat(task): allow changing bucket from task detail view (#2233)
This commit is contained in:
parent
d73222e4a7
commit
cb4f92980b
|
|
@ -0,0 +1,201 @@
|
|||
<template>
|
||||
<template v-if="kanbanView">
|
||||
<span class="has-text-grey-light"> > </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>
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@
|
|||
class="has-text-grey-light"
|
||||
> > </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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue