diff --git a/frontend/src/components/home/ProjectsNavigation.vue b/frontend/src/components/home/ProjectsNavigation.vue index 813ad5d20..a5c396900 100644 --- a/frontend/src/components/home/ProjectsNavigation.vue +++ b/frontend/src/components/home/ProjectsNavigation.vue @@ -89,7 +89,8 @@ async function saveProjectPosition(e: SortableEvent) { if (!project) return const parentNode = e.to.parentNode as HTMLElement | null - const parentProjectId = parentNode?.dataset?.projectId ? parseInt(parentNode.dataset.projectId) : 0 + const parentProjectIdFromDom = parentNode?.dataset?.projectId ? parseInt(parentNode.dataset.projectId) : 0 + const parentProjectId = projectStore.getEffectiveParentProjectId(project, parentProjectIdFromDom) const projectBefore = projectsActive[newIndex - 1] ?? null const projectAfter = projectsActive[newIndex + 1] ?? null projectUpdating.value[project.id] = true diff --git a/frontend/src/stores/projects.test.ts b/frontend/src/stores/projects.test.ts new file mode 100644 index 000000000..98b8908dd --- /dev/null +++ b/frontend/src/stores/projects.test.ts @@ -0,0 +1,223 @@ +import {setActivePinia, createPinia} from 'pinia' +import {describe, it, expect, beforeEach, vi} from 'vitest' + +import {useProjectStore} from './projects' + +import type {IProject} from '@/modelTypes/IProject' + +// Mock the dependencies that the store imports +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), + createI18n: () => ({ + global: { + t: (key: string) => key, + }, + }), +})) + +vi.mock('@/stores/base', () => ({ + useBaseStore: () => ({ + currentProject: null, + setCurrentProject: vi.fn(), + }), +})) + +vi.mock('@/indexes', () => ({ + createNewIndexer: () => ({ + add: vi.fn(), + remove: vi.fn(), + search: vi.fn(), + update: vi.fn(), + }), +})) + +function createMockProject(overrides: Partial): IProject { + return { + id: 1, + title: 'Test Project', + description: '', + owner: {id: 1, username: 'test', name: '', email: '', created: new Date(), updated: new Date()}, + tasks: [], + isArchived: false, + hexColor: '', + identifier: '', + backgroundInformation: null, + isFavorite: false, + subscription: null as any, + position: 0, + backgroundBlurHash: '', + parentProjectId: 0, + views: [], + created: new Date(), + updated: new Date(), + ...overrides, + } as IProject +} + +describe('project store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('notArchivedRootProjects', () => { + it('should include root projects (parentProjectId === 0)', () => { + const store = useProjectStore() + const rootProject = createMockProject({id: 1, parentProjectId: 0, title: 'Root'}) + + store.setProject(rootProject) + + expect(store.notArchivedRootProjects).toHaveLength(1) + expect(store.notArchivedRootProjects[0].title).toBe('Root') + }) + + it('should exclude archived projects', () => { + const store = useProjectStore() + const archivedProject = createMockProject({id: 1, parentProjectId: 0, isArchived: true}) + + store.setProject(archivedProject) + + expect(store.notArchivedRootProjects).toHaveLength(0) + }) + + it('should exclude saved filters (id < 0)', () => { + const store = useProjectStore() + const savedFilter = createMockProject({id: -2, parentProjectId: 0}) + + store.setProject(savedFilter) + + expect(store.notArchivedRootProjects).toHaveLength(0) + }) + + it('should exclude sub-projects when parent is accessible', () => { + const store = useProjectStore() + const parentProject = createMockProject({id: 1, parentProjectId: 0, title: 'Parent'}) + const childProject = createMockProject({id: 2, parentProjectId: 1, title: 'Child'}) + + store.setProject(parentProject) + store.setProject(childProject) + + // Only parent should be in root projects + expect(store.notArchivedRootProjects).toHaveLength(1) + expect(store.notArchivedRootProjects[0].title).toBe('Parent') + }) + + it('should include orphaned sub-projects (parent not accessible)', () => { + const store = useProjectStore() + // Sub-project with parentProjectId pointing to a project not in the store + const orphanedProject = createMockProject({id: 2, parentProjectId: 999, title: 'Orphaned'}) + + store.setProject(orphanedProject) + + // Orphaned project should appear as a root project + expect(store.notArchivedRootProjects).toHaveLength(1) + expect(store.notArchivedRootProjects[0].title).toBe('Orphaned') + }) + + it('should handle mixed scenario with root, child, and orphaned projects', () => { + const store = useProjectStore() + const rootProject = createMockProject({id: 1, parentProjectId: 0, title: 'Root', position: 1}) + const childProject = createMockProject({id: 2, parentProjectId: 1, title: 'Child', position: 2}) + const orphanedProject = createMockProject({id: 3, parentProjectId: 999, title: 'Orphaned', position: 3}) + + store.setProject(rootProject) + store.setProject(childProject) + store.setProject(orphanedProject) + + // Root and orphaned should be in root projects, but not child + expect(store.notArchivedRootProjects).toHaveLength(2) + const titles = store.notArchivedRootProjects.map(p => p.title) + expect(titles).toContain('Root') + expect(titles).toContain('Orphaned') + expect(titles).not.toContain('Child') + }) + }) + + describe('isOrphanedSubProject', () => { + it('should return false for root projects', () => { + const store = useProjectStore() + const rootProject = createMockProject({id: 1, parentProjectId: 0}) + + store.setProject(rootProject) + + expect(store.isOrphanedSubProject(rootProject)).toBe(false) + }) + + it('should return false for sub-projects with accessible parent', () => { + const store = useProjectStore() + const parentProject = createMockProject({id: 1, parentProjectId: 0}) + const childProject = createMockProject({id: 2, parentProjectId: 1}) + + store.setProject(parentProject) + store.setProject(childProject) + + expect(store.isOrphanedSubProject(childProject)).toBe(false) + }) + + it('should return true for sub-projects with inaccessible parent', () => { + const store = useProjectStore() + const orphanedProject = createMockProject({id: 2, parentProjectId: 999}) + + store.setProject(orphanedProject) + + expect(store.isOrphanedSubProject(orphanedProject)).toBe(true) + }) + }) + + describe('getEffectiveParentProjectId', () => { + it('should return DOM parentProjectId for root projects', () => { + const store = useProjectStore() + const rootProject = createMockProject({id: 1, parentProjectId: 0}) + + store.setProject(rootProject) + + // Dragged within root level + expect(store.getEffectiveParentProjectId(rootProject, 0)).toBe(0) + // Dragged into a sub-project + expect(store.getEffectiveParentProjectId(rootProject, 5)).toBe(5) + }) + + it('should return DOM parentProjectId for sub-projects with accessible parent', () => { + const store = useProjectStore() + const parentProject = createMockProject({id: 1, parentProjectId: 0}) + const childProject = createMockProject({id: 2, parentProjectId: 1}) + + store.setProject(parentProject) + store.setProject(childProject) + + // Dragged to root level - allow reparenting + expect(store.getEffectiveParentProjectId(childProject, 0)).toBe(0) + // Dragged to another parent + expect(store.getEffectiveParentProjectId(childProject, 5)).toBe(5) + }) + + it('should preserve original parentProjectId for orphaned sub-projects at root level', () => { + const store = useProjectStore() + const orphanedProject = createMockProject({id: 2, parentProjectId: 999}) + + store.setProject(orphanedProject) + + // Dragged within root level (DOM says 0) - preserve original to prevent detachment + expect(store.getEffectiveParentProjectId(orphanedProject, 0)).toBe(999) + }) + + it('should allow orphaned sub-projects to be moved to an accessible parent', () => { + const store = useProjectStore() + const accessibleParent = createMockProject({id: 5, parentProjectId: 0}) + const orphanedProject = createMockProject({id: 2, parentProjectId: 999}) + + store.setProject(accessibleParent) + store.setProject(orphanedProject) + + // Dragged to an accessible parent - allow reparenting + expect(store.getEffectiveParentProjectId(orphanedProject, 5)).toBe(5) + }) + }) +}) diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 3406fb9d8..64b0d0ddd 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -34,8 +34,15 @@ export const useProjectStore = defineStore('project', () => { 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.parentProjectId === 0 && !p.isArchived && p.id > 0)) + .filter(p => !p.isArchived && p.id > 0 && ( + p.parentProjectId === 0 || isOrphanedSubProject(p) + ))) const favoriteProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && p.isFavorite)) const savedFilterProjects = computed(() => projectsArray.value @@ -46,6 +53,16 @@ export const useProjectStore = defineStore('project', () => { 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') { @@ -319,6 +336,8 @@ export const useProjectStore = defineStore('project', () => { savedFilterProjects: readonly(savedFilterProjects), getChildProjects, + isOrphanedSubProject, + getEffectiveParentProjectId, findProjectByExactname, findProjectByIdentifier, searchProject,