From de1b5d485c1feaba63e47e2f17da4cba817c540c Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 31 Mar 2026 13:49:56 +0200 Subject: [PATCH] fix(task-view-customizer): show all tasks when grouping by project or tag When "group by project" or "group by tag" was selected, only tasks from the current work context were shown. Switch the task source to all undone tasks from the store (excluding hidden/archived projects and backlog) when cross-context grouping is active. Closes #7050 --- .../task-view-customizer.service.spec.ts | 132 ++++++++++++++++++ .../task-view-customizer.service.ts | 46 +++++- 2 files changed, 171 insertions(+), 7 deletions(-) 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,