commit
251d981b04
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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,3 @@
|
|||||||
|
mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue