This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.
frontend/src/stores/kanban.ts
kolaente ad27f588a2
All checks were successful
continuous-integration/drone/push Build is passing
feat(kanban): use total task count from the api instead of manually calculating it per bucket
This fixes an ux issue where the total count would show a wrong number of total tasks because that was the number of tasks which were loaded at the time. In combination with bucket limits, this caused error messages when the user would attempt to drag tasks into a bucket which appeared not full but was.
2023-06-08 16:57:58 +02:00

398 lines
10 KiB
TypeScript

import {computed, readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {klona} from 'klona/lite'
import {findById, findIndexById} from '@/helpers/utils'
import {i18n} from '@/i18n'
import {success} from '@/message'
import BucketService from '@/services/bucket'
import TaskCollectionService from '@/services/taskCollection'
import {setModuleLoading} from '@/stores/helper'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import type {IBucket} from '@/modelTypes/IBucket'
const TASKS_PER_BUCKET = 25
function getTaskIndicesById(buckets: IBucket[], taskId: ITask['id']) {
let taskIndex
const bucketIndex = buckets.findIndex(({ tasks }) => {
taskIndex = findIndexById(tasks, taskId)
return taskIndex !== -1
})
return {
bucketIndex: bucketIndex !== -1 ? bucketIndex : null,
taskIndex: taskIndex !== -1 ? taskIndex : null,
}
}
const addTaskToBucketAndSort = (buckets: IBucket[], task: ITask) => {
const bucketIndex = findIndexById(buckets, task.bucketId)
if(typeof buckets[bucketIndex] === 'undefined') {
return
}
buckets[bucketIndex].tasks.push(task)
buckets[bucketIndex].tasks.sort((a, b) => a.kanbanPosition > b.kanbanPosition ? 1 : -1)
}
/**
* This store is intended to hold the currently active kanban view.
* It should hold only the current buckets.
*/
export const useKanbanStore = defineStore('kanban', () => {
const buckets = ref<IBucket[]>([])
const projectId = ref<IProject['id']>(0)
const bucketLoading = ref<{[id: IBucket['id']]: boolean}>({})
const taskPagesPerBucket = ref<{[id: IBucket['id']]: number}>({})
const allTasksLoadedForBucket = ref<{[id: IBucket['id']]: boolean}>({})
const isLoading = ref(false)
const getBucketById = computed(() => (bucketId: IBucket['id']): IBucket | undefined => findById(buckets.value, bucketId))
const getTaskById = computed(() => {
return (id: ITask['id']) => {
const { bucketIndex, taskIndex } = getTaskIndicesById(buckets.value, id)
return {
bucketIndex,
taskIndex,
task: bucketIndex !== null && taskIndex !== null && buckets.value[bucketIndex]?.tasks?.[taskIndex] || null,
}
}
})
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
function setProjectId(newProjectId: IProject['id']) {
projectId.value = Number(newProjectId)
}
function setBuckets(newBuckets: IBucket[]) {
buckets.value = newBuckets
newBuckets.forEach(b => {
taskPagesPerBucket.value[b.id] = 1
allTasksLoadedForBucket.value[b.id] = false
})
}
function addBucket(bucket: IBucket) {
buckets.value.push(bucket)
}
function removeBucket(newBucket: IBucket) {
const bucketIndex = findIndexById(buckets.value, newBucket.id)
buckets.value.splice(bucketIndex, 1)
}
function setBucketById(newBucket: IBucket) {
const bucketIndex = findIndexById(buckets.value, newBucket.id)
buckets.value[bucketIndex] = newBucket
}
function setBucketByIndex({
bucketIndex,
bucket,
} : {
bucketIndex: number,
bucket: IBucket
}) {
buckets.value[bucketIndex] = bucket
}
function setTaskInBucketByIndex({
bucketIndex,
taskIndex,
task,
} : {
bucketIndex: number,
taskIndex: number,
task: ITask
}) {
const bucket = buckets.value[bucketIndex]
bucket.tasks[taskIndex] = task
buckets.value[bucketIndex] = bucket
}
function setTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (buckets.value.length === 0) {
return
}
let found = false
const findAndUpdate = b => {
for (const t in buckets.value[b].tasks) {
if (buckets.value[b].tasks[t].id === task.id) {
const bucket = buckets.value[b]
bucket.tasks[t] = task
if (bucket.id !== task.bucketId) {
bucket.tasks.splice(t, 1)
addTaskToBucketAndSort(buckets.value, task)
}
buckets.value[b] = bucket
found = true
return
}
}
}
for (const b in buckets.value) {
if (buckets.value[b].id === task.bucketId) {
findAndUpdate(b)
if (found) {
return
}
}
}
for (const b in buckets.value) {
findAndUpdate(b)
if (found) {
return
}
}
}
function addTaskToBucket(task: ITask) {
const bucketIndex = findIndexById(buckets.value, task.bucketId)
const oldBucket = buckets.value[bucketIndex]
const newBucket = {
...oldBucket,
count: oldBucket.count + 1,
tasks: [
...oldBucket.tasks,
task,
],
}
buckets.value[bucketIndex] = newBucket
}
function addTasksToBucket({tasks, bucketId}: {
tasks: ITask[];
bucketId: IBucket['id'];
}) {
const bucketIndex = findIndexById(buckets.value, bucketId)
const oldBucket = buckets.value[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
...tasks,
],
}
buckets.value[bucketIndex] = newBucket
}
function removeTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (buckets.value.length === 0) {
return
}
const { bucketIndex, taskIndex } = getTaskIndicesById(buckets.value, task.id)
if (
bucketIndex === null ||
buckets.value[bucketIndex]?.id !== task.bucketId ||
taskIndex === null ||
(buckets.value[bucketIndex]?.tasks[taskIndex]?.id !== task.id)
) {
return
}
buckets.value[bucketIndex].tasks.splice(taskIndex, 1)
}
function setBucketLoading({bucketId, loading}: {bucketId: IBucket['id'], loading: boolean}) {
bucketLoading.value[bucketId] = loading
}
function setTasksLoadedForBucketPage({bucketId, page}: {bucketId: IBucket['id'], page: number}) {
taskPagesPerBucket.value[bucketId] = page
}
function setAllTasksLoadedForBucket(bucketId: IBucket['id']) {
allTasksLoadedForBucket.value[bucketId] = true
}
async function loadBucketsForProject({projectId, params}: {projectId: IProject['id'], params}) {
const cancel = setModuleLoading(setIsLoading)
// Clear everything to prevent having old buckets in the project if loading the buckets from this project takes a few moments
setBuckets([])
const bucketService = new BucketService()
try {
const newBuckets = await bucketService.getAll({projectId}, {
...params,
per_page: TASKS_PER_BUCKET,
})
setBuckets(newBuckets)
setProjectId(projectId)
return newBuckets
} finally {
cancel()
}
}
async function loadNextTasksForBucket(
{projectId, ps = {}, bucketId} :
{projectId: IProject['id'], ps, bucketId: IBucket['id']},
) {
const isLoading = bucketLoading.value[bucketId] ?? false
if (isLoading) {
return
}
const page = (taskPagesPerBucket.value[bucketId] ?? 1) + 1
const alreadyLoaded = allTasksLoadedForBucket.value[bucketId] ?? false
if (alreadyLoaded) {
return
}
const cancel = setModuleLoading(setIsLoading)
setBucketLoading({bucketId: bucketId, loading: true})
const params = JSON.parse(JSON.stringify(ps))
params.sort_by = 'kanban_position'
params.order_by = 'asc'
let hasBucketFilter = false
for (const f in params.filter_by) {
if (params.filter_by[f] === 'bucket_id') {
hasBucketFilter = true
if (params.filter_value[f] !== bucketId) {
params.filter_value[f] = bucketId
}
break
}
}
if (!hasBucketFilter) {
params.filter_by = [...(params.filter_by ?? []), 'bucket_id']
params.filter_value = [...(params.filter_value ?? []), bucketId]
params.filter_comparator = [...(params.filter_comparator ?? []), 'equals']
}
params.per_page = TASKS_PER_BUCKET
const taskService = new TaskCollectionService()
try {
const tasks = await taskService.getAll({projectId}, params, page)
addTasksToBucket({tasks, bucketId: bucketId})
setTasksLoadedForBucketPage({bucketId, page})
if (taskService.totalPages <= page) {
setAllTasksLoadedForBucket(bucketId)
}
return tasks
} finally {
cancel()
setBucketLoading({bucketId, loading: false})
}
}
async function createBucket(bucket: IBucket) {
const cancel = setModuleLoading(setIsLoading)
const bucketService = new BucketService()
try {
const createdBucket = await bucketService.create(bucket)
addBucket(createdBucket)
return createdBucket
} finally {
cancel()
}
}
async function deleteBucket({bucket, params}: {bucket: IBucket, params}) {
const cancel = setModuleLoading(setIsLoading)
const bucketService = new BucketService()
try {
const response = await bucketService.delete(bucket)
removeBucket(bucket)
// We reload all buckets because tasks are being moved from the deleted bucket
loadBucketsForProject({projectId: bucket.projectId, params})
return response
} finally {
cancel()
}
}
async function updateBucket(updatedBucketData: Partial<IBucket>) {
const cancel = setModuleLoading(setIsLoading)
const bucketIndex = findIndexById(buckets.value, updatedBucketData.id)
const oldBucket = klona(buckets.value[bucketIndex])
const updatedBucket = {
...oldBucket,
...updatedBucketData,
}
setBucketByIndex({bucketIndex, bucket: updatedBucket})
const bucketService = new BucketService()
try {
const returnedBucket = await bucketService.update(updatedBucket)
setBucketByIndex({bucketIndex, bucket: returnedBucket})
return returnedBucket
} catch(e) {
// restore original state
setBucketByIndex({bucketIndex, bucket: oldBucket})
throw e
} finally {
cancel()
}
}
async function updateBucketTitle({ id, title }: { id: IBucket['id'], title: IBucket['title'] }) {
const bucket = findById(buckets.value, id)
if (bucket?.title === title) {
// bucket title has not changed
return
}
await updateBucket({ id, title })
success({message: i18n.global.t('project.kanban.bucketTitleSavedSuccess')})
}
return {
buckets,
isLoading: readonly(isLoading),
getBucketById,
getTaskById,
setBuckets,
setBucketById,
setTaskInBucketByIndex,
setTaskInBucket,
addTaskToBucket,
removeTaskInBucket,
loadBucketsForProject,
loadNextTasksForBucket,
createBucket,
deleteBucket,
updateBucket,
updateBucketTitle,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useKanbanStore, import.meta.hot))
}