src/app/issue-list/issue-filter.component.ts
Edits an IssueFilter object.
selector | app-issue-filter |
styleUrls | ./issue-filter.component.scss |
templateUrl | ./issue-filter.component.html |
Properties |
Methods |
Inputs |
Outputs |
allLabelsList | |
Type : ListId
|
|
The list from which to source labels in the label picker. |
projectId | |
Type : string
|
|
Raw project ID. |
filterChange | |
Type : EventEmitter
|
|
Emitted every time the filter is changed. |
addPredicateAfter | ||||||||
addPredicateAfter(index: number)
|
||||||||
Adds a new predicate after the given index in activePredicates.
Parameters :
Returns :
void
|
applyIdChangeset | ||||||||
applyIdChangeset(id: string)
|
||||||||
Returns a function that can be passed to an app-set-editor to apply the changeset to the value of the id predicate.
Parameters :
Returns :
Promise<void>
|
buildFilter |
buildFilter()
|
Builds an IssueFilter from the search query and selected filters.
Returns :
IssueFilter
|
getRemainingTypes | ||||||||
getRemainingTypes(index: number)
|
||||||||
Returns all types of predicates that were not taken in previous items.
Parameters :
Returns :
string[]
|
removePredicateAt | ||||||||
removePredicateAt(index: number)
|
||||||||
Removes the predicate at the given index in activePredicates.
Parameters :
Returns :
void
|
setInEnumArray | ||||||||||||
setInEnumArray(array, item, inArray)
|
||||||||||||
Sets the presence of a value in an array of enum variants.
Parameters :
Returns :
void
|
setPredicateType | ||||||||||||
setPredicateType(index: number, type: string)
|
||||||||||||
Sets the type of the predicate at index in activePredicates.
Parameters :
Returns :
void
|
update |
update()
|
Emits a change event.
Returns :
void
|
activePredicates |
Type : string[]
|
Default value : []
|
The names of currently active predicates. |
predicateCount |
Default value : Object.keys(PREDICATES).length
|
predicates |
Default value : PREDICATES
|
predicateValues |
Type : literal type
|
Default value : {}
|
The values of currently active predicates. |
searchQuery |
Type : string
|
Default value : ''
|
Current search query. |
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {IssueCategory, IssueFilter} from '../../generated/graphql-dgql';
import {ListId, ListType, NodeType, ROOT_NODE} from '@app/data-dgql/id';
/** Returns the ListId for listing all project issues. */
const listAllIssues = (self: IssueFilterComponent): {node: {id: string; type: NodeType}; type: ListType} => ({
node: {type: NodeType.Project, id: self.projectId},
type: ListType.Issues
});
/**
* List of all possible issue filter predicates.
*
* Keyed by their name, each predicate has a type, label,
* and possibly additional options depending on their type.
*/
const PREDICATES = {
isOpen: {type: 'bool', label: 'Is open'},
isDuplicate: {type: 'bool', label: 'Is duplicate'},
category: {
type: 'enum',
label: 'Category',
options: [
[IssueCategory.Unclassified, 'Unclassified'],
[IssueCategory.Bug, 'Bug'],
[IssueCategory.FeatureRequest, 'Feature Request']
]
},
labels: {
type: 'ids',
label: 'Labels',
dataType: 'label',
scoreKeys: ['name'],
listAll: (self: IssueFilterComponent): ListId => self.allLabelsList,
makeFilter: (query: string): {name: string} => ({name: query}),
ifEmpty: 'No labels selected'
},
linksIssues: {type: 'bool', label: 'Has linked issues'},
linkedIssues: {
type: 'ids',
label: 'Linked issues',
dataType: 'issue',
scoreKeys: ['title'],
listAll: listAllIssues,
makeFilter: (query: string): {title: string} => ({title: query}),
ifEmpty: 'No issues selected'
},
isLinkedByIssues: {type: 'bool', label: 'Is linked by issues'},
linkedByIssues: {
type: 'ids',
label: 'Linked by issues',
dataType: 'issue',
scoreKeys: ['title'],
listAll: listAllIssues,
makeFilter: (query: string): {title: string} => ({title: query}),
ifEmpty: 'No issues selected'
},
participants: {
type: 'ids',
label: 'Participants',
dataType: 'user',
scoreKeys: ['username', 'displayName'],
listAll: (): {node: {id: string; type: NodeType}; type: ListType} => ({node: ROOT_NODE, type: ListType.SearchUsers}),
makeFilter: (query: string): {username: string} => ({username: query}),
ifEmpty: 'No users selected'
},
locations: {
type: 'ids',
label: 'Locations',
dataType: 'location',
scoreKeys: ['name'],
listAll: (
self: IssueFilterComponent
): {
staticSources: ({node: {id: string; type: NodeType}; type: ListType} | {node: {id: string; type: NodeType}; type: ListType})[];
} => ({
staticSources: [
{
node: {type: NodeType.Project, id: self.projectId},
type: ListType.Components
},
{
node: {type: NodeType.Project, id: self.projectId},
type: ListType.ComponentInterfaces
}
]
}),
makeFilter: (query: string): {title: string} => ({title: query}),
ifEmpty: 'No locations selected'
}
};
/** Returns the default value for a predicate type. */
function getDefaultForType(type: string) {
switch (type) {
case 'bool':
return true;
case 'enum':
case 'ids':
return [];
default:
throw new Error(`unknown type ${type}`);
}
}
/** Converts a predicate value into the backend representation for use in the filter. */
function convertValueForFilter(type: string, value: any) {
switch (type) {
case 'ids':
return value.map((item) => item.id);
default:
return value;
}
}
/**
* Edits an IssueFilter object.
*/
@Component({
selector: 'app-issue-filter',
templateUrl: './issue-filter.component.html',
styleUrls: ['./issue-filter.component.scss']
})
export class IssueFilterComponent {
/** Raw project ID. */
@Input() projectId: string;
/** The list from which to source labels in the label picker. */
@Input() allLabelsList: ListId;
/** Emitted every time the filter is changed. */
@Output() filterChange = new EventEmitter<IssueFilter>();
// constants as class properties because angular
predicates = PREDICATES;
predicateCount = Object.keys(PREDICATES).length;
/** The names of currently active predicates. */
activePredicates: string[] = [];
/** The values of currently active predicates. */
predicateValues: {[key: string]: any} = {};
/** Current search query. */
searchQuery = '';
/**
* Returns all types of predicates that were not taken in previous items.
* @param index index in activePredicates
*/
getRemainingTypes(index: number): string[] {
const previousItems = this.activePredicates.slice(0, index);
return Object.keys(PREDICATES).filter((id) => !previousItems.includes(id));
}
/**
* Sets the type of the predicate at index in activePredicates.
* @param index index in activePredicates
* @param type new type (must be unique!)
*/
setPredicateType(index: number, type: string): void {
this.activePredicates[index] = type;
this.predicateValues[type] = getDefaultForType(PREDICATES[type].type);
this.update();
}
/**
* Removes the predicate at the given index in activePredicates.
* @param index index in activePredicates
*/
removePredicateAt(index: number): void {
const type = this.activePredicates.splice(index, 1)[0];
delete this.predicateValues[type];
this.update();
}
/**
* Adds a new predicate after the given index in activePredicates.
* @param index index in activePredicates
*/
addPredicateAfter(index: number): void {
const type = this.getRemainingTypes(index + 1)[0];
this.activePredicates.splice(index + 1, 0, type);
this.predicateValues[type] = getDefaultForType(PREDICATES[type].type);
this.update();
}
/**
* Sets the presence of a value in an array of enum variants.
* @param array the array to mutate
* @param item the enum variant
* @param inArray whether or not it should be in the array
*/
setInEnumArray(array, item, inArray): void {
if (inArray && !array.includes(item)) {
array.push(item);
}
if (!inArray && array.includes(item)) {
array.splice(array.indexOf(item), 1);
}
this.update();
}
/**
* Returns a function that can be passed to an app-set-editor to apply the changeset to the
* value of the id predicate.
* @param id predicate name
*/
applyIdChangeset(id: string): (added: any, removed: any) => Promise<void> {
return async (added, removed) => {
for (const item of added) {
this.predicateValues[id].push(item);
}
for (const item of removed) {
this.predicateValues[id].splice(this.predicateValues[id].indexOf(item), 1);
}
this.update();
};
}
/**
* Builds an IssueFilter from the search query and selected filters.
*/
buildFilter(): IssueFilter {
const filter: IssueFilter = {};
if (this.searchQuery.trim()) {
filter.fullSearch = {text: this.searchQuery.trim()};
}
for (const id of this.activePredicates) {
filter[id] = convertValueForFilter(PREDICATES[id].type, this.predicateValues[id]);
}
return filter;
}
/** Emits a change event. */
update(): void {
this.filterChange.emit(this.buildFilter());
}
}
<div class="filter-box">
<div class="search-box">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search</mat-label>
<input matInput [(ngModel)]="searchQuery" (keyup)="update()" />
</mat-form-field>
<button mat-button class="pred-add" (click)="addPredicateAfter(-1)" *ngIf="!activePredicates.length">
<mat-icon>filter_list</mat-icon>
</button>
</div>
<div class="filter-predicates" *ngIf="activePredicates.length">
<div class="filter-title">Filter</div>
<div class="filter-predicate" *ngFor="let id of activePredicates; index as idx">
<div class="predicate-type">
<mat-form-field appearance="outline" class="predicate-type-selector">
<mat-select [value]="id" (selectionChange)="setPredicateType(idx, $event.value)">
<mat-option *ngFor="let type of getRemainingTypes(idx)" [value]="type"> {{ predicates[type].label }} </mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="predicate-value" [ngSwitch]="predicates[id].type">
<!-- FILTER VALUES -->
<ng-container *ngSwitchCase="'bool'">
<mat-button-toggle-group class="bool-value" [(ngModel)]="predicateValues[id]" (ngModelChange)="update()">
<mat-button-toggle [value]="true"> Yes </mat-button-toggle>
<mat-button-toggle [value]="false"> No </mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
<ng-container *ngSwitchCase="'enum'">
<mat-button-toggle-group class="enum-value" multiple>
<mat-button-toggle
*ngFor="let entry of predicates[id].options"
[checked]="predicateValues[id].includes(entry[0])"
(change)="
setInEnumArray(
predicateValues[id],
entry[0],
$event.source.checked
)
"
[value]="entry[0]"
>
{{ entry[1] }}
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
<ng-container *ngSwitchCase="'ids'">
<app-set-editor
[listSet]="predicateValues[id]"
[listAll]="predicates[id].listAll(this)"
[scoreKeys]="predicates[id].scoreKeys"
[makeFilter]="predicates[id].makeFilter"
[applyChangeset]="applyIdChangeset(id)"
>
<span title>{{ predicates[id].label }}</span>
<span if-empty>{{ predicates[id].ifEmpty }}</span>
<ng-container *appItem="let item" [ngSwitch]="predicates[id].dataType">
<app-issue-label *ngSwitchCase="'label'" [label]="item"></app-issue-label>
<app-issue-item *ngSwitchCase="'issue'" [projectId]="projectId" [issue]="item"></app-issue-item>
<app-user-item *ngSwitchCase="'user'" [user]="item"></app-user-item>
<ng-container *ngSwitchCase="'location'">
<div *ngIf="item.__typename === 'Component'">{{ item.name }}</div>
<div *ngIf="item.__typename === 'ComponentInterface'">
<span class="location-containing-component-name" *ngIf="!!item.component"> {{ item.component.name }} › </span>
{{ item.name }}
</div>
</ng-container>
</ng-container>
</app-set-editor>
</ng-container>
<!-- -------------------- -->
</div>
<div class="predicate-mgmt">
<button mat-button class="pred-remove" (click)="removePredicateAt(idx)">
<mat-icon>remove</mat-icon>
</button>
<button mat-button class="pred-add" (click)="addPredicateAfter(idx)" *ngIf="activePredicates.length < predicateCount">
<mat-icon>add</mat-icon>
</button>
</div>
</div>
</div>
</div>
./issue-filter.component.scss
.filter-box {
padding: 8px;
// we do not want the extra spacing around form fields
// (we wont be showing any helper text or errors anyway)
::ng-deep .mat-form-field-wrapper {
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
}
.pred-add,
.pred-remove {
min-width: 48px;
width: 48px;
padding: 0 8px;
}
.search-box {
display: flex;
align-items: center;
.search-field {
flex: 1;
}
.pred-add {
margin-left: 16px;
}
}
.filter-predicates {
margin-top: 8px;
.filter-title {
font-weight: bold;
padding-left: 8px;
}
.filter-predicate {
display: flex;
align-items: flex-start;
.predicate-type {
margin-right: 16px;
}
.predicate-value {
flex: 1;
}
.predicate-mgmt {
margin-left: 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 4px;
.pred-add {
margin-left: 4px;
}
}
@media (max-width: 500px) {
display: block;
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px 0;
.predicate-type {
margin-right: 0;
margin-bottom: 8px;
.predicate-type-selector {
width: 100%;
}
}
.predicate-value {
.bool-value {
width: 100%;
mat-button-toggle {
width: 100%;
}
}
}
.predicate-mgmt {
border: none;
margin-left: 0;
text-align: right;
}
}
}
}
}