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 <noreply@anthropic.com>

* Address PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lane Sawyer 2026-07-10 02:02:59 -07:00 committed by GitHub
parent 18c48d5ea8
commit ed26c2a9ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 additions and 2 deletions

View file

@ -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<any>;
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', () => {

View file

@ -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<string, string>();
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<string, string>,
): 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<string, string>,
): 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,
},
}),
);
}
}
}