add popup filters

This commit is contained in:
Dan Percic 2021-08-19 17:24:03 +03:00
parent bb51a90738
commit feebed2c56
9 changed files with 290 additions and 4 deletions

View File

@ -7,3 +7,4 @@ $warn: #fdbd00 !default;
$white: white !default;
$separator: rgba(226, 228, 233, 0.9) !default;
$quick-filter-border: #d3d5da !default;
$filter-bg: #f4f5f7 !default;

View File

@ -2,6 +2,7 @@ export * from './lib/common-ui.module';
export * from './lib/buttons/icon-button/icon-button.type';
export * from './lib/buttons/icon-button/icon-button.component';
export * from './lib/utils/functions';
export * from './lib/utils/operators';
export * from './lib/utils/auto-unsubscribe.directive';
export * from './lib/utils/pipes/humanize.pipe';
export * from './lib/utils/types/utility-types';
@ -16,6 +17,7 @@ export * from './lib/filtering/filter.service';
export * from './lib/filtering/models/filter.model';
export * from './lib/filtering/models/filter-group.model';
export * from './lib/filtering/models/nested-filter.model';
export * from './lib/filtering/popup-filter/popup-filter.component';
export * from './lib/filtering/quick-filters/quick-filters.component';
export * from './lib/sorting/sort-by.pipe';
export * from './lib/sorting/sorting.service';

View File

@ -6,6 +6,8 @@ import { DomSanitizer } from '@angular/platform-browser';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { FormsModule } from '@angular/forms';
import { MatMenuModule } from '@angular/material/menu';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { IconButtonComponent } from './buttons/icon-button/icon-button.component';
import { ChevronButtonComponent } from './buttons/chevron-button/chevron-button.component';
import { CircleButtonComponent } from './buttons/circle-button/circle-button.component';
@ -18,16 +20,25 @@ import { TableHeaderComponent } from './tables/table-header/table-header.compone
import { SyncWidthDirective } from './tables/sync-width.directive';
import { StatusBarComponent } from './misc/status-bar/status-bar.component';
import { EditableInputComponent } from './inputs/editable-input/editable-input.component';
import { PopupFilterComponent } from './filtering/popup-filter/popup-filter.component';
const buttons = [IconButtonComponent, ChevronButtonComponent, CircleButtonComponent];
const inputs = [RoundCheckboxComponent, EditableInputComponent];
const matModules = [MatIconModule, MatButtonModule, MatTooltipModule];
const matModules = [MatIconModule, MatButtonModule, MatTooltipModule, MatMenuModule, MatCheckboxModule];
const modules = [...matModules, FormsModule, TranslateModule];
const components = [...buttons, ...inputs, TableColumnNameComponent, QuickFiltersComponent, TableHeaderComponent, StatusBarComponent];
const components = [
...buttons,
...inputs,
TableColumnNameComponent,
QuickFiltersComponent,
PopupFilterComponent,
TableHeaderComponent,
StatusBarComponent
];
const utils = [SortByPipe, HumanizePipe, SyncWidthDirective];

View File

@ -4,6 +4,7 @@ import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'
import { processFilters, toFlatFilters } from './filter-utils';
import { FilterGroup } from './models/filter-group.model';
import { NestedFilter } from './models/nested-filter.model';
import { get } from '../utils/operators';
@Injectable()
export class FilterService {
@ -61,7 +62,7 @@ export class FilterService {
}
getGroup$(slug: string): Observable<FilterGroup | undefined> {
return this.filterGroups$.pipe(map(all => all.find(f => f.slug === slug)));
return this.filterGroups$.pipe(get(group => group.slug === slug));
}
reset(): void {

View File

@ -0,0 +1,109 @@
<ng-container *ngIf="primaryFilterGroup$ | async as primaryGroup">
<iqser-icon-button
*ngIf="primaryGroup.icon"
[icon]="primaryGroup.icon"
[label]="primaryGroup.label || ('filter-menu.label' | translate)"
[matMenuTriggerFor]="filterMenu"
[showDot]="hasActiveFilters$ | async"
[attr.aria-expanded]="expanded$ | async"
></iqser-icon-button>
<iqser-chevron-button
*ngIf="!primaryGroup.icon"
[label]="primaryGroup.label || ('filter-menu.label' | translate)"
[matMenuTriggerFor]="filterMenu"
[showDot]="hasActiveFilters$ | async"
[attr.aria-expanded]="expanded$ | async"
></iqser-chevron-button>
<mat-menu
#filterMenu="matMenu"
(close)="expanded.next(false)"
[class]="(secondaryFilterGroup$ | async)?.filters.length > 0 ? 'padding-bottom-0' : ''"
xPosition="before"
>
<ng-template matMenuContent>
<div class="filter-menu-header">
<div class="all-caps-label" translate="filter-menu.filter-types"></div>
<div class="actions">
<div
(click)="activatePrimaryFilters(); $event.stopPropagation()"
class="all-caps-label primary pointer"
translate="actions.all"
></div>
<div
(click)="deactivateFilters(); $event.stopPropagation()"
class="all-caps-label primary pointer"
translate="actions.none"
></div>
</div>
</div>
<div class="filter-content">
<ng-container
*ngFor="let filter of primaryGroup.filters"
[ngTemplateOutletContext]="{
filter: filter,
atLeastOneIsExpandable: atLeastOneFilterIsExpandable$ | async
}"
[ngTemplateOutlet]="defaultFilterTemplate"
></ng-container>
</div>
<div *ngIf="secondaryFilterGroup$ | async as secondaryGroup" class="filter-options">
<div class="filter-menu-options">
<div class="all-caps-label" translate="filter-menu.filter-options"></div>
</div>
<div *ngFor="let filter of secondaryGroup.filters">
<ng-container
[ngTemplateOutletContext]="{
filter: filter,
atLeastOneIsExpandable: atLeastOneSecondaryFilterIsExpandable$ | async
}"
[ngTemplateOutlet]="defaultFilterTemplate"
></ng-container>
</div>
</div>
</ng-template>
</mat-menu>
<ng-template #defaultFilterLabelTemplate let-filter="filter">
{{ filter?.label }}
</ng-template>
<ng-template #defaultFilterTemplate let-atLeastOneIsExpandable="atLeastOneIsExpandable" let-filter="filter">
<div (click)="toggleFilterExpanded($event, filter)" class="mat-menu-item flex">
<div *ngIf="filter.children?.length > 0" class="arrow-wrapper">
<mat-icon *ngIf="filter.expanded" color="accent" svgIcon="iqser:arrow-down"></mat-icon>
<mat-icon *ngIf="!filter.expanded" color="accent" svgIcon="red:arrow-right"></mat-icon>
</div>
<div *ngIf="atLeastOneIsExpandable && filter.children?.length === 0" class="arrow-wrapper spacer">&nbsp;</div>
<mat-checkbox
(click)="filterCheckboxClicked($event, filter)"
[checked]="filter.checked"
[indeterminate]="filter.indeterminate"
class="filter-menu-checkbox"
>
<ng-container
[ngTemplateOutletContext]="{ filter: filter }"
[ngTemplateOutlet]="primaryGroup.filterTemplate ?? defaultFilterLabelTemplate"
></ng-container>
</mat-checkbox>
<ng-container [ngTemplateOutletContext]="{ filter: filter }" [ngTemplateOutlet]="actionsTemplate"></ng-container>
</div>
<div *ngIf="filter.children?.length && filter.expanded">
<div (click)="$event.stopPropagation()" *ngFor="let child of filter.children" class="padding-left mat-menu-item">
<mat-checkbox (click)="filterCheckboxClicked($event, child, filter)" [checked]="child.checked">
<ng-container
[ngTemplateOutletContext]="{ filter: child }"
[ngTemplateOutlet]="primaryGroup.filterTemplate ?? defaultFilterLabelTemplate"
></ng-container>
</mat-checkbox>
<ng-container [ngTemplateOutletContext]="{ filter: child }" [ngTemplateOutlet]="actionsTemplate"></ng-container>
</div>
</div>
</ng-template>
</ng-container>

View File

@ -0,0 +1,41 @@
@import '../../../assets/styles/variables';
.filter-menu-options,
.filter-menu-header {
display: flex;
justify-content: space-between;
padding: 8px 16px 16px 16px;
width: 350px;
.actions {
display: flex;
> *:not(:last-child) {
margin-right: 8px;
}
}
}
.filter-content {
max-height: 570px;
overflow: auto;
}
.filter-menu-options {
margin-top: 8px;
padding: 16px 16px 3px;
}
.filter-options {
background-color: $filter-bg;
padding-bottom: 8px;
}
::ng-deep .filter-menu-checkbox {
width: 100%;
label {
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,111 @@
import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef } from '@angular/core';
import { FilterGroup, FilterService, handleCheckedValue, NestedFilter } from '@iqser/common-ui';
import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';
import { BehaviorSubject, combineLatest, Observable, pipe } from 'rxjs';
import { delay, distinctUntilChanged, map, shareReplay } from 'rxjs/operators';
import { any } from '../../utils/operators';
const areExpandable = (nestedFilter: NestedFilter) => !!nestedFilter?.children?.length;
const atLeastOneIsExpandable = pipe(
map<FilterGroup | undefined, boolean>(group => !!group?.filters.some(areExpandable)),
distinctUntilChanged(),
shareReplay()
);
@Component({
selector: 'iqser-popup-filter [primaryFiltersSlug]',
templateUrl: './popup-filter.component.html',
styleUrls: ['./popup-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: MAT_CHECKBOX_DEFAULT_OPTIONS,
useValue: {
clickAction: 'noop',
color: 'primary'
}
}
]
})
export class PopupFilterComponent implements OnInit {
@Input() primaryFiltersSlug!: string;
@Input() actionsTemplate?: TemplateRef<unknown>;
@Input() secondaryFiltersSlug = '';
atLeastOneFilterIsExpandable$?: Observable<boolean>;
atLeastOneSecondaryFilterIsExpandable$?: Observable<boolean>;
hasActiveFilters$?: Observable<boolean>;
readonly expanded = new BehaviorSubject<boolean>(false);
readonly expanded$ = this.expanded.asObservable().pipe(delay(200));
primaryFilterGroup$!: Observable<FilterGroup | undefined>;
secondaryFilterGroup$!: Observable<FilterGroup | undefined>;
constructor(readonly filterService: FilterService) {}
ngOnInit(): void {
this.primaryFilterGroup$ = this.filterService.getGroup$(this.primaryFiltersSlug);
this.secondaryFilterGroup$ = this.filterService.getGroup$(this.secondaryFiltersSlug);
this.hasActiveFilters$ = this._hasActiveFilters$;
this.atLeastOneFilterIsExpandable$ = atLeastOneIsExpandable(this.primaryFilterGroup$);
this.atLeastOneSecondaryFilterIsExpandable$ = atLeastOneIsExpandable(this.secondaryFilterGroup$);
}
filterCheckboxClicked($event: MouseEvent, nestedFilter: NestedFilter, parent?: NestedFilter): void {
$event.stopPropagation();
// eslint-disable-next-line no-param-reassign
nestedFilter.checked = !nestedFilter.checked;
if (parent) {
handleCheckedValue(parent);
} else {
// eslint-disable-next-line no-param-reassign
if (nestedFilter.indeterminate) nestedFilter.checked = false;
// eslint-disable-next-line no-param-reassign
nestedFilter.indeterminate = false;
// eslint-disable-next-line no-return-assign,no-param-reassign
nestedFilter.children?.forEach(f => (f.checked = nestedFilter.checked));
}
this.filterService.refresh();
}
activatePrimaryFilters(): void {
this._setFilters(this.primaryFiltersSlug, true);
}
deactivateFilters(): void {
this._setFilters(this.primaryFiltersSlug);
if (this.secondaryFiltersSlug) this._setFilters(this.secondaryFiltersSlug);
}
toggleFilterExpanded($event: MouseEvent, nestedFilter: NestedFilter): void {
$event.stopPropagation();
// eslint-disable-next-line no-param-reassign
nestedFilter.expanded = !nestedFilter.expanded;
this.filterService.refresh();
}
private get _hasActiveFilters$() {
return combineLatest([this.primaryFilterGroup$, this.secondaryFilterGroup$]).pipe(
map(([primary, secondary]) => [...(primary?.filters || []), ...(secondary?.filters || [])]),
any(f => f.checked || !!f.indeterminate),
distinctUntilChanged()
);
}
private _setFilters(filterGroup: string, checked = false) {
const filters = this.filterService.getGroup(filterGroup)?.filters;
filters?.forEach(f => {
// eslint-disable-next-line no-param-reassign
f.checked = checked;
// eslint-disable-next-line no-param-reassign
f.indeterminate = false;
// eslint-disable-next-line no-return-assign,no-param-reassign
f.children?.forEach(ff => (ff.checked = checked));
});
this.filterService.refresh();
}
}

View File

@ -1,4 +1,4 @@
@import '../../../assets/styles/common';
@import '../../../assets/styles/variables';
:host {
display: flex;

View File

@ -0,0 +1,10 @@
import { map } from 'rxjs/operators';
import { OperatorFunction } from 'rxjs';
export function get<T>(predicate: (value: T, index: number) => boolean): OperatorFunction<T[], T | undefined> {
return map(entities => entities.find(predicate));
}
export function any<T>(predicate: (value: T, index: number) => boolean): OperatorFunction<T[], boolean> {
return map(entities => entities.some(predicate));
}