#932 - Kundenfilter

This commit is contained in:
Lorenz Hilpert
2020-10-29 17:06:58 +01:00
parent 32b1a750d4
commit 8be789062a
22 changed files with 588 additions and 742 deletions

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { StringDictionary } from '@cmf/core';
import { AutocompleteDTO, CustomerInfoDTO, CustomerService, InputDTO } from '@swagger/crm';
import { PagedResult, Result } from 'apps/domain/defs/src/public-api';
import { Observable } from 'rxjs';
@@ -7,22 +8,23 @@ import { Observable } from 'rxjs';
export class CrmCustomerService {
constructor(private customerService: CustomerService) {}
complete(queryString: string): Observable<Result<AutocompleteDTO[]>> {
complete(queryString: string, filter?: StringDictionary<string>): Observable<Result<AutocompleteDTO[]>> {
return this.customerService.CustomerCustomerAutocomplete({
input: queryString,
filter: {},
filter,
take: 5,
});
}
getCustomers(
queryString: string,
options: { take?: number; skip?: number } = { take: 20, skip: 0 }
options: { take?: number; skip?: number; filter?: StringDictionary<string> } = { take: 20, skip: 0 }
): Observable<PagedResult<CustomerInfoDTO>> {
return this.customerService.CustomerListCustomers({
input: { qs: queryString },
take: options.take,
skip: options.skip,
filter: options.filter,
});
}

View File

@@ -1,20 +1,20 @@
<button class="filter" [class.active]="true" (click)="filterActive = true">
<button class="filter" [class.active]="true" (click)="showFilters = true">
<ui-icon size="22px" icon="filter_alit"></ui-icon>
<span class="label">Filter</span>
</button>
<router-outlet></router-outlet>
<div class="filter-overlay" [class.active]="filterActive">
<div class="filter-overlay" [class.active]="showFilters">
<div class="filter-content">
<div class="filter-close-right">
<button class="filter-close" (click)="filterActive = false">
<button class="filter-close" (click)="showFilters = false">
<ui-icon size="20px" icon="close"></ui-icon>
</button>
</div>
<h2 class="filter-header">
Filter
</h2>
<page-customer-search-filter></page-customer-search-filter>
<page-customer-search-filter *ngIf="showFilters"> </page-customer-search-filter>
</div>
</div>

View File

@@ -19,8 +19,6 @@ import { CustomerSearch } from './customer-search.service';
],
})
export class CustomerSearchComponent extends CustomerSearch implements OnInit {
filterActive = false;
constructor(
protected customerSearch: CrmCustomerService,
zone: NgZone,

View File

@@ -1,24 +1,16 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, NgZone, OnDestroy, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { EnvironmentService } from '@core/environment';
import { CrmCustomerService } from '@domain/crm';
import { PagedResult } from '@domain/defs';
import { AutocompleteDTO, CustomerInfoDTO } from '@swagger/crm';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import {
catchError,
map,
shareReplay,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import { catchError, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { CustomerSearchType, QueryFilter, ResultState } from './defs';
import { Filter, UiFilterMappingService } from '@ui/filter';
import { NativeContainerService } from 'native-container';
import { StringDictionary } from '@cmf/core';
@Injectable()
export abstract class CustomerSearch implements OnInit, OnDestroy {
@@ -32,8 +24,6 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
return this.queryFilter$.value;
}
availableFilters$: Observable<Filter[]>;
autocompleteResult$: Observable<AutocompleteDTO[]>;
inputChange$ = new Subject<string>();
@@ -53,11 +43,7 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
private get numberOfResultsFetched(): number {
if (
!this.searchResult$ ||
!this.searchResult$.value ||
!Array.isArray(this.searchResult$.value.result)
) {
if (!this.searchResult$ || !this.searchResult$.value || !Array.isArray(this.searchResult$.value.result)) {
return 0;
}
@@ -65,17 +51,15 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
private get totalResults(): number {
if (
!this.searchResult$ ||
!this.searchResult$.value ||
!this.searchResult$.value.hits
) {
if (!this.searchResult$ || !this.searchResult$.value || !this.searchResult$.value.hits) {
return 0;
}
return this.searchResult$.value.hits;
}
showFilters = false;
constructor(
private zone: NgZone,
private router: Router,
@@ -94,17 +78,19 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
private initAvailableFilters() {
this.availableFilters$ = this.customerSearch.getFilters().pipe(
map((result) => {
if (result.error) {
} else {
return result.result.map((input) =>
this.filterMapping.fromInputDto(input)
);
}
}),
shareReplay()
);
this.customerSearch
.getFilters()
.pipe(
map((result) => {
if (result.error) {
} else {
const filters = result.result.map((input) => this.filterMapping.fromInputDto(input));
this.setFilter(filters);
return filters;
}
})
)
.subscribe();
}
setQueryParams(queryParams: Params) {
@@ -125,16 +111,26 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
startSearch() {
if (
!this.queryFilter.query ||
(!this.queryFilter.query.length && this.environmentService.isMobile())
) {
if (!this.queryFilter.query || (!this.queryFilter.query.length && this.environmentService.isMobile())) {
return this.scan();
}
return this.search();
}
getSelecteFiltersAsDictionary(): StringDictionary<string> {
const dict: StringDictionary<string> = {};
for (const filter of this.queryFilter.filters) {
const selected = filter.options.filter((o) => o.selected);
if (selected.length > 0) {
dict[filter.key] = selected.map((o) => o.id).join(';');
}
}
return dict;
}
search(
options: { isNewSearch: boolean; take?: number } = {
isNewSearch: true,
@@ -157,15 +153,14 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
? result.hits - this.numberOfResultsFetched
: options.take;
this.searchResult$.next(
this.addLoadingProducts(this.searchResult, effectiveTake)
);
this.searchResult$.next(this.addLoadingProducts(this.searchResult, effectiveTake));
}),
switchMap(() => {
return this.customerSearch
.getCustomers(this.queryFilter.query, {
skip: options.isNewSearch ? 0 : this.numberOfResultsFetched,
take: options.take,
filter: this.getSelecteFiltersAsDictionary(),
})
.pipe(
takeUntil(this.destroy$),
@@ -190,9 +185,7 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
})
)
.subscribe((r) => {
this.searchResult$.next(
this.removeLoadingProducts(this.searchResult)
);
this.searchResult$.next(this.removeLoadingProducts(this.searchResult));
if (options.isNewSearch) {
this.searchResult$.next({
...r,
@@ -208,12 +201,13 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
} else {
this.searchResult$.next({
...r,
result: [
...this.searchResult$.value.result,
...r.result.map((a) => ({ ...a, loaded: true })),
],
result: [...this.searchResult$.value.result, ...r.result.map((a) => ({ ...a, loaded: true }))],
});
}
if (r.hits > 0) {
this.showFilters = false;
}
});
}
}
@@ -255,7 +249,7 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
if (!hasChanges) {
if (current.filters.length !== next.filters.length) {
if (current.filters !== next.filters) {
hasChanges = true;
}
}
@@ -271,26 +265,18 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
): PagedResult<CustomerSearchType> {
return {
...currentResult,
result: [
...currentResult.result,
...new Array(numberOfLoadingProducts).fill({ loaded: false }),
],
result: [...currentResult.result, ...new Array(numberOfLoadingProducts).fill({ loaded: false })],
};
}
private removeLoadingProducts(
currentResult: PagedResult<CustomerSearchType>
): PagedResult<CustomerSearchType> {
private removeLoadingProducts(currentResult: PagedResult<CustomerSearchType>): PagedResult<CustomerSearchType> {
return {
...currentResult,
result: currentResult.result.filter((r) => !!r.loaded),
};
}
private shouldFetchNewProducts(options: {
isNewSearch: boolean;
take?: number;
}): boolean {
private shouldFetchNewProducts(options: { isNewSearch: boolean; take?: number }): boolean {
if (options.isNewSearch) {
return true;
}

View File

@@ -1,11 +1,19 @@
<page-customer-searchbox></page-customer-searchbox>
<page-customer-searchbox (searchStarted)="applyFilters(false)"></page-customer-searchbox>
<ng-container *ngIf="this.search.availableFilters$ | async as filters">
<ui-selected-filter-options [value]="selectedFilters"></ui-selected-filter-options>
<ui-selected-filter-options [(value)]="selectedFilters" (valueChange)="updateFilter($event)"> </ui-selected-filter-options>
<ui-filter-group [value]="search.queryFilter.filters" (valueChanged)="updateFilter($event)"> </ui-filter-group>
</ng-container>
<ui-filter-group [(value)]="selectedFilters" (valueChange)="updateFilter($event)"></ui-filter-group>
<div class="sticky-cta-wrapper">
<button class="apply-filter" (click)="applyFilters()">Filter anwenden</button>
<button
class="apply-filter"
[class.loading]="search.searchState === 'fetching'"
(click)="applyFilters()"
[disabled]="search.searchState === 'fetching'"
>
<span *ngIf="search.searchState !== 'fetching'">
Filter anwenden
</span>
<ui-icon class="spin" *ngIf="search.searchState === 'fetching'" icon="loading" size="26px"></ui-icon>
</button>
</div>

View File

@@ -9,4 +9,22 @@
button.apply-filter {
@apply border-none bg-brand text-white rounded-full py-cta-y-l px-cta-x-l text-cta-l font-bold;
min-width: 201px;
}
button.apply-filter.loading {
padding-top: 15px;
padding-bottom: 17px;
}
ui-selected-filter-options {
@apply my-px-8;
}
ui-icon {
@apply inline-flex;
}
.spin {
@apply animate-spin;
}

View File

@@ -1,9 +1,4 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, Input, OnDestroy } from '@angular/core';
import { Filter } from '@ui/filter';
import { CustomerSearch } from '../customer-search.service';
@@ -13,24 +8,32 @@ import { CustomerSearch } from '../customer-search.service';
styleUrls: ['search-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomerSearchFilterComponent implements OnInit {
export class CustomerSearchFilterComponent implements OnInit, OnDestroy {
selectedFilters: Filter[];
initialFilter: Filter[];
constructor(public search: CustomerSearch, private cdr: ChangeDetectorRef) {}
ngOnInit() {
this.selectedFilters = this.search.queryFilter.filters;
this.initialFilter = this.search.queryFilter.filters;
}
applyFilters() {
// 1. Filter setzen
// 2. Suchen
// 3. Weiterleitung
ngOnDestroy() {
this.search.setFilter(this.initialFilter);
}
applyFilters(search: boolean = true) {
this.search.setFilter(this.selectedFilters);
this.initialFilter = this.selectedFilters;
if (search) {
this.search.search({ isNewSearch: true });
}
}
updateFilter(filters: Filter[]) {
this.selectedFilters = [...filters.map((c) => ({ ...c }))];
this.search.setFilter(filters);
this.cdr.markForCheck();
console.log(filters);
}
}

View File

@@ -7,7 +7,7 @@
uiSearchboxInput
placeholder="Name, E-Mail, Kundenkartennummer, ..."
(inputChange)="search.inputChange$.next($event)"
(keydown.enter)="search.startSearch(); autocomplete.close()"
(keydown.enter)="startSearch(); autocomplete.close()"
/>
<ui-searchbox-warning *ngIf="searchState === 'empty'">
Keine Suchergebnisse
@@ -19,7 +19,7 @@
[class.scan]="isMobile && !input?.value?.length"
type="submit"
uiSearchboxSearchButton
(click)="search.startSearch()"
(click)="startSearch()"
[disabled]="searchState === 'fetching'"
>
<ui-icon class="spin" *ngIf="searchState === 'fetching'" icon="spinner" size="32px"></ui-icon>

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy, ViewChild, Output, EventEmitter } from '@angular/core';
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy, ViewChild, EventEmitter, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import { EnvironmentService } from '@core/environment';
import { CrmCustomerService } from '@domain/crm';
import { AutocompleteDTO } from '@swagger/crm';
import { UiFilterMappingService } from '@ui/filter';
import { UiSearchboxAutocompleteComponent } from '@ui/searchbox';
import { Observable, of, Subject } from 'rxjs';
import { catchError, delay, filter, map, shareReplay, skip, switchMap, takeUntil, tap } from 'rxjs/operators';
@@ -28,6 +29,9 @@ export class CustomerSearchboxComponent implements OnInit, OnDestroy {
queryControl = new FormControl(this.search.queryFilter.query || '');
@Output()
searchStarted = new EventEmitter<void>();
constructor(
public search: CustomerSearch,
private environmentService: EnvironmentService,
@@ -35,6 +39,11 @@ export class CustomerSearchboxComponent implements OnInit, OnDestroy {
private crmCustomerService: CrmCustomerService
) {}
startSearch() {
this.search.startSearch();
this.searchStarted.emit();
}
ngOnInit() {
this.detectDevice();
this.initAutocomplete();
@@ -53,7 +62,7 @@ export class CustomerSearchboxComponent implements OnInit, OnDestroy {
takeUntil(this.destroy$),
switchMap((queryString) => {
if (queryString.length >= 3) {
return this.crmCustomerService.complete(queryString).pipe(
return this.crmCustomerService.complete(queryString, this.search.getSelecteFiltersAsDictionary()).pipe(
map((response) => response.result),
catchError(() => {
// TODO Dialog Service impl. fur Anzeige der Fehlermeldung
@@ -65,7 +74,6 @@ export class CustomerSearchboxComponent implements OnInit, OnDestroy {
}
}),
tap((results) => {
console.log({ results, autocomplete: this.autocomplete });
if (this.autocomplete) {
if (results.length > 0) {
this.autocomplete.open();

View File

@@ -3,26 +3,19 @@
<button
*ngFor="let filter of value; trackBy: trackByKeyOrName; let first = first"
type="button"
(click)="selected = filter"
(click)="active = filter"
class="isa-btn isa-btn-block isa-btn-default-bg isa-btn-large"
[class.customer]="module === 'Customer'"
[class.isa-mt-10]="!first"
[class.is-active]="filter === selected"
[class.isa-font-weight-emphasis]="filter !== selected"
[class.isa-font-weight-bold]="filter === selected"
[class.is-active]="filter?.key === active?.key"
[class.isa-font-weight-emphasis]="filter?.key !== active?.key"
[class.isa-font-weight-bold]="filter?.key === active?.key"
>
<span>{{ filter.name }}</span>
<ui-icon size="17px" icon="arrow_head"></ui-icon>
</button>
</div>
<div class="filter-content isa-ml-10" [ngSwitch]="selected?.type">
<ui-select-filter
*ngSwitchCase="'select'"
[options]="selected.options"
[module]="module"
[max]="selected.max"
(optionsChanged)="emitOnChange($event)"
>
</ui-select-filter>
<div class="filter-content isa-ml-10" [ngSwitch]="active?.type">
<ui-select-filter *ngSwitchCase="'select'" [filter]="active" [module]="module"> </ui-select-filter>
</div>
</div>

View File

@@ -11,9 +11,10 @@ import {
SimpleChanges,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { isArray } from '@utils/common';
import { Filter, SelectFilterOption } from '../../../models';
import {} from '@utils/common';
import { Filter } from '../../../models';
import { cloneFilter } from '../../../utils';
import { FilterGroup } from '../../filter-group';
@Component({
selector: 'ui-filter-group',
@@ -25,24 +26,42 @@ import { cloneFilter } from '../../../utils';
useExisting: forwardRef(() => UiFilterGroupComponent),
multi: true,
},
{
provide: FilterGroup,
useExisting: forwardRef(() => UiFilterGroupComponent),
},
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterGroupComponent implements OnInit, ControlValueAccessor, OnChanges {
export class UiFilterGroupComponent extends FilterGroup implements OnInit, ControlValueAccessor, OnChanges {
@Input()
disabled: boolean;
@Input()
value: Filter[];
@Output()
valueChanged = new EventEmitter<Filter[]>();
@Output()
valueGroupChanged = new EventEmitter<Filter>();
private _value: Filter[];
@Input()
selected: Filter;
get value() {
return this._value;
}
set value(val) {
this._value = val.map(cloneFilter);
this.updateView.emit();
}
@Output()
valueChange = new EventEmitter<Filter[]>();
@Input()
active: Filter;
// @Input()
// value: Filter[];
// @Output()
// valueChanged = new EventEmitter<Filter[]>();
// @Output()
// valueGroupChanged = new EventEmitter<Filter>();
@Input() module: 'Customer' | 'Branch' = 'Branch';
@@ -52,22 +71,32 @@ export class UiFilterGroupComponent implements OnInit, ControlValueAccessor, OnC
trackByKeyOrName = (filter: Filter) => filter.key || filter.name;
constructor(private cdr: ChangeDetectorRef) {}
constructor(protected cdr: ChangeDetectorRef) {
super();
}
ngOnChanges({ value, selected }: SimpleChanges): void {
if (value && isArray(value.currentValue)) {
this.value = value.currentValue.map(cloneFilter);
}
if (value || selected) {
console.log({ value, selected });
const selectedFilter = this.getSelectedFilter();
if (selectedFilter) {
this.selected = selectedFilter.filter;
}
ngOnChanges({ value }: SimpleChanges) {
if (value && value.isFirstChange() && !this.active) {
this.active = this.value[0];
}
}
ngOnInit() {}
// ngOnChanges({ value, selected }: SimpleChanges): void {
// if (value && isArray(value.currentValue)) {
// this.value = value.currentValue.map(cloneFilter);
// }
// if (value || selected) {
// console.log({ value, selected });
// const selectedFilter = this.getSelectedFilter();
// if (selectedFilter) {
// this.selected = selectedFilter.filter;
// }
// }
// }
ngOnInit() {
this.updateView.subscribe((_) => this.cdr.markForCheck());
}
writeValue(obj: Filter[]): void {
if (obj !== this.value) {
@@ -88,26 +117,26 @@ export class UiFilterGroupComponent implements OnInit, ControlValueAccessor, OnC
this.disabled = isDisabled;
}
emitOnChange(changedOptionGroup: SelectFilterOption[]) {
const selected = this.getSelectedFilter();
selected.filter.options = changedOptionGroup;
// emitOnChange(changedOptionGroup: SelectFilterOption[]) {
// const selected = this.getSelectedFilter();
// selected.filter.options = changedOptionGroup;
if (typeof this.onChange === 'function') {
this.onChange(this.value);
}
this.valueChanged.emit(this.value);
this.valueGroupChanged.emit(selected.filter);
this.cdr.markForCheck();
}
// if (typeof this.onChange === 'function') {
// this.onChange(this.value);
// }
// this.valueChanged.emit(this.value);
// this.valueGroupChanged.emit(selected.filter);
// this.cdr.markForCheck();
// }
getSelectedFilter() {
if (this.value && this.selected) {
const index = this.value.findIndex((f) => this.selected.key === f.key || this.selected.name === f.name);
const filter = this.value[index];
// getSelectedFilter() {
// if (this.value && this.selected) {
// const index = this.value.findIndex((f) => this.selected.key === f.key || this.selected.name === f.name);
// const filter = this.value[index];
return { index, filter };
}
// return { index, filter };
// }
return undefined;
}
// return undefined;
// }
}

View File

@@ -7,9 +7,11 @@ import {
ChangeDetectorRef,
OnChanges,
SimpleChanges,
OnInit,
} from '@angular/core';
import { SelectFilterOption } from '../../../models';
import { SelectFilter, SelectFilterOption } from '../../../models';
import { cloneSelectFilterOption } from '../../../utils';
import { FilterGroup } from '../../filter-group';
@Component({
selector: 'ui-select-filter-option',
@@ -17,22 +19,28 @@ import { cloneSelectFilterOption } from '../../../utils';
styleUrls: ['select-filter-option.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiSelectFilterOptionComponent implements OnChanges {
export class UiSelectFilterOptionComponent implements OnInit, OnChanges {
@Input()
filter: SelectFilter;
@Input()
option: SelectFilterOption;
@Output()
optionChanged = new EventEmitter<SelectFilterOption>();
@Input() module: 'Customer' | 'Branch' = 'Branch';
constructor(private cdr: ChangeDetectorRef) {}
constructor(private cdr: ChangeDetectorRef, private filterGroup: FilterGroup) {}
ngOnInit() {
this.filterGroup.updateView.subscribe((_) => {
this.cdr.markForCheck();
});
}
ngOnChanges({ option }: SimpleChanges): void {
if (option) {
this.option = cloneSelectFilterOption(this.option);
this.cdr.markForCheck();
}
// if (option) {
// this.option = cloneSelectFilterOption(this.option);
// this.cdr.markForCheck();
// }
}
toggle(
@@ -42,45 +50,42 @@ export class UiSelectFilterOptionComponent implements OnChanges {
updateChildren: true,
}
) {
this.option.selected = typeof val === 'boolean' ? val : !this.option.selected;
if (updateChildren && Array.isArray(this.option.options)) {
const selectFn = (option: SelectFilterOption): SelectFilterOption => {
return {
...option,
selected: this.option.selected,
options: Array.isArray(option.options) ? option.options.map(selectFn) : option.options,
};
};
this.option.options = this.option.options.map(selectFn);
}
// tslint:disable-next-line: no-unused-expression
emitChanges && this.onChange();
this.filterGroup.toggleOption(this.filter, this.option);
this.cdr.markForCheck();
// this.option.selected = typeof val === 'boolean' ? val : !this.option.selected;
// if (updateChildren && Array.isArray(this.option.options)) {
// const selectFn = (option: SelectFilterOption): SelectFilterOption => {
// return {
// ...option,
// selected: this.option.selected,
// options: Array.isArray(option.options) ? option.options.map(selectFn) : option.options,
// };
// };
// this.option.options = this.option.options.map(selectFn);
// }
// // tslint:disable-next-line: no-unused-expression
// emitChanges && this.onChange();
// this.cdr.markForCheck();
}
onChange() {
this.optionChanged.emit(this.option);
// this.optionChanged.emit(this.option);
}
toggleExpanded() {
this.option.expanded = !this.option.expanded;
this.onChange();
this.cdr.markForCheck();
// this.option.expanded = !this.option.expanded;
// this.onChange();
// this.cdr.markForCheck();
}
childChanged(option: SelectFilterOption, index: number) {
this.option.options[index] = option;
const selectedTrueChilds = this.option.options.filter((child) => !!child.selected);
const selected = selectedTrueChilds.length === this.option.options.length;
if (this.option.selected !== selected) {
this.option.selected = selected;
}
this.onChange();
this.cdr.markForCheck();
// this.option.options[index] = option;
// const selectedTrueChilds = this.option.options.filter((child) => !!child.selected);
// const selected = selectedTrueChilds.length === this.option.options.length;
// if (this.option.selected !== selected) {
// this.option.selected = selected;
// }
// this.onChange();
// this.cdr.markForCheck();
}
}

View File

@@ -5,17 +5,16 @@
class="isa-btn isa-p-0 isa-font-weight-emphasis"
[class.isa-font-color-customer]="module === 'Customer'"
[class.isa-font-lightgrey]="module !== 'Customer'"
*ngIf="!max"
*ngIf="!filter?.max"
(click)="selectAll()"
>
{{ (allSelected$ | async) ? 'Alle entfernen' : 'Alle auswählen' }}
{{ filterGroup.isAllSelected(filter) ? 'Alle entfernen' : 'Alle auswählen' }}
</button>
</div>
<div class="select-filter-options isa-mt-10">
<div class="select-filter-options-content" (scroll)="updateScrollButton()" #optionsContainer (cdkObserveContent)="updateScrollButton()">
<ng-container *ngFor="let option of options; let index = index">
<ui-select-filter-option [module]="module" [option]="option" (optionChanged)="optionChanged($event, index)">
</ui-select-filter-option>
<ng-container *ngFor="let option of filterGroup.getFilterRef(filter)?.options; let index = index">
<ui-select-filter-option [module]="module" [filter]="filter" [option]="option"> </ui-select-filter-option>
</ng-container>
</div>
</div>

View File

@@ -2,8 +2,6 @@ import {
Component,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
ElementRef,
ViewChild,
OnChanges,
@@ -11,10 +9,11 @@ import {
ChangeDetectorRef,
AfterViewInit,
OnDestroy,
OnInit,
} from '@angular/core';
import { SelectFilterOption } from '../../../models';
import { BehaviorSubject, interval, Subscription } from 'rxjs';
import { cloneSelectFilterOption, flattenSelectFilterOption } from '../../../utils';
import { SelectFilter, SelectFilterOption } from '../../../models';
import { interval, Subscription } from 'rxjs';
import { FilterGroup } from '../../filter-group';
@Component({
selector: 'ui-select-filter',
@@ -22,15 +21,9 @@ import { cloneSelectFilterOption, flattenSelectFilterOption } from '../../../uti
styleUrls: ['select-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiSelectFilterComponent implements OnChanges, AfterViewInit, OnDestroy {
export class UiSelectFilterComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
@Input()
options: SelectFilterOption[];
@Output()
optionsChanged = new EventEmitter<SelectFilterOption[]>();
@Input()
max?: number;
filter: SelectFilter;
@Input() module: 'Customer' | 'Branch' = 'Branch';
@@ -39,11 +32,14 @@ export class UiSelectFilterComponent implements OnChanges, AfterViewInit, OnDest
canScroll = false;
scrollPositionPersantage = 0;
allSelected$ = new BehaviorSubject<boolean>(false);
updateScrollPositionPersantageIntervalSub: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
constructor(protected cdr: ChangeDetectorRef, public filterGroup: FilterGroup) {}
ngOnInit() {
this.filterGroup.updateView.subscribe((_) => this.cdr.markForCheck());
}
ngOnDestroy(): void {
if (this.updateScrollPositionPersantageIntervalSub) {
@@ -67,7 +63,6 @@ export class UiSelectFilterComponent implements OnChanges, AfterViewInit, OnDest
const container = this.optionsContainer.nativeElement;
const scrollHeight = container.scrollHeight;
const height = container.clientHeight;
if (this.canScroll !== scrollHeight > height) {
this.canScroll = scrollHeight > height;
this.cdr.markForCheck();
@@ -75,12 +70,12 @@ export class UiSelectFilterComponent implements OnChanges, AfterViewInit, OnDest
}
}
ngOnChanges({ options }: SimpleChanges): void {
if (options) {
this.options = this.options.map(cloneSelectFilterOption);
this.setAllSelected(options.currentValue);
this.cdr.markForCheck();
}
ngOnChanges({}: SimpleChanges): void {
// if (options) {
// this.options = this.options.map(cloneSelectFilterOption);
// this.setAllSelected(options.currentValue);
// this.cdr.markForCheck();
// }
}
createSelctFilterOptionContext(option: SelectFilterOption) {
@@ -88,67 +83,16 @@ export class UiSelectFilterComponent implements OnChanges, AfterViewInit, OnDest
}
selectAll() {
const selectFn = (option: SelectFilterOption): SelectFilterOption => {
return {
...option,
selected: !this.allSelected$.value,
options: Array.isArray(option.options) ? option.options.map(selectFn) : option.options,
};
};
this.options = this.options.map(cloneSelectFilterOption).map(selectFn);
this.optionsChanged.emit(this.options);
this.setAllSelected(this.options);
this.filterGroup.toggleAll(this.filter);
this.cdr.markForCheck();
}
optionChanged(option: SelectFilterOption, index: number) {
let options = [...this.options];
options[index] = option;
const selectedOptions = flattenSelectFilterOption(options).filter((o) => o.selected);
if (this.max > 0 && selectedOptions.length > this.max) {
const unselectOptions = selectedOptions
.filter((o) => !(o.key === option.key && o.name === option.name))
.slice(0, selectedOptions.length - this.max);
const updateOption = (o: SelectFilterOption): SelectFilterOption =>
unselectOptions.some((s) => s.key === o.key && s.name === o.name) ? { ...o, selected: false } : o;
options = options.map(updateOption);
}
this.options = options;
this.optionsChanged.emit(this.options);
this.setAllSelected(this.options);
this.cdr.markForCheck();
}
setAllSelected(options: SelectFilterOption[]) {
this.allSelected$.next(this.isAllSelected(options));
}
private isAllSelected(options: SelectFilterOption[]): boolean {
if (options.every((option) => !!option.selected)) {
return true;
}
return options.every((option) => {
if (!option.options || !option.options.length) {
return option.selected;
}
return option.options.every((o) => o.selected);
});
}
updateScrollPositionPersantage() {
if (this.optionsContainer) {
const container: HTMLElement = this.optionsContainer.nativeElement;
const scrollHeight = container.scrollHeight;
const scrollTop = container.scrollTop;
const height = container.clientHeight;
const scrollPositionPersantage = Math.round((100 / (scrollHeight - height)) * scrollTop);
if (this.scrollPositionPersantage !== scrollPositionPersantage) {
this.scrollPositionPersantage = scrollPositionPersantage;

View File

@@ -0,0 +1,58 @@
import { EventEmitter } from '@angular/core';
import { Filter, FilterOption } from '../models';
import { isBoolean } from '@utils/common';
export abstract class FilterGroup {
abstract value: Filter[];
abstract valueChange: EventEmitter<Filter[]>;
abstract active: Filter;
updateView = new EventEmitter();
toggleAll(filter: Filter) {
const allSelected = this.isAllSelected(filter);
if (isBoolean(allSelected)) {
const current = this.getFilterRef(filter);
current.options.forEach((option) => (option.selected = !allSelected));
this.valueChange.emit(this.value);
this.updateView.emit();
}
}
toggleOption(filter: Filter, option: FilterOption) {
const optionRef = this.getOptionRef(filter, option);
if (optionRef) {
optionRef.selected = !optionRef.selected;
this.valueChange.emit(this.value);
this.updateView.emit();
}
}
isAllSelected(filter: Filter) {
if (this.value && filter) {
const filterRef = this.getFilterRef(filter);
return filterRef.options.filter((f) => f.selected).length === filterRef.options.length;
}
return undefined;
}
getFilterRef(filter: Filter) {
return this.value.find((f) => f.key === filter.key);
}
getOptionRef(filter: Filter, option: FilterOption) {
const filterRef = this.getFilterRef(filter);
if (filterRef) {
const currentOption = filterRef.options.find((o) => o.id === option.id);
if (currentOption) {
return currentOption;
}
//TODO: Find Sub Option Ref
}
}
}

View File

@@ -1,33 +1,26 @@
<div class="container" *ngIf="value">
<div class="clear-all" *ngIf="showResetButton">
<button class="isa-btn isa-btn-primary-link remove-all isa-pt-0 isa-pb-0 isa-pl-15 isa-pr-15" (click)="resetFilters()">
{{ resetStrategy | filterResetWording }}
<button class="filters-clear" *ngIf="selectedOptions?.length" (click)="unselectAllOptions()">
Alle Filter Entfernen
</button>
<div
class="filter-option"
*ngFor="let option of selectedOptions; let optionIndex = index"
[class.collapsed]="collapsed && optionIndex >= collapseAtIndex"
>
<span class="option-name">{{ option.name }}</span>
<button class="option-clear" (click)="unselectOption(option)">
<ui-icon icon="close" size="15px"></ui-icon>
</button>
</div>
<ng-container *ngFor="let filter of value">
<ng-container *ngTemplateOutlet="optionTmpl; context: { $implicit: filter }"></ng-container>
</ng-container>
<button class="display-more" (click)="collapsed = false" *ngIf="collapsed && selectedOptions?.length > collapseAtIndex">
Mehr
<ui-icon size="15px" icon="arrow"></ui-icon>
</button>
<ng-container [ngSwitch]="isCollapsed" *ngIf="items && items.length > collapsedStateNumberOfSelectedFiltersShown">
<div class="expand c-pointer" *ngSwitchCase="true" (click)="updateCollapsed(false)">
<span [class.isa-font-color-customer]="module === 'Customer'">Mehr</span>
<ui-icon size="20px" icon="arrow"></ui-icon>
</div>
<div class="collapse c-pointer" *ngSwitchDefault (click)="updateCollapsed(true)">
<ui-icon size="20px" icon="arrow" rotate="-180deg"></ui-icon>
<span [class.isa-font-color-customer]="module === 'Customer'">Weniger</span>
</div>
</ng-container>
<button class="display-less" (click)="collapsed = true" *ngIf="!collapsed && selectedOptions?.length > collapseAtIndex">
<ui-icon size="15px" icon="arrow" rotate="-180deg"></ui-icon>
Weniger
</button>
</div>
<ng-template #optionTmpl let-filter>
<div *ngIf="filter.selected" class="selected-filter" #filteredItem>
<span class="filter-name">{{ filter.name }}</span>
<ui-icon (click)="clearFilter(filter); (false)" icon="close"></ui-icon>
</div>
<ng-container *ngFor="let option of filter.options | checkAllChildOptionsSelected">
<ng-container *ngTemplateOutlet="optionTmpl; context: { $implicit: option }"></ng-container>
</ng-container>
</ng-template>

View File

@@ -1,103 +1,31 @@
$color-grey: #596470;
$filter-padding-right: 13px;
$icon-margin: 6px;
$font-size: 18px;
.container {
display: flex;
flex-wrap: wrap;
margin-top: 17px;
margin-bottom: 17px;
justify-content: center;
padding: 0 2rem;
.filter,
.clear-all {
display: flex;
align-items: center;
justify-content: center;
}
.selected-filter {
display: flex;
align-items: center;
}
.clear-all {
.filter-name {
padding-right: $filter-padding-right;
padding-left: 2rem;
}
}
.filter-name {
font-weight: bold;
font-size: $font-size;
line-height: 2;
display: flex;
padding-left: 15px;
}
.remove-all {
font-weight: bold;
line-height: 2;
font-size: $font-size;
cursor: pointer;
color: #f70400;
}
.selected-filter {
margin-right: $filter-padding-right;
.filter-name {
padding-right: $filter-padding-right;
}
}
lib-icon {
margin-top: 6px;
cursor: pointer;
}
.expand,
.collapse {
margin-left: $icon-margin;
font-weight: bold;
font-size: $font-size;
color: $color-grey;
display: flex;
align-items: center;
}
.expand {
margin-left: 16px;
lib-icon {
margin-left: $icon-margin;
}
}
.collapse {
lib-icon {
margin-right: $icon-margin;
}
}
.collapsed {
@apply hidden;
}
@apply flex flex-row justify-center items-center flex-wrap gap-3 my-px-20;
}
::ng-deep ui-selected-filter-options .expand > ui-icon,
::ng-deep ui-selected-filter-options .collapse > ui-icon,
::ng-deep [theme='customer'] ui-selected-filter-options .expand > ui-icon,
::ng-deep [theme='customer'] ui-selected-filter-options .collapse > ui-icon {
@apply text-ucla-blue;
.container.collapsed {
}
::ng-deep ui-selected-filter-options .selected-filter > ui-icon,
::ng-deep [theme='customer'] ui-selected-filter-options .selected-filter > ui-icon {
@apply text-ucla-blue;
.filters-clear {
@apply bg-transparent text-brand border-none outline-none text-cta-l font-bold;
}
::ng-deep [theme='branch'] ui-selected-filter-options .selected-filter > ui-icon {
@apply text-cool-grey;
.filter-option {
@apply flex flex-row items-baseline text-cta-l font-bold gap-2;
}
.filter-option.collapsed {
@apply hidden;
}
.option-name {
line-height: 2;
}
.option-clear {
@apply bg-transparent border-none outline-none text-ucla-blue;
}
.display-more,
.display-less {
@apply flex items-baseline gap-2 bg-transparent text-ucla-blue border-none outline-none text-cta-l font-bold;
}

View File

@@ -1,20 +1,6 @@
import {
Component,
ChangeDetectionStrategy,
Input,
EventEmitter,
Output,
ViewChildren,
QueryList,
ChangeDetectorRef,
AfterViewInit,
Renderer2,
ElementRef,
OnChanges,
} from '@angular/core';
import { Filter, SelectFilterOption } from '../../models';
import { Component, ChangeDetectionStrategy, Input, EventEmitter, Output, ChangeDetectorRef } from '@angular/core';
import { Filter, FilterOption } from '../../models';
import { cloneFilter } from '../../utils';
import { FilterResetStrategy } from './defs';
@Component({
selector: 'ui-selected-filter-options',
@@ -22,160 +8,58 @@ import { FilterResetStrategy } from './defs';
styleUrls: ['./selected-filter-options.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiSelectedFilterOptionsComponent implements AfterViewInit, OnChanges {
@Input() value: Filter[];
@Input() module: 'Customer' | 'Branch' = 'Branch';
@Input() resetStrategy: FilterResetStrategy = 'all';
@Input() showResetButton = true;
export class UiSelectedFilterOptionsComponent {
private _value: Filter[];
@Output() filterChanged = new EventEmitter<Filter[]>();
@Output() reset = new EventEmitter<void>();
@Input()
get value() {
return this._value;
}
set value(val) {
this._value = val.map(cloneFilter);
@ViewChildren('filteredItem') items: QueryList<ElementRef>;
this.selectedOptions = this.value.reduce((aggr, filter) => {
const selected = filter.options.filter((f) => f.selected);
return [...aggr, ...selected];
}, []);
collapsedStateNumberOfSelectedFiltersShown = 3;
isCollapsed = true;
constructor(private cdr: ChangeDetectorRef, private renderer: Renderer2) {}
ngAfterViewInit() {
this.items.changes.subscribe((_) => {
this.cdr.detectChanges();
this.updateCollapsed(this.isCollapsed);
});
setTimeout(() => this.cdr.detectChanges(), 0);
this.cdr.markForCheck();
}
ngOnChanges(changes) {
console.log(changes);
}
@Output()
valueChange = new EventEmitter<Filter[]>();
updateCollapsed(isCollapsed: boolean) {
this.isCollapsed = isCollapsed;
selectedOptions: FilterOption[] = [];
this.items.forEach((item, index) => {
if (this.isCollapsed && index >= this.collapsedStateNumberOfSelectedFiltersShown) {
this.renderer.addClass(item.nativeElement, 'collapsed');
} else {
this.renderer.removeClass(item.nativeElement, 'collapsed');
}
});
collapsed = true;
this.cdr.detectChanges();
}
collapseAtIndex = 3;
resetFilters() {
switch (this.resetStrategy) {
case 'default':
this.filterChanged.emit(this.resetToDefault(this.value));
break;
constructor(private cdr: ChangeDetectorRef) {}
case 'all':
this.filterChanged.emit(this.deselectAllFilters(this.value));
break;
}
this.reset.emit();
}
clearFilter(filter: SelectFilterOption) {
let clonedFilter = this.value.map(cloneFilter);
const deselect = (sf: SelectFilterOption) => {
if (sf.name === filter.name) {
return this.deselect(sf);
}
if (Array.isArray(sf.options)) {
deselectIfMatch(sf.options);
}
};
function deselectIfMatch(selectFilterOptions: SelectFilterOption[]) {
selectFilterOptions.forEach((option) => {
if (option.name === filter.name) {
deselect(option);
}
if (Array.isArray(option.options)) {
deselectIfMatch(option.options);
}
unselectAllOptions() {
function unselectOptions(options: FilterOption[]) {
options?.forEach((option) => {
option.selected = false;
unselectOptions(option.options);
});
}
clonedFilter = clonedFilter
.map((f) => {
if (Array.isArray(f.options)) {
f.options = f.options.map((o) => {
deselect(o);
this.value?.forEach((filter) => unselectOptions(filter.options));
this.valueChange.emit(this.value);
}
return o;
});
unselectOption(option: FilterOption) {
function unselectOptions(options: FilterOption[]) {
options?.forEach((o) => {
if (o.id === option.id) {
o.selected = false;
}
return f;
})
.map(cloneFilter);
this.filterChanged.emit(clonedFilter);
}
deselectAllFilters(filters: Filter[]): Filter[] {
return filters.reduce((acc, curr) => {
if (Array.isArray(curr.options)) {
curr.options.forEach((option) => this.deselect(option));
}
return acc.concat({ ...curr });
}, []);
}
resetToDefault(filters: Filter[]): Filter[] {
const updatedFilters = filters.map((filter) => {
let updatedOptions = filter.options || [];
if (filter.options) {
updatedOptions = filter.options.map((f) => ({
...this.resetToDefaultValue(f),
}));
}
return { ...filter, options: updatedOptions };
});
return updatedFilters;
}
private deselectAllMatches(filter: SelectFilterOption, match?: string) {
if (filter.name === match) {
filter.selected = false;
return filter.options.forEach((option) => this.deselect(option));
unselectOptions(o.options);
});
}
if (this.hasNestedOptions(filter)) {
filter.options.forEach((option) => option.name === match && this.deselectAllMatches(option));
}
}
private deselect(filter: SelectFilterOption, match?: string): SelectFilterOption {
if (this.hasNestedOptions(filter)) {
filter.options.forEach((option) => this.deselect(option));
}
if (filter.name === match || !match) {
filter.selected = false;
}
return filter;
}
private resetToDefaultValue(filter: SelectFilterOption): SelectFilterOption {
return {
...filter,
selected: filter.initial_selected_state,
options: [...(filter.options ? filter.options.map((option) => this.resetToDefaultValue(option)) : [])],
};
}
private hasNestedOptions(filter: SelectFilterOption): boolean {
return filter && Array.isArray(filter.options) && !!filter.options.length;
this.value.forEach((filter) => unselectOptions(filter.options));
this.valueChange.emit(this.value);
}
}

View File

@@ -1,14 +1,4 @@
import {
Directive,
ElementRef,
EventEmitter,
forwardRef,
HostBinding,
HostListener,
Input,
Output,
Renderer2,
} from '@angular/core';
import { Directive, ElementRef, EventEmitter, forwardRef, HostBinding, HostListener, Input, Output, Renderer2 } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Directive({
@@ -46,7 +36,6 @@ export class UiSearchboxInputDirective implements ControlValueAccessor {
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
writeValue(obj: any): void {
console.log(obj);
this.setValue(obj, { inputType: 'init' });
}
@@ -73,10 +62,7 @@ export class UiSearchboxInputDirective implements ControlValueAccessor {
this.setValue(value, { inputType: 'input' });
}
setValue(
value: string,
{ inputType }: { inputType?: 'input' | 'autocomplete' | 'init' }
) {
setValue(value: string, { inputType }: { inputType?: 'input' | 'autocomplete' | 'init' }) {
this.value = value;
this.onChange(value);
this.onTouched();

View File

@@ -1,5 +1,6 @@
// start:ng42.barrel
export * from './is-array';
export * from './is-boolean';
export * from './is-number';
export * from './is-string';
// end:ng42.barrel

View File

@@ -0,0 +1,3 @@
export function isBoolean(value: any) {
return typeof value === 'boolean';
}

368
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff