[Customer Search Results] Add Lazy Loading and Loading Spinner

This commit is contained in:
Sebastian
2020-10-01 21:47:43 +02:00
parent 08103ab552
commit c4c9a0a46d
10 changed files with 224 additions and 25 deletions

View File

@@ -19,10 +19,14 @@ export class CrmCustomerService {
});
}
getCustomers(queryString: string): Observable<PagedResult<CustomerInfoDTO>> {
getCustomers(
queryString: string,
options: { take?: number; skip?: number } = { take: 20, skip: 0 }
): Observable<PagedResult<CustomerInfoDTO>> {
return this.customerService.CustomerListCustomers({
input: { qs: queryString },
take: 20,
take: options.take,
skip: options.skip,
});
}
}

View File

@@ -6,7 +6,16 @@ import { CrmCustomerService } from '@domain/crm';
import { PagedResult } from '@domain/defs';
import { AutocompleteDTO, CustomerInfoDTO } from '@swagger/crm';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { catchError, debounceTime, filter, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
import {
catchError,
debounceTime,
filter,
map,
shareReplay,
switchMap,
takeUntil,
tap,
} from 'rxjs/operators';
import { ResultState } from './defs';
@Injectable()
@@ -30,6 +39,30 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
return this.searchState$.value;
}
private get numberOfResultsFetched(): number {
if (
!this.searchResult$ ||
!this.searchResult$.value ||
!Array.isArray(this.searchResult$.value.result)
) {
return 0;
}
return this.searchResult$.value.result.length;
}
private get totalResults(): number {
if (
!this.searchResult$ ||
!this.searchResult$.value ||
!this.searchResult$.value.hits
) {
return 0;
}
return this.searchResult$.value.hits;
}
constructor(private zone: NgZone, private router: Router) {}
ngOnInit() {
@@ -63,7 +96,10 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
private initQueryControl() {
this.queryControl = new FormControl('', [Validators.required, Validators.minLength(3)]);
this.queryControl = new FormControl('', [
Validators.required,
Validators.minLength(3),
]);
}
getQueryParams() {
@@ -89,11 +125,20 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
});
}
search(): void {
search(options: { isNewSearch: boolean } = { isNewSearch: true }): void {
if (
!options.isNewSearch &&
this.numberOfResultsFetched >= this.totalResults
) {
return; // early exit because no new products need to be fetched
}
if (this.searchState !== 'fetching') {
this.searchState$.next('fetching');
this.customerSearch
.getCustomers(this.queryControl.value)
.getCustomers(this.queryControl.value, {
skip: options.isNewSearch ? 0 : this.numberOfResultsFetched,
take: 10,
})
.pipe(
takeUntil(this.destroy$),
catchError((err: HttpErrorResponse) => {
@@ -114,7 +159,14 @@ export abstract class CustomerSearch implements OnInit, OnDestroy {
}
})
)
.subscribe((r) => this.searchResult$.next(r));
.subscribe((r) =>
options.isNewSearch
? this.searchResult$.next(r)
: this.searchResult$.next({
...r,
result: [...this.searchResult$.value.result, ...r.result],
})
);
}
}

View File

@@ -1,7 +1,10 @@
<a class="card-create-customer">
<span class="title"> Kundendaten erfassen </span>
</a>
<div class="card-search-customer" *ngIf="search.searchState$ | async as searchState">
<div
class="card-search-customer"
*ngIf="search.searchState$ | async as searchState"
>
<h1 class="title">Kundensuche</h1>
<p class="info">
Wie lautet Ihr Name oder <br />
@@ -28,15 +31,36 @@
>
<ui-icon icon="close" size="22px"></ui-icon>
</button>
<button type="submit" uiSearchboxSearchButton (click)="search.search()" [disabled]="searchState === 'fetching'">
<ui-icon class="spin" *ngIf="searchState === 'fetching'" icon="spinner" size="32px"></ui-icon>
<ui-icon *ngIf="searchState !== 'fetching'" icon="search" size="24px"></ui-icon>
<button
type="submit"
uiSearchboxSearchButton
(click)="search.search()"
[disabled]="searchState === 'fetching'"
>
<ui-icon
class="spin"
*ngIf="searchState === 'fetching'"
icon="spinner"
size="32px"
></ui-icon>
<ui-icon
*ngIf="searchState !== 'fetching'"
icon="search"
size="24px"
></ui-icon>
</button>
<ui-searchbox-autocomplete uiClickOutside (clicked)="autocomplete.close()" #autocomplete>
<button uiSearchboxAutocompleteOption [value]="result?.display" *ngFor="let result of search.autocompleteResult$ | async">
<ui-searchbox-autocomplete
uiClickOutside
(clicked)="autocomplete.close()"
#autocomplete
>
<button
uiSearchboxAutocompleteOption
[value]="result?.display"
*ngFor="let result of search.autocompleteResult$ | async"
>
{{ result?.display }}
</button>
</ui-searchbox-autocomplete>
</ui-searchbox>
<pre>{{ search.searchResult$ | async | json }}</pre>
</div>

View File

@@ -1,12 +1,24 @@
<div class="scroll-container">
<div
class="scroll-container"
*ngIf="search.searchState$ | async as searchState"
>
<a
[class.last]="last"
[class.load]="searchState === 'fetching'"
href="#"
*ngFor="
let customer of (search.searchResult$ | async)?.result;
let last = last
"
uiIsInViewport
[options]="viewportEnterOptions"
(viewportEntered)="checkIfReload($event)"
>
<page-customer-result-card [customer]="customer" [class.last]="last">
<page-customer-result-card [customer]="customer">
</page-customer-result-card>
</a>
<div class="loader" *ngIf="searchState === 'fetching'">
<ui-icon class="spin" icon="spinner" size="32px"></ui-icon>
<p class="hint">Weitere Kunden werden geladen...</p>
</div>
</div>

View File

@@ -1,15 +1,38 @@
.scroll-container {
@apply h-full overflow-y-scroll;
@apply h-full overflow-y-scroll relative;
}
page-customer-result-card {
@apply mb-px-10;
&.last {
@apply mb-px-100;
}
}
a {
@apply no-underline;
&.last {
page-customer-result-card {
@apply mb-px-100;
}
&.load {
page-customer-result-card {
@apply mb-px-150;
}
}
}
}
.loader {
@apply sticky w-full flex items-center justify-center text-font-customer;
bottom: 110px;
height: 32px;
.hint {
@apply font-light whitespace-no-wrap ml-5;
}
.spin {
@apply animate-spin;
}
}

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { CustomerSearch } from '../customer-search.service';
@Component({
@@ -7,8 +12,25 @@ import { CustomerSearch } from '../customer-search.service';
styleUrls: ['./search-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomerSearchResultComponent implements OnInit {
export class CustomerSearchResultComponent implements OnInit, OnDestroy {
protected readonly viewportEnterOptions: IntersectionObserverInit = {
threshold: 0.75,
};
constructor(public search: CustomerSearch) {}
ngOnInit() {}
ngOnDestroy() {
this.search.searchState$.next('init');
}
checkIfReload(target: HTMLElement): void {
if (target.classList.contains('last')) {
this.triggerSearch();
}
}
triggerSearch() {
this.search.search({ isNewSearch: false });
}
}

View File

@@ -1,10 +1,11 @@
import { NgModule } from '@angular/core';
import { UiClickOutsideDirective } from './click-outside.directive';
import { IsInViewportDirective } from './is-in-viewport.directive';
@NgModule({
imports: [],
exports: [UiClickOutsideDirective],
declarations: [UiClickOutsideDirective],
exports: [UiClickOutsideDirective, IsInViewportDirective],
declarations: [UiClickOutsideDirective, IsInViewportDirective],
providers: [],
})
export class UiCommonModule {}

View File

@@ -1,4 +1,5 @@
// start:ng42.barrel
export * from './click-outside.directive';
export * from './common.module';
export * from './is-in-viewport.directive';
// end:ng42.barrel

View File

@@ -0,0 +1,59 @@
import {
Directive,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
Type,
} from '@angular/core';
@Directive({ selector: '[uiIsInViewport]' })
export class IsInViewportDirective implements OnInit, OnDestroy {
public observer: IntersectionObserver;
@Input() observee: ElementRef | HTMLElement;
@Input() options: IntersectionObserverInit = {
root: null,
rootMargin: '0px',
threshold: 0,
};
@Output() viewportEntered = new EventEmitter<HTMLElement>();
private get target(): HTMLElement {
if (this.observee) {
return this.observee instanceof ElementRef
? this.observee.nativeElement
: this.observee;
}
return this.elementRef.nativeElement;
}
constructor(private elementRef: ElementRef) {}
ngOnInit() {
this.observer = new IntersectionObserver(
this.intersectionCallback,
this.options
);
this.observer.observe(this.target);
}
ngOnDestroy() {
this.observer.unobserve(this.target);
this.observer.disconnect();
}
private intersectionCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.viewportEntered.emit(this.target);
}
});
};
}

View File

@@ -30,6 +30,7 @@ module.exports = {
'px-15': '15px',
'px-20': '20px',
'px-100': '100px',
'px-150': '150px',
},
padding: {
card: '20px',