From ed26c2a9ff94e47bf5566e05ff4e7922827217f3 Mon Sep 17 00:00:00 2001 From: Lane Sawyer Date: Fri, 10 Jul 2026 02:02:59 -0700 Subject: [PATCH] fix(project): carry sections forward when duplicating a project (#8872) * fix(project): carry sections forward when duplicating a project Duplicating a project recreated its tasks and notes but never touched the section store, so the copy came up with a flat, uncategorized task list. Duplicate the template's sections too, remapping each section's taskIds through an old->new task id map so tasks keep their section membership in the copy. Fixes #8293 Co-Authored-By: Claude Opus 4.8 * Address PR feedback --------- Co-authored-by: Claude Opus 4.8 --- .../features/project/project.service.spec.ts | 79 +++++++++++++++++++ src/app/features/project/project.service.ts | 42 +++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/app/features/project/project.service.spec.ts b/src/app/features/project/project.service.spec.ts index cc64fa8181..ce0c566c30 100644 --- a/src/app/features/project/project.service.spec.ts +++ b/src/app/features/project/project.service.spec.ts @@ -19,6 +19,8 @@ import { WorkContextType } from '../work-context/work-context.model'; import { T } from '../../t.const'; import { selectNoteFeatureState } from '../note/store/note.reducer'; import { NoteState } from '../note/note.model'; +import { selectSectionFeatureState } from '../section/store/section.selectors'; +import { SectionState } from '../section/section.model'; import { DateService } from '../../core/date/date.service'; import { selectUnarchivedProjects, @@ -88,6 +90,45 @@ describe('ProjectService', () => { }, todayOrder: [], }; + + const initialSectionState: SectionState = { + ids: ['section-1', 'section-2', 'section-other', 'section-today'], + entities: { + 'section-1': { + id: 'section-1', + contextId: 'project-1', + contextType: WorkContextType.PROJECT, + title: 'Section 1', + isExpanded: true, + taskIds: ['task-1'], + }, + 'section-2': { + id: 'section-2', + contextId: 'project-1', + contextType: WorkContextType.PROJECT, + title: 'Section 2', + isExpanded: true, + taskIds: ['task-2'], + }, + // Foreign-context sections must NOT be copied when duplicating project-1 + 'section-other': { + id: 'section-other', + contextId: 'project-2', + contextType: WorkContextType.PROJECT, + title: 'Other Project Section', + isExpanded: true, + taskIds: [], + }, + 'section-today': { + id: 'section-today', + contextId: 'TODAY', + contextType: WorkContextType.TAG, + title: 'Today Section', + isExpanded: true, + taskIds: [], + }, + }, + }; /* eslint-enable @typescript-eslint/naming-convention */ beforeEach(() => { @@ -191,6 +232,7 @@ describe('ProjectService', () => { store = TestBed.inject(Store) as MockStore; store.overrideSelector(selectTaskFeatureState, initialTaskState); store.overrideSelector(selectNoteFeatureState, initialNoteState); + store.overrideSelector(selectSectionFeatureState, initialSectionState); }); afterEach(() => { @@ -379,6 +421,43 @@ describe('ProjectService', () => { expect(addNoteCalls.length).toBe(1); expect((addNoteCalls[0][0] as any).note.isPinnedToToday).toBe(false); })); + + it('should duplicate sections and remap their task membership', fakeAsync(() => { + const project = createProject({ + id: 'project-1', + title: 'Project 1', + taskIds: ['task-1', 'task-2'], + }); + spyOn(service, 'getByIdOnce$').and.returnValue(of(project)); + const dispatchSpy = spyOn(store, 'dispatch').and.callThrough(); + service.duplicateProject('project-1'); + tick(); + const addSectionCalls = dispatchSpy.calls + .allArgs() + .filter((args: any) => args[0]?.type === '[Section] Add Section'); + // Only project-1's sections — not project-2's or the TODAY section + expect(addSectionCalls.length).toBe(2); + const copiedTitles = addSectionCalls.map((args: any) => args[0].section.title); + expect(copiedTitles).not.toContain('Other Project Section'); + expect(copiedTitles).not.toContain('Today Section'); + // task-1 -> new-task-1 (first parent duplicated) + // task-2 -> new-task-3 (after task-1's subtask new-task-2) + expect((addSectionCalls[0][0] as any).section).toEqual( + jasmine.objectContaining({ + contextId: 'new-project-id', + contextType: WorkContextType.PROJECT, + title: 'Section 1', + taskIds: ['new-task-1'], + }), + ); + expect((addSectionCalls[1][0] as any).section).toEqual( + jasmine.objectContaining({ + contextId: 'new-project-id', + title: 'Section 2', + taskIds: ['new-task-3'], + }), + ); + })); }); describe('unarchive', () => { diff --git a/src/app/features/project/project.service.ts b/src/app/features/project/project.service.ts index 03caa373e7..05a955261d 100644 --- a/src/app/features/project/project.service.ts +++ b/src/app/features/project/project.service.ts @@ -48,6 +48,9 @@ import { sortByTitle } from '../../util/sort-by-title'; import { Note } from '../note/note.model'; import { selectNoteFeatureState } from '../note/store/note.reducer'; import { addNote } from '../note/store/note.actions'; +import { Section } from '../section/section.model'; +import { addSection } from '../section/store/section.actions'; +import { selectSectionsByContextIdMap } from '../section/store/section.selectors'; import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component'; import { LOCAL_ACTIONS } from '../../util/local-actions.token'; import { DateService } from '../../core/date/date.service'; @@ -502,9 +505,16 @@ export class ProjectService { const newNoteIds = this._duplicateNotesToProject(notesToCopy, newProjectId); this.update(newProjectId, { noteIds: newNoteIds }); - this._duplicateTasksToProject(parentTasks, newProjectId, false, taskState); + const sectionsMap = await firstValueFrom( + this._store$.select(selectSectionsByContextIdMap), + ); + const sectionsToCopy = sectionsMap.get(templateProjectId) ?? []; - this._duplicateTasksToProject(backlogTasks, newProjectId, true, taskState); + const taskIdMap = new Map(); + this._duplicateTasksToProject(parentTasks, newProjectId, false, taskState, taskIdMap); + this._duplicateTasksToProject(backlogTasks, newProjectId, true, taskState, taskIdMap); + + this._duplicateSectionsToProject(sectionsToCopy, newProjectId, taskIdMap); return newProjectId; } @@ -514,6 +524,7 @@ export class ProjectService { newProjectId: string, isBacklog: boolean, taskState: TaskState, + taskIdMap: Map, ): void { // For each parent task create a copy in the new project and then copy its subtasks for (const p of tasks) { @@ -541,6 +552,7 @@ export class ProjectService { workContextType: WorkContextType.PROJECT, workContextId: newProjectId, }); + taskIdMap.set(p.id, newParentTask.id); // dispatch addTask for the parent task this._store$.dispatch( @@ -580,6 +592,7 @@ export class ProjectService { workContextType: WorkContextType.PROJECT, workContextId: newProjectId, }); + taskIdMap.set(st.id, newSub.id); this._store$.dispatch(addSubTask({ task: newSub, parentId: newParentTask.id })); } @@ -607,4 +620,29 @@ export class ProjectService { } return newNoteIds; } + + private _duplicateSectionsToProject( + sections: Section[], + newProjectId: string, + taskIdMap: Map, + ): void { + for (const section of sections) { + const newTaskIds = section.taskIds + .map((id) => taskIdMap.get(id)) + .filter((id): id is string => !!id); + + this._store$.dispatch( + addSection({ + section: { + id: nanoid(), + contextId: newProjectId, + contextType: WorkContextType.PROJECT, + title: section.title, + isExpanded: section.isExpanded, + taskIds: newTaskIds, + }, + }), + ); + } + } }