This commit is contained in:
Hiller Hoover 2026-06-30 07:04:08 -05:00 committed by GitHub
commit c4f6dfd202
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1768 additions and 4 deletions

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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,
}
})