mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +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
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
.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,
|
||||
|
||||
Reference in New Issue
Block a user