[HIMA-67] - Replaced infinite scroll with virtual scroll implementation

This commit is contained in:
Peter Skrlj
2019-02-12 08:36:07 +01:00
parent abca531e91
commit 741d042030
11 changed files with 271 additions and 123 deletions

View File

@@ -12,6 +12,7 @@
"private": true,
"dependencies": {
"@angular/animations": "~7.2.0",
"@angular/cdk": "7.0.0",
"@angular/common": "~7.2.0",
"@angular/compiler": "~7.2.0",
"@angular/core": "~7.2.0",

View File

@@ -34,7 +34,16 @@ import { FilterState } from './core/store/state/filter.state';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from './shared/shared.module';
import { AutocompleteState } from './core/store/state/autocomplete.state';
const states = [FeedState, ProcessState, BreadcrumbsState, FilterState, AutocompleteState];
import { ScrollingModule } from '@angular/cdk/scrolling';
const states = [
FeedState,
ProcessState,
BreadcrumbsState,
FilterState,
AutocompleteState
];
export function _configInitializer(conf: ConfigService) {
// load config from /assets/config.json
@@ -75,7 +84,8 @@ export function _feedServiceEndpointProviderFactory(conf: ConfigService) {
FeedServiceModule,
FormsModule,
ReactiveFormsModule,
SharedModule
SharedModule,
ScrollingModule
],
providers: [
{
@@ -104,7 +114,7 @@ export function _feedServiceEndpointProviderFactory(conf: ConfigService) {
provide: FEED_SERVICE_ENDPOINT,
useFactory: _feedServiceEndpointProviderFactory,
deps: [ConfigService]
},
}
// { provide: CatSearchService, useClass: CatSearchMockService }, // Uncomment if u want to use the CatSearchMockService
// { provide: FeedService, useClass: FeedMockService } // Uncomment if u want to use the FeedMockService
],

View File

@@ -1,13 +1,12 @@
@import '../../../assets/scss/variables';
.card-container {
display: grid;
grid-template-columns: min-content auto;
grid-gap: 7vh;
background-color: white;
padding: 24px;
margin-top: 10px;
border-radius: 5px;
padding: 24px;
min-height: 134px;
}
.recommanded {

View File

@@ -1,130 +1,130 @@
@import "../../../assets/scss/variables";
@import '../../../assets/scss/variables';
.card-container {
display: grid;
grid-template-columns: min-content auto;
grid-gap: 7vh;
background-color: white;
padding: 24px;
margin-top: 10px;
border-radius: 5px;
display: grid;
grid-template-columns: min-content auto;
grid-gap: 7vh;
background-color: white;
border-radius: 5px;
padding: 24px;
min-height: 134px;
}
.recommanded {
background-color: $important-notification;
color: white;
background-color: $important-notification;
color: white;
}
.product-icon {
width: 69px;
width: 69px;
}
.content-container {
display: grid;
grid-template-columns: auto;
display: grid;
grid-template-columns: auto;
}
.author span {
font-size: 16px;
font-size: 16px;
}
.title-price {
display: grid;
grid-template-columns: auto max-content;
grid-gap: 8vh;
margin-top: 10px;
display: grid;
grid-template-columns: auto max-content;
grid-gap: 8vh;
margin-top: 10px;
}
.title span {
font-size: 22px;
font-weight: bold;
font-size: 22px;
font-weight: bold;
}
.price span {
font-size: 20px;
font-weight: bold;
font-size: 20px;
font-weight: bold;
}
.title-grid {
display: grid;
grid-template-columns: min-content auto;
grid-gap: 2vh;
display: grid;
grid-template-columns: min-content auto;
grid-gap: 2vh;
}
.rec-icon-container {
padding-top: 4px;
padding-top: 4px;
}
.currency {
margin-left: 5px;
margin-left: 5px;
}
.type-stock {
display: grid;
grid-template-columns: auto max-content;
margin-top: 10px;
display: grid;
grid-template-columns: auto max-content;
margin-top: 10px;
}
.type {
display: grid;
grid-template-columns: min-content auto;
grid-gap: 2vh;
display: grid;
grid-template-columns: min-content auto;
grid-gap: 2vh;
}
.type-text {
font-size: 18px;
font-weight: bold;
font-size: 18px;
font-weight: bold;
}
.available-stock {
display: grid;
grid-template-columns: auto auto auto auto;
grid-gap: 2vh;
display: grid;
grid-template-columns: auto auto auto auto;
grid-gap: 2vh;
}
.available-icon-container {
padding-top: 2px;
padding-top: 2px;
}
.stock-icon {
width: 15px;
width: 15px;
}
.available-text span {
font-size: 18px;
font-weight: bold;
font-size: 18px;
font-weight: bold;
}
.stock span {
font-size: 18px;
font-weight: bold;
font-size: 18px;
font-weight: bold;
}
.publisher-order {
display: grid;
grid-template-columns: auto max-content;
margin-top: 10px;
display: grid;
grid-template-columns: auto max-content;
margin-top: 10px;
}
.publisher-serial {
display: grid;
grid-template-columns: max-content min-content auto;
grid-gap: 2vh;
display: grid;
grid-template-columns: max-content min-content auto;
grid-gap: 2vh;
}
.publisher-serial span {
font-size: 16px;
font-size: 16px;
}
.order span {
font-size: 16px;
color: #a7b9cb;
font-size: 16px;
color: #a7b9cb;
}
.type-icon-container {
padding-top: 3px;
padding-top: 3px;
}
.publisher {
max-width: 300px;
}
max-width: 300px;
}

View File

@@ -1,27 +1,22 @@
<div class="result-container">
<app-filter></app-filter>
<div
id="start"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollThrottle]="50"
(scrolled)="onScroll()"
[scrollWindow]="true"
>
<app-product-card *ngFor="let product of products" [product]="product">
</app-product-card>
</div>
<div *ngIf="(process$ | async)?.loading">
<div [@stagger]="'yes'">
<div *ngFor="let dummy of dummies">
<app-product-card-loading> </app-product-card-loading>
</div>
<cdk-virtual-scroll-viewport itemSize="180" class="viewport">
<div *cdkVirtualFor="let product of ds" class="product-item">
<app-product-card
[product]="product"
*ngIf="product != null; else loadingComponent"
>
</app-product-card>
<ng-template #loadingComponent>
<app-product-card-loading></app-product-card-loading>
</ng-template>
</div>
<app-loading
[style.marginTop.px]="60"
[style.marginBottom.px]="60"
loading="true"
text="Inhalte werden geladen"
></app-loading>
</div>
</cdk-virtual-scroll-viewport>
<app-loading
*ngIf="(process$ | async)?.loading"
[style.marginTop.px]="60"
[style.marginBottom.px]="60"
loading="true"
text="Inhalte werden geladen"
></app-loading>
</div>

View File

@@ -3,3 +3,20 @@ app-product-card-loading {
position: relative;
width: 100%;
}
app-filter {
display: block;
}
.result-container {
height: calc(100% - 100px);
}
.viewport {
height: 100%;
width: 100%;
}
.product-item {
height: 180px;
overflow: hidden;
padding-top: 10px;
}

View File

@@ -1,12 +1,13 @@
import { ProductService } from './../../core/services/product.service';
import { Component, OnInit } from '@angular/core';
import { Search } from 'src/app/core/models/search.model';
import { Process } from 'src/app/core/models/process.model';
import { Product } from 'src/app/core/models/product.model';
import { Router } from '@angular/router';
import { map, filter } from 'rxjs/operators';
import { map, filter, take } from 'rxjs/operators';
import { Select, Store } from '@ngxs/store';
import { ItemDTO } from 'dist/cat-service/lib/dtos';
import { Observable } from 'rxjs';
import { Observable, BehaviorSubject, Subscription } from 'rxjs';
import { GetProducts } from 'src/app/core/store/actions/product.actions';
import { ProcessState } from 'src/app/core/store/state/process.state';
import {
@@ -15,6 +16,7 @@ import {
} from 'src/app/core/store/actions/process.actions';
import { ProductMapping } from 'src/app/core/mappings/product.mapping';
import { staggerAnimation } from './stagger.animation';
import { DataSource, CollectionViewer } from '@angular/cdk/collections';
@Component({
selector: 'app-search-results',
@@ -26,16 +28,17 @@ export class SearchResultsComponent implements OnInit {
@Select(ProcessState.getCurrentProcess) process$;
currentSearch: Search;
products: Product[];
loading = true;
@Select(ProcessState.getProducts) products$: Observable<ItemDTO[]>;
skip = 0;
dummies = [1, 2, 3, 4, 5, 6, 7, 8, 9];
ds: SearchDataSource;
constructor(
private store: Store,
private router: Router,
private productMapping: ProductMapping
private productMapping: ProductMapping,
private productService: ProductService
) {}
ngOnInit() {
@@ -44,8 +47,12 @@ export class SearchResultsComponent implements OnInit {
this.router.navigate(['dashboard']);
return;
}
this.store.dispatch(new GetProducts(this.currentSearch));
this.loadProducts();
this.ds = new SearchDataSource(
this.productService,
this.currentSearch.query
);
// this.store.dispatch(new GetProducts(this.currentSearch));
// this.loadProducts();
}
loadCurrentSearch() {
@@ -73,6 +80,73 @@ export class SearchResultsComponent implements OnInit {
filter(f => Array.isArray(f)),
map(items => items.map(item => this.productMapping.fromItemDTO(item)))
)
.subscribe(data => (this.products = data), () => (this.loading = false));
.subscribe(data => (this.products = data));
}
}
export class SearchDataSource extends DataSource<Product | undefined> {
private pageSize = 10;
private cachedData = Array.from<Product>({ length: 0 });
private fetchedPages = new Set<number>();
private dataStream = new BehaviorSubject<(Product | undefined)[]>(
this.cachedData
);
private subscription = new Subscription();
private productMapping = new ProductMapping();
constructor(private searchService: ProductService, private search: string) {
super();
}
connect(
collectionViewer: CollectionViewer
): Observable<(Product | undefined)[]> {
this.subscription.add(
collectionViewer.viewChange.subscribe(range => {
const startPage = this.getPageForIndex(range.start);
const endPage = this.getPageForIndex(range.end - 1);
for (let i = startPage; i <= endPage; i++) {
this.fetchPage(i);
}
})
);
this.fetchPage(0);
return this.dataStream;
}
disconnect(): void {
this.subscription.unsubscribe();
}
private getPageForIndex(index: number): number {
return Math.floor(index / this.pageSize);
}
private fetchPage(page: number) {
if (this.fetchedPages.has(page)) {
return;
}
this.fetchedPages.add(page);
this.searchService
.searchItemsWithPagination(
this.search,
page * this.pageSize,
this.pageSize,
[]
)
.pipe(take(1))
.subscribe(data => {
if (page === 0) {
this.cachedData = Array.from<Product>({ length: data.hits });
}
this.cachedData.splice(
page * this.pageSize,
this.pageSize,
...data.result.map(item => this.productMapping.fromItemDTO(item))
);
this.dataStream.next(this.cachedData);
});
}
}

View File

@@ -14,47 +14,54 @@ import { map } from 'rxjs/operators';
import { Search } from '../models/search.model';
import { FilterMapping } from '../mappings/filter.mapping';
@Injectable({
providedIn: 'root'
})
export class ProductService {
searchResponse$: Observable<PagedApiResponse<ItemDTO>>;
constructor(
private searchService: CatSearchService,
private filterMapping: FilterMapping
) { }
) {}
persistLastSearchToLocalStorage(param: string) {
// get recent searches from local storage
const recentSearches: RecentArticleSearch[] = JSON.parse(localStorage.getItem('recent_searches'));
const recentSearches: RecentArticleSearch[] = JSON.parse(
localStorage.getItem('recent_searches')
);
/*
* check if there are search items in local storage, if there are not add search to local storage
* else check if current search already exists in loacl storage, if exist delete it (deletion is made becouse
* we want every search to display in LIFO order).
* finaly push the new search at the end of the local storage array
*/
* check if there are search items in local storage, if there are not add search to local storage
* else check if current search already exists in loacl storage, if exist delete it (deletion is made becouse
* we want every search to display in LIFO order).
* finaly push the new search at the end of the local storage array
*/
if (recentSearches) {
const searches = [...recentSearches.filter((data) => {
return data.name !== param;
}), <RecentArticleSearch>{
id: recentSearches[recentSearches.length - 1].id + 1,
name: param
}];
const searches = [
...recentSearches.filter(data => {
return data.name !== param;
}),
<RecentArticleSearch>{
id: recentSearches[recentSearches.length - 1].id + 1,
name: param
}
];
localStorage.setItem('recent_searches', JSON.stringify(searches));
} else {
const searches = [<RecentArticleSearch>{
id: 1,
name: param
}];
const searches = [
<RecentArticleSearch>{
id: 1,
name: param
}
];
localStorage.setItem('recent_searches', JSON.stringify(searches));
}
}
getRecentSearches(): Observable<RecentArticleSearch[]> {
const recentSearches: RecentArticleSearch[] = JSON.parse(localStorage.getItem('recent_searches'));
const recentSearches: RecentArticleSearch[] = JSON.parse(
localStorage.getItem('recent_searches')
);
return of(recentSearches);
}
// placeholder service method for calling product search API
@@ -66,13 +73,16 @@ export class ProductService {
// service method for calling product search API
searchItems(search: Search): Observable<ItemDTO[]> {
this.persistLastSearchToLocalStorage(search.query);
const queryToken = <QueryTokenDTO> {
input: {qs: search.query},
const queryToken = <QueryTokenDTO>{
input: { qs: search.query },
skip: search.skip,
take: search.take
};
const queryWithFilters = this.filterMapping.toQueryTokenDto(queryToken, search.fitlers);
const queryWithFilters = this.filterMapping.toQueryTokenDto(
queryToken,
search.fitlers
);
return this.searchService.search(queryWithFilters).pipe(
map(response => {
if (response.error) {
@@ -84,6 +94,31 @@ export class ProductService {
);
}
searchItemsWithPagination(
query,
skip,
size,
filters
): Observable<PagedApiResponse<ItemDTO>> {
const queryToken = <QueryTokenDTO>{
input: { qs: query },
skip: skip,
take: size
};
const queryWithFilters = this.filterMapping.toQueryTokenDto(
queryToken,
filters
);
return this.searchService.search(queryWithFilters).pipe(
map(response => {
if (response.error) {
throw new Error(response.message);
}
return response;
})
);
}
getItemById(id: number): Observable<ItemDTO> {
return this.searchService.getById(id).pipe(
map(response => {

View File

@@ -25,6 +25,7 @@ import { CheckoutComponent } from '../components/checkout/checkout.component';
import { SharedModule } from '../shared/shared.module';
import { ProcessModule } from './process/process.module';
import { ProductCardLoadingComponent } from '../components/product-card-loading/product-card-loading.component';
import { ScrollingModule } from '@angular/cdk/scrolling';
@NgModule({
declarations: [
@@ -43,6 +44,7 @@ import { ProductCardLoadingComponent } from '../components/product-card-loading/
imports: [
CommonModule,
AppRoutingModule,
ScrollingModule,
FormsModule,
SharedModule,
NewsletterSignupModule,

View File

@@ -1,9 +1,12 @@
@import "../../../assets/scss/variables";
@import '../../../assets/scss/variables';
.content-body {
height:100%;
background-color: $hima-content-color;
padding-top: 150px;
padding-left: 15px;
padding-right: 15px;
}
background-color: $hima-content-color;
padding-top: 150px;
padding-left: 15px;
padding-right: 15px;
position: absolute;
width: calc(100% - 30px);
height: calc(100% - 230px);
top: 0;
}

View File

@@ -128,6 +128,14 @@
dependencies:
tslib "^1.9.0"
"@angular/cdk@7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-7.0.0.tgz#b98d7378e84fed1af30c460aa91af4ada2cf252b"
dependencies:
tslib "^1.7.1"
optionalDependencies:
parse5 "^5.0.0"
"@angular/cli@~7.2.1":
version "7.2.2"
resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-7.2.2.tgz#408dc7cd69931301c108ee2637836f0e9e7e3f02"
@@ -4958,6 +4966,10 @@ parse5@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
parse5@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2"
parseqs@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
@@ -6658,7 +6670,7 @@ tsickle@>=0.34.0:
mkdirp "^0.5.1"
source-map "^0.7.3"
tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.9.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"