import {watch, reactive, shallowReactive, toValue, readonly, ref, computed, type MaybeRefOrGetter} 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 type {IProject} from '@/modelTypes/IProject' import ProjectModel from '@/models/project' import {success} from '@/message' import {useBaseStore} from '@/stores/base' import SavedFilterService from '@/services/savedFilter' import {getSavedFilterIdFromProjectId, isSavedFilter} from '@/services/savedFilter' import SavedFilterModel from '@/models/savedFilter' import type {IProjectView} from '@/modelTypes/IProjectView' import {PERMISSIONS} from '@/constants/permissions.ts' 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<{ [id: IProject['id']]: IProject }>({}) const projectsArray = computed(() => Object.values(projects.value) .sort((a, b) => a.position - b.position)) // Check if a project is an orphaned sub-project (has a parent that isn't accessible) function isOrphanedSubProject(project: IProject): boolean { return project.parentProjectId !== 0 && !projects.value[project.parentProjectId] } const notArchivedRootProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && !p.isTemplate && p.id > 0 && ( p.parentProjectId === 0 || isOrphanedSubProject(p) ))) const favoriteProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && !p.isTemplate && p.isFavorite)) const savedFilterProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && p.id < -1)) const hasProjects = computed(() => projectsArray.value.length > 0) const templateProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && p.isTemplate)) const hasTemplates = computed(() => templateProjects.value.length > 0) const getChildProjects = computed(() => { return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id) }) // Get the effective parentProjectId for saving position changes. // For orphaned sub-projects shown at root level, preserve the original parentProjectId // to prevent accidentally detaching them from their real parent. function getEffectiveParentProjectId(project: IProject, parentProjectIdFromDom: number): number { if (parentProjectIdFromDom === 0 && isOrphanedSubProject(project)) { return project.parentProjectId } return parentProjectIdFromDom } const getAncestors = computed(() => { return (project: IProject): IProject[] => { if (typeof project === 'undefined') { return [] } if (!project?.parentProjectId) { return [project] } const parentProject = projects.value[project.parentProjectId] return [ ...(parentProject ? getAncestors.value(parentProject) : []), project, ] } }) const findProjectByExactname = computed(() => { return (name: string) => { const project = projectsArray.value.find(l => { return l.title.toLowerCase() === name.toLowerCase() }) return typeof project === 'undefined' ? null : project } }) const findProjectByIdentifier = computed(() => { return (identifier: string) => { const project = projectsArray.value.find(p => { return p.identifier.toLowerCase() === identifier.toLowerCase() }) return typeof project === 'undefined' ? null : project } }) function searchByQuery(query: string): IProject[] { if (query === '') return [] const q = query.toLowerCase() return projectsArray.value.filter(p => p.title.toLowerCase().includes(q) || (p.description ?? '').toLowerCase().includes(q), ) } const searchProjectAndFilter = computed(() => { return (query: string, includeArchived = false) => { return searchByQuery(query).filter(project => project.isArchived === includeArchived) } }) const searchProject = computed(() => { return (query: string, includeArchived = false) => { return searchByQuery(query) .filter(p => p.id > 0) .filter(project => project.isArchived === includeArchived) } }) const searchSavedFilter = computed(() => { return (query: string, includeArchived = false) => { return searchByQuery(query) .filter(p => getSavedFilterIdFromProjectId(p.id) > 0) .filter(project => project.isArchived === includeArchived) } }) function setIsLoading(newIsLoading: boolean) { isLoading.value = newIsLoading } function setProject(project: IProject) { projects.value[project.id] = 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 child projects from state as well projectsArray.value .filter(p => p.parentProjectId === project.id) .forEach(p => removeProjectById(p)) 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 } if (isSavedFilter(project)) { return toggleSavedFilterFavorite(project) } return updateProject({ ...project, isFavorite: !project.isFavorite, }) } async function toggleSavedFilterFavorite(project: IProject) { if (!isSavedFilter(project)) { return } const wasFavorite = project.isFavorite const filterId = getSavedFilterIdFromProjectId(project.id) const savedFilterService = new SavedFilterService() // Optimistically update the UI setProject({ ...project, isFavorite: !wasFavorite, }) try { const savedFilter = await savedFilterService.get(new SavedFilterModel({id: filterId})) savedFilter.isFavorite = !wasFavorite await savedFilterService.update(savedFilter) } catch (e) { setProject({ ...project, isFavorite: wasFavorite, }) throw e } } 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 loadAllProjects() { const cancel = setModuleLoading(setIsLoading) const projectService = new ProjectService() const loadedProjects: IProject[] = [] let page = 1 try { do { const newProjects = await projectService.getAll({}, {is_archived: true, is_template: true, expand: 'permissions'}, page) as IProject[] loadedProjects.push(...newProjects) page++ } while (page <= projectService.totalPages) } finally { cancel() } projects.value = {} setProjects(loadedProjects) return loadedProjects } function setProjectView(view: IProjectView) { const views = [...projects.value[view.projectId].views] const viewPos = views.findIndex(v => v.id === view.id) if (viewPos !== -1) { views[viewPos] = view } else { views.push(view) } views.sort((a, b) => a.position - b.position) setProject({ ...projects.value[view.projectId], views, }) } function removeProjectView(projectId: IProject['id'], viewId: IProjectView['id']) { const project = projects.value[projectId] const updatedViews = project.views.filter(v => v.id !== viewId) setProject({ ...project, views: updatedViews, }) } // Add method to ensure single project loading works for link shares async function loadProject(projectId: number) { const project = projects.value[projectId] if (project) { return project } try { const projectService = new ProjectService() const loadedProject = await projectService.get({id: projectId}) setProject(loadedProject) return loadedProject } catch (e) { console.error(`Failed to load project ${projectId}:`, e) throw e } } return { isLoading: readonly(isLoading), projects: readonly(projects), projectsArray: readonly(projectsArray), notArchivedRootProjects: readonly(notArchivedRootProjects), favoriteProjects: readonly(favoriteProjects), hasProjects: readonly(hasProjects), savedFilterProjects: readonly(savedFilterProjects), templateProjects: readonly(templateProjects), hasTemplates: readonly(hasTemplates), getChildProjects, isOrphanedSubProject, getEffectiveParentProjectId, findProjectByExactname, findProjectByIdentifier, searchProject, searchSavedFilter, searchProjectAndFilter, setProject, setProjects, removeProjectById, toggleProjectFavorite, loadAllProjects, loadProject, createProject, updateProject, deleteProject, getAncestors, setProjectView, removeProjectView, } }) export function useProject(projectId: MaybeRefOrGetter) { 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( () => toValue(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(toValue(projectId)), parentProjectId, }) const duplicate = await projectDuplicateService.create(projectDuplicate) if (duplicate.duplicatedProject) { duplicate.duplicatedProject.maxPermission = PERMISSIONS.ADMIN } 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)) }