Smart Filter Polish & New Filters (#2283)

This commit is contained in:
Joe Milazzo 2023-09-15 09:39:06 -07:00 committed by GitHub
parent 0d8c081093
commit 45f6fb67d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 375 additions and 181 deletions

View file

@ -20,6 +20,7 @@ export enum SortField {
LastChapterAdded = 4,
TimeToRead = 5,
ReleaseYear = 6,
ReadProgress = 7,
}
export const allSortFields = Object.keys(SortField)

View file

@ -27,7 +27,8 @@ export enum FilterField
ReadTime = 23,
Path = 24,
FilePath = 25,
WantToRead = 26
WantToRead = 26,
ReadingDate = 27
}
export const allFields = Object.keys(FilterField)

View file

@ -205,7 +205,7 @@ export class ActionFactoryService {
action: Action.Scan,
title: 'scan-library',
callback: this.dummyCallback,
requiresAdmin: false,
requiresAdmin: true,
children: [],
},
{

View file

@ -1,47 +1,73 @@
<ng-container *transloco="let t; read: 'metadata-filter-row'">
<form [formGroup]="formGroup">
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</div>
<form [formGroup]="formGroup">
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</div>
<div class="col-md-2 me-2 col-10 mb-2">
<select class="col-auto form-select" formControlName="comparison">
<option *ngFor="let comparison of validComparisons$ | async" [value]="comparison">{{comparison | filterComparison}}</option>
</select>
</div>
<div class="col-md-2 me-2 col-10 mb-2">
<select class="col-auto form-select" formControlName="comparison">
<option *ngFor="let comparison of validComparisons$ | async" [value]="comparison">{{comparison | filterComparison}}</option>
</select>
</div>
<div class="col-md-4 col-10 mb-2">
<ng-container *ngIf="predicateType$ | async as predicateType">
<ng-container [ngSwitch]="predicateType">
<ng-container *ngSwitchCase="PredicateType.Text">
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Number">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Boolean">
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Date">
<div class="input-group">
<input
class="form-control"
placeholder="yyyy-mm-dd"
name="dp"
formControlName="filterValue"
(dateSelect)="onDateSelect($event)"
(blur)="updateIfDateFilled()"
ngbDatepicker
#d="ngbDatepicker"
/>
<button class="btn btn-outline-secondary fa-solid fa-calendar-days" (click)="d.toggle()" type="button"></button>
</div>
<div class="col-md-4 col-10 mb-2">
<ng-container *ngIf="predicateType$ | async as predicateType">
<ng-container [ngSwitch]="predicateType">
<ng-container *ngSwitchCase="PredicateType.Text">
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Number">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Boolean">
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
<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 *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
<ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
<select2 [data]="options"
formControlName="filterValue"
[hideSelectedItems]="true"
[multiple]="multipleAllowed"
[infiniteScroll]="true"
[resettable]="true">
</select2>
</ng-template>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<ng-content #removeBtn></ng-content>
</div>
</form>
<div class="col pt-2 ms-2">
<ng-container *ngIf="UiLabel !== null">
<span class="text-muted">{{t(UiLabel.unit)}}</span>
<i *ngIf="UiLabel.tooltip" class="fa fa-info-circle ms-1" aria-hidden="true" [ngbTooltip]="t(UiLabel.tooltip)"></i>
</ng-container>
</div>
<ng-content #removeBtn></ng-content>
</div>
</form>
</ng-container>

View file

@ -1,3 +1,23 @@
::ng-deep .select2-selection__rendered {
padding-top: 4px !important;
}
::ng-deep .ngb-dp-content, ::ng-deep .ngb-dp-header, ::ng-deep .dropdown-menu{
background: var(--bs-body-bg);
color: var(--body-text-color);
}
::ng-deep .ngb-dp-header, ::ng-deep .ngb-dp-weekdays {
background-color: var(--bs-body-bg) !important;
}
::ng-deep .ngb-dp-day .btn-light, ::ng-deep .ngb-dp-weekday {
background: var(--bs-body-bg);
color: var(--body-text-color);
}
::ng-deep [ngbDatepickerDayView]:hover:not(.bg-primary), [ngbDatepickerDayView].active:not(.bg-primary) {
background: var(--primary-color-dark-shade) !important;
outline: 1px solid var(--primary-color-dark-shade) !important;
}

View file

@ -7,7 +7,7 @@ import {
inject,
Input,
OnInit,
Output,
Output, ViewChild,
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
@ -25,14 +25,38 @@ import {FilterComparisonPipe} from "../../_pipes/filter-comparison.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Select2Module, Select2Option} from "ng-select2-component";
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
import {
NgbDate,
NgbDateParserFormatter,
NgbDatepicker,
NgbDateStruct,
NgbInputDatepicker,
NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@ngneat/transloco";
enum PredicateType {
Text = 1,
Number = 2,
Dropdown = 3,
Boolean = 4
Boolean = 4,
Date = 5
}
class FilterRowUi {
unit = '';
tooltip = ''
constructor(unit: string = '', tooltip: string = '') {
this.unit = unit;
this.tooltip = tooltip;
}
}
const unitLabels: Map<FilterField, FilterRowUi> = new Map([
[FilterField.ReadingDate, new FilterRowUi('unit-reading-date')],
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
]);
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating];
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
@ -42,7 +66,8 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
];
const BooleanFields = [FilterField.WantToRead]
const BooleanFields = [FilterField.WantToRead];
const DateFields = [FilterField.ReadingDate];
const DropdownFieldsWithoutMustContains = [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
@ -59,7 +84,8 @@ const StringComparisons = [FilterComparison.Equal,
FilterComparison.BeginsWith,
FilterComparison.EndsWith,
FilterComparison.Matches];
const DateComparisons = [FilterComparison.IsBefore, FilterComparison.IsAfter, FilterComparison.IsInLast, FilterComparison.IsNotInLast];
const DateComparisons = [FilterComparison.IsBefore, FilterComparison.IsAfter, FilterComparison.Equal,
FilterComparison.NotEqual,];
const NumberComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual,
FilterComparison.LessThan,
@ -91,7 +117,11 @@ const BooleanComparisons = [
NgIf,
Select2Module,
NgTemplateOutlet,
TagBadgeComponent
TagBadgeComponent,
NgbTooltip,
TranslocoDirective,
NgbDatepicker,
NgbInputDatepicker
],
changeDetection: ChangeDetectionStrategy.OnPush
})
@ -105,8 +135,10 @@ export class MetadataFilterRowComponent implements OnInit {
@Input() availableFields: Array<FilterField> = allFields;
@Output() filterStatement = new EventEmitter<FilterStatement>();
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly dateParser = inject(NgbDateParserFormatter);
formGroup: FormGroup = new FormGroup({
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
@ -119,6 +151,12 @@ export class MetadataFilterRowComponent implements OnInit {
loaded: boolean = false;
protected readonly PredicateType = PredicateType;
get UiLabel(): FilterRowUi | null {
const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField;
if (!unitLabels.has(field)) return null;
return unitLabels.get(field) as FilterRowUi;
}
get MultipleDropdownAllowed() {
const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains;
@ -149,30 +187,36 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup!.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => {
const stmt = {
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField,
value: this.formGroup.get('filterValue')?.value!
};
// Some ids can get through and be numbers, convert them to strings for the backend
if (typeof stmt.value === 'number' && !Number.isNaN(stmt.value)) {
stmt.value = stmt.value + '';
}
if (typeof stmt.value === 'boolean') {
stmt.value = stmt.value + '';
}
if (!stmt.value && (stmt.field !== FilterField.SeriesName && !BooleanFields.includes(stmt.field))) return;
this.filterStatement.emit(stmt);
this.propagateFilterUpdate();
});
this.loaded = true;
this.cdRef.markForCheck();
}
propagateFilterUpdate() {
const stmt = {
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField,
value: this.formGroup.get('filterValue')?.value!
};
if (typeof stmt.value === 'object' && DateFields.includes(stmt.field)) {
stmt.value = this.dateParser.format(stmt.value);
}
// Some ids can get through and be numbers, convert them to strings for the backend
if (typeof stmt.value === 'number' && !Number.isNaN(stmt.value)) {
stmt.value = stmt.value + '';
}
if (typeof stmt.value === 'boolean') {
stmt.value = stmt.value + '';
}
if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return;
this.filterStatement.emit(stmt);
}
populateFromPreset() {
const val = this.preset.value === "undefined" || !this.preset.value ? '' : this.preset.value;
@ -183,7 +227,10 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (BooleanFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (DropdownFields.includes(this.preset.field)) {
} else if (DateFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val)); // TODO: Figure out how this works
}
else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed || val.includes(',')) {
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
} else {
@ -281,6 +328,16 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (DateFields.includes(inputVal)) {
this.validComparisons$.next(DateComparisons);
this.predicateType$.next(PredicateType.Date);
if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue(false);
}
return;
}
if (BooleanFields.includes(inputVal)) {
this.validComparisons$.next(BooleanComparisons);
this.predicateType$.next(PredicateType.Boolean);
@ -306,4 +363,15 @@ export class MetadataFilterRowComponent implements OnInit {
}
}
onDateSelect(event: NgbDate) {
console.log('date selected: ', event);
this.propagateFilterUpdate();
}
updateIfDateFilled() {
console.log('date inputted: ', this.formGroup.get('filterValue')?.value);
this.propagateFilterUpdate();
}
}

View file

@ -64,6 +64,8 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.file-path');
case FilterField.WantToRead:
return translate('filter-field-pipe.want-to-read');
case FilterField.ReadingDate:
return translate('filter-field-pipe.read-date');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View file

@ -1,12 +1,12 @@
<ng-container *transloco="let t; read: 'metadata-filter'">
<ng-container *ngIf="toggleService.toggleState$ | async as isOpen">
<div class="phone-hidden" *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet">
<div *ngIf="utilityService.getActiveBreakpoint() >= Breakpoint.Tablet">
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</div>
<div class="not-phone-hidden" *ngIf="utilityService.getActiveBreakpoint() < Breakpoint.Desktop">
<div *ngIf="utilityService.getActiveBreakpoint() < Breakpoint.Desktop">
<app-drawer #commentDrawer="drawer" [isOpen]="isOpen" [options]="{topOffset: 56}" (drawerClosed)="toggleService.set(false)">
<h5 header>
{{t('filter-title')}}
@ -51,6 +51,16 @@
<div class="col-md-3 col-sm-10">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
<!-- <select2 [data]="smartFilters"-->
<!-- id="filter-name"-->
<!-- formControlName="name"-->
<!-- (update)="updateFilterValue($event)"-->
<!-- [autoCreate]="true"-->
<!-- [multiple]="false"-->
<!-- [infiniteScroll]="false"-->
<!-- [hideSelectedItems]="true"-->
<!-- [resettable]="true">-->
<!-- </select2>-->
</div>
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
@ -63,15 +73,13 @@
</ng-template>
<ng-template #buttons>
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
<div class="col-md-2 col-sm-6 mt-4 pt-2 d-flex justify-content-between">
<button class="btn btn-secondary col-6 me-1" (click)="clear()"><i class="fa-solid fa-arrow-rotate-left me-1" aria-hidden="true"></i>{{t('reset')}}</button>
<button class="btn btn-primary col-6" (click)="apply()"><i class="fa-solid fa-play me-1" aria-hidden="true"></i>{{t('apply')}}</button>
</div>
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<div class="col-md-1 col-sm-6 mt-4 pt-2">
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings.saveDisabled || !this.sortGroup.get('name')?.value">
<!-- TODO: Icon here -->
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{t('save')}}
</button>
</div>

View file

@ -30,6 +30,8 @@ import {MetadataService} from "../_services/metadata.service";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr";
import {Select2Module, Select2Option, Select2UpdateEvent} from "ng-select2-component";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
@Component({
selector: 'app-metadata-filter',
@ -38,7 +40,7 @@ import {ToastrService} from "ngx-toastr";
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf]
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf, Select2Module]
})
export class MetadataFilterComponent implements OnInit {
@ -78,16 +80,22 @@ export class MetadataFilterComponent implements OnInit {
allSortFields = allSortFields;
allFilterFields = allFields;
handleFilters(filter: SeriesFilterV2) {
this.filterV2 = filter;
}
smartFilters!: Array<Select2Option>;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
constructor(public toggleService: ToggleService, private filterService: FilterService) {}
constructor(public toggleService: ToggleService, private filterService: FilterService) {
this.filterService.getAllFilters().subscribe(res => {
this.smartFilters = res.map(r => {
return {
value: r,
label: r.name,
}
});
});
}
ngOnInit(): void {
if (this.filterSettings === undefined) {
@ -106,6 +114,11 @@ export class MetadataFilterComponent implements OnInit {
this.loadFromPresetsAndSetup();
}
updateFilterValue(event: Select2UpdateEvent<any>) {
console.log('event: ', event);
}
close() {
this.filterOpen.emit(false);
this.filteringCollapsed = true;
@ -137,6 +150,10 @@ export class MetadataFilterComponent implements OnInit {
return clonedObj;
}
handleFilters(filter: SeriesFilterV2) {
this.filterV2 = filter;
}
loadFromPresetsAndSetup() {
this.fullyLoaded = false;
@ -187,7 +204,7 @@ export class MetadataFilterComponent implements OnInit {
apply() {
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!});
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) {
this.toggleSelected();
}

View file

@ -14,17 +14,19 @@ export class SortFieldPipe implements PipeTransform {
transform(value: SortField): string {
switch (value) {
case SortField.SortName:
return this.translocoService.translate('sort-field-pipe.sort-name')
return this.translocoService.translate('sort-field-pipe.sort-name');
case SortField.Created:
return this.translocoService.translate('sort-field-pipe.created')
return this.translocoService.translate('sort-field-pipe.created');
case SortField.LastModified:
return this.translocoService.translate('sort-field-pipe.last-modified')
return this.translocoService.translate('sort-field-pipe.last-modified');
case SortField.LastChapterAdded:
return this.translocoService.translate('sort-field-pipe.last-chapter-added')
return this.translocoService.translate('sort-field-pipe.last-chapter-added');
case SortField.TimeToRead:
return this.translocoService.translate('sort-field-pipe.time-to-read')
return this.translocoService.translate('sort-field-pipe.time-to-read');
case SortField.ReleaseYear:
return this.translocoService.translate('sort-field-pipe.release-year')
return this.translocoService.translate('sort-field-pipe.release-year');
case SortField.ReadProgress:
return this.translocoService.translate('sort-field-pipe.read-progress');
}
}

View file

@ -74,7 +74,7 @@
<button ngbDropdownItem (click)="read(true)">
<span>
<i class="fa fa-glasses" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}} Incognito</span>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
</span>
</button>
</div>
@ -101,7 +101,7 @@
</div>
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series" [disabled]="downloadInProgress">
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="downloadInProgress">
<ng-container *ngIf="downloadInProgress; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">{{t('downloading-status')}}</span>

View file

@ -681,6 +681,8 @@
"continue-from": "Continue {{title}}",
"read": "{{common.read}}",
"continue": "Continue",
"read-incognito": "Read Incognito",
"continue-incognito": "Continue Incognito",
"read-options-alt": "Read options",
"incognito": "Incognito",
"remove-from-want-to-read": "Remove from Want to Read",
@ -1509,7 +1511,7 @@
"reset": "{{common.reset}}",
"apply": "{{common.apply}}",
"save": "{{common.save}}",
"limit-label": "Limit To",
"limit-label": "Limit",
"format-label": "Format",
"libraries-label": "Libraries",
@ -1541,13 +1543,19 @@
"max": "Max"
},
"metadata-filter-row": {
"unit-reading-date": "Date",
"unit-reading-progress": "Percent"
},
"sort-field-pipe": {
"sort-name": "Sort Name",
"created": "Created",
"last-modified": "Last Modified",
"last-chapter-added": "Item Added",
"time-to-read": "Time to Read",
"release-year": "Release Year"
"release-year": "Release Year",
"read-progress": "Read Progress"
},
"edit-series-modal": {
@ -1751,7 +1759,8 @@
"writers": "Writers",
"path": "Path",
"file-path": "File Path",
"want-to-read": "Want to Read"
"want-to-read": "Want to Read",
"read-date": "Reading Date"
},
"filter-comparison-pipe": {