fix(task-view-customizer): scope Group By to current work context (#7279)

Group By: Tag / Group By: Project pulled tasks from every project when
used inside a specific project view, because de1b5d485c (fix for #7050)
deliberately switched the source to all undone tasks for these two
options. That closed #7050 but broke the more common case reported in
#7279 (and its duplicate #7290).

- TaskViewCustomizerService: revert customizeUndoneTasks() to the work
  context's undoneTasks$, drop _allUndoneTasksWithSubTasks$ and the
  cross-store selector/switchMap.
- Hide Group By: Project in a PROJECT context (it would collapse into
  one group) via a new availableGroupOptions computed signal backed by
  isActiveWorkContextProject$.
- Sanitize any stored group=project state inside the service's
  context-load subscription, not from a lazy panel effect — the panel
  is only instantiated when the mobile bottom-sheet customizer opens.
- Drop the now-redundant cross-context selection guard in
  WorkViewComponent and retire its #7269 test block: after this fix
  customizedUndoneTasks.list is always a subset of undoneTasks, so the
  scenario those tests constructed can no longer occur.
- Stub isActiveWorkContextProject$ in all WorkContextService test
  doubles so the service's toSignal() call has something to subscribe
  to.

Closes #7290.
This commit is contained in:
Johannes Millan 2026-04-20 13:51:17 +02:00
parent ecba6c4240
commit e9ee2c4e45
5 changed files with 97 additions and 125 deletions

View file

@ -99,7 +99,7 @@
<!-- Group submenu -->
<mat-menu #groupMenu="matMenu">
@for (opt of OPTIONS.group.list; track opt.type) {
@for (opt of customizerService.availableGroupOptions(); track opt.type) {
<button
mat-menu-item
(click)="customizerService.setGroup(opt)"

View file

@ -9,6 +9,7 @@ import { selectAllTags } from '../tag/store/tag.reducer';
import { getTomorrow } from '../../util/get-tomorrow';
import { getDbDateStr } from '../../util/get-db-date-str';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { WorkContextType } from '../work-context/work-context.model';
import { WorkContextService } from '../work-context/work-context.service';
import { selectAllTasksWithSubTasks } from '../tasks/store/task.selectors';
@ -41,6 +42,7 @@ describe('TaskViewCustomizerService', () => {
activeId: string;
activeType: WorkContextType;
}>;
isActiveWorkContextProject$: Observable<boolean>;
mainListTasks$: Observable<TaskWithSubTasks[]>;
undoneTasks$: Observable<TaskWithSubTasks[]>;
};
@ -133,6 +135,7 @@ describe('TaskViewCustomizerService', () => {
activeId: 'TODAY',
activeType: WorkContextType.TAG,
}),
isActiveWorkContextProject$: of(false),
mainListTasks$: of<TaskWithSubTasks[]>([]),
undoneTasks$: of<TaskWithSubTasks[]>([]),
};
@ -1035,6 +1038,9 @@ describe('TaskViewCustomizerService', () => {
activeWorkContextId: null,
activeWorkContextType: null,
activeWorkContextTypeAndId$: ctx$,
isActiveWorkContextProject$: ctx$.pipe(
map(({ activeType }) => activeType === WorkContextType.PROJECT),
),
mainListTasks$: of<TaskWithSubTasks[]>([]),
undoneTasks$: of<TaskWithSubTasks[]>([]),
},
@ -1148,12 +1154,12 @@ describe('TaskViewCustomizerService', () => {
});
});
describe('customizeUndoneTasks with group by project (issue #7050)', () => {
const inboxTask: TaskWithSubTasks = {
id: 'inbox-task',
title: 'Inbox Task',
tagIds: [],
projectId: 'INBOX_PROJECT',
describe('customizeUndoneTasks respects current work context (issue #7279)', () => {
const projectATask: TaskWithSubTasks = {
id: 'project-a-task',
title: 'Project A Task',
tagIds: ['Tag A'],
projectId: 'Project A',
timeEstimate: 0,
timeSpentOnDay: {},
created: 1,
@ -1164,11 +1170,11 @@ describe('TaskViewCustomizerService', () => {
attachments: [],
} as TaskWithSubTasks;
const projectATask: TaskWithSubTasks = {
id: 'project-a-task',
title: 'Project A Task',
tagIds: [],
projectId: 'Project A',
const projectBTask: TaskWithSubTasks = {
id: 'project-b-task',
title: 'Project B Task',
tagIds: ['Tag B'],
projectId: 'Project B',
timeEstimate: 0,
timeSpentOnDay: {},
created: 2,
@ -1179,23 +1185,7 @@ describe('TaskViewCustomizerService', () => {
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,
];
@ -1211,14 +1201,15 @@ describe('TaskViewCustomizerService', () => {
});
mockWorkContextService = {
activeWorkContextId: 'INBOX_PROJECT',
activeWorkContextId: 'Project A',
activeWorkContextType: WorkContextType.PROJECT,
activeWorkContextTypeAndId$: of({
activeId: 'INBOX_PROJECT',
activeId: 'Project A',
activeType: WorkContextType.PROJECT,
}),
mainListTasks$: of<TaskWithSubTasks[]>([inboxTask]),
undoneTasks$: of<TaskWithSubTasks[]>([inboxTask]),
isActiveWorkContextProject$: of(true),
mainListTasks$: of<TaskWithSubTasks[]>([projectATask]),
undoneTasks$: of<TaskWithSubTasks[]>([projectATask]),
};
TestBed.configureTestingModule({
@ -1242,7 +1233,7 @@ describe('TaskViewCustomizerService', () => {
{ selector: selectAllTags, value: mockTags },
{
selector: selectAllTasksWithSubTasks,
value: [inboxTask, projectATask, projectBTask],
value: [projectATask, projectBTask],
},
],
}),
@ -1253,30 +1244,46 @@ describe('TaskViewCustomizerService', () => {
(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<TaskWithSubTasks[]>([inboxTask]);
it('should only group tasks from the current project when group by tag is selected', (done) => {
// User is in Project A — undoneTasks$ only has Project A's task
const contextUndoneTasks$ = of<TaskWithSubTasks[]>([projectATask]);
// Set group to project
testService.setGroup({ type: GROUP_OPTION_TYPE.project, label: 'Project' });
testService.setGroup({ type: GROUP_OPTION_TYPE.tag, label: 'Tag' });
const result$ = TestBed.runInInjectionContext(() =>
testService.customizeUndoneTasks(contextUndoneTasks$),
);
requestAnimationFrame(() => {
result$.subscribe((result) => {
expect(result.grouped).toBeDefined();
// Project B's task must NOT leak into the view
expect(result.list.map((t) => t.id)).toEqual(['project-a-task']);
expect(Object.keys(result.grouped!)).toEqual(['Tag A']);
expect(result.grouped!['Tag A']?.length).toBe(1);
expect(result.grouped!['Tag A']?.[0].id).toBe('project-a-task');
done();
});
});
});
it('should only group tasks from the current project when group by project is selected', (done) => {
// Edge case: even though the panel hides this option in a project context,
// the service must still scope to the current context if it's set.
const contextUndoneTasks$ = of<TaskWithSubTasks[]>([projectATask]);
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);
expect(groupKeys).toEqual(['Project A']);
expect(groupKeys).not.toContain('Project B');
done();
});
});

View file

@ -1,14 +1,13 @@
import { Injectable, signal, inject, effect } from '@angular/core';
import { Observable, animationFrameScheduler, combineLatest } from 'rxjs';
import { map, observeOn, switchMap, take } from 'rxjs/operators';
import { map, observeOn, 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';
import { Tag } from '../tag/tag.model';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { computed } from '@angular/core';
import { getDbDateStr } from '../../util/get-db-date-str';
import { getWeekRange } from '../../util/get-week-range';
@ -28,6 +27,7 @@ import {
FILTER_SCHEDULE,
SORT_ORDER,
FILTER_COMMON,
OPTIONS,
} from './types';
import { DateAdapter } from '@angular/material/core';
import { lsGetJSON, lsSetJSON } from '../../util/ls-util';
@ -36,6 +36,10 @@ import { LanguageService } from 'src/app/core/language/language.service';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
const GROUP_OPTIONS_NO_PROJECT = OPTIONS.group.list.filter(
(opt) => opt.type !== GROUP_OPTION_TYPE.project,
);
@Injectable({ providedIn: 'root' })
export class TaskViewCustomizerService {
private store = inject(Store);
@ -60,6 +64,16 @@ export class TaskViewCustomizerService {
].some((x) => x !== null);
});
private _isInProject = toSignal(this._workContextService.isActiveWorkContextProject$, {
initialValue: false,
});
// "Group By: Project" would collapse into a single group inside a single-project
// view, so offer it only for tag contexts. See issue #7279.
availableGroupOptions = computed(() =>
this._isInProject() ? GROUP_OPTIONS_NO_PROJECT : OPTIONS.group.list,
);
private _stateByContext: Record<string, CustomizerContextState> = lsGetJSON<
Record<string, CustomizerContextState>
>(LS.TASK_VIEW_CUSTOMIZER_BY_CONTEXT, {});
@ -77,7 +91,7 @@ export class TaskViewCustomizerService {
this._currentContextKey = `${activeType}:${activeId}`;
const stored = this._stateByContext[this._currentContextKey];
this.selectedSort.set(stored?.sort ?? DEFAULT_OPTIONS.sort);
this.selectedGroup.set(stored?.group ?? DEFAULT_OPTIONS.group);
this.selectedGroup.set(this._sanitizeGroupForContext(stored?.group, activeType));
this.selectedFilter.set(stored?.filter ?? DEFAULT_OPTIONS.filter);
});
@ -123,24 +137,28 @@ export class TaskViewCustomizerService {
}
}
private _sanitizeGroupForContext(
stored: GroupOption | undefined,
activeType: WorkContextType,
): GroupOption {
if (!stored) return DEFAULT_OPTIONS.group;
if (
activeType === WorkContextType.PROJECT &&
stored.type === GROUP_OPTION_TYPE.project
) {
return DEFAULT_OPTIONS.group;
}
return stored;
}
customizeUndoneTasks(undoneTasks$: Observable<TaskWithSubTasks[]>): Observable<{
list: TaskWithSubTasks[];
grouped?: Record<string, TaskWithSubTasks[]>;
}> {
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([
tasks$,
undoneTasks$,
toObservable(this.selectedSort),
group$,
toObservable(this.selectedGroup),
toObservable(this.selectedFilter),
]).pipe(
observeOn(animationFrameScheduler),
@ -171,27 +189,6 @@ export class TaskViewCustomizerService {
);
}
private _allUndoneTasksWithSubTasks$(): Observable<TaskWithSubTasks[]> {
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,

View file

@ -27,26 +27,20 @@ import {
import { TODAY_TAG } from '../tag/tag.const';
/**
* Tests for issue #7269: Detail panel closes immediately when opened for a task
* that only appears in the task-view customizer's list.
*
* The WorkViewComponent has a constructor `effect()` that deselects the currently
* selected task whenever it is no longer present in any of the visible task lists.
* Previously it only consulted the primary context lists (undone / done / later /
* overdue / backlog). When the customizer pulls tasks in from other work
* contexts, the selected task would not be found in any of those lists and would
* be deselected immediately, closing the detail panel.
*
* The fix additionally checks `customizedUndoneTasks().list` before resetting
* the selection. These tests exercise the real component; the template is
* overridden to a no-op so we don't have to stand up every child component.
* Tests for the constructor effect() in WorkViewComponent that deselects the
* currently selected task when it is no longer present in any visible task list
* (undone / done / later / overdue / backlog). The customizer's list is
* intentionally NOT consulted: after #7279 it is always a subset of the context's
* undoneTasks, so checking undoneTasks is sufficient. These tests exercise the
* real component; the template is overridden to a no-op so we don't have to
* stand up every child component.
*/
const buildTask = (id: string, subTasks: TaskWithSubTasks[] = []): TaskWithSubTasks =>
({ id, subTasks }) as unknown as TaskWithSubTasks;
describe('WorkViewComponent', () => {
describe('selected task retention effect (#7269)', () => {
describe('selected task retention effect', () => {
let selectedTaskId: ReturnType<typeof signal<string | null>>;
let setSelectedId: jasmine.Spy;
let customized$: BehaviorSubject<{ list: TaskWithSubTasks[] }>;
@ -149,29 +143,8 @@ describe('WorkViewComponent', () => {
store.overrideSelector(selectTaskRepeatCfgsByTagId, []);
});
it('keeps the selection when the task is only in customizedUndoneTasks.list (bug #7269)', async () => {
it('deselects when the task is absent from every list', async () => {
await createComponent();
customized$.next({ list: [buildTask('cross-ctx-1')] });
selectedTaskId.set('cross-ctx-1');
TestBed.flushEffects();
expect(setSelectedId).not.toHaveBeenCalled();
});
it('keeps the selection when the task is a subtask inside customizedUndoneTasks.list', async () => {
await createComponent();
customized$.next({
list: [buildTask('parent', [buildTask('nested-sub')])],
});
selectedTaskId.set('nested-sub');
TestBed.flushEffects();
expect(setSelectedId).not.toHaveBeenCalled();
});
it('deselects when the task is absent from every list (including customizedUndoneTasks)', async () => {
await createComponent();
customized$.next({ list: [buildTask('other')] });
selectedTaskId.set('ghost');
TestBed.flushEffects();

View file

@ -225,11 +225,6 @@ export class WorkViewComponent implements OnInit, OnDestroy {
// Check if task is in backlog
if (this._hasTaskInList(this.backlogTasks(), currentSelectedId)) return;
// Group/filter may pull tasks from other work contexts into the view;
// keep those selected so the detail panel can open for them.
if (this._hasTaskInList(this.customizedUndoneTasks()?.list, currentSelectedId))
return;
// if task really is gone
this.taskService.setSelectedId(null);
});