diff --git a/mediarepo-ui/src/app/app.module.ts b/mediarepo-ui/src/app/app.module.ts index ea345f8..f8a82d4 100644 --- a/mediarepo-ui/src/app/app.module.ts +++ b/mediarepo-ui/src/app/app.module.ts @@ -56,6 +56,7 @@ import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-si 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'; @NgModule({ declarations: [ @@ -82,6 +83,7 @@ import { TagFilterListItemComponent } from './components/file-search/filter-dial FilesystemImportComponent, FilterDialogComponent, TagFilterListItemComponent, + TagInputComponent, ], imports: [ BrowserModule, diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.ts b/mediarepo-ui/src/app/components/file-search/file-search.component.ts index 7d77655..2f30d31 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.ts +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.ts @@ -24,6 +24,7 @@ import { SingleFilterExpression } from "../../models/FilterExpression"; import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component"; +import {Tag} from "../../models/Tag"; @Component({ @@ -38,6 +39,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { public filters: FilterExpression[] = []; public suggestionTags: Observable; + @Input() availableTags: Tag[] = []; @Input() validTags: string[] = []; @Output() searchStartEvent = new EventEmitter(); @Output() searchEndEvent = new EventEmitter(); @@ -148,7 +150,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { minWidth: "25vw", data: { filterEntries, - validTags: this.validTags, + availableTags: this.availableTags, }, disableClose: true, }); diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html index 6b734e8..ce83f9a 100644 --- a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html @@ -2,19 +2,12 @@
- + - - Enter tags to filter for - - - - {{tag}} - - - +
diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts index 8a10146..fdea7c4 100644 --- a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts @@ -11,11 +11,10 @@ import { FilterExpression, OrFilterExpression, SingleFilterExpression } 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 {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', @@ -24,22 +23,14 @@ import {TagQuery} from "../../../models/TagQuery"; }) export class FilterDialogComponent { - public filters: FilterExpression[]; - public suggestionTags: Observable; - public validTags: string[] = []; - public formControl = new FormControl(); + public filters: Selectable[]; + public availableTags: Tag[] = []; public mode: "AND" | "OR" = "AND"; - @ViewChild("tagInput") tagInput!: ElementRef; constructor(public dialogRef: MatDialogRef, @Inject( MAT_DIALOG_DATA) data: any) { - this.filters = data.filterEntries; - this.validTags = data.validTags; - - this.suggestionTags = this.formControl.valueChanges.pipe(startWith(null), - map( - (tag: string | null) => tag ? this.filterSuggestionTag( - tag) : this.validTags.slice(0, 20))); + this.filters = data.filterEntries.map((f: FilterExpression) => new Selectable(f, false)) ?? []; + this.availableTags = data.availableTags ?? []; } public cancelFilter(): void { @@ -47,48 +38,33 @@ export class FilterDialogComponent { } public confirmFilter(): void { - this.dialogRef.close(this.filters); - } - - 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); + this.dialogRef.close(this.filters.map(f => f.data)); } - public addFilterByAutocomplete(event: MatAutocompleteSelectedEvent): void { - this.addFilter(event.option.value); - this.formControl.setValue(null); - this.tagInput.nativeElement.value = ''; - } - - public addFilterByInput(): void { - this.addFilter(this.formControl.value); - this.formControl.setValue(null); - this.tagInput.nativeElement.value = ''; + 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); + } } public addFilter(tag: string) { const query = TagQuery.fromString(tag); - if (this.mode === "AND") { - this.filters.push(new SingleFilterExpression(query)); + if (this.mode === "AND" || this.filters.length === 0) { + this.filters.push(new Selectable(new SingleFilterExpression(query), false)); tag = tag.replace(/^-/g, ''); - if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) { - const index = this.filters.findIndex(t => t.partiallyEq(tag)); + 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()?.queryList() ?? []; + let queryList = this.filters.pop()?.data.queryList() ?? []; queryList.push(query); - this.filters.push(new OrFilterExpression(queryList)); + this.filters.push(new Selectable(new OrFilterExpression(queryList), false)); } } diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html index 2682840..8b573d9 100644 --- a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html @@ -1,14 +1,15 @@ - - {{expression.getDisplayName()}} - -
+
- - {{query.getNormalizedTag()}} - diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss index 4e0e0a9..e64d496 100644 --- a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss @@ -1,16 +1,33 @@ -.remove-button { +.remove-button, .remove-button-inner-list { position: absolute; - top: calc(0.5em - 15px); 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; + border-radius: 0.25em; } mat-list-item.or-filter-list-item { padding: 0.5em 0; height: 100%; width: 100%; + + ::ng-deep .mat-list-item-content { + padding-right: 0; + margin-right: 0; + } +} + +.or-span { + margin-right: 0.5em; } diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts index 7997d47..abe1391 100644 --- a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts @@ -1,5 +1,18 @@ -import {Component, Input, OnInit} from '@angular/core'; -import {FilterExpression} from "../../../../models/FilterExpression"; +import { + 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({ selector: 'app-tag-filter-list-item', @@ -8,11 +21,23 @@ import {FilterExpression} from "../../../../models/FilterExpression"; }) export class TagFilterListItemComponent { - @Input() expression!: FilterExpression; + @Input() expression!: Selectable; + @Output() removeClicked = new EventEmitter(); constructor() { } - ngOnInit(): void { + public enumerate(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]); + } + } } diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html new file mode 100644 index 0000000..b1b8997 --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html @@ -0,0 +1,15 @@ + + + Enter a tag + + + + + {{tag}} + + + diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss new file mode 100644 index 0000000..c7acb4b --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts new file mode 100644 index 0000000..b94bb54 --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ TagInputComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TagInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts new file mode 100644 index 0000000..178f138 --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts @@ -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(); + + @ViewChild("tagInput") tagInput!: ElementRef; + public formControl = new FormControl(); + public autosuggestTags: Observable; + 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) + } + } +} diff --git a/mediarepo-ui/src/app/models/FilterExpression.ts b/mediarepo-ui/src/app/models/FilterExpression.ts index 391877d..7929872 100644 --- a/mediarepo-ui/src/app/models/FilterExpression.ts +++ b/mediarepo-ui/src/app/models/FilterExpression.ts @@ -43,6 +43,10 @@ export class OrFilterExpression implements FilterExpression{ public queryList(): TagQuery[] { return this.filter; } + + public removeQueryEntry(index: number) { + this.filter.splice(index, 1); + } } export class SingleFilterExpression implements FilterExpression { diff --git a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html index e837b8f..00a98ef 100644 --- a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html +++ b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html @@ -5,7 +5,8 @@
+ [validTags]="this.getValidTagsForSearch()" + [availableTags]="this.tagsOfFiles">