mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
fix(nav): show shared sub-projects in sidebar when the parent is inaccessible (#2176)
Fixes #2175
This commit is contained in:
@@ -89,7 +89,8 @@ async function saveProjectPosition(e: SortableEvent) {
|
|||||||
if (!project) return
|
if (!project) return
|
||||||
|
|
||||||
const parentNode = e.to.parentNode as HTMLElement | null
|
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 projectBefore = projectsActive[newIndex - 1] ?? null
|
||||||
const projectAfter = projectsActive[newIndex + 1] ?? null
|
const projectAfter = projectsActive[newIndex + 1] ?? null
|
||||||
projectUpdating.value[project.id] = true
|
projectUpdating.value[project.id] = true
|
||||||
|
|||||||
223
frontend/src/stores/projects.test.ts
Normal file
223
frontend/src/stores/projects.test.ts
Normal file
@@ -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>): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,8 +34,15 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
const projectsArray = computed(() => Object.values(projects.value)
|
const projectsArray = computed(() => Object.values(projects.value)
|
||||||
.sort((a, b) => a.position - b.position))
|
.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
|
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
|
const favoriteProjects = computed(() => projectsArray.value
|
||||||
.filter(p => !p.isArchived && p.isFavorite))
|
.filter(p => !p.isArchived && p.isFavorite))
|
||||||
const savedFilterProjects = computed(() => projectsArray.value
|
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)
|
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(() => {
|
const getAncestors = computed(() => {
|
||||||
return (project: IProject): IProject[] => {
|
return (project: IProject): IProject[] => {
|
||||||
if (typeof project === 'undefined') {
|
if (typeof project === 'undefined') {
|
||||||
@@ -319,6 +336,8 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
savedFilterProjects: readonly(savedFilterProjects),
|
savedFilterProjects: readonly(savedFilterProjects),
|
||||||
|
|
||||||
getChildProjects,
|
getChildProjects,
|
||||||
|
isOrphanedSubProject,
|
||||||
|
getEffectiveParentProjectId,
|
||||||
findProjectByExactname,
|
findProjectByExactname,
|
||||||
findProjectByIdentifier,
|
findProjectByIdentifier,
|
||||||
searchProject,
|
searchProject,
|
||||||
|
|||||||
Reference in New Issue
Block a user