diff --git a/src/index.ts b/src/index.ts index 84b883b..6c4761a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,8 @@ export * from './lib/base/auto-unsubscribe.component'; export * from './lib/utils/decorators/required.decorator'; export * from './lib/buttons/circle-button/circle-button.component'; export * from './lib/buttons/circle-button/circle-button.type'; -export * from './lib/types/tooltip-positions.type'; +export * from './lib/utils/types/tooltip-positions.type'; +export * from './lib/filtering/filter.service'; +export * from './lib/filtering/filter-utils'; +export * from './lib/filtering/models/filter-group.model'; +export * from './lib/filtering/models/filter.model'; diff --git a/src/lib/buttons/circle-button/circle-button.component.ts b/src/lib/buttons/circle-button/circle-button.component.ts index b7707b1..1973f36 100644 --- a/src/lib/buttons/circle-button/circle-button.component.ts +++ b/src/lib/buttons/circle-button/circle-button.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, On import { MatTooltip } from '@angular/material/tooltip'; import { CircleButtonType, CircleButtonTypes } from './circle-button.type'; import { Required } from '../../utils/decorators/required.decorator'; -import { TooltipPositionsType, TooltipPositionsTypes } from '../../types/tooltip-positions.type'; +import { TooltipPositionsType, TooltipPositionsTypes } from '../../utils/types/tooltip-positions.type'; @Component({ selector: 'iqser-circle-button', @@ -24,7 +24,7 @@ export class CircleButtonComponent implements OnInit { @Input() isSubmit = false; @Input() size = 34; @Input() iconSize = 14; - @Output() action = new EventEmitter(); + @Output() action = new EventEmitter(); @ViewChild(MatTooltip) private readonly _matTooltip!: MatTooltip; @@ -35,7 +35,7 @@ export class CircleButtonComponent implements OnInit { this._elementRef.nativeElement.style.setProperty('--iconSize', this.iconSize + 'px'); } - performAction($event: any) { + performAction($event: MouseEvent) { if (this.disabled) return; if (this.removeTooltip) { diff --git a/src/lib/buttons/icon-button/icon-button.component.ts b/src/lib/buttons/icon-button/icon-button.component.ts index 0f59630..1d8d64a 100644 --- a/src/lib/buttons/icon-button/icon-button.component.ts +++ b/src/lib/buttons/icon-button/icon-button.component.ts @@ -16,5 +16,5 @@ export class IconButtonComponent { @Input() showDot = false; @Input() disabled = false; @Input() type: IconButtonType = IconButtonTypes.default; - @Output() action = new EventEmitter(); + @Output() action = new EventEmitter(); } diff --git a/src/lib/filtering/filter-utils.ts b/src/lib/filtering/filter-utils.ts new file mode 100644 index 0000000..9693860 --- /dev/null +++ b/src/lib/filtering/filter-utils.ts @@ -0,0 +1,82 @@ +import { FilterModel } from './models/filter.model'; +import { FilterGroup } from './models/filter-group.model'; + +export function processFilters(oldFilters: FilterModel[], newFilters: FilterModel[]) { + copySettings(oldFilters, newFilters); + if (newFilters) { + newFilters.forEach(filter => { + handleCheckedValue(filter); + }); + } + return newFilters; +} + +function copySettings(oldFilters: FilterModel[], newFilters: FilterModel[]) { + if (oldFilters && newFilters) { + for (const oldFilter of oldFilters) { + const newFilter = newFilters.find(f => f.key === oldFilter.key); + if (newFilter) { + newFilter.checked = oldFilter.checked; + newFilter.indeterminate = oldFilter.indeterminate; + if (oldFilter.filters && newFilter.filters) copySettings(oldFilter.filters, newFilter.filters); + } + } + } +} + +export function handleCheckedValue(filter: FilterModel) { + if (filter.filters && filter.filters.length) { + filter.checked = filter.filters.reduce((acc, next) => acc && !!next.checked, true); + if (filter.checked) { + filter.indeterminate = false; + } else { + filter.indeterminate = filter.filters.reduce((acc, next) => acc || !!next.checked, false); + } + } else { + filter.indeterminate = false; + } +} + +export function checkFilter(entity: any, filters: FilterModel[], validate: Function, validateArgs: any = [], matchAll: boolean = false) { + const hasChecked = filters.find(f => f.checked); + + if (validateArgs) { + if (!Array.isArray(validateArgs)) { + validateArgs = [validateArgs]; + } + } else { + validateArgs = []; + } + if (!hasChecked) { + return true; + } + + let filterMatched = matchAll; + for (const filter of filters) { + if (filter.checked) { + if (matchAll) { + filterMatched = filterMatched && validate(entity, filter, ...validateArgs); + } else { + filterMatched = filterMatched || validate(entity, filter, ...validateArgs); + } + } + } + + return filterMatched; +} + +export const keyChecker = (key: string) => (entity: any, filter: FilterModel) => entity[key] === filter.key; + +export function getFilteredEntities(entities: T[], filters: FilterGroup[]) { + const filteredEntities: T[] = []; + for (const entity of entities) { + let add = true; + for (const filter of filters) { + add = add && checkFilter(entity, filter.filters, filter.checker, filter.checkerArgs, filter.matchAll); + } + if (add) { + filteredEntities.push(entity); + } + } + return filteredEntities; +} diff --git a/src/lib/filtering/filter.service.ts b/src/lib/filtering/filter.service.ts new file mode 100644 index 0000000..1ebff7d --- /dev/null +++ b/src/lib/filtering/filter.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@angular/core'; +import { processFilters } from './filter-utils'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { FilterGroup } from './models/filter-group.model'; +import { FilterModel } from './models/filter.model'; + +@Injectable() +export class FilterService { + private readonly _filterGroups$ = new BehaviorSubject([]); + private readonly _refresh$ = new Subject(); + readonly filterGroups$ = this._refresh$.pipe( + startWith(''), + switchMap(() => this._filterGroups$.asObservable()) + ); + readonly showResetFilters$ = this._showResetFilters$; + + get filterGroups(): FilterGroup[] { + return Object.values(this._filterGroups$.getValue()); + } + + private get _showResetFilters$(): Observable { + return this.filterGroups$.pipe( + map(all => this._toFlatFilters(all)), + map(f => !!f.find(el => el.checked)), + distinctUntilChanged() + ); + } + + refresh(): void { + this._refresh$.next(); + } + + toggleFilter(filterGroupSlug: string, key: string): void { + const filters = this.filterGroups.find(group => group.slug === filterGroupSlug)?.filters; + if (!filters) return console.error(`Cannot find filter group "${filterGroupSlug}"`); + + let found = filters.find(f => f.key === key); + if (!found) found = filters.map(f => f.filters?.find(ff => ff.key === key))[0]; + if (!found) return console.error(`Cannot find filter with key "${key}" in group "${filterGroupSlug}"`); + + found.checked = !found.checked; + + this.refresh(); + } + + addFilterGroup(value: FilterGroup): void { + const oldFilters = this.getFilterGroup(value.slug)?.filters; + if (!oldFilters) return this._filterGroups$.next([...this.filterGroups, value]); + + value.filters = processFilters(oldFilters, value.filters); + this._filterGroups$.next([...this.filterGroups.filter(f => f.slug !== value.slug), value]); + } + + getFilterGroup(slug: string): FilterGroup | undefined { + return this.filterGroups.find(group => group.slug === slug); + } + + getFilterModels$(filterGroupSlug: string): Observable { + return this.getFilterGroup$(filterGroupSlug).pipe(map(f => f?.filters)); + } + + getFilterGroup$(slug: string): Observable { + return this.filterGroups$.pipe(map(all => all.find(f => f.slug === slug))); + } + + reset(): void { + this.filterGroups.forEach(item => { + item.filters.forEach(child => { + child.checked = false; + child.indeterminate = false; + child.filters?.forEach(f => { + f.checked = false; + f.indeterminate = false; + }); + }); + }); + + this.refresh(); + } + + private _toFlatFilters(entities: FilterGroup[]): FilterModel[] { + const flatChildren = (filters: FilterModel[]) => + (filters ?? []).reduce((acc: FilterModel[], f) => [...acc, ...(f?.filters ?? [])], []); + + return entities.reduce((acc: FilterModel[], f) => [...acc, ...f.filters, ...flatChildren(f.filters)], []); + } +} diff --git a/src/lib/filtering/models/filter-group.model.ts b/src/lib/filtering/models/filter-group.model.ts new file mode 100644 index 0000000..7309ac4 --- /dev/null +++ b/src/lib/filtering/models/filter-group.model.ts @@ -0,0 +1,14 @@ +import { FilterModel } from './filter.model'; +import { TemplateRef } from '@angular/core'; + +export interface FilterGroup { + filters: FilterModel[]; + readonly slug: string; + readonly label?: string; + readonly icon?: string; + readonly filterTemplate?: TemplateRef; + readonly hide?: boolean; + readonly checker: Function; + readonly matchAll?: boolean; + readonly checkerArgs?: any; +} diff --git a/src/lib/filtering/models/filter.model.ts b/src/lib/filtering/models/filter.model.ts new file mode 100644 index 0000000..f015950 --- /dev/null +++ b/src/lib/filtering/models/filter.model.ts @@ -0,0 +1,13 @@ +export interface FilterModel { + readonly key: string; + checked?: boolean; + indeterminate?: boolean; + expanded?: boolean; + matches?: number; + readonly label?: string; + readonly icon?: string; + readonly topLevelFilter?: boolean; + readonly filters?: FilterModel[]; + readonly checker?: (obj?: unknown) => boolean; + readonly required?: boolean; +} diff --git a/src/lib/types/tooltip-positions.type.ts b/src/lib/utils/types/tooltip-positions.type.ts similarity index 100% rename from src/lib/types/tooltip-positions.type.ts rename to src/lib/utils/types/tooltip-positions.type.ts