diff --git a/cypress/integration/favorites.spec.ts b/cypress/integration/favorites.spec.ts index 1bad5a20..0829c951 100644 --- a/cypress/integration/favorites.spec.ts +++ b/cypress/integration/favorites.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 StApps + * Copyright (C) 2023 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -40,7 +40,6 @@ describe('favorites', function () { cy.get('.title').should('contain', text); cy.get('stapps-favorite-button').click(); }); - cy.get('cdk-virtual-scroll-viewport').should('be.not.visible'); cy.get('stapps-data-list').contains('Keine Ergebnisse').should('be.visible'); }); }); diff --git a/src/app/modules/data/data.module.ts b/src/app/modules/data/data.module.ts index 85d7ff9b..9949a445 100644 --- a/src/app/modules/data/data.module.ts +++ b/src/app/modules/data/data.module.ts @@ -95,11 +95,11 @@ import {BookDetailContentComponent} from './types/book/book-detail-content.compo import {BookListItemComponent} from './types/book/book-list-item.component'; import {PeriodicalListItemComponent} from './types/periodical/periodical-list-item.component'; import {PeriodicalDetailContentComponent} from './types/periodical/periodical-detail-content.component'; -import {SCThingListItemVirtualScrollStrategyDirective} from './list/sc-thing-list-item-virtual-scroll-strategy.directive'; import {DataListItemHostDirective} from './list/data-list-item-host.directive'; import {DataListItemHostDefaultComponent} from './list/data-list-item-host-default.component'; import {browserFactory, SimpleBrowser} from '../../util/browser.factory'; import {DishCharacteristicsComponent} from './types/dish/dish-characteristics.component'; +import {SkeletonListComponent} from './list/skeleton-list.component'; /** * Module for handling data @@ -128,6 +128,7 @@ import {DishCharacteristicsComponent} from './types/dish/dish-characteristics.co EventListItemComponent, FavoriteButtonComponent, FavoriteDetailContentComponent, + SkeletonListComponent, FavoriteListItemComponent, FoodDataListComponent, LocateActionChipComponent, @@ -147,7 +148,6 @@ import {DishCharacteristicsComponent} from './types/dish/dish-characteristics.co PlaceListItemComponent, PlaceMensaDetailComponent, SearchPageComponent, - SCThingListItemVirtualScrollStrategyDirective, SemesterDetailContentComponent, SemesterListItemComponent, DataListItemHostDirective, @@ -209,7 +209,6 @@ import {DishCharacteristicsComponent} from './types/dish/dish-characteristics.co }, ], exports: [ - SCThingListItemVirtualScrollStrategyDirective, DataDetailComponent, DataDetailContentComponent, DataIconPipe, diff --git a/src/app/modules/data/list/data-list.component.ts b/src/app/modules/data/list/data-list.component.ts index 6fd07fa9..74535865 100644 --- a/src/app/modules/data/list/data-list.component.ts +++ b/src/app/modules/data/list/data-list.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 StApps + * Copyright (C) 2023 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -12,7 +12,6 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling'; import { Component, ContentChild, @@ -29,6 +28,7 @@ import { } from '@angular/core'; import {SCThings} from '@openstapps/core'; import {BehaviorSubject, Observable, Subscription} from 'rxjs'; +import {IonInfiniteScroll} from '@ionic/angular'; export interface DataListContext { $implicit: T; @@ -43,11 +43,6 @@ export interface DataListContext { styleUrls: ['data-list.scss'], }) export class DataListComponent implements OnChanges, OnInit, OnDestroy { - /** - * Amount of list items left to show (in percent) that should trigger a data reload - */ - private readonly reloadThreshold = 0.2; - /** * All SCThings to display */ @@ -86,7 +81,7 @@ export class DataListComponent implements OnChanges, OnInit, OnDestroy { */ subscriptions: Subscription[] = []; - @ViewChild(CdkVirtualScrollViewport) viewPort: CdkVirtualScrollViewport; + @ViewChild(IonInfiniteScroll) infiniteScroll: IonInfiniteScroll; /** * Signalizes that the data is being loaded @@ -113,6 +108,7 @@ export class DataListComponent implements OnChanges, OnInit, OnDestroy { ngOnChanges(changes: SimpleChanges): void { if (Array.isArray(this.items) && typeof changes.items !== 'undefined') { this.itemStream.next(this.items); + this.infiniteScroll.complete(); } } @@ -127,7 +123,7 @@ export class DataListComponent implements OnChanges, OnInit, OnDestroy { if (typeof this.resetToTop !== 'undefined') { this.subscriptions.push( this.resetToTop.subscribe(() => { - this.viewPort.scrollToIndex(0); + // this.viewPort.scrollToIndex(0); }), ); } @@ -139,18 +135,4 @@ export class DataListComponent implements OnChanges, OnInit, OnDestroy { notifyLoadMore() { this.loadMore.emit(); } - - /** - * Function to call whenever scroll view visible range changed - */ - scrolled(index: number) { - if ( - // first condition prevents "load more" to be executed even before scrolling - index > 0 && - (this.items?.length ?? 0) - this.viewPort.getRenderedRange().end <= - (this.items?.length ?? 0) * this.reloadThreshold - ) { - this.notifyLoadMore(); - } - } } diff --git a/src/app/modules/data/list/data-list.html b/src/app/modules/data/list/data-list.html index b6569188..a0f8c0ea 100644 --- a/src/app/modules/data/list/data-list.html +++ b/src/app/modules/data/list/data-list.html @@ -15,29 +15,23 @@ - - + + - + + + +
{{ 'search.nothing_found' | translate | titlecase }}
- - - + diff --git a/src/app/modules/data/list/data-list.scss b/src/app/modules/data/list/data-list.scss index 086552c5..6fb09920 100644 --- a/src/app/modules/data/list/data-list.scss +++ b/src/app/modules/data/list/data-list.scss @@ -1,5 +1,5 @@ /*! - * Copyright (C) 2022 StApps + * Copyright (C) 2023 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -13,17 +13,10 @@ * this program. If not, see . */ -cdk-virtual-scroll-viewport { +ion-list { + background: none; +} + +skeleton-list { height: 100%; - width: 100%; -} - -::ng-deep { - .cdk-virtual-scroll-content-wrapper { - width: 100%; - } -} - -.virtual-scroll-expander { - clear: both; } diff --git a/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.directive.ts b/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.directive.ts deleted file mode 100644 index c56468f0..00000000 --- a/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.directive.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -import {Directive, forwardRef, Input, Output, ViewChild} from '@angular/core'; -import {CdkVirtualForOf, VIRTUAL_SCROLL_STRATEGY} from '@angular/cdk/scrolling'; -import {ScThingListItemVirtualScrollStrategy} from './sc-thing-list-item-virtual-scroll-strategy'; - -/** - * - */ -function factory(directive: SCThingListItemVirtualScrollStrategyDirective) { - return directive.scrollStrategy; -} - -@Directive({ - selector: 'cdk-virtual-scroll-viewport[scThingListItemVirtualScrollStrategy]', - providers: [ - { - provide: VIRTUAL_SCROLL_STRATEGY, - useFactory: factory, - deps: [forwardRef(() => SCThingListItemVirtualScrollStrategyDirective)], - }, - ], -}) -export class SCThingListItemVirtualScrollStrategyDirective { - scrollStrategy = new ScThingListItemVirtualScrollStrategy(); - - @ViewChild(CdkVirtualForOf) virtualForOf: CdkVirtualForOf; - - @Output() readonly loadMore = this.scrollStrategy.loadMore; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - @Input() set trackGroupBy(value: (item: any) => unknown) { - this.scrollStrategy.trackGroupBy = value; - } - - @Input() set minimumHeight(value: number) { - this.scrollStrategy.approximateItemHeight = value; - } - - @Input() set buffer(value: number) { - this.scrollStrategy.buffer = value; - } - - @Input() set gap(value: number) { - this.scrollStrategy.gap = value; - } - - @Input() set itemRenderTimeout(value: number) { - this.scrollStrategy.itemRenderTimeout = value; - } -} diff --git a/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.ts b/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.ts deleted file mode 100644 index 0b5aea51..00000000 --- a/src/app/modules/data/list/sc-thing-list-item-virtual-scroll-strategy.ts +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import {CdkVirtualForOf, CdkVirtualScrollViewport, VirtualScrollStrategy} from '@angular/cdk/scrolling'; -import {BehaviorSubject, Subject, Subscription, takeUntil, timer} from 'rxjs'; -import {debounceTime, distinctUntilChanged, tap} from 'rxjs/operators'; -import {SCThingType} from '@openstapps/core'; - -export class ScThingListItemVirtualScrollStrategy implements VirtualScrollStrategy { - private viewport?: CdkVirtualScrollViewport; - - private virtualForOf?: CdkVirtualForOf; - - private index$ = new Subject(); - - private heights = new Map(); - - private groupHeights = new Map(); - - private offsets: number[] = []; - - private totalHeight = 0; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _items?: readonly unknown[]; - - private _groups?: readonly unknown[]; - - /** - * We use this to track loadMore - */ - private currentLength = 0; - - private dataStreamSubscription?: Subscription; - - private mutationObserver?: MutationObserver; - - approximateItemHeight = 67; - - approximateGroupSizes: Map = new Map([[SCThingType.AcademicEvent, 139]]); - - buffer = 4000; - - gap = 8; - - itemRenderTimeout = 1000; - - heightUpdateDebounceTime = 25; - - heightSetDebounceTime = 100; - - loadMore = new Subject(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - trackGroupBy: (value: any) => unknown = it => it.type; - - attach(viewport: CdkVirtualScrollViewport): void { - this.viewport = viewport; - // @ts-expect-error private property - this.virtualForOf = viewport._forOf; - this.dataStreamSubscription = this.virtualForOf?.dataStream.subscribe(items => { - this.items = items; - }); - - this.mutationObserver = new MutationObserver(() => { - const renderedItems = this.renderedItems; - if (!renderedItems) { - this.mutationObserver?.disconnect(); - return this.onPrematureDisconnect(); - } - - this.intersectionObserver?.disconnect(); - this.intersectionObserver = new IntersectionObserver(this.observeIntersection.bind(this), { - rootMargin: `${this.buffer - 64}px`, - threshold: 1, - }); - - for (const node of renderedItems) { - const [item, group] = this.getItemByNode(node, renderedItems); - - if (!this.heights.has(item)) { - this.intersectionObserver.observe(node); - } else { - node.style.height = `${this.getHeight(item, group)}px`; - } - } - }); - - const contentWrapper = this.contentWrapper; - if (!contentWrapper) return this.onPrematureDisconnect(); - this.mutationObserver.observe(contentWrapper, {childList: true, subtree: true}); - - this.setTotalContentSize(); - } - - detach(): void { - this.index$.complete(); - this.dataStreamSubscription?.unsubscribe(); - this.mutationObserver?.disconnect(); - delete this.viewport; - } - - private getHeight(item: unknown, group: unknown) { - return ( - this.heights.get(item) || - this.groupHeights.get(group) || - this.approximateGroupSizes.get(group) || - this.approximateItemHeight - ); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private set items(value: readonly unknown[]) { - const trackBy = this.virtualForOf?.cdkVirtualForTrackBy; - const tracks = value.map((it, i) => (trackBy ? trackBy(i, it) : it)); - if ( - this._items && - tracks.length === this._items.length && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - tracks.every((it, i) => it === this._items![i]) - ) - return; - - this._items = tracks; - this._groups = value.map(this.trackGroupBy); - this.currentLength = tracks.length; - - this.updateHeights(); - } - - scrolledIndexChange = this.index$.pipe(distinctUntilChanged()); - - private getOffsetFromIndex(index: number): number { - return this.offsets[index]; - } - - private getIndexFromOffset(offset: number): number { - return Math.max( - 0, - this.offsets.findIndex((it, i, array) => it >= offset || i === array.length - 1), - ); - } - - private updateHeights() { - if (!this._items) return; - const heights = this._items.map((it, i) => this.getHeight(it, this._groups![i]) + this.gap); - this.offsets = Array.from({length: heights.length}, () => 0); - this.totalHeight = heights.reduce((a, b, index) => (this.offsets[index + 1] = a + b), 0) + this.gap; - - this.setTotalContentSize(); - this.updateRenderedRange(); - } - - private updateRenderedRange() { - if (!this.viewport) return; - const offset = this.viewport.measureScrollOffset('top'); - const viewportSize = this.viewport.getViewportSize(); - const firstVisibleIndex = Math.max(0, this.getIndexFromOffset(offset) - 1); - const range = { - start: this.getIndexFromOffset(Math.max(0, offset - this.buffer)), - end: this.getIndexFromOffset(offset + viewportSize + this.buffer), - }; - const {start, end} = this.viewport.getRenderedRange(); - if (range.start === start && range.end === end) return; - - if (this.currentLength !== 0 && range.end === this.currentLength) { - this.currentLength++; - this.loadMore.next(); - } - - this.viewport.setRenderedRange(range); - this.viewport.setRenderedContentOffset(this.getOffsetFromIndex(range.start), 'to-start'); - - this.index$.next(firstVisibleIndex); - } - - private setTotalContentSize() { - this.viewport?.setTotalContentSize(this.totalHeight); - // @ts-expect-error TODO - this.viewport?._measureViewportSize(); - } - - observeIntersection(entries: IntersectionObserverEntry[], observer: IntersectionObserver) { - const renderedItems = this.renderedItems; - if (!renderedItems) return this.onPrematureDisconnect(); - - const update = new Subject(); - for (const entry of entries) { - if (!entry.isIntersecting) continue; - - const outerNode = entry.target as HTMLElement; - const [item] = this.getItemByNode(outerNode, renderedItems); - const node = outerNode.firstChild! as HTMLElement; - - const height = new BehaviorSubject(node.offsetHeight); - const resizeObserver = new ResizeObserver(() => { - const renderedItems = this.renderedItems; - if (!renderedItems) { - resizeObserver.disconnect(); - return this.onPrematureDisconnect(); - } - const [newItem] = this.getItemByNode(node, renderedItems); - if (newItem !== item) { - this.heights.delete(item); - resizeObserver.disconnect(); - return; - } - height.next(node.offsetHeight); - }); - resizeObserver.observe(node); - height - .pipe( - distinctUntilChanged(), - debounceTime(this.heightSetDebounceTime), - takeUntil(timer(this.itemRenderTimeout)), - tap({complete: () => resizeObserver.disconnect()}), - ) - .subscribe(height => { - this.heights.set(item, height); - outerNode.style.height = `${height}px`; - update.next(); - }); - - observer.unobserve(node); - } - update - .pipe(debounceTime(this.heightUpdateDebounceTime), takeUntil(timer(this.itemRenderTimeout))) - .subscribe(() => { - this.updateHeights(); - }); - } - - intersectionObserver?: IntersectionObserver; - - get contentWrapper() { - return this.viewport?._contentWrapper.nativeElement; - } - - get renderedItems() { - const contentWrapper = this.contentWrapper; - return contentWrapper - ? // eslint-disable-next-line unicorn/prefer-spread - (Array.from(contentWrapper.children).filter(it => it instanceof HTMLElement) as HTMLElement[]) - : undefined; - } - - getItemByNode(node: HTMLElement, renderedItems: HTMLElement[]) { - const {start} = this.viewport!.getRenderedRange(); - const index = renderedItems.indexOf(node) + start; - return [this._items![index], this._groups![index]]; - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - onContentRendered(): void {} - - onContentScrolled() { - this.updateRenderedRange(); - } - - onPrematureDisconnect() { - console.warn('Virtual Scroll strategy was disconnected unexpectedly', new Error('foo').stack); - } - - onDataLengthChanged(): void { - this.setTotalContentSize(); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - onRenderedOffsetChanged(): void {} - - scrollToIndex(index: number, behavior: ScrollBehavior): void { - this.viewport?.scrollToOffset(this.getOffsetFromIndex(index), behavior); - } -} diff --git a/src/app/modules/data/list/skeleton-list.component.ts b/src/app/modules/data/list/skeleton-list.component.ts new file mode 100644 index 00000000..ed122e41 --- /dev/null +++ b/src/app/modules/data/list/skeleton-list.component.ts @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2023 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {Component} from '@angular/core'; + +@Component({ + selector: 'skeleton-list', + templateUrl: 'skeleton-list.html', + styleUrls: ['skeleton-list.scss'], +}) +export class SkeletonListComponent {} diff --git a/src/app/modules/data/list/skeleton-list.html b/src/app/modules/data/list/skeleton-list.html new file mode 100644 index 00000000..5348bcba --- /dev/null +++ b/src/app/modules/data/list/skeleton-list.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/modules/data/list/skeleton-list.scss b/src/app/modules/data/list/skeleton-list.scss new file mode 100644 index 00000000..6b525fd6 --- /dev/null +++ b/src/app/modules/data/list/skeleton-list.scss @@ -0,0 +1,34 @@ +/*! + * Copyright (C) 2023 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +svg { + width: 100%; + height: 100%; +} + +$item-height: 92; +$gap: 4; + +.label { + fill: var(--ion-color-medium); + opacity: 0.1; +} + +.item { + rx: var(--border-radius-default); + fill: var(--ion-item-background, var(--ion-background-color, #fff)); + x: var(--spacing-sm); + y: var(--spacing-sm); + width: calc(100% - var(--spacing-sm) * 2); +}