Merge pull request #5 from Trivernis/feature/filter-dialog

Feature/filter dialog
pull/4/head
Julius Riegel 3 years ago committed by GitHub
commit 251d981b04

@ -54,6 +54,10 @@ import { ImportTabSidebarComponent } from './pages/home/import-tab/import-tab-si
import { NativeFileSelectComponent } from './components/inputs/native-file-select/native-file-select.component'; import { NativeFileSelectComponent } from './components/inputs/native-file-select/native-file-select.component';
import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-sidebar/filesystem-import/filesystem-import.component'; import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-sidebar/filesystem-import/filesystem-import.component';
import {MatCheckboxModule} from "@angular/material/checkbox"; import {MatCheckboxModule} from "@angular/material/checkbox";
import { FilterDialogComponent } from './components/file-search/filter-dialog/filter-dialog.component';
import { TagFilterListItemComponent } from './components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component';
import { TagInputComponent } from './components/inputs/tag-input/tag-input.component';
import { ContextMenuComponent } from './components/context-menu/context-menu.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -78,6 +82,10 @@ import {MatCheckboxModule} from "@angular/material/checkbox";
ImportTabSidebarComponent, ImportTabSidebarComponent,
NativeFileSelectComponent, NativeFileSelectComponent,
FilesystemImportComponent, FilesystemImportComponent,
FilterDialogComponent,
TagFilterListItemComponent,
TagInputComponent,
ContextMenuComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

@ -0,0 +1,5 @@
<div class="menu-anchor" [matMenuTriggerFor]="contextMenu" [style.left]="x" [style.top]="y"></div>
<mat-menu #contextMenu="matMenu">
<ng-content select="mat-menu-item"></ng-content>
<ng-content ></ng-content>
</mat-menu>

@ -0,0 +1,4 @@
.menu-anchor {
visibility: hidden;
position: fixed;
}

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContextMenuComponent } from './context-menu.component';
describe('ContextMenuComponent', () => {
let component: ContextMenuComponent;
let fixture: ComponentFixture<ContextMenuComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ContextMenuComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ContextMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,32 @@
import {
Component,
ComponentFactoryResolver,
OnInit,
ViewChild,
ViewContainerRef
} from '@angular/core';
import {MatMenuTrigger} from "@angular/material/menu";
@Component({
selector: 'app-context-menu',
templateUrl: './context-menu.component.html',
styleUrls: ['./context-menu.component.scss']
})
export class ContextMenuComponent {
public x: string = "0";
public y: string = "0";
@ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger
constructor() {
}
public onContextMenu(event: MouseEvent) {
event.preventDefault();
this.x = event.clientX + "px";
this.y = event.clientY + "px";
this.menuTrigger.openMenu();
}
}

@ -16,6 +16,9 @@
[formControl]="formControl" [formControl]="formControl"
[matAutocomplete]="auto" [matAutocomplete]="auto"
matInput/> matInput/>
<button mat-button class="filter-dialog-button" (click)="openFilterDialog()">
<mat-icon>filter_alt</mat-icon>
</button>
<mat-autocomplete #auto (optionSelected)="addSearchTagByAutocomplete($event)"> <mat-autocomplete #auto (optionSelected)="addSearchTagByAutocomplete($event)">
<mat-option *ngFor="let tag of suggestionTags | async" [value]="tag"> <mat-option *ngFor="let tag of suggestionTags | async" [value]="tag">
{{tag}} {{tag}}

@ -54,3 +54,9 @@
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
.filter-dialog-button {
position: absolute;
right: -13px;
top: -20px;
}

@ -23,6 +23,8 @@ import {
FilterExpression, FilterExpression,
SingleFilterExpression SingleFilterExpression
} from "../../models/FilterExpression"; } from "../../models/FilterExpression";
import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component";
import {Tag} from "../../models/Tag";
@Component({ @Component({
@ -37,6 +39,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
public filters: FilterExpression[] = []; public filters: FilterExpression[] = [];
public suggestionTags: Observable<string[]>; public suggestionTags: Observable<string[]>;
@Input() availableTags: Tag[] = [];
@Input() validTags: string[] = []; @Input() validTags: string[] = [];
@Output() searchStartEvent = new EventEmitter<void>(); @Output() searchStartEvent = new EventEmitter<void>();
@Output() searchEndEvent = new EventEmitter<void>(); @Output() searchEndEvent = new EventEmitter<void>();
@ -70,12 +73,9 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
} }
public addSearchTag(tag: string) { public addSearchTag(tag: string) {
if (tag.startsWith("-")) { this.filters.push(new SingleFilterExpression(TagQuery.fromString(tag)));
tag = tag.replace(/^-/g, ''); tag = tag.replace(/^-/g, '');
this.filters.push(new SingleFilterExpression(new TagQuery(tag, true)));
} else {
this.filters.push(new SingleFilterExpression(new TagQuery(tag, false)));
}
if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) { if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) {
const index = this.filters.findIndex(t => t.partiallyEq(tag)); const index = this.filters.findIndex(t => t.partiallyEq(tag));
this.filters.splice(index, 1); this.filters.splice(index, 1);
@ -143,4 +143,23 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
.map(t => negated ? "-" + t : t) .map(t => negated ? "-" + t : t)
.slice(0, 20); .slice(0, 20);
} }
public openFilterDialog(): void {
const filterEntries = this.filters.map(f => f.clone());
const filterDialog = this.dialog.open(FilterDialogComponent, {
minWidth: "25vw",
height: "80vh",
data: {
filterEntries,
availableTags: this.availableTags,
},
disableClose: true,
});
filterDialog.afterClosed().subscribe(async (filterExpression) => {
if (filterExpression !== undefined || filterExpression?.length > 0) {
this.filters = filterExpression;
await this.searchForFiles();
}
});
}
} }

@ -0,0 +1,23 @@
<h1 mat-dialog-title>Filters</h1>
<div mat-dialog-content fxLayout="column" class="filter-dialog-content">
<div fxFlex fxFlexFill class="filter-dialog-list">
<mat-list>
<mat-list-item class="filter-list-item" *ngFor="let expression of filters" [class.selected]="expression.selected">
<app-tag-filter-list-item (querySelect)="this.addToSelection($event)" (queryUnselect)="this.removeFromSelection($event)" (removeClicked)="this.removeFilter($event)"
(contextmenu)="contextMenu.onContextMenu($event)" [expression]="expression"></app-tag-filter-list-item>
</mat-list-item>
</mat-list>
</div>
<mat-divider fxFlex="10px"></mat-divider>
<app-tag-input fxFlex="5em" class="tag-input" [allowNegation]="true" [availableTags]="this.availableTags"
(tagAdded)="this.addFilter($event)"></app-tag-input>
</div>
<div class="dialog-actions" mat-dialog-actions>
<button mat-flat-button color="primary" (click)="confirmFilter()">Filter</button>
<button mat-stroked-button color="accent" (click)="cancelFilter()">Cancel</button>
</div>
<app-context-menu #contextMenu>
<button mat-menu-item (click)="this.convertSelectionToOrExpression()">Copy to OR-Expression</button>
<button mat-menu-item (click)="this.convertSelectionToAndExpression()">Copy to AND-Expression</button>
<button mat-menu-item (click)="this.invertSelection()">Invert</button>
</app-context-menu>

@ -0,0 +1,41 @@
.dialog-actions {
display: flex;
flex-direction: row-reverse;
width: 100%;
button {
margin-left: 1em;
}
}
.tag-input {
width: 100%;
}
mat-list-item.filter-list-item {
height: 100%;
padding: 0.5em 0;
user-select: none;
cursor: pointer;
}
app-tag-filter-list-item {
width: 100%;
}
.selected {
background-color: #5c5c5c;
}
.filter-dialog-content {
overflow: hidden;
height: 100%;
width: 100%;
margin: 0;
}
.filter-dialog-list {
overflow-y: auto;
height: 100%;
width: 100%;
}

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterDialogComponent } from './filter-dialog.component';
describe('FilterDialogComponent', () => {
let component: FilterDialogComponent;
let fixture: ComponentFixture<FilterDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ FilterDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FilterDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,167 @@
import {Component, HostListener, Inject, ViewChildren} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {SortDialogComponent} from "../sort-dialog/sort-dialog.component";
import {
FilterExpression,
OrFilterExpression,
SingleFilterExpression
} from "../../../models/FilterExpression";
import {TagQuery} from "../../../models/TagQuery";
import {Tag} from "../../../models/Tag";
import {TagFilterListItemComponent} from "./tag-filter-list-item/tag-filter-list-item.component";
import {Selectable} from "../../../models/Selectable";
@Component({
selector: 'app-filter-dialog',
templateUrl: './filter-dialog.component.html',
styleUrls: ['./filter-dialog.component.scss']
})
export class FilterDialogComponent {
public filters: Selectable<FilterExpression>[];
public availableTags: Tag[] = [];
public mode: "AND" | "OR" = "AND";
@ViewChildren(
TagFilterListItemComponent) filterListItems!: TagFilterListItemComponent[];
private selectedQueries: TagQuery[] = [];
constructor(public dialogRef: MatDialogRef<SortDialogComponent>, @Inject(
MAT_DIALOG_DATA) data: any) {
this.filters = data.filterEntries.map(
(f: FilterExpression) => new Selectable<FilterExpression>(f,
false)) ?? [];
this.availableTags = data.availableTags ?? [];
}
public cancelFilter(): void {
this.dialogRef.close();
}
public confirmFilter(): void {
this.dialogRef.close(this.filters.map(f => f.data));
}
public removeFilter(event: TagFilterListItemComponent): void {
const filter = event.expression;
const index = this.filters.findIndex(f => f === filter);
if (index >= 0) {
this.filters.splice(index, 1);
}
this.unselectAll();
}
public addFilter(tag: string) {
const query = TagQuery.fromString(tag);
if (this.mode === "AND" || this.filters.length === 0) {
this.filters.push(
new Selectable<FilterExpression>(new SingleFilterExpression(query),
false));
tag = tag.replace(/^-/g, '');
if (this.filters.filter(t => t.data.partiallyEq(tag)).length > 1) {
const index = this.filters.findIndex(t => t.data.partiallyEq(tag));
this.filters.splice(index, 1);
}
} else {
let queryList = this.filters.pop()?.data.queryList() ?? [];
queryList.push(query);
const filterExpression = new OrFilterExpression(queryList);
filterExpression.removeDuplicates();
this.filters.push(
new Selectable<FilterExpression>(filterExpression,
false));
}
this.unselectAll();
}
public addToSelection(query: TagQuery): void {
this.selectedQueries.push(query);
}
public removeFromSelection(query: TagQuery): void {
const index = this.selectedQueries.indexOf(query);
if (index > 0) {
this.selectedQueries.splice(index, 1);
}
}
public unselectAll() {
this.filters.forEach(filter => filter.selected = false);
this.selectedQueries = [];
this.filterListItems.forEach(i => i.selectedIndices = []);
}
public convertSelectionToAndExpression(): void {
for (const query of this.selectedQueries) {
this.filters.push(new Selectable<FilterExpression>(new SingleFilterExpression(query), false));
}
this.removeFilterDuplicates();
this.unselectAll();
}
public convertSelectionToOrExpression(): void {
const queries = this.selectedQueries;
const expression = new OrFilterExpression(queries);
this.filters.push(new Selectable<FilterExpression>(expression, false));
this.removeFilterDuplicates();
this.unselectAll();
}
public invertSelection(): void {
this.selectedQueries.forEach(query => query.negate = !query.negate);
}
private removeFilterDuplicates() {
const filters = this.filters;
let newFilters: Selectable<FilterExpression>[] = [];
for (const filterItem of filters) {
if (filterItem.data.filter_type == "OrExpression") {
(filterItem.data as OrFilterExpression).removeDuplicates();
}
if (newFilters.findIndex(f => FilterDialogComponent.checkFiltersEqual(f.data, filterItem.data)) < 0) {
if (filterItem.data.filter_type == "OrExpression" && filterItem.data.queryList().length === 1) {
filterItem.data = new SingleFilterExpression(filterItem.data.queryList()[0]);
}
newFilters.push(filterItem);
}
}
this.filters = newFilters;
}
private static checkFiltersEqual(l: FilterExpression, r: FilterExpression): boolean {
const lTags = l.queryList().map(q => q.getNormalizedTag()).sort();
const rTags = r.queryList().map(q => q.getNormalizedTag()).sort();
let match = false;
if (lTags.length == rTags.length) {
match = true;
for (const tag of lTags) {
match = rTags.includes(tag);
if (!match) {
break;
}
}
}
return match;
}
@HostListener("window:keydown", ["$event"])
private async handleKeydownEvent(event: KeyboardEvent) {
if (event.key === "Shift") {
this.mode = "OR";
}
}
@HostListener("window:keyup", ["$event"])
private async handleKeyupEvent(event: KeyboardEvent) {
if (event.key === "Shift") {
this.mode = "AND";
}
}
}

@ -0,0 +1,18 @@
<div *ngIf="expression.data.filter_type === 'Query'" (click)="onSelect()">
{{expression.data.getDisplayName()}}
<button mat-button class="remove-button" (click)="this.removeClicked.emit(this)">
<mat-icon>remove</mat-icon>
</button>
</div>
<div *ngIf="expression.data.filter_type === 'OrExpression'">
<mat-list>
<mat-list-item class="or-filter-list-item" *ngFor="let entry of enumerate(this.expression.data.queryList())"
(mousedown)="$event.button === 0 && this.selectInnerIndex(entry[0])" [class.selected]="this.selectedIndices.includes(entry[0])">
<span class="or-span" *ngIf="entry[0] > 0">OR</span>
{{entry[1].getNormalizedTag()}}
<button mat-button class="remove-button-inner-list" (mousedown)="$event.button === 0 && this.removeOrExpression(entry[0])">
<mat-icon>remove</mat-icon>
</button>
</mat-list-item>
</mat-list>
</div>

@ -0,0 +1,41 @@
.remove-button, .remove-button-inner-list {
position: absolute;
right: 0;
z-index: 999;
top: calc(0.5em - 15px);
}
.remove-button {
right: 16px;
}
mat-list {
height: 100%;
width: 100%;
display: block;
background-color: #353535;
padding: 0;
border-radius: 0.25em;
}
mat-list-item.or-filter-list-item {
padding: 0.5em 0;
height: 100%;
width: 100%;
border-collapse: collapse;
cursor: pointer;
user-select: none;
::ng-deep .mat-list-item-content {
padding-right: 0;
margin-right: 0;
}
}
.or-span {
margin-right: 0.5em;
}
.selected {
background-color: #5c5c5c;
}

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TagFilterListItemComponent } from './tag-filter-list-item.component';
describe('TagFilterListItemComponent', () => {
let component: TagFilterListItemComponent;
let fixture: ComponentFixture<TagFilterListItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TagFilterListItemComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TagFilterListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,75 @@
import {
ChangeDetectorRef,
Component,
EventEmitter,
Inject,
Input, OnChanges,
OnInit,
Output, SimpleChanges
} from '@angular/core';
import {
FilterExpression,
OrFilterExpression, SingleFilterExpression
} from "../../../../models/FilterExpression";
import {TagQuery} from "../../../../models/TagQuery";
import {Selectable} from "../../../../models/Selectable";
@Component({
selector: 'app-tag-filter-list-item',
templateUrl: './tag-filter-list-item.component.html',
styleUrls: ['./tag-filter-list-item.component.scss']
})
export class TagFilterListItemComponent implements OnChanges {
@Input() expression!: Selectable<FilterExpression>;
@Output() removeClicked = new EventEmitter<TagFilterListItemComponent>();
@Output() querySelect = new EventEmitter<TagQuery>();
@Output() queryUnselect = new EventEmitter<TagQuery>();
public selectedIndices: number[] = [];
constructor(private changeDetector: ChangeDetectorRef) { }
public ngOnChanges(changes: SimpleChanges): void {
if (changes["expression"]) {
this.selectedIndices = [];
}
}
public enumerate<T>(items: T[]): [number, T][] {
return items.map((value, index) => [index, value]);
}
public removeOrExpression(index: number) {
const expression = this.expression.data as OrFilterExpression;
expression.removeQueryEntry(index);
if (expression.filter.length == 0) {
this.removeClicked.emit(this);
} else if (expression.filter.length == 1) {
this.expression.data = new SingleFilterExpression(expression.filter[0]);
}
}
public selectInnerIndex(index: number): void {
const expression = this.expression.data as OrFilterExpression;
if (this.selectedIndices.includes(index)) {
const elementIndex = this.selectedIndices.indexOf(index);
this.selectedIndices.splice(elementIndex, 1);
this.queryUnselect.emit(expression.filter[index]);
} else {
this.selectedIndices.push(index);
this.querySelect.emit(expression.filter[index]);
}
}
public onSelect(): void {
this.expression.selected = !this.expression.selected;
if (this.expression.selected) {
this.querySelect.emit(this.expression.data.filter as TagQuery);
} else {
this.queryUnselect.emit(this.expression.data.filter as TagQuery);
}
}
}

@ -0,0 +1,15 @@
<mat-form-field>
<mat-label>
Enter a tag
</mat-label>
<input #tagInput
[formControl]="formControl"
matInput
(keydown.enter)="addTagByInput($event)"
[matAutocomplete]="auto">
<mat-autocomplete #auto (optionSelected)="addTagByAutocomplete($event)">
<mat-option *ngFor="let tag of autosuggestTags | async" [value]="tag">
{{tag}}
</mat-option>
</mat-autocomplete>
</mat-form-field>

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TagInputComponent } from './tag-input.component';
describe('TagInputComponent', () => {
let component: TagInputComponent;
let fixture: ComponentFixture<TagInputComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TagInputComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TagInputComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,114 @@
import {
Component, ElementRef,
EventEmitter,
Input, OnChanges,
OnInit,
Output, SimpleChanges,
ViewChild
} from '@angular/core';
import {Tag} from "../../../models/Tag";
import {FormControl} from "@angular/forms";
import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
import {Observable} from "rxjs";
import {debounceTime, delay, map, startWith} from "rxjs/operators";
@Component({
selector: 'app-tag-input',
templateUrl: './tag-input.component.html',
styleUrls: ['./tag-input.component.scss']
})
export class TagInputComponent implements OnChanges{
@Input() availableTags: Tag[] = [];
@Input() allowNegation: boolean = false;
@Input() allowInvalid: boolean = false;
@Output() tagAdded = new EventEmitter<string>();
@ViewChild("tagInput") tagInput!: ElementRef<HTMLInputElement>;
public formControl = new FormControl();
public autosuggestTags: Observable<string[]>;
private tagsForAutocomplete: string[] = [];
constructor() {
this.tagsForAutocomplete = this.availableTags.map(t => t.getNormalizedOutput());
this.autosuggestTags = this.formControl.valueChanges.pipe(
startWith(null),
debounceTime(250),
map((tag: string | null) => tag ? this.filterSuggestionTag(tag) : this.tagsForAutocomplete.slice(0, 20)));
}
ngOnChanges(changes: SimpleChanges): void {
if (changes["availableTags"]) {
this.tagsForAutocomplete = this.availableTags.map(t => t.getNormalizedOutput());
}
}
public addTagByInput(event: any): void {
this.addTag(this.formControl.value);
}
public addTagByAutocomplete(event: MatAutocompleteSelectedEvent): void {
this.addTag(event.option.value);
}
private addTag(value: string) {
const tag = this.normalizeTag(value);
if (tag.length > 0 && (this.allowInvalid || this.checkTagValid(tag))) {
this.tagAdded.emit(tag);
this.formControl.setValue("");
this.tagInput.nativeElement.value = "";
}
}
private filterSuggestionTag(tag: string) {
let normalizedTag = this.normalizeTag(tag);
const negated = normalizedTag.startsWith("-") && this.allowNegation;
normalizedTag = this.allowNegation? normalizedTag.replace(/^-/, "") : normalizedTag;
return this.tagsForAutocomplete.filter(
t => t.includes(normalizedTag))
.map(t => negated ? "-" + t: t)
.sort((l, r) => this.compareSuggestionTags(normalizedTag, l, r))
.slice(0, 20);
}
private checkTagValid(tag: string): boolean {
if (this.allowNegation) {
tag = tag.replace(/^-/, "");
}
return this.tagsForAutocomplete.includes(tag);
}
/**
* Normalizes the tag by removing whitespaces
* @param {string} tag
* @returns {string}
* @private
*/
private normalizeTag(tag: string): string {
let normalizedTag = tag.trim();
let parts = normalizedTag.split(":");
if (parts.length > 1) {
const namespace = parts.shift()!.trim();
const name = parts.join(":").trim();
return namespace + ":" + name;
} else {
return normalizedTag;
}
}
private compareSuggestionTags(query: string, l: string, r: string): number {
if (l.startsWith(query) && !r.startsWith(query)) {
return -1;
} else if (!l.startsWith(query) && r.startsWith(query)) {
return 1;
} else if (l.length < r.length) {
return -1;
} else if (l.length > r.length) {
return 1;
} else {
return l.localeCompare(r)
}
}
}

@ -9,6 +9,10 @@ export interface FilterExpression {
partiallyEq(value: any): boolean; partiallyEq(value: any): boolean;
getDisplayName(): string; getDisplayName(): string;
clone(): FilterExpression;
queryList(): TagQuery[];
} }
export class OrFilterExpression implements FilterExpression{ export class OrFilterExpression implements FilterExpression{
@ -30,6 +34,31 @@ export class OrFilterExpression implements FilterExpression{
public getDisplayName(): string { public getDisplayName(): string {
return this.filter.map(t => t.getNormalizedTag()).join(" OR "); return this.filter.map(t => t.getNormalizedTag()).join(" OR ");
} }
public clone(): OrFilterExpression {
let tags = this.filter.map((t: TagQuery) => new TagQuery(t.tag, t.negate));
return new OrFilterExpression(tags)
}
public queryList(): TagQuery[] {
return this.filter;
}
public removeQueryEntry(index: number) {
this.filter.splice(index, 1);
}
public removeDuplicates() {
const filters = this.filter.reverse();
let newEntries: TagQuery[] = [];
for (const entry of filters) {
if (newEntries.findIndex(f => f.tag === entry.tag) < 0) {
newEntries.push(entry);
}
}
this.filter = newEntries.reverse();
}
} }
export class SingleFilterExpression implements FilterExpression { export class SingleFilterExpression implements FilterExpression {
@ -51,4 +80,12 @@ export class SingleFilterExpression implements FilterExpression {
public getDisplayName(): string { public getDisplayName(): string {
return this.filter.getNormalizedTag(); return this.filter.getNormalizedTag();
} }
public clone(): FilterExpression {
return new SingleFilterExpression(new TagQuery(this.filter.tag, this.filter.negate))
}
public queryList(): TagQuery[] {
return [this.filter]
}
} }

@ -1,7 +1,17 @@
import {SingleFilterExpression} from "./FilterExpression";
export class TagQuery { export class TagQuery {
constructor(public tag: string, public negate: boolean) { constructor(public tag: string, public negate: boolean) {
} }
public static fromString(tag: string): TagQuery {
if (tag.startsWith("-")) {
return new TagQuery(tag.replace(/^-/g, ''), true);
} else {
return new TagQuery(tag, false);
}
}
public getNormalizedTag(): string { public getNormalizedTag(): string {
return this.negate ? "-" + this.tag : this.tag; return this.negate ? "-" + this.tag : this.tag;
} }

@ -5,7 +5,8 @@
<div id="file-search-input"> <div id="file-search-input">
<app-file-search #filesearch (searchEndEvent)="this.searchEndEvent.emit()" <app-file-search #filesearch (searchEndEvent)="this.searchEndEvent.emit()"
(searchStartEvent)="this.searchStartEvent.emit()" (searchStartEvent)="this.searchStartEvent.emit()"
[validTags]="this.getValidTagsForSearch()"></app-file-search> [validTags]="this.getValidTagsForSearch()"
[availableTags]="this.allTags"></app-file-search>
</div> </div>
<mat-divider fxFlex="1em"></mat-divider> <mat-divider fxFlex="1em"></mat-divider>
<div class="tag-list-header" fxFlex="40px"> <div class="tag-list-header" fxFlex="40px">

@ -32,6 +32,7 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
public tagsOfFiles: Tag[] = []; public tagsOfFiles: Tag[] = [];
public tags: Tag[] = []; public tags: Tag[] = [];
public allTags: Tag[] = [];
public files: File[] = []; public files: File[] = [];
public tagsOfSelection: Tag[] = []; public tagsOfSelection: Tag[] = [];
@ -43,6 +44,7 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
}); });
this.repoService.selectedRepository.subscribe( this.repoService.selectedRepository.subscribe(
async (repo) => repo && this.fileSearch && await this.fileSearch.searchForFiles()); async (repo) => repo && this.fileSearch && await this.fileSearch.searchForFiles());
this.tagService.tags.subscribe(t => this.allTags = t);
} }
async ngOnInit() { async ngOnInit() {

Loading…
Cancel
Save