Add bulk task editing support in table view.
This commit is contained in:
parent
7d1372ece3
commit
42c13fa341
|
|
@ -89,6 +89,12 @@
|
|||
:class="{'is-loading': loading}"
|
||||
class="loader-container"
|
||||
>
|
||||
<BulkTaskToolbar
|
||||
:tasks="tasks"
|
||||
:project-id="projectId"
|
||||
@updated="refreshAfterBulkUpdate"
|
||||
/>
|
||||
|
||||
<Card
|
||||
:padding="false"
|
||||
:has-content="false"
|
||||
|
|
@ -97,6 +103,17 @@
|
|||
<table class="table has-actions is-hoverable is-fullwidth mbe-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bulk-select-column">
|
||||
<input
|
||||
class="bulk-select-checkbox"
|
||||
type="checkbox"
|
||||
:checked="allVisibleTasksSelected"
|
||||
:indeterminate.prop="someVisibleTasksSelected && !allVisibleTasksSelected"
|
||||
@click.stop
|
||||
@change="toggleAllVisibleTasks"
|
||||
>
|
||||
</th>
|
||||
|
||||
<th v-if="activeColumns.index">
|
||||
#
|
||||
<Sort
|
||||
|
|
@ -195,7 +212,17 @@
|
|||
<tr
|
||||
v-for="t in tasks"
|
||||
:key="t.id"
|
||||
:class="{'is-bulk-selected': bulkSelection.isSelected(t.id)}"
|
||||
>
|
||||
<td class="bulk-select-column">
|
||||
<input
|
||||
class="bulk-select-checkbox"
|
||||
type="checkbox"
|
||||
:checked="bulkSelection.isSelected(t.id)"
|
||||
@click.stop="bulkSelection.toggleRange(allVisibleTaskIds, t.id, $event.shiftKey)"
|
||||
>
|
||||
</td>
|
||||
|
||||
<td v-if="activeColumns.index">
|
||||
<RouterLink :to="taskDetailRoutes[t.id]">
|
||||
<template v-if="t.identifier === ''">
|
||||
|
|
@ -316,6 +343,8 @@ import Sort from '@/components/tasks/partials/Sort.vue'
|
|||
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
||||
import Pagination from '@/components/misc/Pagination.vue'
|
||||
import Popup from '@/components/misc/Popup.vue'
|
||||
import BulkTaskToolbar from '@/components/tasks/bulk/BulkTaskToolbar.vue'
|
||||
import {useBulkTaskSelection} from '@/stores/bulkTaskSelection'
|
||||
|
||||
import type {SortBy} from '@/composables/useTaskList'
|
||||
import {useTaskList} from '@/composables/useTaskList'
|
||||
|
|
@ -323,7 +352,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import { camelCase } from 'change-case'
|
||||
import {camelCase} from 'change-case'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
|
|
@ -334,6 +363,7 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const bulkSelection = useBulkTaskSelection()
|
||||
|
||||
const ACTIVE_COLUMNS_DEFAULT = {
|
||||
index: true,
|
||||
|
|
@ -362,8 +392,8 @@ const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT}
|
|||
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
||||
|
||||
const taskList = useTaskList(
|
||||
() => props.projectId,
|
||||
() => props.viewId,
|
||||
() => props.projectId,
|
||||
() => props.viewId,
|
||||
sortBy.value,
|
||||
() => ['comment_count', 'is_unread'],
|
||||
)
|
||||
|
|
@ -417,6 +447,32 @@ function setActiveColumnsSortParam() {
|
|||
}, {})
|
||||
}
|
||||
|
||||
const allVisibleTaskIds = computed(() => tasks.value.map(({id}) => id))
|
||||
|
||||
const allVisibleTasksSelected = computed(() =>
|
||||
allVisibleTaskIds.value.length > 0 &&
|
||||
allVisibleTaskIds.value.every(id => bulkSelection.isSelected(id)),
|
||||
)
|
||||
|
||||
const someVisibleTasksSelected = computed(() =>
|
||||
allVisibleTaskIds.value.some(id => bulkSelection.isSelected(id)),
|
||||
)
|
||||
|
||||
function toggleAllVisibleTasks() {
|
||||
if (allVisibleTasksSelected.value) {
|
||||
bulkSelection.replace(
|
||||
bulkSelection.selectedTaskIds.filter(id => !allVisibleTaskIds.value.includes(id)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
bulkSelection.selectMany(allVisibleTaskIds.value)
|
||||
}
|
||||
|
||||
async function refreshAfterBulkUpdate() {
|
||||
await taskList.loadTasks()
|
||||
}
|
||||
|
||||
// TODO: re-enable opening task detail in modal
|
||||
// const router = useRouter()
|
||||
const taskDetailRoutes = computed(() => Object.fromEntries(
|
||||
|
|
@ -446,6 +502,26 @@ const taskDetailRoutes = computed(() => Object.fromEntries(
|
|||
}
|
||||
}
|
||||
|
||||
.table tbody tr.is-bulk-selected {
|
||||
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.table tbody tr.is-bulk-selected:hover {
|
||||
background-color: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.bulk-select-column {
|
||||
inline-size: 2.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bulk-select-checkbox {
|
||||
inline-size: 1.1rem;
|
||||
block-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.columns-filter {
|
||||
margin: 0;
|
||||
|
||||
|
|
@ -467,4 +543,4 @@ const taskDetailRoutes = computed(() => Object.fromEntries(
|
|||
.filter-container :deep(.popup) {
|
||||
inset-block-start: 7rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,427 @@
|
|||
import TaskService from '@/services/task'
|
||||
import LabelTaskService from '@/services/labelTask'
|
||||
import TaskAssigneeService from '@/services/taskAssignee'
|
||||
import TaskDuplicateService from '@/services/taskDuplicateService'
|
||||
import SubscriptionService from '@/services/subscription'
|
||||
import TaskRelationService from '@/services/taskRelation'
|
||||
|
||||
import LabelTaskModel from '@/models/labelTask'
|
||||
import TaskAssigneeModel from '@/models/taskAssignee'
|
||||
import TaskDuplicateModel from '@/models/taskDuplicateModel'
|
||||
import SubscriptionModel from '@/models/subscription'
|
||||
import TaskRelationModel from '@/models/taskRelation'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {IRelationKind} from '@/types/IRelationKind'
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isIgnorableDuplicateError(error: unknown): boolean {
|
||||
const maybeError = error as {
|
||||
response?: {
|
||||
status?: number,
|
||||
data?: {
|
||||
message?: string,
|
||||
},
|
||||
},
|
||||
message?: string,
|
||||
}
|
||||
|
||||
const status = maybeError.response?.status
|
||||
const message = maybeError.response?.data?.message ?? maybeError.message ?? ''
|
||||
|
||||
return status === 409 ||
|
||||
status === 412 ||
|
||||
message.toLowerCase().includes('already exists') ||
|
||||
message.toLowerCase().includes('duplicate')
|
||||
}
|
||||
|
||||
function isDatabaseLockedError(error: unknown): boolean {
|
||||
const maybeError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string,
|
||||
},
|
||||
},
|
||||
message?: string,
|
||||
}
|
||||
|
||||
const message = maybeError.response?.data?.message ?? maybeError.message ?? ''
|
||||
|
||||
return message.toLowerCase().includes('database is locked')
|
||||
}
|
||||
|
||||
export default class TaskBulkService {
|
||||
taskService = new TaskService()
|
||||
labelTaskService = new LabelTaskService()
|
||||
taskAssigneeService = new TaskAssigneeService()
|
||||
taskDuplicateService = new TaskDuplicateService()
|
||||
subscriptionService = new SubscriptionService()
|
||||
taskRelationService = new TaskRelationService()
|
||||
|
||||
loading = false
|
||||
|
||||
private async runWrite<T>(
|
||||
action: () => Promise<T>,
|
||||
options: {
|
||||
ignoreDuplicates?: boolean,
|
||||
retries?: number,
|
||||
} = {},
|
||||
): Promise<T | null> {
|
||||
const retries = options.retries ?? 5
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await action()
|
||||
} catch (error) {
|
||||
if (options.ignoreDuplicates && isIgnorableDuplicateError(error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isDatabaseLockedError(error) && attempt < retries) {
|
||||
await sleep(250 * (attempt + 1))
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async runSequential<T>(
|
||||
items: T[],
|
||||
action: (item: T) => Promise<unknown>,
|
||||
options: {
|
||||
ignoreDuplicates?: boolean,
|
||||
delayMs?: number,
|
||||
} = {},
|
||||
) {
|
||||
const delayMs = options.delayMs ?? 125
|
||||
|
||||
for (const item of items) {
|
||||
await this.runWrite(
|
||||
() => action(item),
|
||||
{
|
||||
ignoreDuplicates: options.ignoreDuplicates,
|
||||
},
|
||||
)
|
||||
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateTasks(tasks: ITask[], values: Partial<ITask>): Promise<ITask[]> {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const updatedTasks: ITask[] = []
|
||||
|
||||
await this.runSequential(tasks, async task => {
|
||||
const updatedTask = await this.taskService.update({
|
||||
...task,
|
||||
...values,
|
||||
}) as ITask
|
||||
|
||||
updatedTasks.push(updatedTask)
|
||||
})
|
||||
|
||||
return updatedTasks
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async setFavorite(tasks: ITask[], favorite: boolean): Promise<ITask[]> {
|
||||
return this.updateTasks(tasks, {
|
||||
isFavorite: favorite,
|
||||
})
|
||||
}
|
||||
|
||||
async moveTasks(tasks: ITask[], project: IProject): Promise<ITask[]> {
|
||||
return this.updateTasks(tasks, {
|
||||
projectId: project.id,
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTasks(tasks: ITask[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.runSequential(
|
||||
tasks,
|
||||
task => this.taskService.delete(task),
|
||||
{
|
||||
delayMs: 175,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(tasks: ITask[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.runSequential(
|
||||
tasks.filter(task => task.subscription === null),
|
||||
task => this.subscriptionService.create(new SubscriptionModel({
|
||||
entity: 'task',
|
||||
entityId: task.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribe(tasks: ITask[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.runSequential(
|
||||
tasks.filter(task => task.subscription !== null),
|
||||
task => this.subscriptionService.delete(new SubscriptionModel({
|
||||
entity: 'task',
|
||||
entityId: task.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async addAssignees(tasks: ITask[], users: IUser[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const usersToAdd = users.filter(user =>
|
||||
!(task.assignees ?? []).some(existing => existing.id === user.id),
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
usersToAdd,
|
||||
user => this.taskAssigneeService.create(new TaskAssigneeModel({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async removeAssignees(tasks: ITask[], users: IUser[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const usersToRemove = users.filter(user =>
|
||||
(task.assignees ?? []).some(existing => existing.id === user.id),
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
usersToRemove,
|
||||
user => this.taskAssigneeService.delete(new TaskAssigneeModel({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAssignees(tasks: ITask[], users: IUser[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const currentAssignees = task.assignees ?? []
|
||||
|
||||
const usersToRemove = currentAssignees.filter(existing =>
|
||||
!users.some(user => user.id === existing.id),
|
||||
)
|
||||
|
||||
const usersToAdd = users.filter(user =>
|
||||
!currentAssignees.some(existing => existing.id === user.id),
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
usersToRemove,
|
||||
user => this.taskAssigneeService.delete(new TaskAssigneeModel({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
usersToAdd,
|
||||
user => this.taskAssigneeService.create(new TaskAssigneeModel({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async addLabels(tasks: ITask[], labels: ILabel[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const labelsToAdd = labels.filter(label =>
|
||||
!(task.labels ?? []).some(existing => existing.id === label.id),
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
labelsToAdd,
|
||||
label => this.labelTaskService.create(new LabelTaskModel({
|
||||
taskId: task.id,
|
||||
labelId: label.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async removeLabels(tasks: ITask[], labels: ILabel[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const labelsToRemove = labels.filter(label =>
|
||||
(task.labels ?? []).some(existing => existing.id === label.id),
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
labelsToRemove,
|
||||
label => this.labelTaskService.delete(new LabelTaskModel({
|
||||
taskId: task.id,
|
||||
labelId: label.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async replaceLabels(tasks: ITask[], labels: ILabel[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const currentLabels = task.labels ?? []
|
||||
|
||||
const labelsToRemove = currentLabels.filter(existing =>
|
||||
!labels.some(label => label.id === existing.id),
|
||||
)
|
||||
|
||||
const labelsToAdd = labels.filter(label =>
|
||||
!currentLabels.some(existing => existing.id === label.id),
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
labelsToRemove,
|
||||
label => this.labelTaskService.delete(new LabelTaskModel({
|
||||
taskId: task.id,
|
||||
labelId: label.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
|
||||
await this.runSequential(
|
||||
labelsToAdd,
|
||||
label => this.labelTaskService.create(new LabelTaskModel({
|
||||
taskId: task.id,
|
||||
labelId: label.id,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async duplicate(tasks: ITask[]) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.runSequential(
|
||||
tasks,
|
||||
task => this.taskDuplicateService.create(new TaskDuplicateModel({
|
||||
taskId: task.id,
|
||||
})),
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async addRelation(tasks: ITask[], otherTaskId: ITask['id'], relationKind: IRelationKind) {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.runSequential(
|
||||
tasks.filter(task => task.id !== otherTaskId),
|
||||
task => this.taskRelationService.create(new TaskRelationModel({
|
||||
taskId: task.id,
|
||||
otherTaskId,
|
||||
relationKind,
|
||||
})),
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import {computed, ref} from 'vue'
|
||||
import {defineStore} from 'pinia'
|
||||
|
||||
export const useBulkTaskSelection = defineStore('bulkTaskSelection', () => {
|
||||
const selectedTaskIds = ref<number[]>([])
|
||||
const lastSelectedTaskId = ref<number | null>(null)
|
||||
|
||||
const selectedCount = computed(() => selectedTaskIds.value.length)
|
||||
const hasSelection = computed(() => selectedCount.value > 0)
|
||||
|
||||
function isSelected(taskId: number): boolean {
|
||||
return selectedTaskIds.value.includes(taskId)
|
||||
}
|
||||
|
||||
function toggle(taskId: number) {
|
||||
if (isSelected(taskId)) {
|
||||
selectedTaskIds.value = selectedTaskIds.value.filter(id => id !== taskId)
|
||||
} else {
|
||||
selectedTaskIds.value = [...selectedTaskIds.value, taskId]
|
||||
}
|
||||
|
||||
lastSelectedTaskId.value = taskId
|
||||
}
|
||||
|
||||
function toggleRange(visibleTaskIds: number[], taskId: number, shiftKey: boolean) {
|
||||
if (!shiftKey || lastSelectedTaskId.value === null) {
|
||||
toggle(taskId)
|
||||
return
|
||||
}
|
||||
|
||||
const startIndex = visibleTaskIds.indexOf(lastSelectedTaskId.value)
|
||||
const endIndex = visibleTaskIds.indexOf(taskId)
|
||||
|
||||
if (startIndex === -1 || endIndex === -1) {
|
||||
toggle(taskId)
|
||||
return
|
||||
}
|
||||
|
||||
const [start, end] = startIndex < endIndex
|
||||
? [startIndex, endIndex]
|
||||
: [endIndex, startIndex]
|
||||
|
||||
const rangeIds = visibleTaskIds.slice(start, end + 1)
|
||||
|
||||
selectedTaskIds.value = Array.from(new Set([
|
||||
...selectedTaskIds.value,
|
||||
...rangeIds,
|
||||
]))
|
||||
|
||||
lastSelectedTaskId.value = taskId
|
||||
}
|
||||
|
||||
function select(taskId: number) {
|
||||
if (!isSelected(taskId)) {
|
||||
selectedTaskIds.value = [...selectedTaskIds.value, taskId]
|
||||
}
|
||||
|
||||
lastSelectedTaskId.value = taskId
|
||||
}
|
||||
|
||||
function deselect(taskId: number) {
|
||||
selectedTaskIds.value = selectedTaskIds.value.filter(id => id !== taskId)
|
||||
|
||||
if (lastSelectedTaskId.value === taskId) {
|
||||
lastSelectedTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function replace(taskIds: number[]) {
|
||||
selectedTaskIds.value = Array.from(new Set(taskIds))
|
||||
lastSelectedTaskId.value = taskIds.at(-1) ?? null
|
||||
}
|
||||
|
||||
function selectMany(taskIds: number[]) {
|
||||
selectedTaskIds.value = Array.from(new Set([
|
||||
...selectedTaskIds.value,
|
||||
...taskIds,
|
||||
]))
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
lastSelectedTaskId.value = taskIds.at(-1) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedTaskIds.value = []
|
||||
lastSelectedTaskId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTaskIds,
|
||||
selectedCount,
|
||||
hasSelection,
|
||||
lastSelectedTaskId,
|
||||
isSelected,
|
||||
toggle,
|
||||
toggleRange,
|
||||
select,
|
||||
deselect,
|
||||
replace,
|
||||
selectMany,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue