diff --git a/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts b/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts index 8f9fcc83ec..1556671ad6 100644 --- a/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts +++ b/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts @@ -11,6 +11,7 @@ import { getDbDateStr } from '../../util/get-db-date-str'; import { Observable, of } from 'rxjs'; import { WorkContextType } from '../work-context/work-context.model'; import { WorkContextService } from '../work-context/work-context.service'; +import { selectAllTasksWithSubTasks } from '../tasks/store/task.selectors'; import { ProjectService } from '../project/project.service'; import { TagService } from '../tag/tag.service'; import { @@ -1189,4 +1190,135 @@ describe('TaskViewCustomizerService', () => { expect(newService.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter); }); }); + + describe('customizeUndoneTasks with group by project (issue #7050)', () => { + const inboxTask: TaskWithSubTasks = { + id: 'inbox-task', + title: 'Inbox Task', + tagIds: [], + projectId: 'INBOX_PROJECT', + timeEstimate: 0, + timeSpentOnDay: {}, + created: 1, + subTasks: [], + subTaskIds: [], + timeSpent: 0, + isDone: false, + attachments: [], + } as TaskWithSubTasks; + + const projectATask: TaskWithSubTasks = { + id: 'project-a-task', + title: 'Project A Task', + tagIds: [], + projectId: 'Project A', + timeEstimate: 0, + timeSpentOnDay: {}, + created: 2, + subTasks: [], + subTaskIds: [], + timeSpent: 0, + isDone: false, + attachments: [], + } as TaskWithSubTasks; + + const projectBTask: TaskWithSubTasks = { + id: 'project-b-task', + title: 'Project B Task', + tagIds: [], + projectId: 'Project B', + timeEstimate: 0, + timeSpentOnDay: {}, + created: 3, + subTasks: [], + subTaskIds: [], + timeSpent: 0, + isDone: false, + attachments: [], + } as TaskWithSubTasks; + + const allProjects: Project[] = [ + { id: 'INBOX_PROJECT', title: 'Inbox', backlogTaskIds: [] } as unknown as Project, + { id: 'Project A', title: 'Project A', backlogTaskIds: [] } as unknown as Project, + { id: 'Project B', title: 'Project B', backlogTaskIds: [] } as unknown as Project, + ]; + + let testService: TaskViewCustomizerService; + + beforeEach(() => { + localStorage.clear(); + + TestBed.resetTestingModule(); + const dateAdapter = jasmine.createSpyObj>('DateAdapter', [], { + getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK, + }); + + mockWorkContextService = { + activeWorkContextId: 'INBOX_PROJECT', + activeWorkContextType: WorkContextType.PROJECT, + mainListTasks$: of([inboxTask]), + undoneTasks$: of([inboxTask]), + }; + + TestBed.configureTestingModule({ + providers: [ + TaskViewCustomizerService, + { + provide: LanguageService, + useValue: mockLanguageService, + }, + { + provide: TranslateService, + useValue: { instant: (k: string) => k }, + }, + { provide: DateAdapter, useValue: dateAdapter }, + { provide: WorkContextService, useValue: mockWorkContextService }, + { provide: ProjectService, useValue: { update: projectUpdateSpy } }, + { provide: TagService, useValue: { updateTag: tagUpdateSpy } }, + provideMockStore({ + selectors: [ + { selector: selectAllProjects, value: allProjects }, + { selector: selectAllTags, value: mockTags }, + { + selector: selectAllTasksWithSubTasks, + value: [inboxTask, projectATask, projectBTask], + }, + ], + }), + ], + }); + testService = TestBed.inject(TaskViewCustomizerService); + (testService as any)._allProjects = allProjects; + (testService as any)._allTags = mockTags; + }); + + it('should show tasks from ALL projects when group by project is selected, not just current context', (done) => { + // Simulate being on Inbox — undoneTasks$ only has the inbox task + const contextUndoneTasks$ = of([inboxTask]); + + // Set group to project + testService.setGroup({ type: GROUP_OPTION_TYPE.project, label: 'Project' }); + + // customizeUndoneTasks uses toObservable which requires injection context + const result$ = TestBed.runInInjectionContext(() => + testService.customizeUndoneTasks(contextUndoneTasks$), + ); + + // Use requestAnimationFrame since customizeUndoneTasks uses animationFrameScheduler + requestAnimationFrame(() => { + result$.subscribe((result) => { + expect(result.grouped).toBeDefined(); + const groupKeys = Object.keys(result.grouped!); + // Should contain tasks from ALL projects, not just Inbox + expect(groupKeys).toContain('Inbox'); + expect(groupKeys).toContain('Project A'); + expect(groupKeys).toContain('Project B'); + expect(result.grouped!['Inbox']?.length).toBe(1); + expect(result.grouped!['Project A']?.length).toBe(1); + expect(result.grouped!['Project B']?.length).toBe(1); + done(); + }); + }); + }); + }); }); diff --git a/src/app/features/task-view-customizer/task-view-customizer.service.ts b/src/app/features/task-view-customizer/task-view-customizer.service.ts index 40e9870056..c060c16217 100644 --- a/src/app/features/task-view-customizer/task-view-customizer.service.ts +++ b/src/app/features/task-view-customizer/task-view-customizer.service.ts @@ -1,8 +1,9 @@ import { Injectable, signal, inject, effect } from '@angular/core'; import { Observable, animationFrameScheduler, combineLatest } from 'rxjs'; -import { map, observeOn, take } from 'rxjs/operators'; +import { map, observeOn, switchMap, take } from 'rxjs/operators'; import { TaskWithSubTasks } from '../tasks/task.model'; import { selectAllProjects } from '../project/store/project.selectors'; +import { selectAllTasksWithSubTasks } from '../tasks/store/task.selectors'; import { selectAllTags } from './../tag/store/tag.reducer'; import { Store } from '@ngrx/store'; import { Project } from '../project/project.model'; @@ -114,14 +115,24 @@ export class TaskViewCustomizerService { list: TaskWithSubTasks[]; grouped?: Record; }> { + const group$ = toObservable(this.selectedGroup); + const tasks$ = group$.pipe( + switchMap((group) => { + const isCrossContext = + group.type === GROUP_OPTION_TYPE.project || + group.type === GROUP_OPTION_TYPE.tag; + return isCrossContext ? this._allUndoneTasksWithSubTasks$() : undoneTasks$; + }), + ); + return combineLatest([ - undoneTasks$, + tasks$, toObservable(this.selectedSort), - toObservable(this.selectedGroup), + group$, toObservable(this.selectedFilter), ]).pipe( observeOn(animationFrameScheduler), - map(([undone, sort, group, filter]) => { + map(([tasks, sort, group, filter]) => { const normalizedFilterVal = filter.preset?.trim(); const filterValueToUse = normalizedFilterVal ?? ''; @@ -130,12 +141,12 @@ export class TaskViewCustomizerService { const isDefaultGroup = !group.type; if (isDefaultFilter && isDefaultSort && isDefaultGroup) { - return { list: undone }; + return { list: tasks }; } const filtered = isDefaultFilter - ? undone - : this.applyFilter(undone, filter.type, filterValueToUse); + ? tasks + : this.applyFilter(tasks, filter.type, filterValueToUse); const sorted = isDefaultSort ? filtered : this.applySort(filtered, sort.type, sort.order); @@ -148,6 +159,27 @@ export class TaskViewCustomizerService { ); } + private _allUndoneTasksWithSubTasks$(): Observable { + return this.store.select(selectAllTasksWithSubTasks).pipe( + map((tasks) => { + const hiddenProjectIds = new Set( + this._allProjects + .filter((p) => p.isHiddenFromMenu || p.isArchived) + .map((p) => p.id), + ); + const backlogTaskIds = new Set( + this._allProjects.flatMap((p) => p.backlogTaskIds || []), + ); + return tasks.filter( + (task) => + !task.isDone && + (!task.projectId || !hiddenProjectIds.has(task.projectId)) && + !backlogTaskIds.has(task.id), + ); + }), + ); + } + private applyFilter( tasks: TaskWithSubTasks[], type: FILTER_OPTION_TYPE | FILTER_COMMON | null,