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 c7a989d7dc
All checks were successful
continuous-integration/drone/push Build is passing
fix(kanban): don't export buckets as readonly because that makes it impossible to update them, even from within the store
This fixes a bug where the task on the kanban board would not get updated because the "tasks" property of all buckets were still read only if they were exported once as readonly. This has been unnoticed in the past because the visual representation of the board still perfectly matched what the user was doing and what was saved in the api - just not what was stored in pina.
2023-06-08 16:54:52 +02:00

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