Skip to content

Commit 4ebf29c

Browse files
authored
Merge pull request #1341 from b0ink/feat/task-list-filters
feat: task list filters and sorting
2 parents acc3ac2 + e61ba7b commit 4ebf29c

8 files changed

Lines changed: 546 additions & 25 deletions

File tree

src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,12 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy {
103103
);
104104
}
105105

106-
public taskDefinitionsForProject(project: Project): TaskDefinition[] {
106+
public taskDefinitionsForProject(project: Project): readonly TaskDefinition[] {
107107
if (!this.isProjectTaskListReady(project)) {
108108
return [];
109109
}
110110

111-
return project.unit.taskDefinitionsForGrade(project.targetGrade);
111+
return project.unit.taskDefinitions;
112112
}
113113

114114
startedDragging(event: CdkDragStart, boundary: HTMLElement) {

src/app/projects/states/plan/task-planner/task-planner.component.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
<div class="flex items-center justify-between p-3">
22
<div class="flex items-center gap-2">
33
<mat-slide-toggle [(ngModel)]="showDatesColumn">Show Task Dates</mat-slide-toggle>
4+
<mat-slide-toggle
5+
[ngModel]="hideTasksAboveTargetGrade"
6+
(ngModelChange)="setHideTasksAboveTargetGrade($event)"
7+
>
8+
Hide tasks beyond target grade
9+
</mat-slide-toggle>
410
</div>
511
@if (unit.allowFlexibleDates) {
612
<div class="flex items-center gap-2">

src/app/projects/states/plan/task-planner/task-planner.component.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,17 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
7171

7272
public animateBackground: boolean = false;
7373
public showDatesColumn: boolean = false;
74+
public hideTasksAboveTargetGrade: boolean = false;
7475
public overlayLines: boolean = false;
7576

7677
public get unit() {
7778
return this.project?.unit;
7879
}
7980

81+
private get hideTasksAboveTargetGradeStorageKey(): string {
82+
return `ontrack.taskPlanner.${this.project?.id ?? 'unknown'}.hideTasksAboveTargetGrade`;
83+
}
84+
8085
constructor(
8186
private elementRef: ElementRef<HTMLElement>,
8287
private gradeService: GradeService,
@@ -167,6 +172,12 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
167172
this.taskPlannerPrerequisitesModal.show(this.project, td, prereqs);
168173
}
169174

175+
setHideTasksAboveTargetGrade(value: boolean) {
176+
this.hideTasksAboveTargetGrade = value;
177+
localStorage.setItem(this.hideTasksAboveTargetGradeStorageKey, JSON.stringify(value));
178+
this.refreshItems(false);
179+
}
180+
170181
private mapPrerequisites() {
171182
for (const prerequisite of this.allTaskPrerequisites) {
172183
prerequisite.taskDefinition = this.unit.taskDefinitions.find(
@@ -253,6 +264,8 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
253264
}
254265
if (item.highlighted) {
255266
classes.push('[--bar-bg:#03c6fc]');
267+
} else if (this.isAboveTargetGrade(item)) {
268+
classes.push('[--bar-bg:#9ca3af]', 'text-white');
256269
} else if (this.isPastFeedbackDeadline(item)) {
257270
classes.push('[--bar-bg:#cd3704]', 'text-white');
258271
} else if (this.isBlockedByPrerequisite(item)) {
@@ -266,6 +279,10 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
266279
return classes;
267280
}
268281

282+
isAboveTargetGrade(item: TaskGanttItem) {
283+
return item.taskDefinition.targetGrade > this.targetGrade;
284+
}
285+
269286
isPastFeedbackDeadline(item: TaskGanttItem) {
270287
return item.end > item.task.localDeadlineDate().getTime() / 1000;
271288
}
@@ -553,6 +570,8 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
553570
}
554571

555572
ngOnInit(): void {
573+
this.loadHideTasksAboveTargetGradePreference();
574+
556575
this.viewOptions = {
557576
precisionUnit: 'day',
558577
start: new GanttDate(this.earliestStartDate),
@@ -594,6 +613,15 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
594613
});
595614
}
596615

616+
private loadHideTasksAboveTargetGradePreference(): void {
617+
const rawPreference = localStorage.getItem(this.hideTasksAboveTargetGradeStorageKey);
618+
try {
619+
this.hideTasksAboveTargetGrade = rawPreference ? JSON.parse(rawPreference) === true : false;
620+
} catch {
621+
this.hideTasksAboveTargetGrade = false;
622+
}
623+
}
624+
597625
private setupGanttHeaderObserver(): void {
598626
const ganttElement = this.elementRef.nativeElement.querySelector('ngx-gantt');
599627

@@ -820,7 +848,9 @@ export class TaskPlannerComponent implements OnInit, AfterViewInit, OnDestroy {
820848
}
821849

822850
return this.project.unit.taskDefinitions
823-
.filter((taskDef) => taskDef.targetGrade <= this.targetGrade)
851+
.filter(
852+
(taskDef) => !this.hideTasksAboveTargetGrade || taskDef.targetGrade <= this.targetGrade,
853+
)
824854
.sort((a, b) => {
825855
const taskA = this.project.findTaskForDefinition(a.id);
826856
const taskB = this.project.findTaskForDefinition(b.id);

src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -89,26 +89,104 @@
8989
>
9090
@if (!isCollapsed) {
9191
<div class="omnisearch rounded-t-2xl md:rounded-2xl">
92-
<div class="flex items-center">
93-
<button
94-
aria-label="Search Icon"
95-
class="flex flex-none items-center"
96-
disabled
97-
mat-icon-button
98-
>
99-
<mat-icon class="flex items-center" disabled>search</mat-icon>
100-
</button>
101-
<div class="grow">
92+
<div class="flex items-center justify-between">
93+
<div class="flex min-w-0 flex-1 items-center">
94+
<button
95+
aria-label="Search Icon"
96+
class="flex flex-none items-center"
97+
disabled
98+
mat-icon-button
99+
>
100+
<mat-icon class="flex items-center" disabled>search</mat-icon>
101+
</button>
102102
<input
103103
autocomplete="off"
104-
class="search"
104+
class="search w-full"
105105
placeholder="Search Tasks"
106106
spellcheck="false"
107107
type="text"
108108
[(ngModel)]="searchText"
109109
(ngModelChange)="applyFilters()"
110110
/>
111111
</div>
112+
<button
113+
aria-label="Task list filters"
114+
mat-icon-button
115+
matTooltip="Task list filters"
116+
[matMenuTriggerFor]="taskListFiltersMenu"
117+
>
118+
<mat-icon
119+
class="text-gray-500"
120+
matBadgeColor="accent"
121+
matBadgeSize="small"
122+
[matBadge]="activeViewPreferenceCount || null"
123+
>tune</mat-icon
124+
>
125+
</button>
126+
<mat-menu #taskListFiltersMenu="matMenu" class="task-list-filters-menu" xPosition="after">
127+
<div class="px-3 py-2" (click)="$event.stopPropagation()">
128+
<div class="mb-1 text-xs font-bold tracking-normal text-gray-500 uppercase">
129+
Sort tasks
130+
</div>
131+
<div class="mb-2">
132+
@for (option of sortOptions; track option.value) {
133+
<button
134+
class="flex h-10 w-full items-center gap-2 px-2 text-left text-sm hover:bg-gray-50"
135+
type="button"
136+
[ngClass]="{
137+
'bg-blue-50 text-formatif-blue': viewPreferences.sortBy === option.value,
138+
'text-gray-700': viewPreferences.sortBy !== option.value,
139+
}"
140+
(click)="setSortBy(option.value)"
141+
>
142+
<mat-icon class="task-list-menu-icon shrink-0">{{ option.icon }}</mat-icon>
143+
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
144+
<mat-icon
145+
class="task-list-menu-icon shrink-0"
146+
[ngClass]="{
147+
invisible: viewPreferences.sortBy !== option.value,
148+
}"
149+
>
150+
{{ sortStateIconFor(option) }}
151+
</mat-icon>
152+
</button>
153+
}
154+
</div>
155+
156+
<div class="mb-1 text-xs font-bold tracking-normal text-gray-500 uppercase">
157+
Filters
158+
</div>
159+
<div class="flex flex-col gap-1">
160+
<mat-checkbox
161+
color="primary"
162+
[ngModel]="viewPreferences.hideCompleted"
163+
(ngModelChange)="toggleHideCompleted($event)"
164+
>
165+
Hide completed tasks
166+
</mat-checkbox>
167+
<mat-checkbox
168+
color="primary"
169+
[disabled]="!project"
170+
[ngModel]="viewPreferences.hideAboveTargetGrade"
171+
(ngModelChange)="toggleHideAboveTargetGrade($event)"
172+
>
173+
Hide tasks beyond target grade
174+
</mat-checkbox>
175+
</div>
176+
177+
<div class="mt-2 flex items-center justify-end gap-2 text-xs text-gray-500">
178+
<button
179+
class="shrink-0 text-formatif-blue disabled:text-gray-400"
180+
mat-button
181+
type="button"
182+
[disabled]="activeViewPreferenceCount === 0"
183+
(click)="resetViewPreferences()"
184+
>
185+
Reset
186+
</button>
187+
</div>
188+
</div>
189+
</mat-menu>
112190
</div>
113191
</div>
114192
}
@@ -159,23 +237,27 @@
159237
<mat-icon class="shrink-0 scale-75 text-gray-600">person</mat-icon>
160238
}
161239

162-
<div class="truncate text-[14px] leading-none text-gray-500">
240+
<div class="mr-1 truncate text-[14px] leading-none text-gray-500">
163241
{{ taskDef.abbreviation }}
164242
-
165243
{{ gradeNames[taskDef.targetGrade] }} Task
166244
</div>
167245
@if (taskListItem(taskDef); as task) {
168-
<!-- @if (task.isBeforeStartDate() && !task.inSubmittedState()) {
246+
@if (taskStartApproaching(task)) {
247+
<span
248+
class="flex shrink-0 items-center gap-px text-[12px] font-normal text-sky-500"
249+
>
250+
<mat-icon class="size-4! text-[16px]!"> event_upcoming </mat-icon>
251+
{{ taskStartLabel(task) }}
252+
</span>
253+
} @else if (taskOngoing(task)) {
169254
<span
170-
class="flex shrink-0 items-center gap-px text-[12px] text-gray-500"
255+
class="flex shrink-0 items-center gap-px text-[12px] font-normal text-sky-500"
171256
>
172-
<mat-icon class="!h-[16px] !w-[16px] !text-[16px]"
173-
>hourglass_top</mat-icon
174-
>
175-
{{ task.timeToStart() }}
257+
<mat-icon class="size-4! text-[16px]!"> event_upcoming </mat-icon>
258+
Start now
176259
</span>
177-
} -->
178-
@if (!task.isBeforeStartDate() && !task.inSubmittedState()) {
260+
} @else if (!task.isBeforeStartDate() && !task.inSubmittedState()) {
179261
<span
180262
class="flex shrink-0 items-center gap-px text-[12px] font-bold"
181263
[ngClass]="{

src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ $my-palette: mat.m2-define-palette($md-formatif);
5555
}
5656
}
5757

58+
.task-list-menu-icon {
59+
width: 20px;
60+
height: 20px;
61+
font-size: 20px;
62+
line-height: 20px;
63+
}
64+
65+
::ng-deep .task-list-filters-menu.mat-mdc-menu-panel {
66+
margin-left: 50px;
67+
}
68+
5869
.full-width {
5970
width: 100%;
6071
}

src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,30 @@ import {beforeEach, describe, expect, it} from 'vitest';
22
import {NO_ERRORS_SCHEMA} from '@angular/core';
33
import {ComponentFixture, TestBed} from '@angular/core/testing';
44
import {ActivatedRoute, Router} from '@angular/router';
5+
import {Task, TaskDefinition} from 'src/app/api/models/doubtfire-model';
56
import {FUnitTaskListComponent} from './unit-task-list.component';
67

78
const emptyProvider = {};
89

10+
const taskDefinition = (
11+
id: number,
12+
abbreviation: string,
13+
startDate = new Date(2026, 0, id + 1),
14+
): TaskDefinition =>
15+
({
16+
id,
17+
seq: id,
18+
abbreviation,
19+
name: abbreviation,
20+
startDate,
21+
}) as TaskDefinition;
22+
23+
const taskForDefinition = (definition: TaskDefinition, topWeight: number): Task =>
24+
({
25+
definition,
26+
topWeight,
27+
}) as Task;
28+
929
describe('FUnitTaskListComponent', () => {
1030
let component: FUnitTaskListComponent;
1131
let fixture: ComponentFixture<FUnitTaskListComponent>;
@@ -31,4 +51,61 @@ describe('FUnitTaskListComponent', () => {
3151
it('should create', () => {
3252
expect(component).toBeTruthy();
3353
});
54+
55+
it('sorts task definitions by task top weight by default', () => {
56+
const middlePriorityTask = taskDefinition(0, 'C');
57+
const lowPriorityTask = taskDefinition(1, 'A');
58+
const highPriorityTask = taskDefinition(2, 'B');
59+
component.taskDefinitions = [lowPriorityTask, middlePriorityTask, highPriorityTask];
60+
component.tasks = [
61+
taskForDefinition(middlePriorityTask, 1),
62+
taskForDefinition(lowPriorityTask, 2),
63+
taskForDefinition(highPriorityTask, 0),
64+
];
65+
66+
component.applyFilters();
67+
68+
expect(component.filteredTaskDefinitions).toEqual([
69+
highPriorityTask,
70+
middlePriorityTask,
71+
lowPriorityTask,
72+
]);
73+
});
74+
75+
it('restores top weight order when switching back to default sorting', () => {
76+
const middlePriorityTask = taskDefinition(0, 'C');
77+
const lowPriorityTask = taskDefinition(1, 'A');
78+
const highPriorityTask = taskDefinition(2, 'B');
79+
component.taskDefinitions = [lowPriorityTask, middlePriorityTask, highPriorityTask];
80+
component.tasks = [
81+
taskForDefinition(middlePriorityTask, 1),
82+
taskForDefinition(lowPriorityTask, 2),
83+
taskForDefinition(highPriorityTask, 0),
84+
];
85+
86+
component.setSortBy('abbreviation');
87+
expect(component.filteredTaskDefinitions).toEqual([
88+
lowPriorityTask,
89+
highPriorityTask,
90+
middlePriorityTask,
91+
]);
92+
93+
component.setSortBy('default');
94+
expect(component.filteredTaskDefinitions).toEqual([
95+
highPriorityTask,
96+
middlePriorityTask,
97+
lowPriorityTask,
98+
]);
99+
});
100+
101+
it('falls back to task definition sequence when no task is available', () => {
102+
const firstTask = taskDefinition(0, 'C');
103+
const secondTask = taskDefinition(1, 'A');
104+
const thirdTask = taskDefinition(2, 'B');
105+
component.taskDefinitions = [thirdTask, firstTask, secondTask];
106+
107+
component.applyFilters();
108+
109+
expect(component.filteredTaskDefinitions).toEqual([firstTask, secondTask, thirdTask]);
110+
});
34111
});

0 commit comments

Comments
 (0)