Filter card

This commit is contained in:
Adina Țeudan 2022-07-21 21:38:17 +03:00
parent 6d9c169b3e
commit ee433cdee2
8 changed files with 302 additions and 233 deletions

View File

@ -0,0 +1,101 @@
<ng-container *ngIf="primaryFilterGroup$ | async as primaryGroup">
<iqser-input-with-action
(click)="$event.stopPropagation()"
*ngIf="primaryGroup.filterceptionPlaceholder"
[(value)]="searchService.searchValue"
[id]="'filterception-' + primaryGroup.slug"
[placeholder]="primaryGroup.filterceptionPlaceholder"
[width]="'full'"
></iqser-input-with-action>
<ng-container *ngTemplateOutlet="filterHeader"></ng-container>
<div *ngIf="primaryFilters$ | async as filters" class="filter-content">
<ng-container
*ngFor="let filter of filters"
[ngTemplateOutletContext]="{
filter: filter,
filterGroup: primaryGroup,
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>
<ng-container
*ngFor="let filter of secondaryGroup.filters"
[ngTemplateOutletContext]="{
filter: filter,
filterGroup: secondaryGroup,
atLeastOneIsExpandable: atLeastOneSecondaryFilterIsExpandable$ | async
}"
[ngTemplateOutlet]="defaultFilterTemplate"
></ng-container>
</div>
</ng-container>
<ng-template #defaultFilterLabelTemplate let-filter="filter">
{{ filter?.label }}
</ng-template>
<ng-template #filterHeader>
<div *ngIf="primaryFilterGroup$ | async as primaryGroup" class="filter-menu-header">
<div [translateParams]="{ count: primaryGroup.filters.length }" [translate]="primaryFiltersLabel" class="all-caps-label"></div>
<div class="actions">
<div
(click)="activatePrimaryFilters(); $event.stopPropagation()"
*ngIf="!primaryGroup.singleSelect"
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>
</ng-template>
<ng-template #defaultFilterTemplate let-atLeastOneIsExpandable="atLeastOneIsExpandable" let-filter="filter" let-filterGroup="filterGroup">
<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="iqser:arrow-right"></mat-icon>
</div>
<div *ngIf="atLeastOneIsExpandable && filter.children?.length === 0" class="arrow-wrapper spacer">&nbsp;</div>
<mat-checkbox
(click)="filterCheckboxClicked($event, filter, filterGroup)"
[checked]="filter.checked"
[id]="'filter-checkbox-' + (filter.id ? filter.id.replaceAll(' ', '-').replaceAll('.', '-') : 'none')"
[indeterminate]="filter.indeterminate"
class="filter-menu-checkbox"
>
<ng-container
[ngTemplateOutletContext]="{ filter: filter }"
[ngTemplateOutlet]="filterGroup.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, filterGroup, filter)" [checked]="child.checked">
<ng-container
[ngTemplateOutletContext]="{ filter: child }"
[ngTemplateOutlet]="filterGroup.filterTemplate ?? defaultFilterLabelTemplate"
></ng-container>
</mat-checkbox>
<ng-container [ngTemplateOutletContext]="{ filter: child }" [ngTemplateOutlet]="actionsTemplate"></ng-container>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,46 @@
@use '../../../assets/styles/common-mixins' as mixins;
.filter-menu-options,
.filter-menu-header {
display: flex;
justify-content: space-between;
padding: 8px 16px 16px 16px;
min-width: var(--filter-card-min-width);
.actions {
display: flex;
> *:not(:last-child) {
margin-right: 8px;
}
}
}
.filter-content {
@include mixins.scroll-bar;
max-height: 570px;
overflow: auto;
}
.filter-menu-options {
margin-top: 8px;
padding: 16px 16px 3px;
}
.filter-options {
background-color: var(--iqser-side-nav);
padding-bottom: 8px;
}
iqser-input-with-action {
padding: 0 8px 8px 8px;
}
::ng-deep .filter-menu-checkbox {
width: 100%;
label {
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,132 @@
import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, TemplateRef } from '@angular/core';
import { combineLatest, Observable, pipe } from 'rxjs';
import { IFilter } from '../models/filter.model';
import { INestedFilter } from '../models/nested-filter.model';
import { IFilterGroup } from '../models/filter-group.model';
import { handleCheckedValue } from '../filter-utils';
import { FilterService } from '../filter.service';
import { SearchService } from '../../search';
import { Filter } from '../models/filter';
import { map } from 'rxjs/operators';
import { shareDistinctLast, shareLast } from '../../utils';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';
const areExpandable = (nestedFilter: INestedFilter) => !!nestedFilter?.children?.length;
const atLeastOneIsExpandable = pipe(
map<IFilterGroup | undefined, boolean>(group => !!group?.filters.some(areExpandable)),
shareDistinctLast(),
);
@Component({
selector: 'iqser-filter-card [primaryFiltersSlug]',
templateUrl: './filter-card.component.html',
styleUrls: ['./filter-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: MAT_CHECKBOX_DEFAULT_OPTIONS,
useValue: {
clickAction: 'noop',
color: 'primary',
},
},
],
})
export class FilterCardComponent implements OnInit {
@Input() primaryFiltersSlug!: string;
@Input() actionsTemplate?: TemplateRef<unknown>;
@Input() secondaryFiltersSlug = '';
@Input() primaryFiltersLabel: string = _('filter-menu.filter-types');
@Input() minWidth = 350;
primaryFilterGroup$!: Observable<IFilterGroup | undefined>;
secondaryFilterGroup$!: Observable<IFilterGroup | undefined>;
primaryFilters$!: Observable<IFilter[] | undefined>;
atLeastOneFilterIsExpandable$?: Observable<boolean>;
atLeastOneSecondaryFilterIsExpandable$?: Observable<boolean>;
constructor(
readonly filterService: FilterService,
readonly searchService: SearchService<Filter>,
private readonly _elementRef: ElementRef,
) {}
private get _primaryFilters$(): Observable<IFilter[]> {
return combineLatest([this.primaryFilterGroup$, this.searchService.valueChanges$]).pipe(
map(([group]) => this.searchService.searchIn(group?.filters ?? [])),
shareLast(),
);
}
ngOnInit() {
this.primaryFilterGroup$ = this.filterService.getGroup$(this.primaryFiltersSlug).pipe(shareLast());
this.secondaryFilterGroup$ = this.filterService.getGroup$(this.secondaryFiltersSlug).pipe(shareLast());
this.primaryFilters$ = this._primaryFilters$;
this.atLeastOneFilterIsExpandable$ = atLeastOneIsExpandable(this.primaryFilterGroup$);
this.atLeastOneSecondaryFilterIsExpandable$ = atLeastOneIsExpandable(this.secondaryFilterGroup$);
(this._elementRef.nativeElement as HTMLElement).style.setProperty('--filter-card-min-width', `${this.minWidth}px`);
}
filterCheckboxClicked($event: MouseEvent, nestedFilter: INestedFilter, filterGroup: IFilterGroup, parent?: INestedFilter): void {
$event.stopPropagation();
if (filterGroup.singleSelect) {
this.deactivateFilters(nestedFilter.id);
}
// 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(exceptedFilterId?: string): void {
this._setFilters(this.primaryFiltersSlug, false, exceptedFilterId);
if (this.secondaryFiltersSlug) {
this._setFilters(this.secondaryFiltersSlug, false, exceptedFilterId);
}
}
toggleFilterExpanded($event: MouseEvent, nestedFilter: INestedFilter): void {
$event.stopPropagation();
// eslint-disable-next-line no-param-reassign
nestedFilter.expanded = !nestedFilter.expanded;
this.filterService.refresh();
}
private _setFilters(filterGroup: string, checked = false, exceptedFilterId?: string) {
const filters = this.filterService.getGroup(filterGroup)?.filters;
filters?.forEach(f => {
if (f.id !== exceptedFilterId) {
// 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

@ -102,6 +102,15 @@ export class FilterService {
);
}
updateFilterGroups(newFilters: IFilterGroup[]): void {
const filters = this.filterGroups.map(oldFilter => {
const newFilter = newFilters.find(f => f.slug === oldFilter.slug);
return newFilter ? newFilter : oldFilter;
});
this.#filterGroups$.next(filters);
}
addSingleFilter(filter: IFilter) {
if (!this.#singleFilters.has(filter.id)) {
return this.#singleFilters.set(filter.id, new BehaviorSubject<IFilter | undefined>(filter));

View File

@ -10,10 +10,11 @@ import { IqserIconsModule } from '../icons';
import { IqserInputsModule } from '../inputs';
import { IqserHelpModeModule } from '../help-mode';
import { SingleFilterComponent } from './single-filter/single-filter.component';
import { FilterCardComponent } from './filter-card/filter-card.component';
const matModules = [MatCheckboxModule, MatMenuModule];
const modules = [TranslateModule, IqserButtonsModule, IqserIconsModule, IqserInputsModule, IqserHelpModeModule];
const components = [QuickFiltersComponent, PopupFilterComponent, SingleFilterComponent];
const components = [QuickFiltersComponent, PopupFilterComponent, SingleFilterComponent, FilterCardComponent];
@NgModule({
declarations: [...components],

View File

@ -5,7 +5,7 @@
[class.disabled]="primaryFiltersDisabled$ | async"
[disabled]="primaryFiltersDisabled$ | async"
[icon]="primaryGroup.icon"
[label]="primaryGroup.label || ('filter-menu.label' | translate)"
[label]="primaryGroup.label.capitalize() || ('filter-menu.label' | translate)"
[matMenuTriggerFor]="filterMenu"
[showDot]="hasActiveFilters$ | async"
id="{{ primaryGroup.slug }}"
@ -17,7 +17,7 @@
[attr.aria-expanded]="expanded$ | async"
[class.disabled]="primaryFiltersDisabled$ | async"
[disabled]="primaryFiltersDisabled$ | async"
[label]="primaryGroup.label || ('filter-menu.label' | translate)"
[label]="primaryGroup.label.capitalize() || ('filter-menu.label' | translate)"
[matMenuTriggerFor]="filterMenu"
[showDot]="hasActiveFilters$ | async"
></iqser-chevron-button>
@ -30,106 +30,12 @@
xPosition="before"
>
<ng-template matMenuContent>
<iqser-input-with-action
(click)="$event.stopPropagation()"
*ngIf="primaryGroup.filterceptionPlaceholder"
[(value)]="searchService.searchValue"
[id]="'filterception-' + primaryGroup.slug"
[placeholder]="primaryGroup.filterceptionPlaceholder"
[width]="'full'"
></iqser-input-with-action>
<ng-container *ngTemplateOutlet="filterHeader"></ng-container>
<div *ngIf="primaryFilters$ | async as filters" class="filter-content">
<ng-container
*ngFor="let filter of filters"
[ngTemplateOutletContext]="{
filter: filter,
filterGroup: primaryGroup,
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>
<ng-container
*ngFor="let filter of secondaryGroup.filters"
[ngTemplateOutletContext]="{
filter: filter,
filterGroup: secondaryGroup,
atLeastOneIsExpandable: atLeastOneSecondaryFilterIsExpandable$ | async
}"
[ngTemplateOutlet]="defaultFilterTemplate"
></ng-container>
</div>
<iqser-filter-card
[actionsTemplate]="actionsTemplate"
[primaryFiltersLabel]="primaryFiltersLabel"
[primaryFiltersSlug]="primaryFiltersSlug"
[secondaryFiltersSlug]="secondaryFiltersSlug"
></iqser-filter-card>
</ng-template>
</mat-menu>
</ng-container>
<ng-template #defaultFilterLabelTemplate let-filter="filter">
{{ filter?.label }}
</ng-template>
<ng-template #filterHeader>
<div *ngIf="primaryFilterGroup$ | async as primaryGroup" class="filter-menu-header">
<div [translateParams]="{ count: primaryGroup.filters.length }" [translate]="primaryFiltersLabel" class="all-caps-label"></div>
<div class="actions">
<div
(click)="activatePrimaryFilters(); $event.stopPropagation()"
*ngIf="!primaryGroup.singleSelect"
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>
</ng-template>
<ng-template #defaultFilterTemplate let-atLeastOneIsExpandable="atLeastOneIsExpandable" let-filter="filter" let-filterGroup="filterGroup">
<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="iqser:arrow-right"></mat-icon>
</div>
<div *ngIf="atLeastOneIsExpandable && filter.children?.length === 0" class="arrow-wrapper spacer">&nbsp;</div>
<mat-checkbox
(click)="filterCheckboxClicked($event, filter, filterGroup)"
[checked]="filter.checked"
[id]="'filter-checkbox-' + (filter.id ? filter.id.replaceAll(' ', '-').replaceAll('.', '-') : 'none')"
[indeterminate]="filter.indeterminate"
class="filter-menu-checkbox"
>
<ng-container
[ngTemplateOutletContext]="{ filter: filter }"
[ngTemplateOutlet]="filterGroup.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, filterGroup, filter)" [checked]="child.checked">
<ng-container
[ngTemplateOutletContext]="{ filter: child }"
[ngTemplateOutlet]="filterGroup.filterTemplate ?? defaultFilterLabelTemplate"
></ng-container>
</mat-checkbox>
<ng-container [ngTemplateOutletContext]="{ filter: child }" [ngTemplateOutlet]="actionsTemplate"></ng-container>
</div>
</div>
</ng-template>

View File

@ -1,50 +1,5 @@
@use '../../../assets/styles/common-mixins' as mixins;
.filter-menu-options,
.filter-menu-header {
display: flex;
justify-content: space-between;
padding: 8px 16px 16px 16px;
min-width: 350px;
.actions {
display: flex;
> *:not(:last-child) {
margin-right: 8px;
}
}
}
.filter-content {
@include mixins.scroll-bar;
max-height: 570px;
overflow: auto;
}
.filter-menu-options {
margin-top: 8px;
padding: 16px 16px 3px;
}
.filter-options {
background-color: var(--iqser-side-nav);
padding-bottom: 8px;
}
::ng-deep .filter-menu-checkbox {
width: 100%;
label {
width: 100%;
height: 100%;
}
}
iqser-input-with-action {
padding: 0 8px 8px 8px;
}
.disabled {
pointer-events: none;
}

View File

@ -1,47 +1,27 @@
import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef } from '@angular/core';
import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';
import { BehaviorSubject, combineLatest, Observable, pipe } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { delay, map } from 'rxjs/operators';
import { any, shareDistinctLast, shareLast } from '../../utils';
import { handleCheckedValue } from '../filter-utils';
import { FilterService } from '../filter.service';
import { IFilterGroup } from '../models/filter-group.model';
import { INestedFilter } from '../models/nested-filter.model';
import { SearchService } from '../../search';
import { Filter, IFilter } from '..';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
const areExpandable = (nestedFilter: INestedFilter) => !!nestedFilter?.children?.length;
const atLeastOneIsExpandable = pipe(
map<IFilterGroup | undefined, boolean>(group => !!group?.filters.some(areExpandable)),
shareDistinctLast(),
);
@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',
},
},
SearchService,
],
providers: [SearchService],
})
export class PopupFilterComponent implements OnInit {
@Input() primaryFiltersSlug!: string;
@Input() actionsTemplate?: TemplateRef<unknown>;
@Input() secondaryFiltersSlug = '';
@Input() primaryFiltersLabel: string = _('filter-menu.filter-types');
@Input() primaryFiltersLabel?: string;
atLeastOneFilterIsExpandable$?: Observable<boolean>;
atLeastOneSecondaryFilterIsExpandable$?: Observable<boolean>;
hasActiveFilters$?: Observable<boolean>;
readonly expanded = new BehaviorSubject<boolean>(false);
readonly expanded$ = this.expanded.asObservable().pipe(delay(200));
@ -82,66 +62,5 @@ export class PopupFilterComponent implements OnInit {
this.hasActiveFilters$ = this._hasActiveFilters$;
this.primaryFiltersDisabled$ = this._primaryFiltersDisabled$;
this.atLeastOneFilterIsExpandable$ = atLeastOneIsExpandable(this.primaryFilterGroup$);
this.atLeastOneSecondaryFilterIsExpandable$ = atLeastOneIsExpandable(this.secondaryFilterGroup$);
}
filterCheckboxClicked($event: MouseEvent, nestedFilter: INestedFilter, filterGroup: IFilterGroup, parent?: INestedFilter): void {
$event.stopPropagation();
if (filterGroup.singleSelect) {
this.deactivateFilters(nestedFilter.id);
}
// 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(exceptedFilterId?: string): void {
this._setFilters(this.primaryFiltersSlug, false, exceptedFilterId);
if (this.secondaryFiltersSlug) {
this._setFilters(this.secondaryFiltersSlug, false, exceptedFilterId);
}
}
toggleFilterExpanded($event: MouseEvent, nestedFilter: INestedFilter): void {
$event.stopPropagation();
// eslint-disable-next-line no-param-reassign
nestedFilter.expanded = !nestedFilter.expanded;
this.filterService.refresh();
}
private _setFilters(filterGroup: string, checked = false, exceptedFilterId?: string) {
const filters = this.filterService.getGroup(filterGroup)?.filters;
filters?.forEach(f => {
if (f.id !== exceptedFilterId) {
// 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();
}
}