Merged PR 760: Filter Artikelsuche

Related work items: #2008
This commit is contained in:
Lorenz Hilpert
2021-07-26 04:40:48 +00:00
parent f38f0954c6
commit 43ebda6bb2
174 changed files with 6217 additions and 484 deletions

View File

@@ -2616,6 +2616,126 @@
}
}
}
},
"@ui/autocomplete": {
"projectType": "library",
"root": "apps/ui/autocomplete",
"sourceRoot": "apps/ui/autocomplete/src",
"prefix": "ui",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/ui/autocomplete/tsconfig.lib.json",
"project": "apps/ui/autocomplete/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/ui/autocomplete/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/ui/autocomplete/src/test.ts",
"tsConfig": "apps/ui/autocomplete/tsconfig.spec.json",
"karmaConfig": "apps/ui/autocomplete/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/ui/autocomplete/tsconfig.lib.json",
"apps/ui/autocomplete/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@ui/switch": {
"projectType": "library",
"root": "apps/ui/switch",
"sourceRoot": "apps/ui/switch/src",
"prefix": "ui",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/ui/switch/tsconfig.lib.json",
"project": "apps/ui/switch/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/ui/switch/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/ui/switch/src/test.ts",
"tsConfig": "apps/ui/switch/tsconfig.spec.json",
"karmaConfig": "apps/ui/switch/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/ui/switch/tsconfig.lib.json",
"apps/ui/switch/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"@ui/tooltip": {
"projectType": "library",
"root": "apps/ui/tooltip",
"sourceRoot": "apps/ui/tooltip/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "apps/ui/tooltip/tsconfig.lib.json",
"project": "apps/ui/tooltip/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "apps/ui/tooltip/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "apps/ui/tooltip/src/test.ts",
"tsConfig": "apps/ui/tooltip/tsconfig.spec.json",
"karmaConfig": "apps/ui/tooltip/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/ui/tooltip/tsconfig.lib.json",
"apps/ui/tooltip/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "sales"

View File

@@ -31,7 +31,7 @@
*ngFor="let contributor of contributors$ | async; let last = last"
class="autor"
[routerLink]="['/product/search/results']"
[queryParams]="{ query: contributor, inputSelector: 'author' }"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>

View File

@@ -1,28 +1,8 @@
<button class="filter" [class.active]="hasFilter$ | async" (click)="filterActive$.next(true)">
<button class="filter" [class.active]="true" (click)="toggleFilterOverlay()">
<ui-icon size="20px" icon="filter_alit"></ui-icon>
<span class="label">Filter</span>
</button>
<ng-container>
<router-outlet></router-outlet>
</ng-container>
<router-outlet></router-outlet>
<div class="filter-overlay" [class.active]="filterActive$ | async">
<div class="filter-content">
<div class="filter-close-right">
<button class="filter-close" (click)="filterActive$.next(false)">
<ui-icon size="20px" icon="close"></ui-icon>
</button>
</div>
<h2 class="filter-header">
Filter
</h2>
<page-article-search-filter
(lastSelectedFilterCategory)="lastSelectedFilterCategory = $event"
[updateFilterCategory]="lastSelectedFilterCategory"
(exitFilter)="filterActive$.next(false)"
*ngIf="filterActive$ | async"
>
</page-article-search-filter>
</div>
</div>
<page-article-search-filter [@slideInOut] *ngIf="showFilterOverlay" (close)="toggleFilterOverlay()"></page-article-search-filter>

View File

@@ -1,12 +1,5 @@
:host {
@apply flex flex-col w-full box-content absolute;
}
.filter {
@apply absolute font-sans flex items-center font-bold bg-wild-blue-yonder border-0 text-regular py-px-8 px-px-15 rounded-filter justify-center;
right: 0;
top: -52px;
@apply absolute font-sans flex items-center font-bold bg-gray-400 border-0 text-regular py-px-8 px-px-15 rounded-filter justify-center right-0 top-0;
min-width: 106px;
.label {
@@ -18,33 +11,7 @@
}
}
.filter-content {
@apply max-w-content mx-auto mt-px-25 px-px-15;
.filter-close-right {
@apply pr-px-10 text-right;
}
.filter-header {
@apply text-center text-page-heading mt-0;
}
button.filter-close {
@apply border-0 bg-transparent text-ucla-blue;
}
}
.filter-overlay {
@apply fixed bg-glitter z-fixed;
transform: translatex(100%);
transition: transform 0.5s ease-out;
top: 135px;
right: 0;
bottom: 0;
left: 0;
&.active {
transform: translatex(0%);
}
page-article-search-filter {
@apply fixed right-0 bottom-0 left-0 z-fixed;
top: 136px;
}

View File

@@ -1,33 +1,50 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { Filter } from '@ui/filter';
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
import { delay, map, switchMap } from 'rxjs/operators';
import { ArticleSearchStore } from './article-search-new.store';
import { UiFilterAutocompleteProvider, UiFilterScanProvider } from '@ui/filter';
import { Subscription } from 'rxjs';
import { ArticleSearchService } from './article-search.store';
import { FocusSearchboxEvent } from './focus-searchbox.event';
import { ArticleSearchMainAutocompleteProvider, ArticleSearchMainScanProviderService } from './providers';
@Component({
selector: 'page-article-search',
templateUrl: 'article-search.component.html',
styleUrls: ['article-search.component.scss'],
providers: [ArticleSearchStore, FocusSearchboxEvent],
providers: [
FocusSearchboxEvent,
ArticleSearchService,
{
provide: UiFilterAutocompleteProvider,
useClass: ArticleSearchMainAutocompleteProvider,
multi: true,
},
{
provide: UiFilterScanProvider,
useClass: ArticleSearchMainScanProviderService,
multi: true,
},
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('slideInOut', [
transition(':enter', [style({ transform: 'translateX(100%)' }), animate('250ms', style({ transform: 'translateX(0%)' }))]),
transition(':leave', [style({ transform: '*' }), animate('250ms', style({ transform: 'translateX(100%)' }))]),
]),
],
})
export class ArticleSearchComponent implements OnInit, OnDestroy {
filterActive$ = new BehaviorSubject<boolean>(false);
showMainContent$ = this.getShowMainContent();
lastSelectedFilterCategory: Filter;
showFilterOverlay = false;
subscriptions = new Subscription();
hasFilter$: Observable<boolean> = this.store.queryParamsFilter$.pipe(map((filter) => filter !== ''));
constructor(
private store: ArticleSearchStore,
private breadcrumb: BreadcrumbService,
private application: ApplicationService,
private focusSearchbox: FocusSearchboxEvent
private router: Router,
private articleSearch: ArticleSearchService
) {}
ngOnInit() {
@@ -37,13 +54,19 @@ export class ArticleSearchComponent implements OnInit, OnDestroy {
})
);
this.subscriptions.add(
this.filterActive$.pipe(delay(500)).subscribe((active) => {
if (!active) {
this.focusSearchbox.emit();
this.articleSearch.searchCompleted.subscribe((s) => {
if (s.searchState === '') {
if (s.hits === 1) {
const item = s.items.find((f) => f);
this.router.navigate(['/product', 'details', item.id]);
} else {
const params = s.filter.getQueryParams();
this.router.navigate(['/product', 'search', 'results'], {
queryParams: params,
});
}
})
);
}
});
}
ngOnDestroy() {
@@ -61,15 +84,7 @@ export class ArticleSearchComponent implements OnInit, OnDestroy {
});
}
getShowMainContent(animationDelayInMs: number = 500): Observable<boolean> {
return this.filterActive$.pipe(
switchMap((filterActive) => {
const onExitMainContent = filterActive;
if (onExitMainContent) {
return of(!filterActive).pipe(delay(animationDelayInMs));
}
return of(!filterActive);
})
);
toggleFilterOverlay() {
this.showFilterOverlay = !this.showFilterOverlay;
}
}

View File

@@ -0,0 +1,138 @@
import { Injectable } from '@angular/core';
import { DomainCatalogService } from '@domain/catalog';
import { Observable, Subject } from 'rxjs';
import { debounceTime, filter, map, shareReplay, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import { UiFilter } from '@ui/filter';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { ItemDTO } from '@swagger/cat';
export interface ArticleSearchState {
processId: number;
filter: UiFilter;
searchState: '' | 'fetching' | 'empty' | 'error';
items: ItemDTO[];
hits: number;
}
@Injectable()
export class ArticleSearchService extends ComponentStore<ArticleSearchState> {
get processId() {
return this.get((s) => s.processId);
}
filter$ = this.select((s) => s.filter);
get filter() {
return this.get((s) => s.filter);
}
items$ = this.select((s) => s.items);
get items() {
return this.get((s) => s.items);
}
get friendlyName() {
return this.filter.input
?.find((f) => f.group === 'main')
.input?.find((f) => f)
.toStringValue();
}
fetching$ = this.select((s) => s.searchState === 'fetching');
searchStarted = new Subject<{ clear?: boolean; reload?: boolean }>();
searchCompleted = new Subject<ArticleSearchState>();
searchboxHint$ = this.select((s) => (s.searchState === 'empty' ? 'Keine Suchergebnisse' : undefined));
hits$ = this.select((s) => s.hits);
get hits() {
return this.get((s) => s.hits);
}
constructor(private catalog: DomainCatalogService) {
super({
filter: undefined,
hits: 0,
items: [],
processId: 0,
searchState: '',
});
this.setDefaultFilter();
}
setProcess(processId: number) {
this.patchState({ processId });
}
setItems(items: ItemDTO[]) {
this.patchState({ items });
}
setHits(hits: number) {
this.patchState({ hits });
}
async setDefaultFilter() {
const filter = await this.catalog
.getSettings()
.pipe(map((settings) => UiFilter.create(settings)))
.toPromise();
this.setFilter(filter);
}
setFilter(filter: UiFilter) {
this.patchState({ filter });
}
search = this.effect((options$: Observable<{ clear?: boolean; reload?: boolean }>) =>
options$.pipe(
tap((_) => this.patchState({ searchState: 'fetching' })),
debounceTime(250),
withLatestFrom(this.filter$, this.items$),
switchMap(([options, filter, items]) =>
this.catalog
.search({
queryToken: {
...filter.getQueryToken(),
skip: options.clear || options.reload ? 0 : items.length,
take: options.reload ? items.length : 25,
friendlyName: this.friendlyName,
},
})
.pipe(
tapResponse(
(res) => {
const searchState = res.hits ? '' : 'empty';
if (options.clear || options.reload) {
this.patchState({
hits: res.hits,
items: res.result,
searchState,
});
} else {
const items = this.get((s) => s.items);
this.patchState({
hits: res.hits,
items: [...items, ...res.result],
searchState,
});
}
},
(err) => {
this.patchState({ hits: 0, searchState: 'error', items: [] });
}
),
tap((_) => {
this.searchCompleted.next(this.get());
})
)
)
)
);
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { DomainCatalogService } from '@domain/catalog';
import { UiFilterAutocomplete, UiFilterAutocompleteProvider, UiInput } from '@ui/filter';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@Injectable()
export class ArticleSearchMainAutocompleteProvider extends UiFilterAutocompleteProvider {
for = 'main';
constructor(private domainCatalogSearch: DomainCatalogService) {
super();
}
complete(input: UiInput): Observable<UiFilterAutocomplete[]> {
if (input.value?.length > 2) {
return this.domainCatalogSearch
.searchComplete({
queryToken: {
filter: input?.parent?.parent?.getQueryToken()?.filter,
input: input.value,
catalogType: undefined,
take: 5,
type: 'qs',
},
})
.pipe(
catchError(() => of({ result: [] })),
map((res) => res.result)
);
}
return of([]);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { UiFilterScanProvider } from '@ui/filter';
import { NativeContainerService } from 'native-container';
import { NEVER, Observable, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
@Injectable()
export class ArticleSearchMainScanProviderService extends UiFilterScanProvider {
for = 'main';
constructor(private nativeContainer: NativeContainerService) {
super();
}
scan(): Observable<string> {
return this.nativeContainer.openScanner('scanBook').pipe(
mergeMap((result) => {
if (result.status === 'RESULT') {
return of(result.data);
}
return NEVER;
}),
catchError((err) => {
return NEVER;
})
);
}
}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './article-search-main-autocomplete.provider';
export * from './article-search-main-scan.provider';
// end:ng42.barrel

View File

@@ -1,29 +1,22 @@
<div class="filter-chips">
<page-filter-chips class="filter" (filterChange)="checkMainFilter($event)" [filter]="mainFilters$ | async"></page-filter-chips>
<page-filter-chips class="filter" (filterChange)="checkInputFilter($event)" [filter]="inputFilters$ | async"></page-filter-chips>
</div>
<ng-container *ngIf="filter$ | async; let filter">
<div class="catalog-search-filter-content">
<button class="btn-close" type="button" (click)="close.emit()">
<ui-icon icon="close" size="20px"></ui-icon>
</button>
<page-article-searchbox mode="filter" (closeFilterOverlay)="closeOverlayAfterSearch()"></page-article-searchbox>
<div class="catalog-search-filter-content-main">
<h1 class="title">Filter</h1>
<ui-filter [filter]="filter" [loading]="fetching$ | async" (search)="applyFilter(filter)" [hint]="searchboxHint$ | async"></ui-filter>
</div>
</div>
<ng-container *ngIf="filters$ | async; let selectedFilters">
<ng-container *ngIf="initialFiltersBackend$ | async; let initialFilters">
<ui-selected-filter-options [value]="selectedFilters" [initialFilter]="initialFilters" (valueChange)="updateFilter($event)">
</ui-selected-filter-options>
<div class="cta-wrapper">
<button class="cta-reset-filter" (click)="resetFilter()">
Filter zurücksetzen
</button>
<ui-filter-group
theme="filter"
[value]="selectedFilters"
(activeChange)="updateCategory($event)"
[active]="updateFilterCategory$ | async"
(valueChange)="updateFilter($event)"
></ui-filter-group>
</ng-container>
</ng-container>
<div class="sticky-cta-wrapper">
<button class="apply-filter" (click)="applyFilters()">
<span>
<button class="cta-apply-filter" (click)="applyFilter(filter)">
Filter anwenden
</span>
</button>
</div>
</button>
</div>
</ng-container>

View File

@@ -1,38 +1,41 @@
:host {
@apply flex flex-col box-border w-full;
@apply block bg-glitter;
}
.sticky-cta-wrapper {
@apply fixed text-center inset-x-0 bottom-0;
bottom: 30px;
.catalog-search-filter-content {
@apply relative mx-auto;
max-width: 916px;
}
.filter-chips {
@apply flex flex-row justify-center;
.btn-close {
@apply absolute text-cool-grey top-0 right-4 outline-none border-none bg-transparent;
}
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;
.catalog-search-filter-content-main {
h1.title {
@apply text-center;
}
}
button.apply-filter:disabled {
@apply bg-inactive-customer;
::ng-deep ui-filter-input-options .input-options {
max-height: calc(100vh - 555px);
}
button.apply-filter.loading {
padding-top: 15px;
padding-bottom: 17px;
.cta-wrapper {
@apply fixed bottom-8 whitespace-nowrap;
left: 50%;
transform: translateX(-50%);
}
ui-selected-filter-options {
@apply my-px-8;
.cta-reset-filter,
.cta-apply-filter {
@apply text-lg font-bold px-6 py-3 rounded-full border-solid border-2 border-brand outline-none mx-2;
}
ui-icon {
@apply inline-flex;
.cta-reset-filter {
@apply bg-white text-brand;
}
.spin {
@apply animate-spin;
.cta-apply-filter {
@apply text-white bg-brand;
}

View File

@@ -1,10 +1,8 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Filter } from '@ui/filter';
import { clone, cloneDeep } from 'lodash';
import { BehaviorSubject, Subscription } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { ArticleSearchStore } from '../article-search-new.store';
import { FocusSearchboxEvent } from '../focus-searchbox.event';
import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output } from '@angular/core';
import { UiFilter } from '@ui/filter';
import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { ArticleSearchService } from '../article-search.store';
@Component({
selector: 'page-article-search-filter',
@@ -12,101 +10,47 @@ import { FocusSearchboxEvent } from '../focus-searchbox.event';
styleUrls: ['search-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
@Output() exitFilter = new EventEmitter<void>();
@Output() lastSelectedFilterCategory = new EventEmitter<Filter>();
export class ArticleSearchFilterComponent implements OnInit {
@Output()
close = new EventEmitter();
readonly initialFiltersBackend$ = this.store.defaultFilter$.pipe(map((filter) => filter.filter));
fetching$: Observable<boolean>;
readonly filters$ = this.store.filter$;
readonly inputFilters$ = this.store.inputSelectorFilter$;
readonly mainFilters$ = this.store.mainFilter$;
filter$: Observable<UiFilter>;
queryCopy: string;
filtersCopy: Filter[];
inputFiltersCopy: Filter[];
mainFiltersCopy: Filter[];
isFilterApplied = false;
searchboxHint$ = this.articleSearch.searchboxHint$;
readonly searchState$ = this.store.searchState$;
/* @internal */
updateFilterCategory$ = new BehaviorSubject<Filter>(undefined);
@Input()
get updateFilterCategory(): Filter {
return this.updateFilterCategory$.value;
}
set updateFilterCategory(value) {
this.updateFilterCategory$.next(value);
}
searchStateSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef, private store: ArticleSearchStore, private focusSearchbox: FocusSearchboxEvent) {}
constructor(private articleSearch: ArticleSearchService) {}
ngOnInit() {
this.copyFilterLatestStatus();
this.fetching$ = this.articleSearch.fetching$;
this.filter$ = this.articleSearch.filter$.pipe(
map((filter) => UiFilter.create(filter))
// tap((filter) =>
// filter.fromQueryParams({
// main_qs: 'harry potter',
// filter_format: 'eb;!hc',
// filter_dbhwgr: '110;121',
// main_author: 'author',
// filter_region: '9780*|9781*;97884*',
// 'filter_reading-age': '1-10',
// main_stock: '1-',
// })
// )
);
}
ngOnDestroy() {
if (!this.isFilterApplied) {
this.resetFilterToLatestStatus();
}
if (!!this.searchStateSubscription) {
this.searchStateSubscription.unsubscribe();
}
}
closeOverlayAfterSearch() {
this.isFilterApplied = true;
this.exitFilter.emit();
}
applyFilters() {
this.isFilterApplied = true;
this.store.search({ clear: true });
this.searchStateSubscription = this.store.searchState$.subscribe((state) => {
if (state !== 'fetching' && state !== 'empty') {
this.closeOverlayAfterSearch();
applyFilter(value: UiFilter) {
this.articleSearch.setFilter(value);
this.articleSearch.search({ clear: true });
this.articleSearch.searchCompleted.pipe(take(1)).subscribe((s) => {
if (s.searchState === '') {
this.close.emit();
}
});
}
checkInputFilter(filter: Filter[]) {
this.store.setInputSelectorFilter({ filter });
this.focusSearchbox.emit();
}
checkMainFilter(filter: Filter[]) {
this.store.setMainFilter({ filter });
this.focusSearchbox.emit();
}
updateCategory(filter: Filter) {
this.lastSelectedFilterCategory.emit(filter);
this.cdr.markForCheck();
}
updateFilter(filter: Filter[]) {
this.store.setFilter({ filter });
this.cdr.markForCheck();
}
async copyFilterLatestStatus() {
this.queryCopy = clone(this.store.query);
this.filtersCopy = cloneDeep(await this.filters$.pipe(first()).toPromise());
this.inputFiltersCopy = cloneDeep(await this.inputFilters$.pipe(first()).toPromise());
this.mainFiltersCopy = cloneDeep(await this.mainFilters$.pipe(first()).toPromise());
}
resetFilterToLatestStatus() {
this.store.setQuery({ query: this.queryCopy });
this.store.setFilter({ filter: this.filtersCopy });
this.store.setInputSelectorFilter({ filter: this.inputFiltersCopy });
this.store.setMainFilter({ filter: this.mainFiltersCopy });
resetFilter() {
this.articleSearch.setDefaultFilter();
}
}

View File

@@ -1,16 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { UiCommonModule } from '@ui/common';
import { UiFilterModule } from '@ui/filter';
import { UiFilterNextModule } from '@ui/filter';
import { UiIconModule } from '@ui/icon';
import { ArticleSearchboxModule } from '../containers/article-searchbox/article-searchbox.module';
import { FilterChipsModule } from '../containers/filter-chips/filter-chips.module';
import { ArticleSearchFilterComponent } from './search-filter.component';
@NgModule({
imports: [CommonModule, FilterChipsModule, ArticleSearchboxModule, UiIconModule, UiFilterModule],
imports: [CommonModule, UiFilterNextModule, UiIconModule],
exports: [ArticleSearchFilterComponent],
declarations: [ArticleSearchFilterComponent],
providers: [],

View File

@@ -3,20 +3,26 @@
<p class="info">
Welchen Artikel suchen Sie?
</p>
<div class="filter-chips">
<page-filter-chips (filterChange)="checkMainFilter($event)" [filter]="mainFilter$ | async"></page-filter-chips>
<page-filter-chips (filterChange)="checkInputFilter($event)" [filter]="inputFilter$ | async"></page-filter-chips>
</div>
<page-article-searchbox mode="main"></page-article-searchbox>
<div class="recent-searches-wrapper">
<h3 class="recent-searches-header">Deine letzten Suchanfragen</h3>
<ul>
<li class="recent-searches-items" *ngFor="let recentQuery of history$ | async">
<button (click)="setQueryHistory(recentQuery.friendlyName)">
<ui-icon icon="search" size="15px"></ui-icon>
<p>{{ recentQuery.friendlyName }}</p>
</button>
</li>
</ul>
</div>
<ng-container *ngIf="filter$ | async; let filter">
<ui-filter-filter-group-main [inputGroup]="filter?.filter | group: 'main'"></ui-filter-filter-group-main>
<ui-filter-input-group-main
[hint]="searchboxHint$ | async"
[loading]="fetching$ | async"
[inputGroup]="filter?.input | group: 'main'"
(search)="search(filter)"
[showDescription]="false"
></ui-filter-input-group-main>
<div class="recent-searches-wrapper">
<h3 class="recent-searches-header">Deine letzten Suchanfragen</h3>
<ul>
<li class="recent-searches-items" *ngFor="let recentQuery of history$ | async">
<button (click)="setQueryHistory(filter, recentQuery.friendlyName)">
<ui-icon icon="search" size="15px"></ui-icon>
<p>{{ recentQuery.friendlyName }}</p>
</button>
</li>
</ul>
</div>
</ng-container>
</div>

View File

@@ -24,6 +24,19 @@
height: calc(100vh - 380px);
}
ui-filter-filter-group-main {
@apply mb-8 w-full;
}
::ng-deep page-article-search-main ui-filter-filter-group-main .ui-filter-chip:not(.selected) {
@apply bg-glitter !important text-ucla-blue !important;
}
ui-filter-input-group-main {
@apply block mx-auto;
max-width: 600px;
}
.recent-searches-wrapper {
@apply flex flex-col mx-auto items-start py-6;
width: 50%;

View File

@@ -1,14 +1,14 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { ApplicationService } from '@core/application';
import { DomainCatalogService } from '@domain/catalog';
import { Filter } from '@ui/filter';
import { UiFilter } from '@ui/filter';
import { combineLatest, NEVER, Subscription } from 'rxjs';
import { catchError, debounceTime, first, switchMap } from 'rxjs/operators';
import { ArticleSearchStore } from '../article-search-new.store';
import { isEqual } from 'lodash';
import { catchError, debounceTime, first } from 'rxjs/operators';
import { FocusSearchboxEvent } from '../focus-searchbox.event';
import { ArticleSearchService } from '../article-search.store';
import { isEmpty, isEqual } from 'lodash';
@Component({
selector: 'page-article-search-main',
@@ -18,18 +18,22 @@ import { FocusSearchboxEvent } from '../focus-searchbox.event';
})
export class ArticleSearchMainComponent implements OnInit, OnDestroy {
readonly history$ = this.catalog.getSearchHistory({ take: 5 }).pipe(catchError(() => NEVER));
readonly inputFilter$ = this.store.inputSelectorFilter$;
readonly mainFilter$ = this.store.mainFilter$;
fetching$ = this.searchService.fetching$;
filter$ = this.searchService.filter$;
searchboxHint$ = this.searchService.searchboxHint$;
subscriptions = new Subscription();
constructor(
private store: ArticleSearchStore,
private searchService: ArticleSearchService,
private catalog: DomainCatalogService,
private route: ActivatedRoute,
private breadcrumb: BreadcrumbService,
private application: ApplicationService,
private focusSearchbox: FocusSearchboxEvent
private breadcrumb: BreadcrumbService,
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
@@ -37,42 +41,80 @@ export class ArticleSearchMainComponent implements OnInit, OnDestroy {
combineLatest([this.application.activatedProcessId$, this.route.queryParams])
.pipe(debounceTime(0))
.subscribe(async ([processId, queryParams]) => {
// Setzen des aktuellen Prozesses
if (this.store?.processId !== processId) {
this.store.patchState({ processId });
if (!(this.searchService.filter instanceof UiFilter)) {
await this.searchService.setDefaultFilter();
}
// Updaten der QueryParams wenn diese sich ändern
if (!isEqual(this.store.queryParams, queryParams)) {
const params = { ...queryParams };
delete params.scrollPos;
this.store.setQueryParams({ params });
if (processId !== this.searchService.processId) {
this.updateBreadcrumb(this.searchService.processId, this.searchService.filter.getQueryParams());
this.searchService.setProcess(processId);
}
const crumbs = await this.breadcrumb.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'main']).pipe(first()).toPromise();
const cleanQueryParams = this.cleanupQueryParams(queryParams);
for (const crumb of crumbs) {
this.breadcrumb.removeBreadcrumbsAfter(crumb.id, ['catalog']);
if (!isEqual(cleanQueryParams, this.cleanupQueryParams(this.searchService.filter.getQueryParams()))) {
if (isEmpty(cleanQueryParams)) {
await this.searchService.setDefaultFilter();
}
this.searchService.filter.fromQueryParams(queryParams);
}
this.removeResultsAndDetailsBreadcrumbs(processId);
})
);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
this.updateBreadcrumb(this.searchService.processId, this.searchService.filter.getQueryParams());
}
checkInputFilter(filter: Filter[]) {
this.store.setInputSelectorFilter({ filter });
this.focusSearchbox.emit();
search(filter: UiFilter) {
this.searchService.setFilter(filter);
this.searchService.search({ clear: true });
}
checkMainFilter(filter: Filter[]) {
this.store.setMainFilter({ filter });
this.focusSearchbox.emit();
setQueryHistory(filter: UiFilter, query: string) {
filter.fromQueryParams({ main_qs: query });
}
setQueryHistory(recentQuery: string) {
this.store.setQuery({ query: recentQuery });
cleanupQueryParams(params: Record<string, string> = {}) {
const clean = { ...params };
for (const key in clean) {
if (Object.prototype.hasOwnProperty.call(clean, key)) {
if (clean[key] == undefined) {
delete clean[key];
}
}
}
return clean;
}
async updateBreadcrumb(processId: number, queryParams: Record<string, string>) {
const crumbs = await this.breadcrumb.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'main']).pipe(first()).toPromise();
crumbs.forEach((crumb) => {
this.breadcrumb.patchBreadcrumb(crumb.id, {
params: queryParams,
});
});
}
async removeResultsAndDetailsBreadcrumbs(processId: number) {
const resultsCrumbs = await this.breadcrumb.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'results']).pipe(first()).toPromise();
const detailCrumbs = await this.breadcrumb.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'details']).pipe(first()).toPromise();
resultsCrumbs?.forEach((crumb) => {
this.breadcrumb.removeBreadcrumb(crumb.id);
});
detailCrumbs?.forEach((crumb) => {
this.breadcrumb.removeBreadcrumb(crumb.id);
});
}
}

View File

@@ -1,13 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { UiFilterNextModule } from '@ui/filter';
import { UiIconModule } from '@ui/icon';
import { ArticleSearchboxModule } from '../containers/article-searchbox/article-searchbox.module';
import { FilterChipsModule } from '../containers/filter-chips/filter-chips.module';
import { ArticleSearchMainComponent } from './search-main.component';
@NgModule({
imports: [CommonModule, UiIconModule, FilterChipsModule, ArticleSearchboxModule],
imports: [CommonModule, UiIconModule, UiFilterNextModule],
exports: [ArticleSearchMainComponent],
declarations: [ArticleSearchMainComponent],
providers: [],

View File

@@ -1,11 +1,16 @@
<div class="grw"></div>
<div class="order-by-filter-button-wrapper">
<button class="order-by-filter-button" type="button" *ngFor="let by of orderBy$ | async" (click)="setActive(by.next)">
<button class="order-by-filter-button" type="button" *ngFor="let by of orderByKeys" (click)="setActive(by)">
<span>
{{ by.by }}
{{ by }}
</span>
<ui-icon [class.asc]="by.active && !by.desc" [class.desc]="by.active && by.desc" icon="arrow" size="14px"></ui-icon>
<ui-icon
[class.asc]="by === activeOrderBy?.by && !activeOrderBy.desc"
[class.desc]="by === activeOrderBy?.by && activeOrderBy.desc"
icon="arrow"
size="14px"
></ui-icon>
</button>
</div>
<div class="grw"></div>
<div *ngIf="hits$ | async; let hits" class="hits">{{ hits }} Titel</div>
<div *ngIf="hits" class="hits">{{ hits }} Titel</div>

View File

@@ -1,5 +1,6 @@
:host {
@apply box-border flex justify-center items-center;
min-height: 30px;
}
.order-by-filter-button-wrapper {

View File

@@ -1,8 +1,5 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { OrderByDTO } from '@swagger/cat';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { ArticleSearchStore } from '../../article-search-new.store';
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef, Output, EventEmitter } from '@angular/core';
import { UiOrderBy } from '@ui/filter';
@Component({
selector: 'page-order-by-filter',
@@ -11,38 +8,43 @@ import { ArticleSearchStore } from '../../article-search-new.store';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderByFilterComponent {
active$ = this.store.orderBy$;
hits$ = this.store.hits$;
@Input()
orderBy: UiOrderBy[];
orderBy$ = combineLatest([this.store.orderByOptions$.pipe(map((bys) => bys.map((b) => ({ ...b, desc: !!b.desc })))), this.active$]).pipe(
map(([orderBy, activeOrderBy]) => {
const bys = orderBy.map((o) => o.by).reduce((agg, by) => (!agg.includes(by) ? [...agg, by] : agg), []);
@Input()
hits: number;
const orderBys: (OrderByDTO & { active: boolean; next?: OrderByDTO })[] = [];
@Output()
selectedOrderByChange = new EventEmitter<UiOrderBy>();
for (const by of bys) {
const asc = orderBy.find((ob) => ob.by === by && !ob.desc);
const desc = orderBy.find((ob) => ob.by === by && !!ob.desc);
const ascActive = asc && asc.by === activeOrderBy?.by && !activeOrderBy?.desc;
const descActive = desc && desc.by === activeOrderBy?.by && activeOrderBy?.desc;
get orderByKeys() {
return this.orderBy?.map((ob) => ob.by).filter((key, idx, self) => self.indexOf(key) === idx);
}
if (!!desc && descActive) {
orderBys.push({ ...desc, active: true, next: asc });
} else if (!!asc && ascActive) {
orderBys.push({ ...asc, active: true });
} else if (!!asc || !!desc) {
orderBys.push({ ...desc, active: false, next: desc || asc });
}
get activeOrderBy() {
return this.orderBy?.find((f) => f.selected);
}
constructor(private cdr: ChangeDetectorRef) {}
setActive(orderBy: string) {
const active = this.activeOrderBy;
const orderBys = this.orderBy?.filter((f) => f.by === orderBy);
let next: UiOrderBy;
if (orderBys?.length) {
if (active?.by !== orderBy) {
next = orderBys?.find((f) => !f.desc);
} else if (!active.desc) {
next = orderBys?.find((f) => f.desc);
}
}
return orderBys;
})
);
this.orderBy?.filter((f) => f.selected)?.forEach((f) => f.setSelected(false));
constructor(private store: ArticleSearchStore) {}
next?.setSelected(true);
setActive(orderBy: OrderByDTO) {
this.store.setOrderBy({ orderBy });
this.store.search({ reload: true });
this.selectedOrderByChange.next(next);
this.cdr.markForCheck();
}
}

View File

@@ -6,9 +6,9 @@
<div class="item-contributors">
<a
*ngFor="let contributor of contributors; let last = last"
[routerLink]="[]"
[queryParams]="{ query: contributor, inputSelector: 'author' }"
(click)="$event.stopPropagation()"
[routerLink]="['/product/search/results']"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>

View File

@@ -1,4 +1,9 @@
<page-order-by-filter></page-order-by-filter>
<page-order-by-filter
[hits]="hits$ | async"
[orderBy]="(filter$ | async)?.orderBy"
(selectedOrderByChange)="search(); updateBreadcrumbs()"
></page-order-by-filter>
<cdk-virtual-scroll-viewport
#scrollContainer
class="product-list scroll-bar"

View File

@@ -4,11 +4,12 @@ import { ActivatedRoute } from '@angular/router';
import { ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { ItemDTO } from '@swagger/cat';
import { UiFilter } from '@ui/filter';
import { CacheService } from 'apps/core/cache/src/public-api';
import { isEqual } from 'lodash';
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import { debounceTime, first, map } from 'rxjs/operators';
import { ArticleSearchStore } from '../article-search-new.store';
import { debounceTime, first } from 'rxjs/operators';
import { ArticleSearchService } from '../article-search.store';
@Component({
selector: 'page-search-results',
@@ -22,15 +23,18 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy {
loading$ = new BehaviorSubject<boolean>(false);
results$ = this.store.items$;
fetching$ = this.store.searchState$.pipe(map((state) => state === 'fetching'));
results$ = this.searchService.items$;
fetching$ = this.searchService.fetching$;
hits$ = this.searchService.hits$;
filter$ = this.searchService.filter$;
trackByItemId = (item: ItemDTO) => item.id;
private subscriptions = new Subscription();
constructor(
private store: ArticleSearchStore,
private searchService: ArticleSearchService,
private route: ActivatedRoute,
private application: ApplicationService,
private breadcrumb: BreadcrumbService,
@@ -42,42 +46,36 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy {
combineLatest([this.application.activatedProcessId$, this.route.queryParams])
.pipe(debounceTime(0))
.subscribe(async ([processId, queryParams]) => {
// Wenn ein Prozess bereits zugewiesen ist und der Prozess sich ändert
// Speicher Ergebnisse in den Cache und Update Breadcrumb Params
if (this.store?.processId !== processId) {
this.cacheCurrentItems();
await this.updateBreadcrumbs();
if (processId !== this.searchService.processId) {
if (!!this.searchService.processId && this.searchService.filter instanceof UiFilter) {
this.cacheCurrentData(this.searchService.processId, this.searchService.filter.getQueryParams());
this.updateBreadcrumbs(this.searchService.processId, this.searchService.filter.getQueryParams());
}
this.searchService.setProcess(processId);
}
// Setzen des aktuellen Prozesses
this.store.patchState({ processId });
// Updaten der QueryParams wenn diese sich ändern
// scrollPos muss entfernt werden um die items anhand der QueryParams zu cachen
if (!isEqual(this.store.queryParams, queryParams)) {
const params = { ...queryParams };
delete params.scrollPos;
const items = this.cache.get<ItemDTO[]>(params);
this.store.setQueryParams({ params });
this.store.setItems({ items });
this.store.search({ reload: true });
if (!(this.searchService.filter instanceof UiFilter)) {
await this.searchService.setDefaultFilter();
}
// Nach dem setzen der Items im store an die letzte Position scrollen
this.scrollTop(Number(queryParams.scrollPos ?? 0));
const cleanQueryParams = this.cleanupQueryParams(queryParams);
// Fügt Breadcrumb hinzu falls dieser noch nicht vorhanden ist
await this.breadcrumb.addBreadcrumbIfNotExists({
key: processId,
name: `${this.store.query ? this.store.query : 'Alle Artikel'}`,
path: '/product/search/results',
params: queryParams,
tags: ['catalog', 'filter', 'results'],
});
if (!isEqual(cleanQueryParams, this.cleanupQueryParams(this.searchService.filter.getQueryParams()))) {
this.searchService.filter.fromQueryParams(queryParams);
const data = this.getCachedData(processId, queryParams);
this.searchService.setItems(data.items);
this.searchService.setHits(data.hits);
await this.removeDetailBreadcrumb(processId);
await this.store.updateBreadcrumbs();
if (data.items?.length === 0) {
this.search();
} else {
this.scrollTop(Number(queryParams.scroll_position ?? 0));
}
}
await this.updateBreadcrumbs(processId, queryParams);
await this.createBreadcrumb(processId, queryParams);
await this.removeDetailsBreadcrumb(processId);
})
);
}
@@ -86,8 +84,12 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy {
this.loading$.complete();
this.subscriptions?.unsubscribe();
this.cacheCurrentItems();
this.updateBreadcrumbs();
this.cacheCurrentData(this.searchService.processId, this.searchService.filter.getQueryParams());
this.updateBreadcrumbs(this.searchService.processId, this.searchService.filter.getQueryParams());
}
search() {
this.searchService.search({ clear: true });
}
async removeDetailBreadcrumb(processId: number) {
@@ -105,25 +107,85 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy {
async scrolledIndexChange(index: number) {
const results = await this.results$.pipe(first()).toPromise();
if (index >= results.length - 20 && results.length - 20 > 0) {
this.store.search({ clear: false });
this.searchService.search({ clear: false });
}
}
async updateBreadcrumbs() {
const scrollPos = this.scrollContainer.measureScrollOffset('top');
const processId = this.store.processId;
const queryParams = { ...this.store.queryParams };
async updateBreadcrumbs(
processId: number = this.searchService.processId,
queryParams: Record<string, string> = this.searchService.filter?.getQueryParams()
) {
const scroll_position = this.scrollContainer.measureScrollOffset('top');
const crumbs = await this.breadcrumb.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'filter', 'results']).pipe(first()).toPromise();
if (queryParams) {
const crumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'filter', 'results'])
.pipe(first())
.toPromise();
const params = { ...queryParams, scrollPos };
const name = queryParams.main_qs ? queryParams.main_qs : 'Alle Artikel';
const params = { ...queryParams, scroll_position };
for (const crumb of crumbs) {
this.breadcrumb.patchBreadcrumb(crumb.id, { params });
for (const crumb of crumbs) {
this.breadcrumb.patchBreadcrumb(crumb.id, {
name,
params,
});
}
}
}
cacheCurrentItems() {
this.cache.set(this.store.queryParams, this.store.items);
async createBreadcrumb(processId: number, queryParams: Record<string, string>) {
if (queryParams) {
const name = queryParams.main_qs ? queryParams.main_qs : 'Alle Artikel';
await this.breadcrumb.addBreadcrumbIfNotExists({
key: processId,
name,
path: '/product/search/results',
params: queryParams,
tags: ['catalog', 'filter', 'results'],
});
}
}
async removeDetailsBreadcrumb(processId: number) {
const crumbs = await this.breadcrumb.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'details']).pipe(first()).toPromise();
crumbs?.forEach((crumb) => this.breadcrumb.removeBreadcrumb(crumb.id));
}
cacheCurrentData(processId: number, params: Record<string, string> = {}) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(processId) });
this.cache.set(qparams, {
items: this.searchService.items,
hits: this.searchService.hits,
});
}
getCachedData(processId: number, params: Record<string, string> = {}) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(processId) });
return (
this.cache.get<{
items: ItemDTO[];
hits: number;
}>(qparams) || { items: [], hits: 0 }
);
}
cleanupQueryParams(params: Record<string, string> = {}) {
const clean = { ...params };
delete clean['scroll_position'];
for (const key in clean) {
if (Object.prototype.hasOwnProperty.call(clean, key)) {
if (clean[key] == undefined) {
delete clean[key];
}
}
}
return clean;
}
}

View File

@@ -2,6 +2,7 @@ const errorWhiteList: { url: string; codes: number[] }[] = [
{ url: '/eiswebapi/v1', codes: [-1] },
{ url: 'isa/logging', codes: [-1] },
{ url: '/isa/userstate', codes: [404] },
{ url: '/s/complete', codes: [400] },
{
url: '/availability/',
codes: [401, 403, 404, 406, 407, 410, 412, 416, 418, 451, 500, 501, 502, 503, 504, 0, -1],

View File

@@ -0,0 +1,25 @@
# Autocomplete
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 10.1.2.
## Code scaffolding
Run `ng generate component component-name --project autocomplete` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project autocomplete`.
> Note: Don't forget to add `--project autocomplete` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build autocomplete` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build autocomplete`, go to the dist folder `cd dist/autocomplete` and run `npm publish`.
## Running unit tests
Run `ng test autocomplete` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../../../coverage/ui/autocomplete'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/ui/autocomplete",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@ui/autocomplete",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^10.1.2",
"@angular/core": "^10.1.2"
},
"dependencies": {
"tslib": "^2.0.0"
}
}

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -0,0 +1,85 @@
import { createHostFactory, SpectatorHost } from '@ngneat/spectator';
import { UiAutocompleteItemComponent } from './autocomplete-item.component';
describe('UiSearchboxAutocompleteItem', () => {
let spectator: SpectatorHost<UiAutocompleteItemComponent>;
const createComponent = createHostFactory(UiAutocompleteItemComponent);
beforeEach(() => (spectator = createComponent(`<button [uiAutocompleteItem]="item">My Autocomplete Item</button>`)));
it('should create', () => {
expect(spectator).toBeTruthy();
});
it('should set the class ui-autocomplete-item', () => {
expect(spectator.element).toHaveClass('ui-autocomplete-item');
});
it('should not have class active if isActive is false', () => {
spectator.component.isActive = false;
spectator.detectChanges();
expect(spectator.element).not.toHaveClass('active');
});
it('should have class active if isActive is true', () => {
spectator.component.isActive = true;
spectator.detectChanges();
expect(spectator.element).toHaveClass('active');
});
it('should not be disabled if disabled is false', () => {
spectator.setInput({ disabled: false });
spectator.detectChanges();
expect(spectator.element).not.toBeDisabled();
});
it('should be disabled if disabled is true', () => {
spectator.setInput({ disabled: true });
spectator.detectChanges();
expect(spectator.element).toBeDisabled();
});
it('should display the content', () => {
expect(spectator.element).toHaveText('My Autocomplete Item');
});
it('should call handleClickEvent on click', () => {
spyOn(spectator.component, 'handleClickEvent');
spectator.click();
expect(spectator.component.handleClickEvent).toHaveBeenCalled();
});
describe('setActiveStyles', () => {
it('should set isActive to true', () => {
spectator.component.setActiveStyles();
expect(spectator.component.isActive).toBe(true);
});
});
describe('setInactiveStyles', () => {
it('should set isActive to false', () => {
spectator.component.setInactiveStyles();
expect(spectator.component.isActive).toBe(false);
});
});
describe('registerOnClick()', () => {
it('should set onClick', () => {
const fn = (item: any) => item?.data;
spectator.component.registerOnClick(fn);
expect(spectator.component.onClick).toBe(fn);
});
});
describe('handleClickEvent()', () => {
it('should call onClick with the item', () => {
const item = { unit: 'test' };
spectator.setInput({ item });
spectator.detectComponentChanges();
spyOn(spectator.component, 'onClick');
spectator.component.handleClickEvent();
expect(spectator.component.onClick).toHaveBeenCalledWith(item);
});
});
});

View File

@@ -0,0 +1,52 @@
import { Highlightable, ListKeyManagerOption } from '@angular/cdk/a11y';
import {
Component,
ChangeDetectionStrategy,
Output,
EventEmitter,
Input,
ViewEncapsulation,
HostBinding,
HostListener,
} from '@angular/core';
@Component({
selector: '[uiAutocompleteItem]',
templateUrl: 'autocomplete-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class UiAutocompleteItemComponent implements Highlightable {
@HostBinding('class.ui-autocomplete-item') clazz = true;
@Input('uiAutocompleteItem')
item: any;
@HostBinding('class.active')
isActive = false;
@Input()
@HostBinding('disabled')
disabled = false;
onClick = (item: any) => {};
constructor() {}
@HostListener('click')
handleClickEvent() {
this.onClick?.call(null, this.item);
}
registerOnClick(fn: (item: any) => void) {
this.onClick = fn;
}
setActiveStyles(): void {
this.isActive = true;
}
setInactiveStyles(): void {
this.isActive = false;
}
}

View File

@@ -0,0 +1,3 @@
<div class="ui-autocomplete-output-wrapper" *ngIf="opend">
<ng-content select="[uiAutocompleteItem]"></ng-content>
</div>

View File

@@ -0,0 +1,139 @@
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import { fakeAsync } from '@angular/core/testing';
import { createHostFactory, Spectator } from '@ngneat/spectator';
import { UiAutocompleteItemComponent } from './autocomplete-item.component';
import { UiAutocompleteComponent } from './autocomplete.component';
describe('UiSearchboxAutocomplete', () => {
let spectator: Spectator<UiAutocompleteComponent>;
const createComponent = createHostFactory({
component: UiAutocompleteComponent,
declarations: [UiAutocompleteItemComponent],
});
beforeEach(
() =>
(spectator = createComponent(`
<ui-autocomplete>
<button [uiAutocompleteItem]>My Autocomplete Item</button>
</ui-autocomplete>`))
);
it('should create', () => {
expect(spectator).toBeTruthy();
});
describe('ngAfterContentInit', () => {
it('should create an instance of ActiveDescendantKeyManager and set it to listKeyManager', () => {
spyOn(spectator.component, 'registerItemOnClick');
spyOn(spectator.component.subscriptions, 'add');
spectator.component.ngAfterContentInit();
expect(spectator.component.listKeyManager instanceof ActiveDescendantKeyManager).toBe(true);
expect(spectator.component.registerItemOnClick).toHaveBeenCalledTimes(1);
expect(spectator.component.subscriptions.add).toHaveBeenCalledTimes(1);
});
});
describe('ngOnDestroy', () => {
it('should call unsubscribe', () => {
spyOn(spectator.component.subscriptions, 'unsubscribe');
spectator.component.ngOnDestroy();
expect(spectator.component.subscriptions.unsubscribe).toHaveBeenCalled();
});
});
describe('handleKeyboardEvent', () => {
it('should emit selectItem and call close and not call listKeyManager.onKeyDown on Enter key', () => {
spyOn(spectator.component, 'close');
spyOn(spectator.component.listKeyManager, 'onKeydown');
spyOnProperty(spectator.component, 'activeItem', 'get').and.returnValue({ item: 'Test' });
let selectItem;
spectator.output('selectItem').subscribe((item) => (selectItem = item));
spectator.component.opend = true;
const event = new KeyboardEvent('keyup', { key: 'Enter' });
spectator.component.handleKeyboardEvent(event);
expect(spectator.component.close).toHaveBeenCalled();
expect(selectItem).toEqual({ item: 'Test' });
expect(spectator.component.listKeyManager.onKeydown).not.toHaveBeenCalledWith(event);
});
it('should call close and not emit selectItem and not call listKeyManager.onKeydown on Escape key', () => {
spyOn(spectator.component, 'close');
spyOn(spectator.component.listKeyManager, 'onKeydown');
spyOnProperty(spectator.component, 'activeItem', 'get').and.returnValue({ item: 'Test' });
let selectItem;
spectator.output('selectItem').subscribe((item) => (selectItem = item));
spectator.component.opend = true;
const event = new KeyboardEvent('keyup', { key: 'Escape' });
spectator.component.handleKeyboardEvent(event);
expect(spectator.component.close).toHaveBeenCalled();
expect(selectItem).toBeUndefined();
expect(spectator.component.listKeyManager.onKeydown).not.toHaveBeenCalledWith(event);
});
it('should call listKeyManager.onKeyDown and not emit selectItem and not call close on ArrowDown key', () => {
spyOn(spectator.component, 'close');
spyOn(spectator.component.listKeyManager, 'onKeydown');
spyOnProperty(spectator.component, 'activeItem', 'get').and.returnValue({ item: 'Test' });
let selectItem;
spectator.output('selectItem').subscribe((item) => (selectItem = item));
spectator.component.opend = true;
const event = new KeyboardEvent('keyup', { key: 'ArrowDown' });
spectator.component.handleKeyboardEvent(event);
expect(spectator.component.close).not.toHaveBeenCalled();
expect(selectItem).toBeUndefined();
expect(spectator.component.listKeyManager.onKeydown).toHaveBeenCalledWith(event);
});
it('should not call close, listKeyManager.onKeyDown and not emit selectItem if opened is false', () => {
spyOn(spectator.component, 'close');
spyOn(spectator.component.listKeyManager, 'onKeydown');
spyOnProperty(spectator.component, 'activeItem', 'get').and.returnValue({ item: 'Test' });
let selectItem;
spectator.output('selectItem').subscribe((item) => (selectItem = item));
spectator.component.opend = false;
const event = new KeyboardEvent('keyup', { key: 'ArrowDown' });
spectator.component.handleKeyboardEvent(event);
expect(spectator.component.close).not.toHaveBeenCalled();
expect(selectItem).toBeUndefined();
expect(spectator.component.listKeyManager.onKeydown).not.toHaveBeenCalledWith(event);
});
});
describe('open', () => {
it('should set opened to true', () => {
spectator.component.open();
expect(spectator.component.opend).toBe(true);
});
});
describe('close', () => {
it('should set opened to false and call listKeyManager.setActiveItem(undefined)', () => {
spyOn(spectator.component.listKeyManager, 'setActiveItem');
spectator.component.close();
expect(spectator.component.opend).toBe(false);
expect(spectator.component.listKeyManager.setActiveItem).toHaveBeenCalledWith(undefined);
});
});
describe('registerItemOnClick()', () => {
it('should call registerOnClick for each item', () => {
const items = spectator.component.items;
items.forEach((item) => spyOn(item, 'registerOnClick'));
spectator.component.registerItemOnClick();
items.forEach((item) => expect(item.registerOnClick).toHaveBeenCalled());
});
});
});

View File

@@ -0,0 +1,88 @@
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import {
Component,
ChangeDetectionStrategy,
QueryList,
ViewEncapsulation,
ContentChildren,
AfterContentInit,
EventEmitter,
Output,
ChangeDetectorRef,
OnDestroy,
} from '@angular/core';
import { Subscription } from 'rxjs';
import { UiAutocompleteItemComponent } from './autocomplete-item.component';
@Component({
selector: 'ui-autocomplete',
templateUrl: 'autocomplete.component.html',
styleUrls: ['autocomplete.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
exportAs: 'uiAutocomplete',
})
export class UiAutocompleteComponent implements AfterContentInit, OnDestroy {
@ContentChildren(UiAutocompleteItemComponent)
items: QueryList<UiAutocompleteItemComponent>;
@Output()
selectItem = new EventEmitter<any>();
get activeItem() {
return this.listKeyManager?.activeItem;
}
opend = false;
listKeyManager: ActiveDescendantKeyManager<UiAutocompleteItemComponent>;
subscriptions = new Subscription();
constructor(private cdr: ChangeDetectorRef) {}
ngAfterContentInit() {
this.listKeyManager = new ActiveDescendantKeyManager(this.items);
this.listKeyManager.withWrap(true);
this.registerItemOnClick();
this.subscriptions.add(this.items.changes.subscribe(() => this.registerItemOnClick()));
}
ngOnDestroy() {
this.subscriptions?.unsubscribe();
}
registerItemOnClick() {
this.items.forEach((item) =>
item.registerOnClick((aitem) => {
this.selectItem.emit(aitem);
this.close();
})
);
}
handleKeyboardEvent(event: KeyboardEvent) {
if (this.opend) {
switch (event.key) {
case 'Enter':
this.selectItem.emit(this.activeItem);
case 'Escape':
this.close();
break;
default:
this.listKeyManager.onKeydown(event);
}
}
}
open() {
this.opend = true;
this.cdr.markForCheck();
}
close() {
this.opend = false;
this.listKeyManager.setActiveItem(undefined);
this.cdr.markForCheck();
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiAutocompleteComponent } from './autocomplete.component';
import { UiAutocompleteItemComponent } from './autocomplete-item.component';
@NgModule({
imports: [CommonModule],
exports: [UiAutocompleteComponent, UiAutocompleteItemComponent],
declarations: [UiAutocompleteComponent, UiAutocompleteItemComponent],
})
export class UiAutocompleteModule {}

View File

@@ -0,0 +1,30 @@
ui-autocomplete {
@apply block relative z-dropdown;
}
ui-autocomplete .ui-autocomplete-output-wrapper {
@apply flex flex-col shadow-input absolute left-0 right-0;
}
ui-searchbox ui-autocomplete {
@apply shadow-none;
.ui-autocomplete-output-wrapper {
@apply rounded-b-autocomplete;
}
}
.ui-autocomplete-item {
@apply border-none outline-none bg-transparent text-base font-sans px-4 py-5 text-left bg-white;
&.active,
&:hover {
@apply bg-customer;
}
}
ui-searchbox .ui-autocomplete-item {
&:last-child {
@apply rounded-b-autocomplete;
}
}

View File

@@ -0,0 +1,5 @@
// start:ng42.barrel
export * from './autocomplete-item.component';
export * from './autocomplete.component';
export * from './autocomplete.module';
// end:ng42.barrel

View File

@@ -0,0 +1,5 @@
/*
* Public API Surface of autocomplete
*/
export * from './lib';

View File

@@ -0,0 +1,24 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone';
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(
path: string,
deep?: boolean,
filter?: RegExp
): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@@ -0,0 +1,25 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/lib",
"target": "es2015",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"lib": [
"dom",
"es2018"
]
},
"angularCompilerOptions": {
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"enableResourceInlining": true
},
"exclude": [
"src/test.ts",
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,10 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"enableIvy": false
}
}

View File

@@ -0,0 +1,17 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tslint.json",
"rules": {
"directive-selector": [
true,
"attribute",
"ui",
"camelCase"
],
"component-selector": [
true,
"element",
"ui",
"kebab-case"
]
}
}

View File

@@ -6,4 +6,5 @@ export * from './selected-filter-options/components';
export * from './selected-filter-options/pipe';
export * from './mapping.service';
export * from './utils';
export * from './next';
// end:ng42.barrel

View File

@@ -0,0 +1,23 @@
<div>
<div class="inputs">
<button
class="ui-input"
type="button"
*ngFor="let input of uiInputGroup?.input"
[class.active]="activeInput === input"
[class.has-options]="input?.hasSelectedOptions() || input?.hasUnselectedOptions()"
(click)="setActiveInput(input)"
>
<div class="grow">
{{ input?.label }}
</div>
<ui-icon icon="arrow_head" size="1rem"></ui-icon>
</button>
</div>
</div>
<ui-filter-input-options
[class.remove-rounded-top-left]="isFirstInputSelected"
*ngIf="activeInput?.options"
class="options"
[inputOptions]="activeInput?.options"
></ui-filter-input-options>

View File

@@ -0,0 +1,48 @@
:host {
@apply grid grid-flow-col gap-2;
grid-template-columns: 240px 1fr;
}
ui-icon {
@apply transition transform text-cool-grey;
}
.inputs {
@apply grid grid-flow-row gap-2;
.ui-input {
@apply flex flex-row items-center border-none outline-none p-4 font-bold text-base bg-white text-left rounded-card transition transform;
}
.has-options {
@apply text-white bg-cool-grey;
ui-icon {
@apply text-white;
}
}
.ui-input.active {
@apply text-black bg-white pr-6 -mr-2 rounded-r-none;
ui-icon {
@apply rotate-180 text-cool-grey;
}
}
.space-right {
@apply mr-4;
}
.grow {
@apply flex-grow;
}
}
::ng-deep ui-filter-filter-group-filter .input-options-wrapper {
min-height: 300px;
}
::ng-deep ui-filter-filter-group-filter .remove-rounded-top-left .input-options-wrapper {
@apply rounded-tl-none;
}

View File

@@ -0,0 +1,19 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UiFilterInputOptionsModule } from '../../shared/filter-input-options';
import { UiFilterFilterGroupFilterComponent } from './filter-filter-group-filter.component';
describe('UiFilterFilterGroupFilterComponent', () => {
let spectator: Spectator<UiFilterFilterGroupFilterComponent>;
const createComponent = createComponentFactory({
component: UiFilterFilterGroupFilterComponent,
imports: [UiFilterInputOptionsModule],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
});

View File

@@ -0,0 +1,41 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { IUiInputGroup, UiInput, UiInputGroup } from '../../tree';
@Component({
selector: 'ui-filter-filter-group-filter',
templateUrl: 'filter-filter-group-filter.component.html',
styleUrls: ['filter-filter-group-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterFilterGroupFilterComponent {
private _inputGroup: UiInputGroup;
@Input()
set inputGroup(value: IUiInputGroup) {
if (value instanceof UiInputGroup) {
this._inputGroup = value;
} else {
this._inputGroup = UiInputGroup.create(value);
}
}
get uiInputGroup() {
return this._inputGroup;
}
private _activeInput: UiInput;
get activeInput() {
return this.uiInputGroup?.input?.find((f) => f?.key === this._activeInput?.key) || this.uiInputGroup?.input?.find((f) => f);
}
get isFirstInputSelected() {
return this.activeInput === this.uiInputGroup?.input?.find((f) => f);
}
constructor() {}
setActiveInput(input: UiInput) {
this._activeInput = input;
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiFilterFilterGroupFilterComponent } from './filter-filter-group-filter.component';
import { UiFilterInputOptionsModule } from '../../shared/filter-input-options';
import { UiIconModule } from '@ui/icon';
@NgModule({
imports: [CommonModule, UiIconModule, UiFilterInputOptionsModule],
exports: [UiFilterFilterGroupFilterComponent],
declarations: [UiFilterFilterGroupFilterComponent],
})
export class UiFilterFilterGroupFilterModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-filter-group-filter.component';
export * from './filter-filter-group-filter.module';
// end:ng42.barrel

View File

@@ -0,0 +1 @@
<ui-filter-input-chip *ngFor="let input of uiInputGroup?.input" [input]="input"></ui-filter-input-chip>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-col items-center justify-center gap-4;
}

View File

@@ -0,0 +1,49 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UiFilterInputChipModule } from '../../shared/filter-input-chip';
import { UiInputGroup } from '../../tree';
import { UiFilterFilterGroupMainComponent } from './filter-filter-group-main.component';
describe('UiFilterFilterGroupMainComponent', () => {
let spectator: Spectator<UiFilterFilterGroupMainComponent>;
const createComponent = createComponentFactory({
component: UiFilterFilterGroupMainComponent,
imports: [UiFilterInputChipModule],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
describe('inputGroup', () => {
it('should create an instance of UiInputGroup when value is a plain object', () => {
spectator.setInput({ inputGroup: {} });
expect(spectator.component.uiInputGroup instanceof UiInputGroup).toBe(true);
});
it('should set the UiInputGroup if it is already an instance of UiInputGroup', () => {
const instance = UiInputGroup.create({});
spectator.setInput({ inputGroup: instance });
expect(spectator.component.uiInputGroup).toBe(instance);
});
});
describe('ui-filter-input-chip Element', () => {
it('should render an Element for each uiInputGroup.input', () => {
const instance = UiInputGroup.create({
input: [
{ label: 'Label 1', type: 0 },
{ label: 'label 2', type: 0 },
{ label: 'label 3', type: 0 },
],
});
spectator.setInput({ inputGroup: instance });
spectator.detectComponentChanges();
expect(spectator.queryAll('ui-filter-input-chip').length).toBe(3);
});
});
});

View File

@@ -0,0 +1,27 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { IUiInputGroup, UiInputGroup } from '../../tree';
@Component({
selector: 'ui-filter-filter-group-main',
templateUrl: 'filter-filter-group-main.component.html',
styleUrls: ['filter-filter-group-main.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterFilterGroupMainComponent {
private _inputGroup: UiInputGroup;
@Input()
set inputGroup(value: IUiInputGroup) {
if (value instanceof UiInputGroup) {
this._inputGroup = value;
} else {
this._inputGroup = UiInputGroup.create(value);
}
}
get uiInputGroup() {
return this._inputGroup;
}
constructor() {}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiFilterFilterGroupMainComponent } from './filter-filter-group-main.component';
import { UiFilterInputChipModule } from '../../shared/filter-input-chip';
@NgModule({
imports: [CommonModule, UiFilterInputChipModule],
exports: [UiFilterFilterGroupMainComponent],
declarations: [UiFilterFilterGroupMainComponent],
})
export class UiFilterFilterGroupMainModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-filter-group-main.component';
export * from './filter-filter-group-main.module';
// end:ng42.barrel

View File

@@ -0,0 +1,29 @@
<ui-searchbox
[placeholder]="uiInput?.placeholder"
[query]="uiInput?.value"
(queryChange)="uiInput?.setValue($event)"
(complete)="complete.next($event)"
(search)="search.emit($event)"
[loading]="loading"
[hint]="hint"
[scanProvider]="scanProvider"
>
<ui-autocomplete *ngIf="autocompleteProvider">
<button *ngFor="let item of autocompleteResults$ | async" [uiAutocompleteItem]="item.query">
{{ item.display }}
</button>
</ui-autocomplete>
</ui-searchbox>
<ng-container *ngIf="showDescription && uiInput?.description">
<button
[uiTooltip]="tooltipContent"
[uiTooltipPosition]="{ offsetX: 20, offsetY: -20, originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom' }"
class="info-tooltip-button"
type="button"
>
i
</button>
<ng-template #tooltipContent>
{{ uiInput.description }}
</ng-template>
</ng-container>

View File

@@ -0,0 +1,12 @@
:host {
@apply relative;
}
.info-tooltip-button {
@apply border-font-customer bg-white rounded-md text-base font-bold absolute;
border-style: outset;
width: 31px;
height: 31px;
top: 14px;
right: -45px;
}

View File

@@ -0,0 +1,154 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UiAutocompleteModule } from '../../../autocomplete';
import { UiSearchboxModule } from '../../../searchbox';
import { IUiInput, IUiInputGroup, UiInput, UiInputGroup, UiInputType } from '../../tree';
import { isObservable, NEVER, of } from 'rxjs';
import { UiFilterInputGroupMainComponent } from './filter-input-group-main.component';
describe('UiFilterInputGroupMainComponent', () => {
const inputGroupData: IUiInputGroup = {
input: [{ key: 'qs', placeholder: 'ISBN/EAN, Titel, Kundenname', type: 1, constraint: '^.{2,100}$' }],
};
let spectator: Spectator<UiFilterInputGroupMainComponent>;
const createSpectator = createComponentFactory({
component: UiFilterInputGroupMainComponent,
imports: [UiSearchboxModule, UiAutocompleteModule],
});
beforeEach(() => {
spectator = createSpectator();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
describe('uiInput', () => {
it('should return undefined when inputGroup is not set', () => {
expect(spectator.component.uiInput).toBeUndefined();
});
it('should return an instance of UiInput', () => {
spectator.setInput({
inputGroup: inputGroupData,
});
expect(spectator.component.uiInput instanceof UiInput).toBeTruthy();
});
it('should return the same instance of the input', () => {
const instance = UiInputGroup.create(inputGroupData);
spectator.setInput({
inputGroup: instance,
});
expect(spectator.component.uiInput).toBe(instance.input[0]);
});
});
describe('ngOnInit()', () => {
it('should set the autocompleteProvider with the property for to be main', () => {
const provider = { for: 'main', complete: () => NEVER };
spectator.component['autocompleteProviders'] = [provider, { for: 'not main', complete: () => NEVER }];
spectator.component.ngOnInit();
expect(spectator.component.autocompleteProvider).toBe(provider);
});
it('should set the scanProvider with the property for to be main', () => {
const provider = { for: 'main', scan: () => NEVER };
spectator.component['scanProviders'] = [provider, { for: 'not main', scan: () => NEVER }];
spectator.component.ngOnInit();
expect(spectator.component.scanProvider).toBe(provider);
});
});
describe('initAutocomplete()', () => {
it('should create an Observable for autocompleteResult$', () => {
spectator.setInput({
inputGroup: inputGroupData,
});
spectator.component.setAutocompleteProvider({ for: 'main', complete: () => NEVER });
spectator.component.initAutocomplete();
expect(isObservable(spectator.component.autocompleteResults$)).toBe(true);
});
it('should call autocompleteComponent.open() if the prvider returns at least one result', () => {
spectator.setInput({
inputGroup: inputGroupData,
});
spectator.component.setAutocompleteProvider({
for: 'main',
complete: (query) => of([{ display: `${query} first` }, { display: `${query} second` }]),
});
spectator.detectComponentChanges();
spyOn(spectator.component.autocompleteComponent, 'open');
spectator.component.complete.next('test');
expect(spectator.component.autocompleteComponent.open).toHaveBeenCalled();
});
it('should call autocompleteComponent.close() if the prvider returns at no results', () => {
spectator.setInput({
inputGroup: inputGroupData,
});
spectator.component.setAutocompleteProvider({
for: 'main',
complete: (query) => of([]),
});
spectator.detectComponentChanges();
spyOn(spectator.component.autocompleteComponent, 'close');
spectator.component.complete.next('test');
expect(spectator.component.autocompleteComponent.close).toHaveBeenCalled();
});
});
describe('ui-searchbox', () => {
describe('input.ui-searchbox-input', () => {
it('should have a placeholder', () => {
spectator.setInput({
inputGroup: { input: [{ type: UiInputType.Text, placeholder: 'Unit Test Placeholder' }] },
});
spectator.detectComponentChanges();
expect(spectator.query('input.ui-searchbox-input')).toContainProperty('placeholder', 'Unit Test Placeholder');
});
it('should have a value', async () => {
spectator.setInput({
inputGroup: { input: [{ type: UiInputType.Text, value: 'Unit Test Value' }] },
});
spectator.detectComponentChanges();
await spectator.fixture.whenStable();
expect(spectator.query('input.ui-searchbox-input')).toHaveValue('Unit Test Value');
});
it('should call input.setValue when value changes', async () => {
spectator.setInput({
inputGroup: { input: [{ type: UiInputType.Text, value: 'Unit Test Value' }] },
});
spyOn(spectator.component.uiInput, 'setValue');
spectator.detectComponentChanges();
await spectator.fixture.whenStable();
spectator.typeInElement('Unit Test', spectator.query('input.ui-searchbox-input'));
expect(spectator.component.uiInput.setValue).toHaveBeenCalledWith('Unit Test');
});
});
});
describe('ui-autocomplete', () => {
it('should not render the element if no autocompleteProvider is set', () => {
spyOnProperty(spectator.component, 'autocompleteProvider', 'get').and.returnValue(undefined);
spectator.detectComponentChanges();
expect(spectator.query('ui-autocomplete')).toBeNull();
});
it('should render the element if autocompleteProvider is set', () => {
spectator.setInput({ inputGroup: inputGroupData });
spyOnProperty(spectator.component, 'autocompleteProvider', 'get').and.returnValue({ for: 'main', complete: () => NEVER });
spectator.detectComponentChanges();
expect(spectator.query('ui-autocomplete')).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,126 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Optional,
ViewChild,
AfterViewInit,
OnInit,
Inject,
OnDestroy,
ChangeDetectorRef,
Output,
EventEmitter,
} from '@angular/core';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { Observable, Subject, Subscription } from 'rxjs';
import { filter, switchMap, tap } from 'rxjs/operators';
import { UiFilterAutocomplete, UiFilterAutocompleteProvider, UiFilterScanProvider } from '../../providers';
import { IUiInputGroup, UiInput, UiInputGroup } from '../../tree';
@Component({
selector: 'ui-filter-input-group-main',
templateUrl: 'filter-input-group-main.component.html',
styleUrls: ['filter-input-group-main.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterInputGroupMainComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild(UiAutocompleteComponent, { read: UiAutocompleteComponent, static: false })
autocompleteComponent: UiAutocompleteComponent;
@Output()
search = new EventEmitter<string>();
@Input()
loading: boolean;
@Input()
hint: string;
@Input()
showDescription: boolean = true;
private _inputGroup: UiInputGroup;
@Input()
set inputGroup(value: IUiInputGroup) {
if (value instanceof UiInputGroup) {
this._inputGroup = value;
} else {
this._inputGroup = UiInputGroup.create(value);
}
this.subscribeChanges();
this.initAutocomplete();
}
get uiInput(): UiInput {
return this._inputGroup?.input?.find((f) => f);
}
private _scanProvider: UiFilterScanProvider;
get scanProvider() {
return this._scanProvider;
}
private _autocompleteProvider: UiFilterAutocompleteProvider;
get autocompleteProvider() {
return this._autocompleteProvider;
}
autocompleteResults$: Observable<UiFilterAutocomplete[]>;
complete = new Subject<string>();
private changeSubscriptions: Subscription;
constructor(
@Inject(UiFilterAutocompleteProvider) @Optional() private autocompleteProviders: UiFilterAutocompleteProvider[],
@Inject(UiFilterScanProvider) @Optional() private scanProviders: UiFilterScanProvider[],
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
this._autocompleteProvider = this.autocompleteProviders?.find((provider) => provider.for === 'main');
this._scanProvider = this.scanProviders?.find((provider) => provider.for === 'main');
}
ngAfterViewInit() {
this.initAutocomplete();
}
ngOnDestroy() {
this.unsubscribeChanges();
}
subscribeChanges() {
this.unsubscribeChanges();
const sub = this.uiInput?.changes?.pipe(filter((changes) => changes?.keys.includes('value'))).subscribe(() => {
this.cdr.markForCheck();
});
if (sub) {
this.changeSubscriptions.add(sub);
}
}
unsubscribeChanges() {
this.changeSubscriptions?.unsubscribe();
this.changeSubscriptions = new Subscription();
}
initAutocomplete() {
this.autocompleteResults$ = this.complete.asObservable().pipe(
switchMap(() => this.autocompleteProvider.complete(this.uiInput)),
tap((complete) => {
if (complete.length > 0) {
this.autocompleteComponent.open();
} else {
this.autocompleteComponent.close();
}
})
);
}
setAutocompleteProvider(provider: UiFilterAutocompleteProvider) {
this._autocompleteProvider = provider;
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiFilterInputGroupMainComponent } from './filter-input-group-main.component';
import { UiAutocompleteModule } from '@ui/autocomplete';
import { UiSearchboxNextModule } from '@ui/searchbox';
import { UiTooltipModule } from 'apps/ui/tooltip/src/public-api';
@NgModule({
imports: [CommonModule, UiSearchboxNextModule, UiAutocompleteModule, UiTooltipModule],
exports: [UiFilterInputGroupMainComponent],
declarations: [UiFilterInputGroupMainComponent],
})
export class UiFilterInputGroupMainModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-group-main.component';
export * from './filter-input-group-main.module';
// end:ng42.barrel

View File

@@ -0,0 +1,16 @@
// start:ng42.barrel
export * from './ui-filter.component';
export * from './ui-filter.module';
export * from './filter-group/filter-filter-group-filter';
export * from './filter-group/filter-filter-group-main';
export * from './filter-group/filter-input-group-main';
export * from './pipe';
export * from './providers';
export * from './shared/filter-input-chip';
export * from './shared/filter-input-options';
export * from './shared/filter-input-options/filter-input-option-bool';
export * from './shared/filter-input-options/filter-input-option-date-range';
export * from './shared/filter-input-options/filter-input-option-tri-state';
export * from './testing';
export * from './tree';
// end:ng42.barrel

View File

@@ -0,0 +1,3 @@
// start:ng42.barrel
export * from './ui-input-group-selector.pipe';
// end:ng42.barrel

View File

@@ -0,0 +1,17 @@
import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator';
import { IUiInputGroup } from '../tree';
import { UiInputGroupSelectorPipe } from './ui-input-group-selector.pipe';
describe('UiInputGroupSelectorPipe', () => {
let spectator: SpectatorPipe<UiInputGroupSelectorPipe>;
const createPipe = createPipeFactory(UiInputGroupSelectorPipe);
const inputGroups: IUiInputGroup[] = [{ group: 'group1' }, { group: 'group2' }, { group: 'group3' }];
it('should return the IUiInputGroup with group to be unittest', () => {
spectator = createPipe(`{{ (value | group:group)?.group }}`, {
hostProps: { value: inputGroups, group: 'group2' },
});
expect(spectator.element).toHaveText('group2');
});
});

View File

@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
import { IUiInputGroup } from '../tree';
@Pipe({
name: 'group',
})
export class UiInputGroupSelectorPipe implements PipeTransform {
transform(value: IUiInputGroup[], group: string): any {
return value?.find((f) => f?.group === group);
}
}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './ui-filter-autocomplete.provider';
export * from './ui-filter-scan.provider';
// end:ng42.barrel

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { UiInput } from '../tree';
export interface UiFilterAutocomplete {
/**
* Anzeige / Bezeichner
*/
display?: string;
/**
* Id
*/
id?: string;
/**
* Abfragewert
*/
query?: string;
/**
* Art (z.B. Titel, Autor, Verlag, ...)
*/
type?: string;
}
@Injectable()
export abstract class UiFilterAutocompleteProvider {
abstract readonly for: string;
abstract complete(input: UiInput): Observable<UiFilterAutocomplete[]>;
}

View File

@@ -0,0 +1,5 @@
import { UiSearchboxScanProvider } from '@ui/searchbox';
export abstract class UiFilterScanProvider extends UiSearchboxScanProvider {
abstract readonly for: string;
}

View File

@@ -0,0 +1,20 @@
<button
*ngIf="!uiInput?.hasOptions()"
type="button"
class="ui-filter-chip"
[class.selected]="uiInput?.selected"
(click)="uiInput?.setSelected(!uiInput.selected)"
>
{{ uiInput?.label }}
</button>
<ng-container *ngIf="uiInput?.hasOptions()">
<button
*ngFor="let option of uiInput?.options?.values"
type="button"
class="ui-filter-chip"
[class.selected]="option?.selected"
(click)="option?.setSelected(!option.selected)"
>
{{ option?.label }}
</button>
</ng-container>

View File

@@ -0,0 +1,21 @@
:host {
@apply block;
}
button.ui-filter-chip {
@apply grid grid-flow-col gap-2 items-center rounded-full text-base px-4 py-3 bg-white text-dark-cerulean border-none font-bold;
}
/** styling branch bereich **/
::ng-deep .branch ui-filter-input-chip button.ui-filter-chip {
@apply text-cool-grey;
}
button.ui-filter-chip.selected {
@apply bg-dark-cerulean text-white;
}
/** styling branch bereich **/
::ng-deep .branch ui-filter-input-chip button.ui-filter-chip.selected {
@apply bg-cool-grey;
}

View File

@@ -0,0 +1,72 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UiIconModule } from '@ui/icon';
import { UiInput, UiInputGroup, UiInputType } from '../../tree';
import { UiFilterInputChipComponent } from './filter-input-chip.component';
describe('UiFilterInputChipComponent', () => {
let spectator: Spectator<UiFilterInputChipComponent>;
const createComponent = createComponentFactory({
component: UiFilterInputChipComponent,
imports: [UiIconModule],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
describe('input', () => {
it('should create an instance of UiInput if value is a plain object', () => {
spectator.setInput({
input: { type: UiInputType.NotSet },
});
expect(spectator.component.uiInput instanceof UiInput).toBe(true);
});
});
describe('button.ui-filter-chip Element', () => {
it('should have the text content of uiInput.label', () => {
spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ label: 'My Label', type: UiInputType.NotSet }));
spectator.detectComponentChanges();
expect(spectator.query('button.ui-filter-chip')).toContainText('My Label');
});
it('should have the class selected if uiInput.selected is true', () => {
spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ selected: true, type: UiInputType.NotSet }));
spectator.detectComponentChanges();
expect(spectator.query('button.ui-filter-chip')).toHaveClass('selected');
});
it('should not have the class selected if uiInput.selected is true', () => {
spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ selected: false, type: UiInputType.NotSet }));
spectator.detectComponentChanges();
expect(spectator.query('button.ui-filter-chip')).not.toHaveClass('selected');
});
it('should call uiInput.setSelected(!uiInput.selected) on click', () => {
spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ type: UiInputType.NotSet }));
spyOn(spectator.component.uiInput, 'setSelected');
spectator.detectComponentChanges();
spectator.click('button.ui-filter-chip');
expect(spectator.component.uiInput.setSelected).toHaveBeenCalledWith(!spectator.component.uiInput.selected);
});
});
describe('ui-icon Element', () => {
it('should not be visible if selected is false', () => {
spectator.setInput({ input: { type: 0, selected: false } });
spectator.detectComponentChanges();
expect(spectator.query('ui-icon')).not.toBeVisible();
});
it('should be visible if selected is true', () => {
spectator.setInput({ input: { type: 0, selected: true } });
spectator.detectComponentChanges();
expect(spectator.query('ui-icon')).toBeVisible();
});
});
});

View File

@@ -0,0 +1,50 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { merge, Subscription } from 'rxjs';
import { IUiInput, UiInput } from '../../tree';
@Component({
selector: 'ui-filter-input-chip',
templateUrl: 'filter-input-chip.component.html',
styleUrls: ['filter-input-chip.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterInputChipComponent implements OnDestroy {
private _input: UiInput;
@Input()
set input(value: IUiInput) {
if (value instanceof UiInput) {
this._input = value;
} else {
this._input = UiInput.create(value);
}
this.registerChanges();
}
get uiInput() {
return this._input;
}
private changeSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
ngOnDestroy() {
this.unregisterChanges();
}
registerChanges() {
this.unregisterChanges();
if (this.uiInput) {
merge(this.uiInput.changes, ...(this.uiInput?.options?.values?.map((o) => o.changes) || [])).subscribe(() => {
this.cdr.markForCheck();
});
}
}
unregisterChanges() {
this.changeSubscription?.unsubscribe();
this.changeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiFilterInputChipComponent } from './filter-input-chip.component';
@NgModule({
imports: [CommonModule],
exports: [UiFilterInputChipComponent],
declarations: [UiFilterInputChipComponent],
})
export class UiFilterInputChipModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-chip.component';
export * from './filter-input-chip.module';
// end:ng42.barrel

View File

@@ -0,0 +1,17 @@
<div class="ui-option">
<ui-checkbox [ngModel]="uiOption?.selected" (ngModelChange)="uiOption?.setSelected($event)">
{{ uiOption?.label }}
</ui-checkbox>
<button
class="btn-expand"
(click)="uiOption.setExpanded(!uiOption?.expanded)"
[class.expanded]="uiOption?.expanded"
type="button"
*ngIf="uiOption?.values?.length"
>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ng-container *ngIf="uiOption?.expanded">
<ui-input-option-bool *ngFor="let subOption of uiOption?.values" [option]="subOption"></ui-input-option-bool>
</ng-container>

View File

@@ -0,0 +1,23 @@
:host {
@apply grid grid-flow-row;
}
.ui-option {
@apply px-4 py-2 flex flex-row justify-between items-center;
.btn-expand {
@apply border-none outline-none bg-transparent text-cool-grey;
ui-icon {
@apply transition-all transform rotate-90;
}
}
.btn-expand.expanded ui-icon {
@apply -rotate-90;
}
}
::ng-deep ui-input-option-bool ui-checkbox ui-icon {
@apply text-cool-grey;
}

View File

@@ -0,0 +1,17 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UiFilterInputOptionBoolComponent } from './filter-input-option-bool.component';
describe('UiFilterInputOptionBoolComponent', () => {
let spectator: Spectator<UiFilterInputOptionBoolComponent>;
const createComponent = createComponentFactory({
component: UiFilterInputOptionBoolComponent,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeDefined();
});
});

View File

@@ -0,0 +1,51 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { IUiOption, UiOption } from '../../../tree';
@Component({
selector: 'ui-input-option-bool',
templateUrl: 'filter-input-option-bool.component.html',
styleUrls: ['filter-input-option-bool.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterInputOptionBoolComponent implements OnDestroy {
private _option: UiOption;
@Input()
set option(value: IUiOption) {
if (value instanceof UiOption) {
this._option = value;
} else {
this._option = UiOption.create(value);
}
this.subscribeChanges();
}
get uiOption() {
return this._option;
}
optionChangeSubscription = new Subscription();
constructor(private cdr: ChangeDetectorRef) {}
ngOnDestroy() {
this.unsubscribeChanges();
}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiOption) {
this.optionChangeSubscription.add(
this.uiOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiFilterInputOptionBoolComponent } from './filter-input-option-bool.component';
import { UiCheckboxModule } from '@ui/checkbox';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
@NgModule({
imports: [CommonModule, FormsModule, UiCheckboxModule, UiIconModule],
exports: [UiFilterInputOptionBoolComponent],
declarations: [UiFilterInputOptionBoolComponent],
})
export class UiFilterInputOptionBoolModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-bool.component';
export * from './filter-input-option-bool.module';
// end:ng42.barrel

View File

@@ -0,0 +1,27 @@
<div class="options-wrapper">
<div *ngIf="uiStartOption" class="option">
<div class="option-wrapper">
<span> {{ uiStartOption?.label }}: </span>
<button class="cta-picker" [class.open]="dpStart?.opened" type="button" (click)="dpStart?.toggle()">
<span>
{{ uiStartOption?.value | date: 'dd.MM.yy' }}
</span>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ui-datepicker class="dp-left" #dpStart [ngModel]="uiStartOption?.value" (save)="uiStartOption?.setValue($event)"> </ui-datepicker>
</div>
<div *ngIf="uiStopOption" class="option">
<div class="option-wrapper">
<span> {{ uiStopOption?.label }}: </span>
<button class="cta-picker" [class.open]="dpStop?.opened" type="button" (click)="dpStop?.toggle()">
<span>
{{ uiStopOption?.value | date: 'dd.MM.yy' }}
</span>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ui-datepicker class="dp-right" right="true" #dpStop [ngModel]="uiStopOption?.value" (save)="uiStopOption?.setValue($event)">
</ui-datepicker>
</div>
</div>

View File

@@ -0,0 +1,41 @@
:host {
@apply block p-4;
}
.options-wrapper {
@apply grid grid-flow-col justify-start gap-4;
}
.option {
@apply font-bold;
button.cta-picker {
@apply bg-transparent text-base outline-none border-none font-bold inline-flex flex-row items-center justify-between;
width: 100px;
}
ui-icon {
@apply ml-2 transition transform rotate-90 text-cool-grey;
}
button.cta-picker.open ui-icon {
@apply -rotate-90;
}
}
.option-wrapper {
@apply grid grid-flow-col gap-2 items-center;
min-height: 24px;
}
::ng-deep ui-input-option-date-range ui-datepicker.dp-left {
.dp {
left: 102px;
}
}
::ng-deep ui-input-option-date-range ui-datepicker.dp-right {
.dp {
right: -6px;
}
}

View File

@@ -0,0 +1,74 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UiInput, UiInputType, UiOption } from '../../../tree';
import { UiFilterInputOptionDateRangeComponent } from './filter-input-option-date-range.component';
describe('UiFilterInputOptionDateRangeComponent', () => {
let spectator: Spectator<UiFilterInputOptionDateRangeComponent>;
const createComponent = createComponentFactory({
component: UiFilterInputOptionDateRangeComponent,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
describe('set options(value: IUiOption[])', () => {
it('should create an instance of UiOption when values are not an instance of UiOption', () => {
spectator.setInput({
options: [{ key: 'start' }, { key: 'stop' }],
});
spectator.component['_options'].forEach((uiOption) => {
expect(uiOption instanceof UiOption).toBe(true);
});
});
it('should set the values when vales are an isntance of UiOption', () => {
const option1 = UiOption.create({ key: 'start' });
const option2 = UiOption.create({ key: 'stop' });
spectator.setInput({
options: [option1, option2],
});
expect(spectator.component['_options'][0]).toBe(option1);
expect(spectator.component['_options'][1]).toBe(option2);
});
});
describe('get uiOptions()', () => {
it('should return the value of _options', () => {
const option1 = UiOption.create({ key: 'start' });
const option2 = UiOption.create({ key: 'stop' });
const options = [option1, option2];
spectator.component['_options'] = options;
expect(spectator.component.uiOptions).toBe(options);
});
});
describe('get uiStartOption()', () => {
it('should retun the option with the key start', () => {
const option1 = UiOption.create({ key: 'start' });
const option2 = UiOption.create({ key: 'stop' });
const options = [option1, option2];
spectator.component['_options'] = options;
expect(spectator.component.uiStartOption).toBe(option1);
});
});
describe('get uiStopOption()', () => {
it('should return the option with the key stop', () => {
const option1 = UiOption.create({ key: 'start' });
const option2 = UiOption.create({ key: 'stop' });
const options = [option1, option2];
spectator.component['_options'] = options;
expect(spectator.component.uiStopOption).toBe(option2);
});
});
});

View File

@@ -0,0 +1,58 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IUiOption, UiOption } from '../../../tree';
@Component({
selector: 'ui-input-option-date-range',
templateUrl: 'filter-input-option-date-range.component.html',
styleUrls: ['filter-input-option-date-range.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiFilterInputOptionDateRangeComponent {
private _options: UiOption[];
@Input()
set options(value: IUiOption[]) {
this._options = value?.map((option) => (option instanceof UiOption ? option : UiOption.create(option)));
this.subscribeChanges();
}
get uiOptions() {
return this._options;
}
get uiStartOption() {
return this.uiOptions?.find((o) => o.key === 'start');
}
get uiStopOption() {
return this.uiOptions?.find((o) => o.key === 'stop');
}
optionChangeSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiStartOption) {
this.optionChangeSubscription.add(
this.uiStartOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
if (this.uiStopOption) {
this.optionChangeSubscription.add(
this.uiStopOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription?.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiFilterInputOptionDateRangeComponent } from './filter-input-option-date-range.component';
import { UiDatepickerModule } from '@ui/datepicker';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
@NgModule({
imports: [CommonModule, UiDatepickerModule, FormsModule, UiIconModule],
exports: [UiFilterInputOptionDateRangeComponent],
declarations: [UiFilterInputOptionDateRangeComponent],
})
export class UiFilterInputOptionDateRangeModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-date-range.component';
export * from './filter-input-option-date-range.module';
// end:ng42.barrel

View File

@@ -0,0 +1,19 @@
<div class="options-wrapper">
<div class="option" *ngIf="uiStartOption">
<div class="option-wrapper">
<ui-form-control [label]="uiStartOption?.label">
<input type="text" [ngModel]="uiStartOption?.value" (ngModelChange)="uiStartOption?.setValue($event)" />
</ui-form-control>
</div>
</div>
<div class="option" *ngIf="uiStopOption">
<div class="option-wrapper">
<ui-form-control [label]="uiStopOption?.label">
<input type="text" [ngModel]="uiStopOption?.value" (ngModelChange)="uiStopOption?.setValue($event)" />
</ui-form-control>
</div>
</div>
</div>
<p class="ui-filter-date-range-validation">
{{ uiStartOption?.validate() || uiStopOption?.validate() }}
</p>

View File

@@ -0,0 +1,16 @@
:host {
@apply block p-4;
}
.options-wrapper {
@apply grid grid-flow-col justify-start gap-4;
}
.option-wrapper {
@apply grid grid-flow-col gap-2 items-center;
min-height: 24px;
}
.ui-filter-date-range-validation {
@apply text-brand text-base font-bold;
}

View File

@@ -0,0 +1,58 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IUiOption, UiOption } from '../../../tree';
@Component({
selector: 'ui-input-option-number-range',
templateUrl: 'filter-input-option-number-range.component.html',
styleUrls: ['filter-input-option-number-range.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiInputOptionNumberRangeComponent {
private _options: UiOption[];
@Input()
set options(value: IUiOption[]) {
this._options = value?.map((option) => (option instanceof UiOption ? option : UiOption.create(option)));
this.subscribeChanges();
}
get uiOptions() {
return this._options;
}
get uiStartOption() {
return this.uiOptions?.find((o) => o.key === 'start');
}
get uiStopOption() {
return this.uiOptions?.find((o) => o.key === 'stop');
}
optionChangeSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiStartOption) {
this.optionChangeSubscription.add(
this.uiStartOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
if (this.uiStopOption) {
this.optionChangeSubscription.add(
this.uiStopOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription?.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiInputOptionNumberRangeComponent } from './filter-input-option-number-range.component';
import { UiFormControlModule } from '@ui/form-control';
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [CommonModule, UiFormControlModule, FormsModule],
exports: [UiInputOptionNumberRangeComponent],
declarations: [UiInputOptionNumberRangeComponent],
})
export class UiInputOptionNumberRangeModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-number-range.component';
export * from './filter-input-option-number-range.module';
// end:ng42.barrel

View File

@@ -0,0 +1,18 @@
<div class="ui-option">
<div>
<ui-switch [ngModel]="uiOption?.selected" (ngModelChange)="uiOption?.setSelected($event)" labelOn="mit" labelOff="ohne"> </ui-switch>
{{ uiOption?.label }}
</div>
<button
class="btn-expand"
(click)="uiOption.setExpanded(!uiOption?.expanded)"
[class.expanded]="uiOption?.expanded"
type="button"
*ngIf="uiOption?.values?.length"
>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ng-container *ngIf="uiOption?.expanded">
<ui-input-option-tri-state *ngFor="let subOption of uiOption?.values" [option]="subOption"></ui-input-option-tri-state>
</ng-container>

View File

@@ -0,0 +1,27 @@
:host {
@apply grid grid-flow-row;
}
ui-switch {
@apply mr-2;
}
.ui-option {
@apply px-4 py-2 flex flex-row justify-between items-center;
.btn-expand {
@apply border-none outline-none bg-transparent text-cool-grey;
ui-icon {
@apply transition-all transform rotate-90;
}
}
.btn-expand.expanded ui-icon {
@apply -rotate-90;
}
}
::ng-deep ui-input-option-bool ui-checkbox ui-icon {
@apply text-cool-grey;
}

View File

@@ -0,0 +1,51 @@
import { Component, ChangeDetectionStrategy, OnDestroy, Input, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IUiOption, UiOption } from '../../../tree';
@Component({
selector: 'ui-input-option-tri-state',
templateUrl: 'filter-input-option-tri-state.component.html',
styleUrls: ['filter-input-option-tri-state.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UiInputOptionTriStateComponent implements OnDestroy {
private _option: UiOption;
@Input()
set option(value: IUiOption) {
if (value instanceof UiOption) {
this._option = value;
} else {
this._option = UiOption.create(value);
}
this.subscribeChanges();
}
get uiOption() {
return this._option;
}
optionChangeSubscription = new Subscription();
constructor(private cdr: ChangeDetectorRef) {}
ngOnDestroy() {
this.unsubscribeChanges();
}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiOption) {
this.optionChangeSubscription.add(
this.uiOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiInputOptionTriStateComponent } from './filter-input-option-tri-state.component';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
import { UiSwitchModule } from '@ui/switch';
@NgModule({
imports: [CommonModule, UiSwitchModule, FormsModule, UiIconModule],
exports: [UiInputOptionTriStateComponent],
declarations: [UiInputOptionTriStateComponent],
})
export class UiInputOptionTriStateModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-tri-state.component';
export * from './filter-input-option-tri-state.module';
// end:ng42.barrel

View File

@@ -0,0 +1,34 @@
<div class="input-options-wrapper">
<div class="hidden-overflow">
<div class="input-options-header" [class.header-shadow]="scrollPersantage > 0">
<button type="button" (click)="setSelected(undefined)">
Alle entfernen
</button>
<button type="button" (click)="setSelected(true)" *ngIf="uiInputOptions?.parent?.type === 2 || uiInputOptions?.parent?.type === 4">
Alle auswählen
</button>
</div>
</div>
<div class="input-options-wrapper">
<p class="input-desription">
{{ uiInputOptions?.parent?.description }}
</p>
<ng-container *ngIf="uiInputOptions?.parent?.type === 2 || uiInputOptions?.parent?.type === 4">
<div class="input-options" #inputOptionsConainter (scroll)="markForCheck()">
<ng-container *ngIf="uiInputOptions?.parent?.type === 2">
<ui-input-option-bool *ngFor="let option of uiInputOptions?.values" [option]="option"></ui-input-option-bool>
</ng-container>
<ng-container *ngIf="uiInputOptions?.parent?.type === 4">
<ui-input-option-tri-state *ngFor="let option of uiInputOptions?.values" [option]="option"></ui-input-option-tri-state>
</ng-container>
</div>
<button class="cta-scroll" [class.up]="scrollPersantage > 20" *ngIf="scrollable" (click)="scroll(20)">
<ui-icon icon="arrow" size="20px"></ui-icon>
</button>
</ng-container>
<ui-input-option-date-range *ngIf="uiInputOptions?.parent?.type === 128" [options]="uiInputOptions?.values">
</ui-input-option-date-range>
<ui-input-option-number-range *ngIf="uiInputOptions?.parent?.type === 4096" [options]="uiInputOptions?.values">
</ui-input-option-number-range>
</div>
</div>

View File

@@ -0,0 +1,48 @@
:host {
@apply block;
}
.input-desription {
@apply my-0 px-4 font-bold text-base;
}
.input-options-wrapper {
grid-template-rows: auto 1fr;
@apply grid bg-white rounded-card;
}
.hidden-overflow {
@apply overflow-hidden;
}
.input-options-header {
@apply grid grid-flow-col justify-end items-center mb-2;
button {
@apply bg-transparent p-4 text-base outline-none border-none font-semibold text-cool-grey;
}
&.header-shadow {
@apply shadow-card;
}
}
.input-options-wrapper {
@apply relative;
}
.input-options {
@apply overflow-scroll;
}
.cta-scroll {
@apply absolute bottom-4 right-4 shadow-cta border-none outline-none bg-white w-10 h-10 rounded-full grid items-center justify-center text-cool-grey;
ui-icon {
@apply transition-transform transform rotate-90;
}
&.up ui-icon {
@apply -rotate-90;
}
}

Some files were not shown because too many files have changed in this diff Show More