1
0

chore: move frontend files

This commit is contained in:
kolaente
2024-02-07 14:56:56 +01:00
parent 447641c222
commit fc4676315d
606 changed files with 0 additions and 0 deletions

View File

@ -0,0 +1,37 @@
import {ref, readonly} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {findIndexById} from '@/helpers/utils'
import type {IAttachment} from '@/modelTypes/IAttachment'
export const useAttachmentStore = defineStore('attachment', () => {
const attachments = ref<IAttachment[]>([])
function set(newAttachments: IAttachment[]) {
console.debug('Set attachments', newAttachments)
attachments.value = newAttachments
}
function add(attachment: IAttachment) {
console.debug('Add attachement', attachment)
attachments.value.push(attachment)
}
function removeById(id: IAttachment['id']) {
const attachmentIndex = findIndexById(attachments.value, id)
attachments.value.splice(attachmentIndex, 1)
console.debug('Remove attachement', id)
}
return {
attachments: readonly(attachments),
set,
add,
removeById,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAttachmentStore, import.meta.hot))
}

463
frontend/src/stores/auth.ts Normal file
View File

@ -0,0 +1,463 @@
import {computed, readonly, ref} from 'vue'
import {acceptHMRUpdate, defineStore} from 'pinia'
import {AuthenticatedHTTPFactory, HTTPFactory} from '@/helpers/fetcher'
import {getBrowserLanguage, i18n, setLanguage} from '@/i18n'
import {objectToSnakeCase} from '@/helpers/case'
import UserModel, {getAvatarUrl, getDisplayName} from '@/models/user'
import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {setModuleLoading} from '@/stores/helper'
import {success} from '@/message'
import {
getRedirectUrlFromCurrentFrontendPath,
redirectToProvider,
redirectToProviderOnLogout,
} from '@/helpers/redirectToProvider'
import {AUTH_TYPES, type IUser} from '@/modelTypes/IUser'
import type {IUserSettings} from '@/modelTypes/IUserSettings'
import router from '@/router'
import {useConfigStore} from '@/stores/config'
import UserSettingsModel from '@/models/userSettings'
import {MILLISECONDS_A_SECOND} from '@/constants/date'
import {PrefixMode} from '@/modules/parseTaskText'
import type {IProvider} from '@/types/IProvider'
function redirectToProviderIfNothingElseIsEnabled() {
const {auth} = useConfigStore()
if (
auth.local.enabled === false &&
auth.openidConnect.enabled &&
auth.openidConnect.providers?.length === 1 &&
(window.location.pathname.startsWith('/login') || window.location.pathname === '/') && // Kinda hacky, but prevents an endless loop.
window.location.search.includes('redirectToProvider=true')
) {
redirectToProvider(auth.openidConnect.providers[0])
}
}
export const useAuthStore = defineStore('auth', () => {
const authenticated = ref(false)
const isLinkShareAuth = ref(false)
const needsTotpPasscode = ref(false)
const info = ref<IUser | null>(null)
const avatarUrl = ref('')
const settings = ref<IUserSettings>(new UserSettingsModel())
const lastUserInfoRefresh = ref<Date | null>(null)
const isLoading = ref(false)
const isLoadingGeneralSettings = ref(false)
const authUser = computed(() => {
return authenticated.value && (
info.value &&
info.value.type === AUTH_TYPES.USER
)
})
const authLinkShare = computed(() => {
return authenticated.value && (
info.value &&
info.value.type === AUTH_TYPES.LINK_SHARE
)
})
const userDisplayName = computed(() => info.value ? getDisplayName(info.value) : undefined)
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
function setIsLoadingGeneralSettings(isLoading: boolean) {
isLoadingGeneralSettings.value = isLoading
}
function setUser(newUser: IUser | null, saveSettings = true) {
info.value = newUser
if (newUser !== null) {
reloadAvatar()
if (saveSettings && newUser.settings) {
loadSettings(newUser.settings)
}
isLinkShareAuth.value = newUser.id < 0
}
}
function setUserSettings(newSettings: IUserSettings) {
loadSettings(newSettings)
info.value = new UserModel({
...info.value !== null ? info.value : {},
name: newSettings.name,
})
}
function loadSettings(newSettings: IUserSettings) {
settings.value = new UserSettingsModel({
...newSettings,
frontendSettings: {
// Need to set default settings here in case the user does not have any saved in the api already
playSoundWhenDone: true,
quickAddMagicMode: PrefixMode.Default,
colorSchema: 'auto',
...newSettings.frontendSettings,
},
})
// console.log('settings from auth store', {...settings.value.frontendSettings})
}
function setAuthenticated(newAuthenticated: boolean) {
authenticated.value = newAuthenticated
}
function setIsLinkShareAuth(newIsLinkShareAuth: boolean) {
isLinkShareAuth.value = newIsLinkShareAuth
}
function setNeedsTotpPasscode(newNeedsTotpPasscode: boolean) {
needsTotpPasscode.value = newNeedsTotpPasscode
}
function reloadAvatar() {
if (!info.value) return
avatarUrl.value = `${getAvatarUrl(info.value)}&=${new Date().valueOf()}`
}
function updateLastUserRefresh() {
lastUserInfoRefresh.value = new Date()
}
// Logs a user in with a set of credentials.
async function login(credentials) {
const HTTP = HTTPFactory()
setIsLoading(true)
// Delete an eventually preexisting old token
removeToken()
try {
const response = await HTTP.post('login', objectToSnakeCase(credentials))
// Save the token to local storage for later use
saveToken(response.data.token, true)
// Tell others the user is autheticated
await checkAuth()
} catch (e) {
if (
e.response &&
e.response.data.code === 1017 &&
!credentials.totpPasscode
) {
setNeedsTotpPasscode(true)
}
throw e
} finally {
setIsLoading(false)
}
}
/**
* Registers a new user and logs them in.
* Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
*/
async function register(credentials) {
const HTTP = HTTPFactory()
setIsLoading(true)
try {
await HTTP.post('register', credentials)
return login(credentials)
} catch (e) {
if (e.response?.data?.message) {
throw e.response.data
}
throw e
} finally {
setIsLoading(false)
}
}
async function openIdAuth({provider, code}) {
const HTTP = HTTPFactory()
setIsLoading(true)
const {auth} = useConfigStore()
const fullProvider: IProvider = auth.openidConnect.providers.find((p: IProvider) => p.key === provider)
const data = {
code: code,
redirect_url: getRedirectUrlFromCurrentFrontendPath(fullProvider),
}
// Delete an eventually preexisting old token
removeToken()
try {
const response = await HTTP.post(`/auth/openid/${provider}/callback`, data)
// Save the token to local storage for later use
saveToken(response.data.token, true)
// Tell others the user is autheticated
await checkAuth()
} finally {
setIsLoading(false)
}
}
async function linkShareAuth({hash, password}) {
const HTTP = HTTPFactory()
const response = await HTTP.post('/shares/' + hash + '/auth', {
password: password,
})
saveToken(response.data.token, false)
await checkAuth()
return response.data
}
/**
* Populates user information from jwt token saved in local storage in store
*/
async function checkAuth() {
const now = new Date()
const inOneMinute = new Date(new Date().setMinutes(now.getMinutes() + 1))
// This function can be called from multiple places at the same time and shortly after one another.
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
if (
lastUserInfoRefresh.value !== null &&
lastUserInfoRefresh.value > inOneMinute
) {
return
}
const jwt = getToken()
let isAuthenticated = false
if (jwt) {
try {
const base64 = jwt
.split('.')[1]
.replace('-', '+')
.replace('_', '/')
const info = new UserModel(JSON.parse(atob(base64)))
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
isAuthenticated = info.exp >= ts
// Settings should only be loaded from the api request, not via the jwt
setUser(info, false)
} catch (e) {
logout()
}
if (isAuthenticated) {
await refreshUserInfo()
}
}
setAuthenticated(isAuthenticated)
if (!isAuthenticated) {
setUser(null)
redirectToProviderIfNothingElseIsEnabled()
}
return Promise.resolve(authenticated)
}
async function refreshUserInfo() {
const jwt = getToken()
if (!jwt) {
return
}
const HTTP = AuthenticatedHTTPFactory()
try {
const response = await HTTP.get('user')
const newUser = new UserModel({
...response.data,
...(info.value?.type && {type: info.value?.type}),
...(info.value?.email && {email: info.value?.email}),
...(info.value?.exp && {exp: info.value?.exp}),
})
if (newUser.settings.language) {
await setLanguage(newUser.settings.language)
}
setUser(newUser)
updateLastUserRefresh()
if (
newUser.type === AUTH_TYPES.USER &&
(
typeof newUser.settings.language === 'undefined' ||
newUser.settings.language === ''
)
) {
// save current language
await saveUserSettings({
settings: {
...settings.value,
language: settings.value.language ? settings.value.language : getBrowserLanguage(),
},
showMessage: false,
})
}
return newUser
} catch (e) {
if(e?.response?.status === 401 ||
e?.response?.data?.message === 'missing, malformed, expired or otherwise invalid token provided') {
await logout()
return
}
console.log('continuerd')
const cause = {e}
if (typeof e?.response?.data?.message !== 'undefined') {
cause.message = e.response.data.message
}
console.error('Error refreshing user info:', e)
throw new Error('Error while refreshing user info:', {cause})
}
}
/**
* Try to verify the email
*/
async function verifyEmail(): Promise<boolean> {
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
if (emailVerifyToken) {
const stopLoading = setModuleLoading(setIsLoading)
try {
await HTTPFactory().post('user/confirm', {token: emailVerifyToken})
return true
} catch(e) {
throw new Error(e.response.data.message)
} finally {
localStorage.removeItem('emailConfirmToken')
stopLoading()
}
}
return false
}
async function saveUserSettings({
settings,
showMessage = true,
}: {
settings: IUserSettings
showMessage : boolean
}) {
const userSettingsService = new UserSettingsService()
const cancel = setModuleLoading(setIsLoadingGeneralSettings)
try {
const updateSettingsPromise = userSettingsService.update(settings)
setUserSettings({...settings})
await setLanguage(settings.language)
await updateSettingsPromise
if (showMessage) {
success({message: i18n.global.t('user.settings.general.savedSuccess')})
}
} catch (e) {
throw new Error('Error while saving user settings:', {cause: e})
} finally {
cancel()
}
}
/**
* Renews the api token and saves it to local storage
*/
function renewToken() {
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
// the same time and one might win over the other.
setTimeout(async () => {
if (!authenticated.value) {
return
}
try {
await refreshToken(!isLinkShareAuth.value)
await checkAuth()
} catch (e) {
// Don't logout on network errors as the user would then get logged out if they don't have
// internet for a short period of time - such as when the laptop is still reconnecting
if (e?.request?.status) {
await logout()
}
}
}, 5000)
}
async function logout() {
removeToken()
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
await router.push({name: 'user.login'})
await checkAuth()
// if configured, redirect to OIDC Provider on logout
const {auth} = useConfigStore()
if (
auth.local.enabled === false &&
auth.openidConnect.enabled &&
auth.openidConnect.providers?.length === 1)
{
redirectToProviderOnLogout(auth.openidConnect.providers[0])
}
}
return {
// state
authenticated: readonly(authenticated),
isLinkShareAuth: readonly(isLinkShareAuth),
needsTotpPasscode: readonly(needsTotpPasscode),
info: readonly(info),
avatarUrl: readonly(avatarUrl),
settings: readonly(settings),
lastUserInfoRefresh: readonly(lastUserInfoRefresh),
authUser,
authLinkShare,
userDisplayName,
isLoading: readonly(isLoading),
setIsLoading,
isLoadingGeneralSettings: readonly(isLoadingGeneralSettings),
setIsLoadingGeneralSettings,
setUser,
setUserSettings,
setAuthenticated,
setIsLinkShareAuth,
setNeedsTotpPasscode,
reloadAvatar,
updateLastUserRefresh,
login,
register,
openIdAuth,
linkShareAuth,
checkAuth,
refreshUserInfo,
verifyEmail,
saveUserSettings,
renewToken,
logout,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot))
}

175
frontend/src/stores/base.ts Normal file
View File

@ -0,0 +1,175 @@
import { readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import ProjectModel from '@/models/project'
import ProjectService from '../services/project'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {useMenuActive} from '@/composables/useMenuActive'
import {useAuthStore} from '@/stores/auth'
import type {IProject} from '@/modelTypes/IProject'
import type {Right} from '@/constants/rights'
export const useBaseStore = defineStore('base', () => {
const loading = ref(false)
const ready = ref(false)
// This is used to highlight the current project in menu for all project related views
const currentProject = ref<IProject | null>(new ProjectModel({
id: 0,
isArchived: false,
}))
const background = ref('')
const blurHash = ref('')
const hasTasks = ref(false)
const keyboardShortcutsActive = ref(false)
const quickActionsActive = ref(false)
const logoVisible = ref(true)
const updateAvailable = ref(false)
function setLoading(newLoading: boolean) {
loading.value = newLoading
}
function setCurrentProject(newCurrentProject: IProject | null) {
// Server updates don't return the right. Therefore, the right is reset after updating the project which is
// confusing because all the buttons will disappear in that case. To prevent this, we're keeping the right
// when updating the project in global state.
let maxRight: Right | null = newCurrentProject?.maxRight || null
if (
typeof currentProject.value?.maxRight !== 'undefined' &&
newCurrentProject !== null &&
(
typeof newCurrentProject.maxRight === 'undefined' ||
newCurrentProject.maxRight === null
)
) {
maxRight = currentProject.value.maxRight
}
if (newCurrentProject === null) {
currentProject.value = null
return
}
currentProject.value = {
...newCurrentProject,
maxRight,
}
}
function setHasTasks(newHasTasks: boolean) {
hasTasks.value = newHasTasks
}
function setKeyboardShortcutsActive(value: boolean) {
keyboardShortcutsActive.value = value
}
function setQuickActionsActive(value: boolean) {
quickActionsActive.value = value
}
function setBackground(newBackground: string) {
background.value = newBackground
}
function setBlurHash(newBlurHash: string) {
blurHash.value = newBlurHash
}
function setLogoVisible(visible: boolean) {
logoVisible.value = visible
}
function setReady(value: boolean) {
ready.value = value
}
function setUpdateAvailable(value: boolean) {
updateAvailable.value = value
}
async function handleSetCurrentProject(
{project, forceUpdate = false}: {project: IProject | null, forceUpdate?: boolean},
) {
if (project === null || typeof project === 'undefined') {
setCurrentProject({})
setBackground('')
setBlurHash('')
return
}
// The forceUpdate parameter is used only when updating a project background directly because in that case
// the current project stays the same, but we want to show the new background right away.
if (project.id !== currentProject.value?.id || forceUpdate) {
if (project.backgroundInformation) {
try {
const blurHash = await getBlobFromBlurHash(project.backgroundBlurHash)
if (blurHash) {
setBlurHash(window.URL.createObjectURL(blurHash))
}
const projectService = new ProjectService()
const background = await projectService.background(project)
setBackground(background)
} catch (e) {
console.error('Error getting background image for project', project.id, e)
}
}
}
if (
typeof project.backgroundInformation === 'undefined' ||
project.backgroundInformation === null
) {
setBackground('')
setBlurHash('')
}
setCurrentProject(project)
}
const authStore = useAuthStore()
async function loadApp() {
await checkAndSetApiUrl(window.API_URL)
await authStore.checkAuth()
ready.value = true
}
return {
loading: readonly(loading),
ready: readonly(ready),
currentProject: readonly(currentProject),
background: readonly(background),
blurHash: readonly(blurHash),
hasTasks: readonly(hasTasks),
keyboardShortcutsActive: readonly(keyboardShortcutsActive),
quickActionsActive: readonly(quickActionsActive),
logoVisible: readonly(logoVisible),
updateAvailable: readonly(updateAvailable),
setLoading,
setReady,
setCurrentProject,
setHasTasks,
setKeyboardShortcutsActive,
setQuickActionsActive,
setBackground,
setBlurHash,
setLogoVisible,
setUpdateAvailable,
handleSetCurrentProject,
loadApp,
...useMenuActive(),
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useBaseStore, import.meta.hot))
}

View File

@ -0,0 +1,109 @@
import {computed, reactive, toRefs} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import {parseURL} from 'ufo'
import {HTTPFactory} from '@/helpers/fetcher'
import {objectToCamelCase} from '@/helpers/case'
import type {IProvider} from '@/types/IProvider'
import type {MIGRATORS} from '@/views/migrate/migrators'
export interface ConfigState {
version: string,
frontendUrl: string,
motd: string,
linkSharingEnabled: boolean,
maxFileSize: string,
registrationEnabled: boolean,
availableMigrators: Array<keyof typeof MIGRATORS>,
taskAttachmentsEnabled: boolean,
totpEnabled: boolean,
enabledBackgroundProviders: Array<'unsplash' | 'upload'>,
legal: {
imprintUrl: string,
privacyPolicyUrl: string,
},
caldavEnabled: boolean,
userDeletionEnabled: boolean,
taskCommentsEnabled: boolean,
demoModeEnabled: boolean,
auth: {
local: {
enabled: boolean,
},
openidConnect: {
enabled: boolean,
redirectUrl: string,
providers: IProvider[],
},
},
}
export const useConfigStore = defineStore('config', () => {
const state = reactive({
// These are the api defaults.
version: '',
frontendUrl: '',
motd: '',
linkSharingEnabled: true,
maxFileSize: '20MB',
registrationEnabled: true,
availableMigrators: [],
taskAttachmentsEnabled: true,
totpEnabled: true,
enabledBackgroundProviders: [],
legal: {
imprintUrl: '',
privacyPolicyUrl: '',
},
caldavEnabled: false,
userDeletionEnabled: true,
taskCommentsEnabled: true,
demoModeEnabled: false,
auth: {
local: {
enabled: true,
},
openidConnect: {
enabled: false,
redirectUrl: '',
providers: [],
},
},
})
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)
const apiBase = computed(() => {
const {host, protocol} = parseURL(window.API_URL)
return protocol + '//' + host
})
function setConfig(config: ConfigState) {
Object.assign(state, config)
}
async function update(): Promise<boolean> {
const HTTP = HTTPFactory()
const {data: config} = await HTTP.get('info')
if (typeof config.version === 'undefined') {
return false
}
setConfig(objectToCamelCase(config))
const success = !!config
return success
}
return {
...toRefs(state),
migratorsEnabled,
apiBase,
setConfig,
update,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useConfigStore, import.meta.hot))
}

View File

@ -0,0 +1,9 @@
const LOADING_TIMEOUT = 100
export function setModuleLoading(loadFunc: (isLoading: boolean) => void) {
const timeout = setTimeout(() => loadFunc(true), LOADING_TIMEOUT)
return () => {
clearTimeout(timeout)
loadFunc(false)
}
}

View File

@ -0,0 +1,399 @@
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)
buckets.value[bucketIndex].count--
}
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))
}

View File

@ -0,0 +1,55 @@
import {setActivePinia, createPinia} from 'pinia'
import {describe, it, expect, beforeEach} from 'vitest'
import {useLabelStore} from './labels'
import type { ILabel } from '@/modelTypes/ILabel'
const MOCK_LABELS = {
1: {id: 1, title: 'label1'},
2: {id: 2, title: 'label2'},
3: {id: 3, title: 'label3'},
4: {id: 4, title: 'label4'},
5: {id: 5, title: 'label5'},
6: {id: 6, title: 'label6'},
7: {id: 7, title: 'label7'},
8: {id: 8, title: 'label8'},
9: {id: 9, title: 'label9'},
}
function setupStore() {
const store = useLabelStore()
store.setLabels(Object.values(MOCK_LABELS) as ILabel[])
return store
}
describe('filter labels', () => {
beforeEach(() => {
// creates a fresh pinia and make it active so it's automatically picked
// up by any useStore() call without having to pass it to it:
// `useStore(pinia)`
setActivePinia(createPinia())
})
it('should return an empty array for an empty query', () => {
const store = setupStore()
const labels = store.filterLabelsByQuery([], '')
expect(labels).toHaveLength(0)
})
it('should return labels for a query', () => {
const store = setupStore()
const labels = store.filterLabelsByQuery([], 'label2')
expect(labels).toHaveLength(1)
expect(labels[0].title).toBe('label2')
})
it('should not return found but hidden labels', () => {
const store = setupStore()
const labelsToHide = [{id: 1, title: 'label1'}] as ILabel[]
const labels = store.filterLabelsByQuery(labelsToHide, 'label1')
expect(labels).toHaveLength(0)
})
})

View File

@ -0,0 +1,158 @@
import {computed, ref} from 'vue'
import {acceptHMRUpdate, defineStore} from 'pinia'
import LabelService from '@/services/label'
import {success} from '@/message'
import {i18n} from '@/i18n'
import {createNewIndexer} from '@/indexes'
import {setModuleLoading} from '@/stores/helper'
import type {ILabel} from '@/modelTypes/ILabel'
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1): Promise<ILabel[]> {
const labelService = new LabelService()
const labels = await labelService.getAll({}, {}, page) as ILabel[]
if (page < labelService.totalPages) {
const nextLabels = await getAllLabels(page + 1)
return labels.concat(nextLabels)
} else {
return labels
}
}
export interface LabelState {
[id: ILabel['id']]: ILabel
}
export const useLabelStore = defineStore('label', () => {
// The labels are stored as an object which has the label ids as keys.
const labels = ref<LabelState>({})
const isLoading = ref(false)
const getLabelsByIds = computed(() => {
return (ids: ILabel['id'][]) => Object.values(labels.value).filter(({id}) => ids.includes(id))
})
// **
// * Checks if a project of labels is available in the store and filters them then query
// **
const filterLabelsByQuery = computed(() => {
return (labelsToHide: ILabel[], query: string) => {
const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
return search(query)
?.filter(value => !labelIdsToHide.includes(value))
.map(id => labels.value[id])
|| []
}
})
const getLabelsByExactTitles = computed(() => {
return (labelTitles: string[]) => Object
.values(labels.value)
.filter(({title}) => labelTitles.some(l => l.toLowerCase() === title.toLowerCase()))
})
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
function setLabels(newLabels: ILabel[]) {
newLabels.forEach(l => {
labels.value[l.id] = l
add(l)
})
}
function setLabel(label: ILabel) {
labels.value[label.id] = {...label}
update(label)
}
function removeLabelById(label: ILabel) {
remove(label)
delete labels.value[label.id]
}
async function loadAllLabels({forceLoad} : {forceLoad?: boolean} = {}) {
if (isLoading.value && !forceLoad) {
return
}
const cancel = setModuleLoading(setIsLoading)
try {
const newLabels = await getAllLabels()
setLabels(newLabels)
return newLabels
} finally {
cancel()
}
}
async function deleteLabel(label: ILabel) {
const cancel = setModuleLoading(setIsLoading)
const labelService = new LabelService()
try {
const result = await labelService.delete(label)
removeLabelById(label)
success({message: i18n.global.t('label.deleteSuccess')})
return result
} finally {
cancel()
}
}
async function updateLabel(label: ILabel) {
const cancel = setModuleLoading(setIsLoading)
const labelService = new LabelService()
try {
const newLabel = await labelService.update(label)
setLabel(newLabel)
success({message: i18n.global.t('label.edit.success')})
return newLabel
} finally {
cancel()
}
}
async function createLabel(label: ILabel) {
const cancel = setModuleLoading(setIsLoading)
const labelService = new LabelService()
try {
const newLabel = await labelService.create(label) as ILabel
setLabel(newLabel)
return newLabel
} finally {
cancel()
}
}
return {
labels,
isLoading,
getLabelsByIds,
filterLabelsByQuery,
getLabelsByExactTitles,
setLabels,
setLabel,
removeLabelById,
loadAllLabels,
deleteLabel,
updateLabel,
createLabel,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useLabelStore, import.meta.hot))
}

View File

@ -0,0 +1,283 @@
import {watch, reactive, shallowReactive, unref, readonly, ref, computed} from 'vue'
import {acceptHMRUpdate, defineStore} from 'pinia'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import ProjectService from '@/services/project'
import ProjectDuplicateService from '@/services/projectDuplicateService'
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
import {setModuleLoading} from '@/stores/helper'
import {removeProjectFromHistory} from '@/modules/projectHistory'
import {createNewIndexer} from '@/indexes'
import type {IProject} from '@/modelTypes/IProject'
import type {MaybeRef} from '@vueuse/core'
import ProjectModel from '@/models/project'
import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
import {getSavedFilterIdFromProjectId} from '@/services/savedFilter'
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
export interface ProjectState {
[id: IProject['id']]: IProject
}
export const useProjectStore = defineStore('project', () => {
const baseStore = useBaseStore()
const router = useRouter()
const isLoading = ref(false)
// The projects are stored as an object which has the project ids as keys.
const projects = ref<ProjectState>({})
const projectsArray = computed(() => Object.values(projects.value)
.sort((a, b) => a.position - b.position))
const notArchivedRootProjects = computed(() => projectsArray.value
.filter(p => p.parentProjectId === 0 && !p.isArchived && p.id > 0))
const favoriteProjects = computed(() => projectsArray.value
.filter(p => !p.isArchived && p.isFavorite))
const savedFilterProjects = computed(() => projectsArray.value
.filter(p => !p.isArchived && p.id < -1))
const hasProjects = computed(() => projectsArray.value.length > 0)
const getChildProjects = computed(() => {
return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
})
const findProjectByExactname = computed(() => {
return (name: string) => {
const project = Object.values(projects.value).find(l => {
return l.title.toLowerCase() === name.toLowerCase()
})
return typeof project === 'undefined' ? null : project
}
})
const findProjectByIdentifier = computed(() => {
return (identifier: string) => {
const project = Object.values(projects.value).find(p => {
return p.identifier.toLowerCase() === identifier.toLowerCase()
})
return typeof project === 'undefined' ? null : project
}
})
const searchProject = computed(() => {
return (query: string, includeArchived = false) => {
return search(query)
?.filter(value => value > 0)
.map(id => projects.value[id])
.filter(project => project.isArchived === includeArchived)
|| []
}
})
const searchSavedFilter = computed(() => {
return (query: string, includeArchived = false) => {
return search(query)
?.filter(value => getSavedFilterIdFromProjectId(value) > 0)
.map(id => projects.value[id])
.filter(project => project.isArchived === includeArchived)
|| []
}
})
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
function setProject(project: IProject) {
projects.value[project.id] = project
update(project)
// FIXME: This should be a watcher, but using a watcher instead will sometimes crash browser processes.
// Reverted from 31b7c1f217532bf388ba95a03f469508bee46f6a
if (baseStore.currentProject?.id === project.id) {
baseStore.setCurrentProject(project)
}
}
function setProjects(newProjects: IProject[]) {
newProjects.forEach(p => setProject(p))
}
function removeProjectById(project: IProject) {
remove(project)
delete projects.value[project.id]
}
function toggleProjectFavorite(project: IProject) {
// The favorites pseudo project is always favorite
// Archived projects cannot be marked favorite
if (project.id === -1 || project.isArchived) {
return
}
return updateProject({
...project,
isFavorite: !project.isFavorite,
})
}
async function createProject(project: IProject) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const createdProject = await projectService.create(project)
setProject(createdProject)
router.push({
name: 'project.index',
params: { projectId: createdProject.id },
})
return createdProject
} finally {
cancel()
}
}
async function updateProject(project: IProject) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const updatedProject = await projectService.update(project)
setProject(project)
// the returned project from projectService.update is the same!
// in order to not create a manipulation in pinia store we have to create a new copy
return updatedProject
} catch (e) {
// Reset the project state to the initial one to avoid confusion for the user
setProject({
...project,
isFavorite: !project.isFavorite,
})
throw e
} finally {
cancel()
}
}
async function deleteProject(project: IProject) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const response = await projectService.delete(project)
removeProjectById(project)
removeProjectFromHistory({id: project.id})
return response
} finally {
cancel()
}
}
async function loadProjects() {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
projects.value = {}
setProjects(loadedProjects)
loadedProjects.forEach(p => add(p))
return loadedProjects
} finally {
cancel()
}
}
function getAncestors(project: IProject): IProject[] {
if (!project?.parentProjectId) {
return [project]
}
const parentProject = projects.value[project.parentProjectId]
return [
...getAncestors(parentProject),
project,
]
}
return {
isLoading: readonly(isLoading),
projects: readonly(projects),
projectsArray: readonly(projectsArray),
notArchivedRootProjects: readonly(notArchivedRootProjects),
favoriteProjects: readonly(favoriteProjects),
hasProjects: readonly(hasProjects),
savedFilterProjects: readonly(savedFilterProjects),
getChildProjects,
findProjectByExactname,
findProjectByIdentifier,
searchProject,
searchSavedFilter,
setProject,
setProjects,
removeProjectById,
toggleProjectFavorite,
loadProjects,
createProject,
updateProject,
deleteProject,
getAncestors,
}
})
export function useProject(projectId: MaybeRef<IProject['id']>) {
const projectService = shallowReactive(new ProjectService())
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
const isLoading = computed(() => projectService.loading || projectDuplicateService.loading)
const project: IProject = reactive(new ProjectModel())
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const projectStore = useProjectStore()
watch(
() => unref(projectId),
async (projectId) => {
const loadedProject = await projectService.get(new ProjectModel({id: projectId}))
Object.assign(project, loadedProject)
},
{immediate: true},
)
async function save() {
const updatedProject = await projectStore.updateProject(project)
Object.assign(project, updatedProject)
success({message: t('project.edit.success')})
}
async function duplicateProject(parentProjectId: IProject['id']) {
const projectDuplicate = new ProjectDuplicateModel({
projectId: Number(unref(projectId)),
parentProjectId,
})
const duplicate = await projectDuplicateService.create(projectDuplicate)
projectStore.setProject(duplicate.duplicatedProject)
success({message: t('project.duplicate.success')})
router.push({name: 'project.index', params: {projectId: duplicate.duplicatedProject.id}})
}
return {
isLoading: readonly(isLoading),
project,
save,
duplicateProject,
}
}
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
}

View File

@ -0,0 +1,508 @@
import {computed, ref} from 'vue'
import {acceptHMRUpdate, defineStore} from 'pinia'
import router from '@/router'
import TaskService from '@/services/task'
import TaskAssigneeService from '@/services/taskAssignee'
import LabelTaskService from '@/services/labelTask'
import {cleanupItemText, parseTaskText, PREFIXES} from '@/modules/parseTaskText'
import TaskAssigneeModel from '@/models/taskAssignee'
import LabelTaskModel from '@/models/labelTask'
import LabelTask from '@/models/labelTask'
import TaskModel from '@/models/task'
import LabelModel from '@/models/label'
import type {ILabel} from '@/modelTypes/ILabel'
import type {ITask} from '@/modelTypes/ITask'
import type {IUser} from '@/modelTypes/IUser'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {IProject} from '@/modelTypes/IProject'
import {setModuleLoading} from '@/stores/helper'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useAttachmentStore} from '@/stores/attachments'
import {useKanbanStore} from '@/stores/kanban'
import {useBaseStore} from '@/stores/base'
import ProjectUserService from '@/services/projectUsers'
import {useAuthStore} from '@/stores/auth'
import TaskCollectionService from '@/services/taskCollection'
import {getRandomColorHex} from '@/helpers/color/randomColor'
interface MatchedAssignee extends IUser {
match: string,
}
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value, fuzzy = false) {
return Object.values(object).find(l => {
if (fuzzy) {
return l[key]?.toLowerCase().includes(value.toLowerCase())
}
return l[key]?.toLowerCase() === value.toLowerCase()
})
}
// Check if the user exists in the search results
function validateUser(
users: IUser[],
query: IUser['username'] | IUser['name'] | IUser['email'],
) {
if (users.length === 1) {
return (
findPropertyByValue(users, 'username', query, true) ||
findPropertyByValue(users, 'name', query, true) ||
findPropertyByValue(users, 'email', query, true)
)
}
return (
findPropertyByValue(users, 'username', query) ||
findPropertyByValue(users, 'name', query) ||
findPropertyByValue(users, 'email', query)
)
}
// Check if the label exists
function validateLabel(labels: ILabel[], label: string) {
return findPropertyByValue(labels, 'title', label)
}
async function addLabelToTask(task: ITask, label: ILabel) {
const labelTask = new LabelTask({
taskId: task.id,
labelId: label.id,
})
const labelTaskService = new LabelTaskService()
const response = await labelTaskService.create(labelTask)
task.labels.push(label)
return response
}
async function findAssignees(parsedTaskAssignees: string[], projectId: number): Promise<MatchedAssignee[]> {
if (parsedTaskAssignees.length <= 0) {
return []
}
const userService = new ProjectUserService()
const assignees = parsedTaskAssignees.map(async a => {
const users = (await userService.getAll({projectId}, {s: a}))
.map(u => ({
...u,
match: a,
}))
return validateUser(users, a)
})
const validatedUsers = await Promise.all(assignees)
return validatedUsers.filter((item) => Boolean(item))
}
export const useTaskStore = defineStore('task', () => {
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const attachmentStore = useAttachmentStore()
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
const isLoading = ref(false)
const hasTasks = computed(() => Object.keys(tasks.value).length > 0)
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
function setTasks(newTasks: ITask[]) {
newTasks.forEach(task => {
tasks.value[task.id] = task
})
}
async function loadTasks(params, projectId: IProject['id'] | null = null) {
const cancel = setModuleLoading(setIsLoading)
try {
if (projectId === null) {
const taskService = new TaskService()
tasks.value = await taskService.getAll({}, params)
} else {
const taskCollectionService = new TaskCollectionService()
tasks.value = await taskCollectionService.getAll({projectId}, params)
}
baseStore.setHasTasks(tasks.value.length > 0)
return tasks.value
} finally {
cancel()
}
}
async function update(task: ITask) {
const cancel = setModuleLoading(setIsLoading)
const taskService = new TaskService()
try {
const updatedTask = await taskService.update(task)
kanbanStore.setTaskInBucket(updatedTask)
return updatedTask
} finally {
cancel()
}
}
async function deleteTask(task: ITask) {
const taskService = new TaskService()
const response = await taskService.delete(task)
kanbanStore.removeTaskInBucket(task)
return response
}
// Adds a task attachment in store.
// This is an action to be able to commit other mutations
function addTaskAttachment({
taskId,
attachment,
}: {
taskId: ITask['id']
attachment: IAttachment
}) {
const t = kanbanStore.getTaskById(taskId)
if (t.task !== null) {
const attachments = [
...t.task.attachments,
attachment,
]
const newTask = {
...t,
task: {
...t.task,
attachments,
},
}
kanbanStore.setTaskInBucketByIndex(newTask)
}
attachmentStore.add(attachment)
}
async function addAssignee({
user,
taskId,
}: {
user: IUser,
taskId: ITask['id']
}) {
const cancel = setModuleLoading(setIsLoading)
try {
const taskAssigneeService = new TaskAssigneeService()
const r = await taskAssigneeService.create(new TaskAssigneeModel({
userId: user.id,
taskId: taskId,
}))
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
// Vuex seems to have its difficulties with that, so we just log the error and fail silently.
console.debug('Could not add assignee to task in kanban, task not found', t)
return r
}
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
assignees: [
...t.task.assignees,
user,
],
},
})
return r
} finally {
cancel()
}
}
async function removeAssignee({
user,
taskId,
}: {
user: IUser,
taskId: ITask['id']
}) {
const taskAssigneeService = new TaskAssigneeService()
const response = await taskAssigneeService.delete(new TaskAssigneeModel({
userId: user.id,
taskId: taskId,
}))
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
// Vuex seems to have its difficulties with that, so we just log the error and fail silently.
console.debug('Could not remove assignee from task in kanban, task not found', t)
return response
}
const assignees = t.task.assignees.filter(({ id }) => id !== user.id)
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
assignees,
},
})
return response
}
async function addLabel({
label,
taskId,
} : {
label: ILabel,
taskId: ITask['id']
}) {
const labelTaskService = new LabelTaskService()
const r = await labelTaskService.create(new LabelTaskModel({
taskId,
labelId: label.id,
}))
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
// Vuex seems to have its difficulties with that, so we just log the error and fail silently.
console.debug('Could not add label to task in kanban, task not found', {taskId, t})
return r
}
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
labels: [
...t.task.labels,
label,
],
},
})
return r
}
async function removeLabel(
{label, taskId}:
{label: ILabel, taskId: ITask['id']},
) {
const labelTaskService = new LabelTaskService()
const response = await labelTaskService.delete(new LabelTaskModel({
taskId, labelId:
label.id,
}))
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
// Vuex seems to have its difficulties with that, so we just log the error and fail silently.
console.debug('Could not remove label from task in kanban, task not found', t)
return response
}
// Remove the label from the project
const labels = t.task.labels.filter(({ id }) => id !== label.id)
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
labels,
},
})
return response
}
async function ensureLabelsExist(labels: string[]): Promise<LabelModel[]> {
const all = [...new Set(labels)]
const mustCreateLabel = all.map(async labelTitle => {
let label = validateLabel(Object.values(labelStore.labels), labelTitle)
if (typeof label === 'undefined') {
// label not found, create it
const labelModel = new LabelModel({
title: labelTitle,
hexColor: getRandomColorHex(),
})
label = await labelStore.createLabel(labelModel)
}
return label
})
return Promise.all(mustCreateLabel)
}
// Do everything that is involved in finding, creating and adding the label to the task
async function addLabelsToTask(
{ task, parsedLabels }:
{ task: ITask, parsedLabels: string[] },
) {
if (parsedLabels.length <= 0) {
return task
}
const labels = await ensureLabelsExist(parsedLabels)
const labelAddsToWaitFor = labels.map(async l => addLabelToTask(task, l))
// This waits until all labels are created and added to the task
await Promise.all(labelAddsToWaitFor)
return task
}
function findProjectId(
{ project: projectName, projectId }:
{ project: string, projectId: IProject['id'] }) {
let foundProjectId = null
// Uses the following ways to get the project id of the new task:
// 1. If specified in quick add magic, look in store if it exists and use it if it does
if (typeof projectName !== 'undefined' && projectName !== null) {
let project = projectStore.findProjectByExactname(projectName)
if (project === null) {
project = projectStore.findProjectByIdentifier(projectName)
}
foundProjectId = project === null ? null : project.id
}
// 2. Else check if a project was passed as parameter
if (foundProjectId === null && projectId !== 0) {
foundProjectId = projectId
}
// 3. Otherwise use the id from the route parameter
if (typeof router.currentRoute.value.params.projectId !== 'undefined') {
foundProjectId = Number(router.currentRoute.value.params.projectId)
}
// 4. If none of the above worked, reject the promise with an error.
if (typeof foundProjectId === 'undefined' || projectId === null) {
throw new Error('NO_PROJECT')
}
return foundProjectId
}
async function createNewTask({
title,
bucketId,
projectId,
position,
} :
Partial<ITask>,
) {
const cancel = setModuleLoading(setIsLoading)
const quickAddMagicMode = authStore.settings.frontendSettings.quickAddMagicMode
const parsedTask = parseTaskText(title, quickAddMagicMode)
const foundProjectId = await findProjectId({
project: parsedTask.project,
projectId: projectId || 0,
})
if(foundProjectId === null || foundProjectId === 0) {
cancel()
throw new Error('NO_PROJECT')
}
const assignees = await findAssignees(parsedTask.assignees, foundProjectId)
// Only clean up those assignees from the task title which actually exist
let cleanedTitle = parsedTask.text
if (assignees.length > 0) {
const assigneePrefix = PREFIXES[quickAddMagicMode]?.assignee
if (assigneePrefix) {
cleanedTitle = cleanupItemText(cleanedTitle, assignees.map(a => a.match), assigneePrefix)
}
}
// I don't know why, but it all goes up in flames when I just pass in the date normally.
const dueDate = parsedTask.date !== null ? new Date(parsedTask.date).toISOString() : null
const task = new TaskModel({
title: cleanedTitle,
projectId: foundProjectId,
dueDate,
priority: parsedTask.priority,
assignees,
bucketId: bucketId || 0,
position,
})
task.repeatAfter = parsedTask.repeats
const taskService = new TaskService()
try {
const createdTask = await taskService.create(task)
return await addLabelsToTask({
task: createdTask,
parsedLabels: parsedTask.labels,
})
} finally {
cancel()
}
}
async function setCoverImage(task: ITask, attachment: IAttachment | null) {
return update({
...task,
coverImageAttachmentId: attachment ? attachment.id : 0,
})
}
async function toggleFavorite(task: ITask) {
const taskService = new TaskService()
task.isFavorite = !task.isFavorite
task = await taskService.update(task)
// reloading the projects list so that the Favorites project shows up or is hidden when there are (or are not) favorite tasks
await projectStore.loadProjects()
return task
}
return {
tasks,
isLoading,
hasTasks,
setTasks,
loadTasks,
update,
delete: deleteTask, // since delete is a reserved word we have to alias here
addTaskAttachment,
addAssignee,
removeAssignee,
addLabel,
removeLabel,
addLabelsToTask,
createNewTask,
setCoverImage,
findProjectId,
ensureLabelsExist,
toggleFavorite,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useTaskStore, import.meta.hot))
}