mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
[Customer Search Results] Add Lazy Loading and Loading Spinner
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
59
apps/ui/common/src/lib/is-in-viewport.directive.ts
Normal file
59
apps/ui/common/src/lib/is-in-viewport.directive.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ module.exports = {
|
||||
'px-15': '15px',
|
||||
'px-20': '20px',
|
||||
'px-100': '100px',
|
||||
'px-150': '150px',
|
||||
},
|
||||
padding: {
|
||||
card: '20px',
|
||||
|
||||
Reference in New Issue
Block a user