From 0f9a04d5d577025c4dc5e9e3a0a918c2de0c8b91 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 8 Jan 2026 17:25:00 +0100 Subject: [PATCH] fix(frontend): prevent parent project field from jumping back when cleared (#2071) Fixes the parent project field in project settings "jumping back" to the previous value after clearing the value from the input. Fixes #2046 --- .../tasks/partials/ProjectSearch.vue | 6 +- .../project/settings/ProjectSettingsEdit.vue | 2 +- frontend/src/views/tasks/TaskDetailView.vue | 5 +- .../e2e/project/parent-project-clear.spec.ts | 106 ++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/e2e/project/parent-project-clear.spec.ts diff --git a/frontend/src/components/tasks/partials/ProjectSearch.vue b/frontend/src/components/tasks/partials/ProjectSearch.vue index 96808dfeb..2745f24f9 100644 --- a/frontend/src/components/tasks/partials/ProjectSearch.vue +++ b/frontend/src/components/tasks/partials/ProjectSearch.vue @@ -44,7 +44,7 @@ const props = withDefaults(defineProps<{ }) const emit = defineEmits<{ - 'update:modelValue': [value: IProject] + 'update:modelValue': [value: IProject | null] }>() const project: IProject = reactive(new ProjectModel()) @@ -78,7 +78,9 @@ function findProjects(query: string) { function select(p: IProject | null) { if (p === null) { - Object.assign(project, {id: 0}) + Object.assign(project, new ProjectModel()) + emit('update:modelValue', null) + return } Object.assign(project, p) emit('update:modelValue', project) diff --git a/frontend/src/views/project/settings/ProjectSettingsEdit.vue b/frontend/src/views/project/settings/ProjectSettingsEdit.vue index 1e43fc15a..b05fe7922 100644 --- a/frontend/src/views/project/settings/ProjectSettingsEdit.vue +++ b/frontend/src/views/project/settings/ProjectSettingsEdit.vue @@ -145,7 +145,7 @@ async function save() { isSaving.value = true try { - project.parentProjectId = parentProject.value?.id ?? project.parentProjectId + project.parentProjectId = parentProject.value === null ? 0 : (parentProject.value?.id ?? project.parentProjectId) await saveProject() await useBaseStore().handleSetCurrentProject({project}) router.back() diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index bc6b66934..02a29b500 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -1066,7 +1066,10 @@ async function toggleTaskDone() { ) } -async function changeProject(project: IProject) { +async function changeProject(project: IProject | null) { + if (project === null) { + return + } kanbanStore.removeTaskInBucket(task.value) await saveTask({ ...task.value, diff --git a/frontend/tests/e2e/project/parent-project-clear.spec.ts b/frontend/tests/e2e/project/parent-project-clear.spec.ts new file mode 100644 index 000000000..4d2539ee5 --- /dev/null +++ b/frontend/tests/e2e/project/parent-project-clear.spec.ts @@ -0,0 +1,106 @@ +import {test, expect} from '../../support/fixtures' +import {ProjectFactory} from '../../factories/project' +import {createDefaultViews} from './prepareProjects' + +test.describe('Parent Project Clear', () => { + test('Should clear the parent project field and persist the change', async ({authenticatedPage: page}) => { + // Create a parent project + const parentProjects = await ProjectFactory.create(1, { + id: 100, + title: 'Parent Project', + }) + await createDefaultViews(parentProjects[0].id, 100) + + // Create a child project with the parent + const childProjects = await ProjectFactory.create(1, { + id: 101, + title: 'Child Project', + parent_project_id: parentProjects[0].id, + }, false) + const childViews = await createDefaultViews(childProjects[0].id, 104, false) + + // Navigate to the child project first + await page.goto(`/projects/${childProjects[0].id}/${childViews[0].id}`) + await page.waitForLoadState('networkidle') + await expect(page.locator('.project-title')).toContainText('Child Project') + + // Open project settings dropdown and click Edit + await page.locator('.project-title-dropdown .project-title-button').click() + await page.getByRole('link', {name: /^edit$/i}).click() + await page.waitForLoadState('networkidle') + + // Verify the parent project is shown in the modal + const parentProjectInput = page.locator('.multiselect input') + await expect(parentProjectInput).toHaveValue('Parent Project') + + // Click the clear button (X) on the parent project field + await page.locator('.multiselect .removal-button').click() + + // Verify the field is cleared (should show empty/placeholder) + await expect(parentProjectInput).toHaveValue('') + + // Save the project + await page.locator('footer.card-footer .button').filter({hasText: /^Save$/}).click() + await expect(page.locator('.global-notification')).toContainText('Success') + + // Verify the project is no longer nested in the sidebar + // Child Project should now be a top-level item, not inside Parent Project's subtree + const sidebar = page.locator('.menu-container .menu-list') + // The Child Project link should be a direct child of the sidebar, not nested under Parent Project + await expect(sidebar.getByRole('link', {name: 'Child Project'})).toBeVisible() + // Verify Child Project is NOT inside the Parent Project's nested list + const parentProjectItem = sidebar.getByRole('listitem').filter({has: page.getByRole('link', {name: 'Parent Project'})}) + await expect(parentProjectItem.getByRole('link', {name: 'Child Project'})).not.toBeVisible() + + // Open edit again to verify parent is still cleared + await page.locator('.project-title-dropdown .project-title-button').click() + await page.getByRole('link', {name: /^edit$/i}).click() + await page.waitForLoadState('networkidle') + + // Verify the parent project field is still empty + await expect(parentProjectInput).toHaveValue('') + }) + + test('Should not jump back after selecting and deleting the parent project text', async ({authenticatedPage: page}) => { + // Create a parent project + const parentProjects = await ProjectFactory.create(1, { + id: 200, + title: 'Test Parent', + }) + await createDefaultViews(parentProjects[0].id, 200) + + // Create a child project with the parent + const childProjects = await ProjectFactory.create(1, { + id: 201, + title: 'Test Child', + parent_project_id: parentProjects[0].id, + }, false) + const childViews = await createDefaultViews(childProjects[0].id, 204, false) + + // Navigate to the child project first + await page.goto(`/projects/${childProjects[0].id}/${childViews[0].id}`) + await page.waitForLoadState('networkidle') + await expect(page.locator('.project-title')).toContainText('Test Child') + + // Open project settings dropdown and click Edit + await page.locator('.project-title-dropdown .project-title-button').click() + await page.getByRole('link', {name: /^edit$/i}).click() + await page.waitForLoadState('networkidle') + + const parentProjectInput = page.locator('.multiselect input') + + // Verify the parent project is shown + await expect(parentProjectInput).toHaveValue('Test Parent') + + // Select all text and delete it (simulating user manually clearing the field) + await parentProjectInput.click() + await parentProjectInput.selectText() + await page.keyboard.press('Backspace') + + // Wait a moment to ensure the value doesn't jump back + await page.waitForTimeout(500) + + // Verify the field stays empty (this was the bug - it would jump back) + await expect(parentProjectInput).toHaveValue('') + }) +})