Improve filter dialog style

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/4/head
trivernis 3 years ago
parent 6cb91bf263
commit 05c2aa3507

@ -56,6 +56,7 @@ import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-si
import {MatCheckboxModule} from "@angular/material/checkbox"; import {MatCheckboxModule} from "@angular/material/checkbox";
import { FilterDialogComponent } from './components/file-search/filter-dialog/filter-dialog.component'; 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 { 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';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -82,6 +83,7 @@ import { TagFilterListItemComponent } from './components/file-search/filter-dial
FilesystemImportComponent, FilesystemImportComponent,
FilterDialogComponent, FilterDialogComponent,
TagFilterListItemComponent, TagFilterListItemComponent,
TagInputComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

@ -24,6 +24,7 @@ import {
SingleFilterExpression SingleFilterExpression
} from "../../models/FilterExpression"; } from "../../models/FilterExpression";
import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component"; import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component";
import {Tag} from "../../models/Tag";
@Component({ @Component({
@ -38,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>();
@ -148,7 +150,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
minWidth: "25vw", minWidth: "25vw",
data: { data: {
filterEntries, filterEntries,
validTags: this.validTags, availableTags: this.availableTags,
}, },
disableClose: true, disableClose: true,
}); });

@ -2,19 +2,12 @@
<div mat-dialog-content> <div mat-dialog-content>
<mat-list> <mat-list>
<mat-list-item class="filter-list-item" *ngFor="let expression of filters"> <mat-list-item class="filter-list-item" *ngFor="let expression of filters">
<app-tag-filter-list-item [expression]="expression"></app-tag-filter-list-item> <app-tag-filter-list-item (removeClicked)="this.removeFilter($event)" [expression]="expression"></app-tag-filter-list-item>
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<mat-form-field class="tag-input"> <app-tag-input class="tag-input" [allowNegation]="true" [availableTags]="this.availableTags"
<mat-label>Enter tags to filter for</mat-label> (tagAdded)="this.addFilter($event)"></app-tag-input>
<input #tagInput matInput [formControl]="formControl" [matAutocomplete]="auto" (keydown.enter)="this.addFilterByInput()">
<mat-autocomplete #auto (optionSelected)="addFilterByAutocomplete($event)">
<mat-option *ngFor="let tag of suggestionTags | async" [value]="tag">
{{tag}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div> </div>
<div class="dialog-actions" mat-dialog-actions> <div class="dialog-actions" mat-dialog-actions>
<button mat-flat-button color="primary" (click)="confirmFilter()">Filter</button> <button mat-flat-button color="primary" (click)="confirmFilter()">Filter</button>

@ -11,11 +11,10 @@ import {
FilterExpression, OrFilterExpression, FilterExpression, OrFilterExpression,
SingleFilterExpression SingleFilterExpression
} from "../../../models/FilterExpression"; } from "../../../models/FilterExpression";
import {Observable} from "rxjs";
import {FormControl} from "@angular/forms";
import {last, map, startWith} from "rxjs/operators";
import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
import {TagQuery} from "../../../models/TagQuery"; 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({ @Component({
selector: 'app-filter-dialog', selector: 'app-filter-dialog',
@ -24,22 +23,14 @@ import {TagQuery} from "../../../models/TagQuery";
}) })
export class FilterDialogComponent { export class FilterDialogComponent {
public filters: FilterExpression[]; public filters: Selectable<FilterExpression>[];
public suggestionTags: Observable<string[]>; public availableTags: Tag[] = [];
public validTags: string[] = [];
public formControl = new FormControl();
public mode: "AND" | "OR" = "AND"; public mode: "AND" | "OR" = "AND";
@ViewChild("tagInput") tagInput!: ElementRef<HTMLInputElement>;
constructor(public dialogRef: MatDialogRef<SortDialogComponent>, @Inject( constructor(public dialogRef: MatDialogRef<SortDialogComponent>, @Inject(
MAT_DIALOG_DATA) data: any) { MAT_DIALOG_DATA) data: any) {
this.filters = data.filterEntries; this.filters = data.filterEntries.map((f: FilterExpression) => new Selectable<FilterExpression>(f, false)) ?? [];
this.validTags = data.validTags; this.availableTags = data.availableTags ?? [];
this.suggestionTags = this.formControl.valueChanges.pipe(startWith(null),
map(
(tag: string | null) => tag ? this.filterSuggestionTag(
tag) : this.validTags.slice(0, 20)));
} }
public cancelFilter(): void { public cancelFilter(): void {
@ -47,48 +38,33 @@ export class FilterDialogComponent {
} }
public confirmFilter(): void { public confirmFilter(): void {
this.dialogRef.close(this.filters); this.dialogRef.close(this.filters.map(f => f.data));
}
private filterSuggestionTag(tag: string) {
const negated = tag.startsWith("-");
const normalizedTag = tag.replace(/^-/, "");
return this.validTags.filter(
t => t.includes(normalizedTag) && this.filters.findIndex(
f => f.eq(t)) < 0)
.map(t => negated ? "-" + t : t)
.slice(0, 20);
} }
public addFilterByAutocomplete(event: MatAutocompleteSelectedEvent): void { public removeFilter(event: TagFilterListItemComponent): void {
this.addFilter(event.option.value); const filter = event.expression;
this.formControl.setValue(null); const index = this.filters.findIndex(f => f === filter);
this.tagInput.nativeElement.value = ''; if (index >= 0) {
} this.filters.splice(index, 1);
}
public addFilterByInput(): void {
this.addFilter(this.formControl.value);
this.formControl.setValue(null);
this.tagInput.nativeElement.value = '';
} }
public addFilter(tag: string) { public addFilter(tag: string) {
const query = TagQuery.fromString(tag); const query = TagQuery.fromString(tag);
if (this.mode === "AND") { if (this.mode === "AND" || this.filters.length === 0) {
this.filters.push(new SingleFilterExpression(query)); this.filters.push(new Selectable<FilterExpression>(new SingleFilterExpression(query), false));
tag = tag.replace(/^-/g, ''); tag = tag.replace(/^-/g, '');
if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) { if (this.filters.filter(t => t.data.partiallyEq(tag)).length > 1) {
const index = this.filters.findIndex(t => t.partiallyEq(tag)); const index = this.filters.findIndex(t => t.data.partiallyEq(tag));
this.filters.splice(index, 1); this.filters.splice(index, 1);
} }
} else { } else {
let queryList = this.filters.pop()?.queryList() ?? []; let queryList = this.filters.pop()?.data.queryList() ?? [];
queryList.push(query); queryList.push(query);
this.filters.push(new OrFilterExpression(queryList)); this.filters.push(new Selectable<FilterExpression>(new OrFilterExpression(queryList), false));
} }
} }

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

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

@ -1,5 +1,18 @@
import {Component, Input, OnInit} from '@angular/core'; import {
import {FilterExpression} from "../../../../models/FilterExpression"; ChangeDetectorRef,
Component,
EventEmitter,
Inject,
Input,
OnInit,
Output
} from '@angular/core';
import {
FilterExpression,
OrFilterExpression, SingleFilterExpression
} from "../../../../models/FilterExpression";
import {TagQuery} from "../../../../models/TagQuery";
import {Selectable} from "../../../../models/Selectable";
@Component({ @Component({
selector: 'app-tag-filter-list-item', selector: 'app-tag-filter-list-item',
@ -8,11 +21,23 @@ import {FilterExpression} from "../../../../models/FilterExpression";
}) })
export class TagFilterListItemComponent { export class TagFilterListItemComponent {
@Input() expression!: FilterExpression; @Input() expression!: Selectable<FilterExpression>;
@Output() removeClicked = new EventEmitter<TagFilterListItemComponent>();
constructor() { } constructor() { }
ngOnInit(): void { 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]);
}
}
} }

@ -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)
}
}
}

@ -43,6 +43,10 @@ export class OrFilterExpression implements FilterExpression{
public queryList(): TagQuery[] { public queryList(): TagQuery[] {
return this.filter; return this.filter;
} }
public removeQueryEntry(index: number) {
this.filter.splice(index, 1);
}
} }
export class SingleFilterExpression implements FilterExpression { export class SingleFilterExpression implements FilterExpression {

@ -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.tagsOfFiles"></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">

Loading…
Cancel
Save