Filtering Done (#2222)

* Replaced normal dropdowns with select2 (which will eventually replace our custom typeaheads). Still needs styling.

* More css

* Styling. Fixed preloading typeahead with multiple options on load.

* Styling to align with typeahead tag badges.

* Done with filtering story.

* Fixed a bug with switching between filters.

* Fixed some extra } from localization
This commit is contained in:
Joe Milazzo 2023-08-17 19:09:04 -05:00 committed by GitHub
parent 680f3d78d2
commit c2375efe21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 163 additions and 32 deletions

View File

@ -37,6 +37,7 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lazysizes": "^5.3.2", "lazysizes": "^5.3.2",
"ng-circle-progress": "^1.7.1", "ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.2",
"ngx-color-picker": "^14.0.0", "ngx-color-picker": "^14.0.0",
"ngx-extended-pdf-viewer": "^16.2.16", "ngx-extended-pdf-viewer": "^16.2.16",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",
@ -10556,6 +10557,20 @@
"rxjs": ">=6.4.0" "rxjs": ">=6.4.0"
} }
}, },
"node_modules/ng-select2-component": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-13.0.2.tgz",
"integrity": "sha512-8Tms5p0V/0J0vCWOf2Vrk6tJlwbaf3D3As3iigcjRncYlfXN130agniBcZ007C3zK2KyLXJJRkEWzlCls8/TVQ==",
"dependencies": {
"ngx-infinite-scroll": ">=16.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": ">=16.1.0",
"@angular/common": ">=16.1.0",
"@angular/core": ">=16.1.0"
}
},
"node_modules/ngx-color-picker": { "node_modules/ngx-color-picker": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-14.0.0.tgz", "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-14.0.0.tgz",
@ -10598,6 +10613,18 @@
"@angular/core": ">=14.0.0" "@angular/core": ">=14.0.0"
} }
}, },
"node_modules/ngx-infinite-scroll": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-16.0.0.tgz",
"integrity": "sha512-bzyNYd+wVlUUxcopRVr2DAa81eEc8vITtKVvb+c7R1uy8hWPTlxOEXf3L1qA4FMwTEzCQ9b37TXzlJji3qBy+A==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=16.0.0 <17.0.0",
"@angular/core": ">=16.0.0 <17.0.0"
}
},
"node_modules/ngx-slider-v2": { "node_modules/ngx-slider-v2": {
"version": "16.0.2", "version": "16.0.2",
"resolved": "https://registry.npmjs.org/ngx-slider-v2/-/ngx-slider-v2-16.0.2.tgz", "resolved": "https://registry.npmjs.org/ngx-slider-v2/-/ngx-slider-v2-16.0.2.tgz",

View File

@ -42,6 +42,7 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lazysizes": "^5.3.2", "lazysizes": "^5.3.2",
"ng-circle-progress": "^1.7.1", "ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.2",
"ngx-color-picker": "^14.0.0", "ngx-color-picker": "^14.0.0",
"ngx-extended-pdf-viewer": "^16.2.16", "ngx-extended-pdf-viewer": "^16.2.16",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'personal-table-of-contents'"> <ng-container *transloco="let t; read: 'personal-table-of-contents'">
<div class="table-of-contents"> <div class="table-of-contents">
<div *ngIf="Pages.length === 0"> <div *ngIf="Pages.length === 0">
<em>{{t('no-data')}}}</em> <em>{{t('no-data')}}</em>
</div> </div>
<ul> <ul>
<li *ngFor="let page of Pages"> <li *ngFor="let page of Pages">

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'table-of-contents'"> <ng-container *transloco="let t; read: 'table-of-contents'">
<div class="table-of-contents"> <div class="table-of-contents">
<div *ngIf="chapters.length === 0"> <div *ngIf="chapters.length === 0">
<em>{{t('no-data')}}}</em> <em>{{t('no-data')}}</em>
</div> </div>
<div *ngIf="chapters.length === 1; else nestedChildren"> <div *ngIf="chapters.length === 1; else nestedChildren">
<ul> <ul>

View File

@ -49,7 +49,7 @@
<div class="col-md-2 col-1"> <div class="col-md-2 col-1">
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')"> <button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
<i class="fa fa-solid fa-plus" aria-hidden="true"></i> <i class="fa fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}}</span> <span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -25,9 +25,17 @@
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue"> <input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue">
</ng-container> </ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown"> <ng-container *ngSwitchCase="PredicateType.Dropdown">
<select class="col-auto form-select me-2" formControlName="filterValue"> <ng-container *ngIf="dropdownOptions$ | async as opts">
<option *ngFor="let option of dropdownOptions$ | async" [value]="option.value">{{option.title}}</option> <ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
</select> <ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
<select2 [data]="options"
formControlName="filterValue"
[multiple]="multipleAllowed"
[infiniteScroll]="true"
[resettable]="true">
</select2>
</ng-template>
</ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@ -0,0 +1,3 @@
::ng-deep .select2-selection__rendered {
padding-top: 4px !important;
}

View File

@ -19,10 +19,12 @@ import {LibraryService} from 'src/app/_services/library.service';
import {CollectionTagService} from 'src/app/_services/collection-tag.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison';
import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field'; import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field';
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase} from "@angular/common"; import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from "@angular/common";
import {FilterFieldPipe} from "../../_pipes/filter-field.pipe"; import {FilterFieldPipe} from "../../_pipes/filter-field.pipe";
import {FilterComparisonPipe} from "../../_pipes/filter-comparison.pipe"; import {FilterComparisonPipe} from "../../_pipes/filter-comparison.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Select2Module, Select2Option} from "ng-select2-component";
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
enum PredicateType { enum PredicateType {
Text = 1, Text = 1,
@ -70,12 +72,18 @@ const DropdownComparisons = [FilterComparison.Equal,
NgSwitch, NgSwitch,
NgSwitchCase, NgSwitchCase,
NgForOf, NgForOf,
NgIf NgIf,
Select2Module,
NgTemplateOutlet,
TagBadgeComponent
], ],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class MetadataFilterRowComponent implements OnInit { export class MetadataFilterRowComponent implements OnInit {
/**
* Slightly misleading as this is the initial state and will be updated on the filterStatement event emitter
*/
@Input() preset!: FilterStatement; @Input() preset!: FilterStatement;
@Input() availableFields: Array<FilterField> = allFields; @Input() availableFields: Array<FilterField> = allFields;
@Output() filterStatement = new EventEmitter<FilterStatement>(); @Output() filterStatement = new EventEmitter<FilterStatement>();
@ -89,14 +97,18 @@ export class MetadataFilterRowComponent implements OnInit {
}); });
validComparisons$: BehaviorSubject<FilterComparison[]> = new BehaviorSubject([FilterComparison.Equal] as FilterComparison[]); validComparisons$: BehaviorSubject<FilterComparison[]> = new BehaviorSubject([FilterComparison.Equal] as FilterComparison[]);
predicateType$: BehaviorSubject<PredicateType> = new BehaviorSubject(PredicateType.Text as PredicateType); predicateType$: BehaviorSubject<PredicateType> = new BehaviorSubject(PredicateType.Text as PredicateType);
dropdownOptions$ = of<{value: number, title: string}[]>([]); dropdownOptions$ = of<Select2Option[]>([]);
loaded: boolean = false; loaded: boolean = false;
get PredicateType() { return PredicateType }; get PredicateType() { return PredicateType };
get MultipleDropdownAllowed() {
const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains;
}
constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService, constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService,
private readonly collectionTagService: CollectionTagService) {} private readonly collectionTagService: CollectionTagService) {}
@ -106,26 +118,27 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('input')?.valueChanges.subscribe((val: string) => this.handleFieldChange(val)); this.formGroup.get('input')?.valueChanges.subscribe((val: string) => this.handleFieldChange(val));
this.populateFromPreset(); this.populateFromPreset();
this.buildDisabledList();
// Dropdown dynamic option selection // Dropdown dynamic option selection
this.dropdownOptions$ = this.formGroup.get('input')!.valueChanges.pipe( this.dropdownOptions$ = this.formGroup.get('input')!.valueChanges.pipe(
startWith(this.preset.value), startWith(this.preset.value),
switchMap((_) => this.getDropdownObservable()), switchMap((_) => this.getDropdownObservable()),
tap((opts) => { tap((opts) => {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; if (!this.formGroup.get('filterValue')?.value) {
const filterComparison = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison; this. populateFromPreset();
if (this.preset.field === filterField && this.preset.comparison === filterComparison) {
//console.log('using preset value for dropdown option')
return; return;
} }
this.formGroup.get('filterValue')?.setValue(opts[0].value); if (this.MultipleDropdownAllowed) {
this.formGroup.get('filterValue')?.setValue((opts[0].value + '').split(','));
} else {
this.formGroup.get('filterValue')?.setValue(opts[0].value);
}
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => { this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => {
this.filterStatement.emit({ this.filterStatement.emit({
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison, comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
@ -138,13 +151,20 @@ export class MetadataFilterRowComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
buildDisabledList() {
}
populateFromPreset() { populateFromPreset() {
if (StringFields.includes(this.preset.field)) { if (StringFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(this.preset.value); this.formGroup.get('filterValue')?.patchValue(this.preset.value);
} else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed) {
this.formGroup.get('filterValue')?.setValue(this.preset.value.split(','));
} else {
if (this.preset.field === FilterField.Languages) {
this.formGroup.get('filterValue')?.setValue(this.preset.value);
} else {
this.formGroup.get('filterValue')?.setValue(parseInt(this.preset.value, 10));
}
}
} else { } else {
this.formGroup.get('filterValue')?.patchValue(parseInt(this.preset.value, 10)); this.formGroup.get('filterValue')?.patchValue(parseInt(this.preset.value, 10));
} }
@ -154,40 +174,40 @@ export class MetadataFilterRowComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
getDropdownObservable(): Observable<{value: any, title: string}[]> { getDropdownObservable(): Observable<Select2Option[]> {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
switch (filterField) { switch (filterField) {
case FilterField.PublicationStatus: case FilterField.PublicationStatus:
return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
return {value: pub.value, title: pub.title} return {value: pub.value, label: pub.title}
}))); })));
case FilterField.AgeRating: case FilterField.AgeRating:
return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
return {value: rating.value, title: rating.title} return {value: rating.value, label: rating.title}
}))); })));
case FilterField.Genres: case FilterField.Genres:
return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => { return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => {
return {value: genre.id, title: genre.title} return {value: genre.id, label: genre.title}
}))); })));
case FilterField.Languages: case FilterField.Languages:
return this.metadataService.getAllLanguages().pipe(map(statuses => statuses.map(status => { return this.metadataService.getAllLanguages().pipe(map(statuses => statuses.map(status => {
return {value: status.isoCode, title: status.title + ` (${status.isoCode})`} return {value: status.isoCode, label: status.title + ` (${status.isoCode})`}
}))); })));
case FilterField.Formats: case FilterField.Formats:
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
return {value: status.value, title: status.title} return {value: status.value, label: status.title}
}))); })));
case FilterField.Libraries: case FilterField.Libraries:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
return {value: lib.id, title: lib.name} return {value: lib.id, label: lib.name}
}))); })));
case FilterField.Tags: case FilterField.Tags:
return this.metadataService.getAllTags().pipe(map(statuses => statuses.map(status => { return this.metadataService.getAllTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, title: status.title} return {value: status.id, label: status.title}
}))); })));
case FilterField.CollectionTags: case FilterField.CollectionTags:
return this.collectionTagService.allTags().pipe(map(statuses => statuses.map(status => { return this.collectionTagService.allTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, title: status.title} return {value: status.id, label: status.title}
}))); })));
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);
case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist); case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist);
@ -205,7 +225,7 @@ export class MetadataFilterRowComponent implements OnInit {
getPersonOptions(role: PersonRole) { getPersonOptions(role: PersonRole) {
return this.metadataService.getAllPeople().pipe(map(people => people.filter(p2 => p2.role === role).map(person => { return this.metadataService.getAllPeople().pipe(map(people => people.filter(p2 => p2.role === role).map(person => {
return {value: person.id, title: person.name} return {value: person.id, label: person.name}
}))) })))
} }
@ -218,7 +238,6 @@ export class MetadataFilterRowComponent implements OnInit {
this.predicateType$.next(PredicateType.Text); this.predicateType$.next(PredicateType.Text);
if (this.loaded) this.formGroup.get('filterValue')?.setValue(''); if (this.loaded) this.formGroup.get('filterValue')?.setValue('');
return; return;
} }

View File

@ -40,6 +40,7 @@
@import './theme/components/offcanvas'; @import './theme/components/offcanvas';
@import './theme/components/table'; @import './theme/components/table';
@import './theme/components/alerts'; @import './theme/components/alerts';
@import './theme/components/typeahead';
@import './theme/utilities/utilities'; @import './theme/utilities/utilities';

View File

@ -0,0 +1,72 @@
:root {
/* size */
--select2-single-height: 36px;
--select2-multiple-height: 36px;
/* label */
--select2-label-text-color: #000;
--select2-required-color: red;
/* selection */
--select2-selection-border-radius: 4px;
--select2-selection-background: var(--input-bg-readonly-color);
--select2-selection-disabled-background: #eee;
--select2-selection-border-color: var(--input-border-color);
--select2-selection-focus-border-color: var(--input-focus-boxshadow-color);
--select2-selection-text-color: var(--input-text-color);
/* selection: choice item (multiple) */
--select2-selection-choice-background: var(--tagbadge-filled-bg-color);
--select2-selection-choice-text-color: var(--tagbadge-filled-text-color);
--select2-selection-choice-border-color: var(--tagbadge-border-color);
--select2-selection-choice-close-color: var(--tagbadge-filled-text-color);
--select2-selection-choice-hover-close-color: var(--tagbadge-filled-text-color);
/* placeholder */
--select2-placeholder-color: #999;
--select2-placeholder-overflow: ellipsis;
/* no result message */
--select2-no-result-color: #888;
--select2-no-result-font-style: italic;
/* no result message */
--select2-too-much-result-color: #888;
--select2-too-much-result-style: italic;
/* reset */
--select2-reset-color: #999;
/* arrow */
--select2-arrow-color: #ccc;
/* dropdown panel */
--select2-dropdown-background: var(--input-bg-readonly-color);
--select2-dropdown-border-color: var(--input-border-color);
/* overlay */
--select2-overlay-backdrop: transparent;
/* search field */
--select2-search-border-color: var(--input-border-color);
--select2-search-background: var(--input-bg-readonly-color);
--select2-search-border-radius: 0px;
/* dropdown option */
--select2-option-text-color: var(--body-text-color);
--select2-option-disabled-text-color: #999;
--select2-option-disabled-background: transparent;
--select2-option-selected-text-color: lightgrey;
--select2-option-selected-background: var(--btn-disabled-bg-color);
--select2-option-highlighted-text-color: #fff;
--select2-option-highlighted-background: #5897fb; // TODO: This needs to be done correctly and applied throughout the app
--select2-option-group-text-color: var(--body-text-color);
--select2-option-group-background: transparent;
/* hint */
--select2-hint-text-color: #888;
}
//.select2-selection__rendered {
// padding-top: 4px;
//}

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.7.10" "version": "0.7.7.11"
}, },
"servers": [ "servers": [
{ {