Skip to content

Commit

Permalink
Implement filter for Task Owners (#381)
Browse files Browse the repository at this point in the history
Having a function to filter issues/PRs by task owners can 
give users a more organised overview of the contributions
made by each member in the project. 

Hence, let's add it to the list of existing filters we have 
currently for users.
  • Loading branch information
kokerinks authored Jul 20, 2024
1 parent 03ef9e0 commit 610fa8d
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 15 deletions.
3 changes: 2 additions & 1 deletion src/app/core/models/github-user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export interface RawGithubUser {
}

/**
* Represents a GitHub user in WATcher
* Represents a GitHub user in WATcher, used for authentication
* of user as well as representing assignees for issues/PRs.
*/
export class GithubUser implements RawGithubUser, Group {
static NO_ASSIGNEE: GithubUser = new GithubUser({
Expand Down
28 changes: 28 additions & 0 deletions src/app/core/services/assignee.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { GithubUser } from '../models/github-user.model';
import { GithubService } from './github.service';

@Injectable({
providedIn: 'root'
})
export class AssigneeService {
assignees: GithubUser[] = [];
hasNoAssignees: boolean;

constructor(private githubService: GithubService) {}

/**
* Fetch all assignees from github.
*/
public fetchAssignees(): Observable<any> {
return this.githubService.getUsersAssignable().pipe(
map((response) => {
this.assignees = response;
this.hasNoAssignees = response.length === 0;
return response;
})
);
}
}
49 changes: 44 additions & 5 deletions src/app/core/services/filters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { Injectable } from '@angular/core';
import { Sort } from '@angular/material/sort';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, pipe } from 'rxjs';
import { GithubUser } from '../models/github-user.model';
import { SimpleLabel } from '../models/label.model';
import { Milestone } from '../models/milestone.model';
import { AssigneeService } from './assignee.service';
import { LoggingService } from './logging.service';
import { MilestoneService } from './milestone.service';

Expand All @@ -17,6 +19,7 @@ export type Filter = {
hiddenLabels: Set<string>;
deselectedLabels: Set<string>;
itemsPerPage: number;
assignees: string[];
};

@Injectable({
Expand All @@ -39,7 +42,8 @@ export class FiltersService {
milestones: [],
hiddenLabels: new Set<string>(),
deselectedLabels: new Set<string>(),
itemsPerPage: this.itemsPerPage
itemsPerPage: this.itemsPerPage,
assignees: [],
};

readonly presetViews: {
Expand All @@ -53,7 +57,8 @@ export class FiltersService {
labels: [],
milestones: this.getMilestonesForCurrentlyActive().map((milestone) => milestone.title),
deselectedLabels: new Set<string>(),
itemsPerPage: 20
itemsPerPage: 20,
assignees: this.getAssigneesForCurrentlyActive().map((assignee) => assignee.login)
}),
contributions: () => ({
title: '',
Expand All @@ -63,26 +68,29 @@ export class FiltersService {
labels: [],
milestones: this.milestoneService.milestones.map((milestone) => milestone.title),
deselectedLabels: new Set<string>(),
itemsPerPage: 20
itemsPerPage: 20,
assignees: this.assigneeService.assignees.map((assignee) => assignee.login)
}),
custom: () => ({})
};

// List of keys in the new filter change that causes current filter to not qualify to be a preset view.
readonly presetChangingKeys = new Set<string>(['status', 'type', 'sort', 'milestones', 'labels', 'deselectedLabels']);
readonly presetChangingKeys = new Set<string>(['status', 'type', 'sort', 'milestones', 'labels', 'deselectedLabels', 'assignees']);

public filter$ = new BehaviorSubject<Filter>(this.defaultFilter);
// Either 'currentlyActive', 'contributions', or 'custom'.
public presetView$ = new BehaviorSubject<string>('currentlyActive');

// Helps in determining whether all milestones were selected from previous repo during sanitization of milestones
private previousMilestonesLength = 0;
private previousAssigneesLength = 0;

constructor(
private logger: LoggingService,
private router: Router,
private route: ActivatedRoute,
private milestoneService: MilestoneService
private milestoneService: MilestoneService,
private assigneeService: AssigneeService
) {
this.filter$.subscribe((filter: Filter) => {
this.itemsPerPage = filter.itemsPerPage;
Expand Down Expand Up @@ -111,6 +119,7 @@ export class FiltersService {
case 'status':
case 'labels':
case 'milestones':
case 'assignees':
if (filterValue.length === 0) {
delete queryParams[filterName];
continue;
Expand Down Expand Up @@ -148,6 +157,7 @@ export class FiltersService {
this.updateFilters(this.defaultFilter);
this.updatePresetView('currentlyActive');
this.previousMilestonesLength = 0;
this.previousAssigneesLength = 0;
}

initializeFromURLParams() {
Expand All @@ -172,6 +182,7 @@ export class FiltersService {
case 'status':
case 'labels':
case 'milestones':
case 'assignees':
nextFilter[filterName] = filterData;
break;
// Sets
Expand Down Expand Up @@ -280,6 +291,29 @@ export class FiltersService {
});
}

sanitizeAssignees(allAssignees: GithubUser[]) {
const assignees = allAssignees.map((assignee) => assignee.login);
assignees.push(GithubUser.NO_ASSIGNEE.login);
const allAssigneesSet = new Set(assignees);

// All previous assignees were selected, reset to all new assignees selected
if (this.filter$.value.assignees.length === this.previousAssigneesLength) {
this.updateFiltersWithoutUpdatingPresetView({ assignees: [...allAssigneesSet] });
this.previousAssigneesLength = allAssigneesSet.size;
return;
}

const newAssignees: string[] = [];
for (const assignee of this.filter$.value.assignees) {
if (allAssigneesSet.has(assignee)) {
newAssignees.push(assignee);
}
}

this.updateFiltersWithoutUpdatingPresetView({ assignees: newAssignees });
this.previousAssigneesLength = allAssigneesSet.size;
}

sanitizeMilestones(allMilestones: Milestone[]) {
const milestones = allMilestones.map((milestone) => milestone.title);
milestones.push(Milestone.IssueWithoutMilestone.title, Milestone.PRWithoutMilestone.title);
Expand Down Expand Up @@ -320,4 +354,9 @@ export class FiltersService {
}
return [...this.milestoneService.milestones, Milestone.PRWithoutMilestone];
}

getAssigneesForCurrentlyActive(): GithubUser[] {
// TODO Filter out assignees that have not contributed in currently active milestones
return [...this.assigneeService.assignees, GithubUser.NO_ASSIGNEE];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@ export class AssigneeGroupingStrategy implements GroupingStrategy {

/**
* Retrieves data for a specific assignee.
* If it is the"No Assignee" group, unassigned issues are returned.
* If it is the "No Assignee" group, unassigned issues are returned.
* Otherwise, issues assigned to the specified user are returned.
*/
getDataForGroup(issues: Issue[], key: GithubUser): Issue[] {
if (key === GithubUser.NO_ASSIGNEE) {
return this.getUnassignedData(issues);
}

return this.getDataAssignedToUser(issues, key);
}

Expand Down
8 changes: 7 additions & 1 deletion src/app/issues-viewer/card-view/card-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { MatPaginator } from '@angular/material/paginator';
import { Observable, Subscription } from 'rxjs';
import { Group } from '../../core/models/github/group.interface';
import { Issue } from '../../core/models/issue.model';
import { AssigneeService } from '../../core/services/assignee.service';
import { FiltersService } from '../../core/services/filters.service';
import { GroupBy, GroupingContextService } from '../../core/services/grouping/grouping-context.service';
import { IssueService } from '../../core/services/issue.service';
import { LoggingService } from '../../core/services/logging.service';
import { MilestoneService } from '../../core/services/milestone.service';
import { FilterableComponent, FilterableSource } from '../../shared/issue-tables/filterableTypes';
import { IssuesDataTable } from '../../shared/issue-tables/IssuesDataTable';
Expand Down Expand Up @@ -60,14 +62,17 @@ export class CardViewComponent implements OnInit, AfterViewInit, OnDestroy, Filt
public issueService: IssueService,
public groupingContextService: GroupingContextService,
private filtersService: FiltersService,
private milestoneService: MilestoneService
private milestoneService: MilestoneService,
private assigneeService: AssigneeService,
private logger: LoggingService,
) {}

ngOnInit() {
this.issues = new IssuesDataTable(
this.issueService,
this.groupingContextService,
this.filtersService,
this.assigneeService,
this.milestoneService,
this.paginator,
this.headers,
Expand All @@ -84,6 +89,7 @@ export class CardViewComponent implements OnInit, AfterViewInit, OnDestroy, Filt
this.timeoutId = setTimeout(() => {
this.issues.loadIssues();
this.issues$ = this.issues.connect();
this.logger.debug('CardViewComponent: Issues loaded', this.issues$);

// Emit event when issues change
this.issuesLengthSubscription = this.issues$.subscribe(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/app/issues-viewer/issues-viewer.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ export class IssuesViewerComponent implements OnInit, AfterViewInit, OnDestroy {
/**
* Update the list of hidden group based on the new info.
* @param issueLength The number of issues under this group.
* @param group The group.
* @param target The group.
*/
updateHiddenGroups(issueLength: number, target: Group) {
// If the group is in the hidden list, add it if it has no issues.
// Also add it if it is unchecked in the filter.
if (issueLength === 0 && this.groupingContextService.isInHiddenList(target)) {
this.addToHiddenGroups(target);
} else {
Expand Down
18 changes: 18 additions & 0 deletions src/app/shared/filter-bar/filter-bar.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@
<mat-option [value]="50">50</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="standard">
<mat-label>Assigned to</mat-label>
<mat-select
#assigneeSelectorRef
[value]="this.filter.assignees"
(selectionChange)="this.filtersService.updateFilters({ assignees: $event.value })"
[disabled]="this.assigneeService.hasNoAssignees"
multiple
>
<mat-select-trigger *ngIf="this.assigneeService.hasNoAssignees">
<span>No Assignees</span>
</mat-select-trigger>
<mat-option *ngFor="let assignee of this.assigneeService.assignees" [value]="assignee.login">
{{ assignee.login }}
</mat-option>
<mat-option [value]="'Unassigned'">Unassigned</mat-option>
</mat-select>
</mat-form-field>
</div>
</mat-grid-tile>

Expand Down
12 changes: 12 additions & 0 deletions src/app/shared/filter-bar/filter-bar.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AfterViewInit, Component, Input, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core';
import { MatSelect } from '@angular/material/select';
import { BehaviorSubject, Subscription } from 'rxjs';
import { AssigneeService } from '../../core/services/assignee.service';
import { Filter, FiltersService } from '../../core/services/filters.service';
import { GroupBy, GroupingContextService } from '../../core/services/grouping/grouping-context.service';
import { LoggingService } from '../../core/services/logging.service';
Expand Down Expand Up @@ -30,12 +31,14 @@ export class FilterBarComponent implements OnInit, OnDestroy {

/** Milestone subscription */
milestoneSubscription: Subscription;
assigneeSubscription: Subscription;

@ViewChild(LabelFilterBarComponent, { static: true }) labelFilterBar: LabelFilterBarComponent;

@ViewChild('milestoneSelectorRef', { static: false }) milestoneSelectorRef: MatSelect;

constructor(
public assigneeService: AssigneeService,
public milestoneService: MilestoneService,
public filtersService: FiltersService,
private viewService: ViewService,
Expand All @@ -59,6 +62,7 @@ export class FilterBarComponent implements OnInit, OnDestroy {

ngOnDestroy(): void {
this.milestoneSubscription.unsubscribe();
this.assigneeSubscription.unsubscribe();
this.repoChangeSubscription.unsubscribe();
}

Expand Down Expand Up @@ -94,5 +98,13 @@ export class FilterBarComponent implements OnInit, OnDestroy {
(err) => {},
() => {}
);
this.assigneeSubscription = this.assigneeService.fetchAssignees().subscribe(
(response) => {
this.logger.debug('IssuesViewerComponent: Fetched assignees from Github');
this.filtersService.sanitizeAssignees(this.assigneeService.assignees);
},
(err) => {},
() => {}
);
}
}
4 changes: 3 additions & 1 deletion src/app/shared/issue-tables/IssuesDataTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BehaviorSubject, merge, Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { Group } from '../../core/models/github/group.interface';
import { Issue } from '../../core/models/issue.model';
import { AssigneeService } from '../../core/services/assignee.service';
import { Filter, FiltersService } from '../../core/services/filters.service';
import { GroupingContextService } from '../../core/services/grouping/grouping-context.service';
import { IssueService } from '../../core/services/issue.service';
Expand All @@ -26,6 +27,7 @@ export class IssuesDataTable extends DataSource<Issue> implements FilterableSour
private issueService: IssueService,
private groupingContextService: GroupingContextService,
private filtersService: FiltersService,
private assigneeService: AssigneeService,
private milestoneService: MilestoneService,
private paginator: MatPaginator,
private displayedColumn: string[],
Expand Down Expand Up @@ -69,7 +71,7 @@ export class IssuesDataTable extends DataSource<Issue> implements FilterableSour
data = this.groupingContextService.getDataForGroup(data, this.group);

// Apply Filters
data = applyDropdownFilter(this.filter, data, !this.milestoneService.hasNoMilestones);
data = applyDropdownFilter(this.filter, data, !this.milestoneService.hasNoMilestones, !this.assigneeService.hasNoAssignees);

data = applySearchFilter(this.filter.title, this.displayedColumn, this.issueService, data);
this.count = data.length;
Expand Down
25 changes: 24 additions & 1 deletion src/app/shared/issue-tables/dropdownfilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ const infoFromStatus = (statusString: string): StatusInfo => {
* This module exports a single function applyDropDownFilter which is called by IssueList.
* This functions returns the data passed in after all the filters of dropdownFilters are applied
*/
export function applyDropdownFilter(filter: Filter, data: Issue[], isFilteringByMilestone: boolean): Issue[] {
export function applyDropdownFilter(
filter: Filter,
data: Issue[],
isFilteringByMilestone: boolean,
isFilteringByAssignee: boolean
): Issue[] {
const filteredData: Issue[] = data.filter((issue) => {
let ret = true;

Expand All @@ -39,8 +44,26 @@ export function applyDropdownFilter(filter: Filter, data: Issue[], isFilteringBy
}

ret = ret && (!isFilteringByMilestone || filter.milestones.some((milestone) => issue.milestone.title === milestone));
ret = ret && (!isFilteringByAssignee || isFilteredByAssignee(filter, issue));
ret = ret && issue.labels.every((label) => !filter.deselectedLabels.has(label));
return ret && filter.labels.every((label) => issue.labels.includes(label));
});
return filteredData;
}

function isFilteredByAssignee(filter: Filter, issue: Issue): boolean {
if (issue.issueOrPr === 'Issue') {
return (
filter.assignees.some((assignee) => issue.assignees.includes(assignee)) ||
(filter.assignees.includes('Unassigned') && issue.assignees.length === 0)
);
} else if (issue.issueOrPr === 'PullRequest') {
return (
filter.assignees.some((assignee) => issue.author === assignee) || (filter.assignees.includes('Unassigned') && issue.author === null)
);
// note that issue.author is never == null for PRs, but is left for semantic reasons
} else {
// should never occur
throw new Error('Issue or PR is neither Issue nor PullRequest');
}
}
6 changes: 4 additions & 2 deletions tests/constants/filter.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const DEFAULT_FILTER: Filter = {
milestones: ['PR without a milestone'],
hiddenLabels: new Set<string>(),
deselectedLabels: new Set<string>(),
itemsPerPage: 20
itemsPerPage: 20,
assignees: ['Unassigned']
};

export const CHANGED_FILTER: Filter = {
Expand All @@ -21,5 +22,6 @@ export const CHANGED_FILTER: Filter = {
milestones: ['V3.3.6'],
hiddenLabels: new Set<string>(['aspect-testing']),
deselectedLabels: new Set<string>(['aspect-documentation']),
itemsPerPage: 50
itemsPerPage: 50,
assignees: ['test']
};
Loading

0 comments on commit 610fa8d

Please sign in to comment.