move filters to classes, remove _primaryKey from listing component

This commit is contained in:
Dan Percic 2021-09-25 20:58:55 +03:00
parent 93b5ea7f89
commit 8d001e95b5
15 changed files with 111 additions and 86 deletions

View File

@ -1,15 +1,15 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { NestedFilter } from './models/nested-filter.model'; import { INestedFilter } from "./models/nested-filter.model";
import { FilterGroup } from './models/filter-group.model'; import { IFilterGroup } from "./models/filter-group.model";
import { Filter } from './models/filter.model'; import { IFilter } from "./models/filter.model";
function copySettings(oldFilters: NestedFilter[], newFilters: NestedFilter[]) { function copySettings(oldFilters: INestedFilter[], newFilters: INestedFilter[]) {
if (!oldFilters || !newFilters) { if (!oldFilters || !newFilters) {
return; return;
} }
oldFilters.forEach(filter => { oldFilters.forEach(filter => {
const newFilter = newFilters.find(f => f.key === filter.key); const newFilter = newFilters.find(f => f.id === filter.id);
if (newFilter) { if (newFilter) {
newFilter.checked = filter.checked; newFilter.checked = filter.checked;
newFilter.indeterminate = filter.indeterminate; newFilter.indeterminate = filter.indeterminate;
@ -20,7 +20,7 @@ function copySettings(oldFilters: NestedFilter[], newFilters: NestedFilter[]) {
}); });
} }
export function handleCheckedValue(filter: NestedFilter): void { export function handleCheckedValue(filter: INestedFilter): void {
if (filter.children && filter.children.length) { if (filter.children && filter.children.length) {
filter.checked = filter.children.reduce<boolean>((acc, next) => acc && !!next.checked, true); filter.checked = filter.children.reduce<boolean>((acc, next) => acc && !!next.checked, true);
if (filter.checked) { if (filter.checked) {
@ -33,7 +33,7 @@ export function handleCheckedValue(filter: NestedFilter): void {
} }
} }
export function processFilters(oldFilters: NestedFilter[], newFilters: NestedFilter[]): NestedFilter[] { export function processFilters(oldFilters: INestedFilter[], newFilters: INestedFilter[]): INestedFilter[] {
copySettings(oldFilters, newFilters); copySettings(oldFilters, newFilters);
if (newFilters) { if (newFilters) {
newFilters.forEach(filter => { newFilters.forEach(filter => {
@ -45,7 +45,7 @@ export function processFilters(oldFilters: NestedFilter[], newFilters: NestedFil
export function checkFilter( export function checkFilter(
entity: unknown, entity: unknown,
filters: NestedFilter[], filters: INestedFilter[],
validate?: (...args: unknown[]) => boolean, validate?: (...args: unknown[]) => boolean,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
validateArgs: any = [], validateArgs: any = [],
@ -72,9 +72,9 @@ export function checkFilter(
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const keyChecker = (key: string) => (entity: Record<string, string>, filter: NestedFilter) => entity[key] === filter.key; export const keyChecker = (key: string) => (entity: Record<string, string>, filter: INestedFilter) => entity[key] === filter.id;
export function getFilteredEntities<T>(entities: T[], filters: FilterGroup[]): T[] { export function getFilteredEntities<T>(entities: T[], filters: IFilterGroup[]): T[] {
const filteredEntities: T[] = []; const filteredEntities: T[] = [];
entities.forEach(entity => { entities.forEach(entity => {
let add = true; let add = true;
@ -88,10 +88,10 @@ export function getFilteredEntities<T>(entities: T[], filters: FilterGroup[]): T
return filteredEntities; return filteredEntities;
} }
export function flatChildren(filters: NestedFilter[]): Filter[] { export function flatChildren(filters: INestedFilter[]): IFilter[] {
return filters.reduce((acc: Filter[], f) => [...acc, ...(f?.children ?? [])], []); return filters.reduce((acc: IFilter[], f) => [...acc, ...(f?.children ?? [])], []);
} }
export function toFlatFilters(groups: FilterGroup[]): Filter[] { export function toFlatFilters(groups: IFilterGroup[]): IFilter[] {
return groups.reduce((acc: Filter[], f) => [...acc, ...f.filters, ...flatChildren(f.filters)], []); return groups.reduce((acc: IFilter[], f) => [...acc, ...f.filters, ...flatChildren(f.filters)], []);
} }

View File

@ -2,15 +2,15 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
import { processFilters, toFlatFilters } from './filter-utils'; import { processFilters, toFlatFilters } from './filter-utils';
import { FilterGroup } from './models/filter-group.model'; import { IFilterGroup } from './models/filter-group.model';
import { NestedFilter } from './models/nested-filter.model'; import { INestedFilter } from './models/nested-filter.model';
import { get } from '../utils'; import { get } from '../utils';
@Injectable() @Injectable()
export class FilterService { export class FilterService {
readonly showResetFilters$: Observable<boolean>; readonly showResetFilters$: Observable<boolean>;
readonly filterGroups$: Observable<FilterGroup[]>; readonly filterGroups$: Observable<IFilterGroup[]>;
private readonly _filterGroups$ = new BehaviorSubject<FilterGroup[]>([]); private readonly _filterGroups$ = new BehaviorSubject<IFilterGroup[]>([]);
private readonly _refresh$ = new Subject(); private readonly _refresh$ = new Subject();
constructor() { constructor() {
@ -22,7 +22,7 @@ export class FilterService {
this.showResetFilters$ = this._showResetFilters$; this.showResetFilters$ = this._showResetFilters$;
} }
get filterGroups(): FilterGroup[] { get filterGroups(): IFilterGroup[] {
return Object.values(this._filterGroups$.getValue()); return Object.values(this._filterGroups$.getValue());
} }
@ -44,9 +44,9 @@ export class FilterService {
return console.error(`Cannot find filter group "${filterGroupSlug}"`); return console.error(`Cannot find filter group "${filterGroupSlug}"`);
} }
let found = filters.find(f => f.key === key); let found = filters.find(f => f.id === key);
if (!found) { if (!found) {
[found] = filters.map(f => f.children?.find(ff => ff.key === key)); [found] = filters.map(f => f.children?.find(ff => ff.id === key));
} }
if (!found) { if (!found) {
return console.error(`Cannot find filter with key "${key}" in group "${filterGroupSlug}"`); return console.error(`Cannot find filter with key "${key}" in group "${filterGroupSlug}"`);
@ -59,7 +59,7 @@ export class FilterService {
this.refresh(); this.refresh();
} }
addFilterGroup(value: FilterGroup): void { addFilterGroup(value: IFilterGroup): void {
const oldFilters = this.getGroup(value.slug)?.filters; const oldFilters = this.getGroup(value.slug)?.filters;
if (!oldFilters) { if (!oldFilters) {
return this._filterGroups$.next([...this.filterGroups, value]); return this._filterGroups$.next([...this.filterGroups, value]);
@ -69,15 +69,15 @@ export class FilterService {
this._filterGroups$.next([...this.filterGroups.filter(f => f.slug !== newGroup.slug), newGroup]); this._filterGroups$.next([...this.filterGroups.filter(f => f.slug !== newGroup.slug), newGroup]);
} }
getGroup(slug: string): FilterGroup | undefined { getGroup(slug: string): IFilterGroup | undefined {
return this.filterGroups.find(group => group.slug === slug); return this.filterGroups.find(group => group.slug === slug);
} }
getFilterModels$(filterGroupSlug: string): Observable<NestedFilter[] | undefined> { getFilterModels$(filterGroupSlug: string): Observable<INestedFilter[] | undefined> {
return this.getGroup$(filterGroupSlug).pipe(map(f => f?.filters)); return this.getGroup$(filterGroupSlug).pipe(map(f => f?.filters));
} }
getGroup$(slug: string): Observable<FilterGroup | undefined> { getGroup$(slug: string): Observable<IFilterGroup | undefined> {
return this.filterGroups$.pipe(get(group => group.slug === slug)); return this.filterGroups$.pipe(get(group => group.slug === slug));
} }

View File

@ -3,8 +3,10 @@ export * from './filters.module';
export * from './filter-utils'; export * from './filter-utils';
export * from './filter.service'; export * from './filter.service';
export * from './models/filter';
export * from './models/filter.model'; export * from './models/filter.model';
export * from './models/filter-group.model'; export * from './models/filter-group.model';
export * from './models/nested-filter';
export * from './models/nested-filter.model'; export * from './models/nested-filter.model';
export * from './popup-filter/popup-filter.component'; export * from './popup-filter/popup-filter.component';

View File

@ -1,8 +1,8 @@
import { TemplateRef } from '@angular/core'; import { TemplateRef } from '@angular/core';
import { NestedFilter } from './nested-filter.model'; import { INestedFilter } from './nested-filter.model';
export interface FilterGroup { export interface IFilterGroup {
filters: NestedFilter[]; filters: INestedFilter[];
readonly slug: string; readonly slug: string;
readonly label?: string; readonly label?: string;
readonly icon?: string; readonly icon?: string;

View File

@ -1,5 +1,6 @@
export interface Filter { import { IListable } from '../../listing';
readonly key: string;
export interface IFilter extends IListable {
checked?: boolean; checked?: boolean;
matches?: number; matches?: number;
readonly label: string; readonly label: string;

View File

@ -0,0 +1,27 @@
import { IFilter } from './filter.model';
export class Filter implements IFilter {
readonly id: string;
readonly label: string;
checked: boolean;
readonly required: boolean;
readonly topLevelFilter: boolean;
matches?: number;
readonly icon?: string;
readonly checker?: (obj?: unknown) => boolean;
constructor(filter: IFilter) {
this.id = filter.id;
this.label = filter.label;
this.checked = !!filter.checked;
this.matches = filter.matches;
this.icon = filter.icon;
this.topLevelFilter = !!filter.topLevelFilter;
this.checker = filter.checker;
this.required = !!filter.required;
}
get searchKey(): string {
return this.label;
}
}

View File

@ -1,7 +1,7 @@
import { Filter } from './filter.model'; import { IFilter } from './filter.model';
export interface NestedFilter extends Filter { export interface INestedFilter extends IFilter {
expanded?: boolean; expanded?: boolean;
indeterminate?: boolean; indeterminate?: boolean;
readonly children?: Filter[]; readonly children?: IFilter[];
} }

View File

@ -0,0 +1,15 @@
import { Filter } from './filter';
import { IFilter, INestedFilter } from '@iqser/common-ui';
export class NestedFilter extends Filter implements INestedFilter {
expanded: boolean;
indeterminate: boolean;
readonly children?: IFilter[];
constructor(nestedFilter: INestedFilter) {
super(nestedFilter);
this.expanded = !!nestedFilter.expanded;
this.indeterminate = !!nestedFilter.indeterminate;
this.children = nestedFilter.children;
}
}

View File

@ -5,14 +5,14 @@ import { delay, distinctUntilChanged, map, shareReplay } from 'rxjs/operators';
import { any } from '../../utils'; import { any } from '../../utils';
import { handleCheckedValue } from '../filter-utils'; import { handleCheckedValue } from '../filter-utils';
import { FilterService } from '../filter.service'; import { FilterService } from '../filter.service';
import { FilterGroup } from '../models/filter-group.model'; import { IFilterGroup } from '../models/filter-group.model';
import { NestedFilter } from '../models/nested-filter.model'; import { INestedFilter } from '../models/nested-filter.model';
import { SearchService } from '../../search'; import { SearchService } from '../../search';
import { Filter } from '..'; import { IFilter } from '..';
const areExpandable = (nestedFilter: NestedFilter) => !!nestedFilter?.children?.length; const areExpandable = (nestedFilter: INestedFilter) => !!nestedFilter?.children?.length;
const atLeastOneIsExpandable = pipe( const atLeastOneIsExpandable = pipe(
map<FilterGroup | undefined, boolean>(group => !!group?.filters.some(areExpandable)), map<IFilterGroup | undefined, boolean>(group => !!group?.filters.some(areExpandable)),
distinctUntilChanged(), distinctUntilChanged(),
shareReplay() shareReplay()
); );
@ -44,13 +44,11 @@ export class PopupFilterComponent implements OnInit {
readonly expanded = new BehaviorSubject<boolean>(false); readonly expanded = new BehaviorSubject<boolean>(false);
readonly expanded$ = this.expanded.asObservable().pipe(delay(200)); readonly expanded$ = this.expanded.asObservable().pipe(delay(200));
primaryFilterGroup$!: Observable<FilterGroup | undefined>; primaryFilterGroup$!: Observable<IFilterGroup | undefined>;
secondaryFilterGroup$!: Observable<FilterGroup | undefined>; secondaryFilterGroup$!: Observable<IFilterGroup | undefined>;
primaryFilters$!: Observable<Filter[] | undefined>; primaryFilters$!: Observable<IFilter[] | undefined>;
constructor(readonly filterService: FilterService, readonly searchService: SearchService<Filter>) { constructor(readonly filterService: FilterService, readonly searchService: SearchService<IFilter>) {}
this.searchService.setSearchKey('label');
}
private get _hasActiveFilters$() { private get _hasActiveFilters$() {
return combineLatest([this.primaryFilterGroup$, this.secondaryFilterGroup$]).pipe( return combineLatest([this.primaryFilterGroup$, this.secondaryFilterGroup$]).pipe(
@ -70,7 +68,7 @@ export class PopupFilterComponent implements OnInit {
this.atLeastOneSecondaryFilterIsExpandable$ = atLeastOneIsExpandable(this.secondaryFilterGroup$); this.atLeastOneSecondaryFilterIsExpandable$ = atLeastOneIsExpandable(this.secondaryFilterGroup$);
} }
filterCheckboxClicked($event: MouseEvent, nestedFilter: NestedFilter, parent?: NestedFilter): void { filterCheckboxClicked($event: MouseEvent, nestedFilter: INestedFilter, parent?: INestedFilter): void {
$event.stopPropagation(); $event.stopPropagation();
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
@ -86,7 +84,7 @@ export class PopupFilterComponent implements OnInit {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
nestedFilter.indeterminate = false; nestedFilter.indeterminate = false;
// eslint-disable-next-line no-return-assign,no-param-reassign // eslint-disable-next-line no-return-assign,no-param-reassign
nestedFilter.children?.forEach(f => (f.checked = nestedFilter.checked)); nestedFilter.children?.forEach(f => (f.checked = !!nestedFilter.checked));
} }
this.filterService.refresh(); this.filterService.refresh();
@ -103,7 +101,7 @@ export class PopupFilterComponent implements OnInit {
} }
} }
toggleFilterExpanded($event: MouseEvent, nestedFilter: NestedFilter): void { toggleFilterExpanded($event: MouseEvent, nestedFilter: INestedFilter): void {
$event.stopPropagation(); $event.stopPropagation();
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
nestedFilter.expanded = !nestedFilter.expanded; nestedFilter.expanded = !nestedFilter.expanded;
@ -123,7 +121,7 @@ export class PopupFilterComponent implements OnInit {
this.filterService.refresh(); this.filterService.refresh();
} }
private get _primaryFilters$(): Observable<Filter[]> { private get _primaryFilters$(): Observable<IFilter[]> {
return combineLatest([this.primaryFilterGroup$, this.searchService.valueChanges$]).pipe( return combineLatest([this.primaryFilterGroup$, this.searchService.valueChanges$]).pipe(
map(([group]) => this.searchService.searchIn(group?.filters ?? [])) map(([group]) => this.searchService.searchIn(group?.filters ?? []))
); );

View File

@ -1,5 +1,5 @@
<div <div
(click)="filterService.toggleFilter('quickFilters', filter.key)" (click)="filterService.toggleFilter('quickFilters', filter.id)"
*ngFor="let filter of quickFilters$ | async" *ngFor="let filter of quickFilters$ | async"
[class.active]="filter.checked" [class.active]="filter.checked"
class="quick-filter" class="quick-filter"

View File

@ -2,8 +2,8 @@ import { Directive, Injector, OnDestroy } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { FilterService } from '../filtering'; import { FilterService } from '../filtering';
import { SortingOrders, SortingService } from '../sorting'; import { SortingService } from '../sorting';
import { AutoUnsubscribe, Bind, KeysOf } from '../utils'; import { AutoUnsubscribe, KeysOf } from '../utils';
import { SearchService } from '../search'; import { SearchService } from '../search';
import { EntitiesService } from './services'; import { EntitiesService } from './services';
import { IListable, TableColumnConfig } from './models'; import { IListable, TableColumnConfig } from './models';
@ -40,7 +40,6 @@ export abstract class ListingComponent<T extends IListable> extends AutoUnsubscr
protected constructor(protected readonly _injector: Injector) { protected constructor(protected readonly _injector: Injector) {
super(); super();
this.listingMode$ = this._listingMode$.asObservable(); this.listingMode$ = this._listingMode$.asObservable();
setTimeout(() => this.setInitialConfig());
} }
get allEntities(): T[] { get allEntities(): T[] {
@ -71,14 +70,6 @@ export abstract class ListingComponent<T extends IListable> extends AutoUnsubscr
); );
} }
setInitialConfig(): void {
this.sortingService.setSortingOption({
column: this._primaryKey,
order: SortingOrders.asc
});
this.searchService.setSearchKey(this._primaryKey);
}
toggleEntitySelected(event: MouseEvent, entity: T): void { toggleEntitySelected(event: MouseEvent, entity: T): void {
event.stopPropagation(); event.stopPropagation();
this.entitiesService.select(entity); this.entitiesService.select(entity);
@ -87,9 +78,4 @@ export abstract class ListingComponent<T extends IListable> extends AutoUnsubscr
isSelected(entity: T): boolean { isSelected(entity: T): boolean {
return this.entitiesService.isSelected(entity); return this.entitiesService.isSelected(entity);
} }
@Bind()
trackByPrimaryKey(index: number, item: T): unknown {
return item.searchKey ?? item[this._primaryKey];
}
} }

View File

@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component, Input, Optional } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, Optional } from '@angular/core';
import { SortingOrders, SortingService } from '../../sorting'; import { SortingOrders, SortingService } from '../../sorting';
import { KeysOf, Required } from '../../utils'; import { KeysOf, Required } from '../../utils';
import { IListable } from '../models';
const ifHasRightIcon = <T>(thisArg: TableColumnNameComponent<T>) => !!thisArg.rightIcon; const ifHasRightIcon = <T extends IListable>(thisArg: TableColumnNameComponent<T>) => !!thisArg.rightIcon;
@Component({ @Component({
selector: 'iqser-table-column-name', selector: 'iqser-table-column-name',
@ -10,7 +11,7 @@ const ifHasRightIcon = <T>(thisArg: TableColumnNameComponent<T>) => !!thisArg.ri
styleUrls: ['./table-column-name.component.scss'], styleUrls: ['./table-column-name.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class TableColumnNameComponent<T> { export class TableColumnNameComponent<T extends IListable> {
readonly sortingOrders = SortingOrders; readonly sortingOrders = SortingOrders;
@Input() @Required() label!: string; @Input() @Required() label!: string;

View File

@ -22,13 +22,11 @@
<iqser-empty-state *ngIf="listingComponent.noMatch$ | async" [text]="noMatchText"></iqser-empty-state> <iqser-empty-state *ngIf="listingComponent.noMatch$ | async" [text]="noMatchText"></iqser-empty-state>
<cdk-virtual-scroll-viewport [class.no-data]="listingComponent.noContent$ | async" <cdk-virtual-scroll-viewport [class.no-data]="listingComponent.noContent$ | async" [itemSize]="itemSize" iqserHasScrollbar>
[itemSize]="itemSize"
iqserHasScrollbar>
<div <div
(mouseenter)="itemMouseEnterFn && itemMouseEnterFn(entity)" (mouseenter)="itemMouseEnterFn && itemMouseEnterFn(entity)"
(mouseleave)="itemMouseLeaveFn && itemMouseLeaveFn(entity)" (mouseleave)="itemMouseLeaveFn && itemMouseLeaveFn(entity)"
*cdkVirtualFor="let entity of listingComponent.sortedDisplayedEntities$ | async; trackBy: listingComponent.trackByPrimaryKey" *cdkVirtualFor="let entity of listingComponent.sortedDisplayedEntities$ | async"
[ngClass]="getTableItemClasses(entity)" [ngClass]="getTableItemClasses(entity)"
[routerLink]="routerLinkFn && routerLinkFn(entity)" [routerLink]="routerLinkFn && routerLinkFn(entity)"
> >

View File

@ -1,12 +1,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { KeysOf } from '../utils'; import { KeysOf } from '../utils';
import { IListable } from '../listing';
@Injectable() @Injectable()
export class SearchService<T> { export class SearchService<T extends IListable> {
private readonly _query$ = new BehaviorSubject(''); private readonly _query$ = new BehaviorSubject('');
readonly valueChanges$ = this._query$.asObservable(); readonly valueChanges$ = this._query$.asObservable();
private _searchKey!: KeysOf<T>;
get searchValue(): string { get searchValue(): string {
return this._query$.getValue(); return this._query$.getValue();
@ -17,23 +17,16 @@ export class SearchService<T> {
} }
searchIn(entities: T[]): T[] { searchIn(entities: T[]): T[] {
if (!this._searchKey) { const searchValue = this.searchValue.toLowerCase();
if (!searchValue) {
return entities; return entities;
} }
return entities.filter(entity => entity.searchKey?.includes(searchValue));
const searchValue = this.searchValue.toLowerCase();
return entities.filter(entity => this._searchField(entity).includes(searchValue));
} }
setSearchKey(value: KeysOf<T>): void { setSearchKey(value: KeysOf<T>): void {}
this._searchKey = value;
}
reset(): void { reset(): void {
this._query$.next(''); this._query$.next('');
} }
private _searchField(entity: T): string {
return ((<unknown>entity[this._searchKey]) as string).toString().toLowerCase();
}
} }

View File

@ -4,10 +4,14 @@ import { BehaviorSubject } from 'rxjs';
import { SortingOption } from './models/sorting-option.model'; import { SortingOption } from './models/sorting-option.model';
import { SortingOrder, SortingOrders } from './models/sorting-order.type'; import { SortingOrder, SortingOrders } from './models/sorting-order.type';
import { KeysOf } from '../utils'; import { KeysOf } from '../utils';
import { IListable } from '../listing';
@Injectable() @Injectable()
export class SortingService<T> { export class SortingService<T extends IListable> {
private readonly _sortingOption$ = new BehaviorSubject<SortingOption<T> | undefined>(undefined); private readonly _sortingOption$ = new BehaviorSubject<SortingOption<T>>({
column: 'searchKey',
order: SortingOrders.asc
});
readonly sortingOption$ = this._sortingOption$.asObservable(); readonly sortingOption$ = this._sortingOption$.asObservable();
get sortingOption(): SortingOption<T> | undefined { get sortingOption(): SortingOption<T> | undefined {