+ |
+
+ |
+
@@ -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('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;
}
-
+
\ No newline at end of file
diff --git a/frontend/src/components/tasks/bulk/BulkTaskToolbar.vue b/frontend/src/components/tasks/bulk/BulkTaskToolbar.vue
new file mode 100644
index 000000000..94828b5a1
--- /dev/null
+++ b/frontend/src/components/tasks/bulk/BulkTaskToolbar.vue
@@ -0,0 +1,1157 @@
+
+
+
+
+
+
+
+
+
+
+ {{ selectedCountText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ label.title }}
+
+
+
+
+
+ {{ option }}
+
+
+ {{ option.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ task.differentProject }} >
+
+
+ {{ task.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Duplicate {{ selection.selectedCount }} selected tasks?
+
+
+
+
+
+ Delete {{ selection.selectedCount }} selected tasks?
+
+
+ This cannot be undone.
+
+
+
+
+
+ {{ $t('misc.cancel') }}
+
+
+
+ Clear
+
+
+
+ Clear
+
+
+
+ Clear
+
+
+
+ Clear
+
+
+
+ Clear
+
+
+
+ {{ $t('misc.doit') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/services/taskBulk.ts b/frontend/src/services/taskBulk.ts
new file mode 100644
index 000000000..317740e30
--- /dev/null
+++ b/frontend/src/services/taskBulk.ts
@@ -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(
+ action: () => Promise,
+ options: {
+ ignoreDuplicates?: boolean,
+ retries?: number,
+ } = {},
+ ): Promise {
+ 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(
+ items: T[],
+ action: (item: T) => Promise,
+ 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): Promise {
+ 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 {
+ return this.updateTasks(tasks, {
+ isFavorite: favorite,
+ })
+ }
+
+ async moveTasks(tasks: ITask[], project: IProject): Promise {
+ 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
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/stores/bulkTaskSelection.ts b/frontend/src/stores/bulkTaskSelection.ts
new file mode 100644
index 000000000..24ba54f55
--- /dev/null
+++ b/frontend/src/stores/bulkTaskSelection.ts
@@ -0,0 +1,104 @@
+import {computed, ref} from 'vue'
+import {defineStore} from 'pinia'
+
+export const useBulkTaskSelection = defineStore('bulkTaskSelection', () => {
+ const selectedTaskIds = ref([])
+ const lastSelectedTaskId = ref(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,
+ }
+})
\ No newline at end of file
|