Skip to content

Commit

Permalink
Three-state labels (#282)
Browse files Browse the repository at this point in the history
Previously, each label only has 2 states, either selected or
not selected. However, with such design,
the feature of hiding labels can be confused
with hiding issues/PRs with the label.

We implement the three-state label filters,
so that each label can also be used to hide
issues/PRs with the label.
  • Loading branch information
nknguyenhc authored Mar 22, 2024
1 parent caadd66 commit e3d4a34
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 67 deletions.
4 changes: 3 additions & 1 deletion src/app/core/services/filters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Filter = {
labels: string[];
milestones: string[];
hiddenLabels: Set<string>;
deselectedLabels: Set<string>;
};

export const DEFAULT_FILTER: Filter = {
Expand All @@ -20,7 +21,8 @@ export const DEFAULT_FILTER: Filter = {
sort: { active: 'id', direction: 'asc' },
labels: [],
milestones: [],
hiddenLabels: new Set()
hiddenLabels: new Set<string>(),
deselectedLabels: new Set<string>()
};

@Injectable({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,33 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
border-radius: 10px;
height: 40px;
padding: 0px 12px;
margin: 8px 4px;
box-sizing: border-box;
position: relative;
}

.flexbox-container:hover {
background-color: rgba(0, 0, 0, 0.04);
}

.flexbox-container-strikethrough {
position: absolute;
top: 50%;
width: 90%;
left: 50%;
transform: translate(-50%, -50%);
height: 2px;
background-color: black;
}

.input-field {
width: calc(100% - (2 * 15px)); /* To account for left and right padding. */
padding: 0 15px;
}

.list-option {
width: 100%;
}

.mat-chip {
height: auto;
padding: 5.5px 7px;
Expand All @@ -109,7 +125,6 @@
min-height: 16px;
max-height: 42px;
margin: 0px;
top: 50%;
}

.mat-stroked-button {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<button mat-stroked-button *ngIf="loaded" [matMenuTriggerFor]="menu">
{{ selectedLabelNames.length == 0 ? 'All' : selectedLabelNames.length }} Selected | {{ hiddenLabelNames?.size || 0 }} Hidden ▾
{{ selectedLabelNames.size === 0 ? 'All' : selectedLabelNames.size }} Selected | {{ hiddenLabelNames?.size || 0 }} Hidden ▾
</button>

<button mat-stroked-button disabled *ngIf="!loaded" color="accent">
Expand All @@ -19,32 +19,28 @@

<div class="scroll-container-wrapper">
<div class="scroll-container">
<mat-selection-list (selectionChange)="updateSelection($event.options)">
<mat-list-option
#option
*ngFor="let label of this.labels$ | async"
[value]="label.name"
[selected]="selectedLabelNames.includes(label.name)"
class="list-option"
[class.hidden]="filter(input.value, label.name)"
<div
*ngFor="let label of this.allLabels"
class="flexbox-container"
(click)="changeLabelState(label)"
[class.hidden]="filter(input.value, label.name)"
[style]="{ border: '2px solid ' + getColor(label) }"
>
<button mat-icon-button *ngIf="!hiddenLabelNames.has(label.name)" (click)="hide(label.name); $event.stopPropagation()">
<mat-icon>visibility</mat-icon>
</button>
<button mat-icon-button *ngIf="hiddenLabelNames.has(label.name)" (click)="show(label.name); $event.stopPropagation()">
<mat-icon>visibility_off</mat-icon>
</button>
<mat-chip
[ngStyle]="labelService.setLabelStyle(label.color)"
[disabled]="hiddenLabelNames.has(label.name)"
(click)="changeLabelState(label)"
>
<div class="flexbox-container">
<button mat-icon-button *ngIf="!hiddenLabelNames.has(label.name)" (click)="hide(label.name); $event.stopPropagation()">
<mat-icon>visibility</mat-icon>
</button>
<button mat-icon-button *ngIf="hiddenLabelNames.has(label.name)" (click)="show(label.name); $event.stopPropagation()">
<mat-icon>visibility_off</mat-icon>
</button>
<mat-chip
[ngStyle]="labelService.setLabelStyle(label.color)"
[disabled]="hiddenLabelNames.has(label.name)"
(click)="simulateClick(option); $event.stopPropagation()"
>
{{ label.name }}
</mat-chip>
</div>
</mat-list-option>
</mat-selection-list>
{{ label.name }}
</mat-chip>
<div *ngIf="deselectedLabelNames.has(label.name)" class="flexbox-container-strikethrough"></div>
</div>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatListOption, MatSelectionList } from '@angular/material/list';
import { Observable, Subscription } from 'rxjs';
import { SimpleLabel } from '../../../core/models/label.model';
import { FiltersService } from '../../../core/services/filters.service';
Expand All @@ -12,11 +11,14 @@ import { LoggingService } from '../../../core/services/logging.service';
styleUrls: ['./label-filter-bar.component.css']
})
export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild(MatSelectionList) matSelectionList;
private static readonly DEFAULT_LABEL_COLOR: string = 'transparent';
private static readonly DESELECTED_LABEL_COLOR: string = '#b00020';
private static readonly SELECTED_LABEL_COLOR: string = '#41c300';

labels$: Observable<SimpleLabel[]>;
allLabels: SimpleLabel[];
selectedLabelNames: string[] = [];
selectedLabelNames: Set<string> = new Set<string>();
deselectedLabelNames: Set<string> = new Set<string>();
hiddenLabelNames: Set<string> = new Set();
loaded = false;

Expand All @@ -35,7 +37,7 @@ export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy
this.labels$.subscribe((labels) => {
this.allLabels = labels;
this.filtersService.sanitizeLabels(this.allLabels);
this.selectedLabelNames = this.filtersService.filter$.value.labels;
this.selectedLabelNames = new Set<string>(this.filtersService.filter$.value.labels);
this.hiddenLabelNames = this.filtersService.filter$.value.hiddenLabels;
});
});
Expand Down Expand Up @@ -64,16 +66,34 @@ export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy
}

/**
* chip as of the current project version consumes click events
* this method is used as an workaround the issue.
* https://github.com/angular/components/issues/19759
* Change label to the next state.
* Label has the following state rotation: default -> selected -> deselected.
* @param label The label to change state
*/
simulateClick(el: MatListOption): void {
if (el.disabled) {
return;
changeLabelState(label: SimpleLabel) {
if (this.selectedLabelNames.has(label.name)) {
this.selectedLabelNames.delete(label.name);
this.deselectedLabelNames.add(label.name);
} else if (this.deselectedLabelNames.has(label.name)) {
this.deselectedLabelNames.delete(label.name);
} else {
this.selectedLabelNames.add(label.name);
}
this.updateSelection();
}

/**
* Returns the border color of the label.
* The border color represents the state of the label.
*/
getColor(label: SimpleLabel): string {
if (this.selectedLabelNames.has(label.name)) {
return LabelFilterBarComponent.SELECTED_LABEL_COLOR;
} else if (this.deselectedLabelNames.has(label.name)) {
return LabelFilterBarComponent.DESELECTED_LABEL_COLOR;
} else {
return LabelFilterBarComponent.DEFAULT_LABEL_COLOR;
}
el.toggle();
this.updateSelection([el]);
}

/** loads in the labels in the repository */
Expand Down Expand Up @@ -101,22 +121,16 @@ export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy
return this.allLabels.some((label) => !this.filter(filter, label.name));
}

updateSelection(options: MatListOption[]): void {
options.forEach((option) => {
if (option.selected && !this.selectedLabelNames.includes(option.value)) {
this.selectedLabelNames.push(option.value);
}
if (!option.selected && this.selectedLabelNames.includes(option.value)) {
const index = this.selectedLabelNames.indexOf(option.value);
this.selectedLabelNames.splice(index, 1);
}
updateSelection(): void {
this.filtersService.updateFilters({
labels: Array.from(this.selectedLabelNames),
deselectedLabels: this.deselectedLabelNames
});
this.filtersService.updateFilters({ labels: this.selectedLabelNames });
}

removeAllSelection(): void {
this.matSelectionList.deselectAll();
this.selectedLabelNames = [];
this.filtersService.updateFilters({ labels: this.selectedLabelNames });
this.selectedLabelNames = new Set<string>();
this.deselectedLabelNames = new Set<string>();
this.updateSelection();
}
}
2 changes: 1 addition & 1 deletion src/app/shared/issue-tables/dropdownfilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function applyDropdownFilter(filter: Filter, data: Issue[]): Issue[] {
}

ret = ret && filter.milestones.some((milestone) => issue.milestone.title === milestone);

ret = ret && issue.labels.every((label) => !filter.deselectedLabels.has(label));
return ret && filter.labels.every((label) => issue.labels.includes(label));
});
return filteredData;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,23 +119,19 @@ describe('LabelFilterBarComponent', () => {
describe('updateSelection', () => {
it('should update filters service with selected labels', () => {
const selectedLabels = [LABEL_NAME_SEVERITY_HIGH, LABEL_NAME_SEVERITY_LOW];
component.selectedLabelNames = selectedLabels;
component.selectedLabelNames = new Set<string>(selectedLabels);

component.updateSelection([]);
component.updateSelection();

expect(filtersServiceSpy.updateFilters).toHaveBeenCalledWith({ labels: selectedLabels });
expect(filtersServiceSpy.updateFilters).toHaveBeenCalledWith({ labels: selectedLabels, deselectedLabels: new Set<string>() });
});
});

describe('removeAllSelection', () => {
it('should deselect all labels and update the filter', () => {
const matSelectionListSpy = jasmine.createSpyObj<MatSelectionList>('MatSelectionList', ['deselectAll']);
component.matSelectionList = matSelectionListSpy;

component.removeAllSelection();

expect(matSelectionListSpy.deselectAll).toHaveBeenCalled();
expect(filtersServiceSpy.updateFilters).toHaveBeenCalledWith({ labels: [] });
expect(component.selectedLabelNames).toEqual(new Set<string>());
expect(component.deselectedLabelNames).toEqual(new Set<string>());
});
});
});

0 comments on commit e3d4a34

Please sign in to comment.