chore: move frontend files
This commit is contained in:
37
frontend/src/stores/attachments.ts
Normal file
37
frontend/src/stores/attachments.ts
Normal 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
463
frontend/src/stores/auth.ts
Normal 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
175
frontend/src/stores/base.ts
Normal 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))
|
||||
}
|
109
frontend/src/stores/config.ts
Normal file
109
frontend/src/stores/config.ts
Normal 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))
|
||||
}
|
9
frontend/src/stores/helper.ts
Normal file
9
frontend/src/stores/helper.ts
Normal 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)
|
||||
}
|
||||
}
|
399
frontend/src/stores/kanban.ts
Normal file
399
frontend/src/stores/kanban.ts
Normal 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))
|
||||
}
|
55
frontend/src/stores/labels.test.ts
Normal file
55
frontend/src/stores/labels.test.ts
Normal 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)
|
||||
})
|
||||
})
|
158
frontend/src/stores/labels.ts
Normal file
158
frontend/src/stores/labels.ts
Normal 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))
|
||||
}
|
283
frontend/src/stores/projects.ts
Normal file
283
frontend/src/stores/projects.ts
Normal 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))
|
||||
}
|
508
frontend/src/stores/tasks.ts
Normal file
508
frontend/src/stores/tasks.ts
Normal 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))
|
||||
}
|
Reference in New Issue
Block a user