mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-22 09:32:41 +00:00
feat: implement custom cdk virtual scroll behavior
This commit is contained in:
committed by
Rainer Killinger
parent
f5ca1508fb
commit
968cb72957
@@ -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
|
* 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
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
* Software Foundation, version 3.
|
* Software Foundation, version 3.
|
||||||
@@ -24,9 +24,9 @@ describe('ical', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should export a single event', function () {
|
it('should export a single event', function () {
|
||||||
cy.visit('/search');
|
cy.visit('/search?query=test');
|
||||||
cy.get('ion-searchbar').click().type('test');
|
cy.wait('@search');
|
||||||
cy.contains('ion-item', 'UNIcert (Test)').contains('ion-chip', 'Termine Auswählen').click();
|
cy.contains('ion-chip', 'Termine Auswählen').first().click();
|
||||||
|
|
||||||
cy.get('ion-app > ion-modal').within(() => {
|
cy.get('ion-app > ion-modal').within(() => {
|
||||||
cy.get('ion-footer > ion-toolbar > ion-button').should('have.attr', 'disabled');
|
cy.get('ion-footer > ion-toolbar > ion-button').should('have.attr', 'disabled');
|
||||||
|
|||||||
@@ -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
|
~ 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
|
~ under the terms of the GNU General Public License as published by the Free
|
||||||
~ Software Foundation, version 3.
|
~ Software Foundation, version 3.
|
||||||
@@ -13,8 +13,6 @@
|
|||||||
~ this program. If not, see <https://www.gnu.org/licenses/>.
|
~ this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div>
|
<stapps-locate-action-chip *ngIf="applicable.locate" [item]="item"></stapps-locate-action-chip>
|
||||||
<stapps-locate-action-chip *ngIf="applicable.locate" [item]="item"></stapps-locate-action-chip>
|
<!-- Add Event Chip needs to load data and should be the last -->
|
||||||
<!-- Add Event Chip needs to load data and should be the last -->
|
<stapps-add-event-action-chip *ngIf="applicable.event" [item]="item"></stapps-add-event-action-chip>
|
||||||
<stapps-add-event-action-chip *ngIf="applicable.event" [item]="item"></stapps-add-event-action-chip>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
div {
|
/*!
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
||||||
|
&:has(*) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
* Software Foundation, version 3.
|
* Software Foundation, version 3.
|
||||||
|
|||||||
@@ -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
|
* 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
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
* Software Foundation, version 3.
|
* Software Foundation, version 3.
|
||||||
@@ -95,6 +95,9 @@ import {BookDetailContentComponent} from './types/book/book-detail-content.compo
|
|||||||
import {BookListItemComponent} from './types/book/book-list-item.component';
|
import {BookListItemComponent} from './types/book/book-list-item.component';
|
||||||
import {PeriodicalListItemComponent} from './types/periodical/periodical-list-item.component';
|
import {PeriodicalListItemComponent} from './types/periodical/periodical-list-item.component';
|
||||||
import {PeriodicalDetailContentComponent} from './types/periodical/periodical-detail-content.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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for handling data
|
* Module for handling data
|
||||||
@@ -141,8 +144,11 @@ import {PeriodicalDetailContentComponent} from './types/periodical/periodical-de
|
|||||||
PlaceListItemComponent,
|
PlaceListItemComponent,
|
||||||
PlaceMensaDetailComponent,
|
PlaceMensaDetailComponent,
|
||||||
SearchPageComponent,
|
SearchPageComponent,
|
||||||
|
SCThingListItemVirtualScrollStrategyDirective,
|
||||||
SemesterDetailContentComponent,
|
SemesterDetailContentComponent,
|
||||||
SemesterListItemComponent,
|
SemesterListItemComponent,
|
||||||
|
DataListItemHostDirective,
|
||||||
|
DataListItemHostDefaultComponent,
|
||||||
SimpleCardComponent,
|
SimpleCardComponent,
|
||||||
SkeletonListItemComponent,
|
SkeletonListItemComponent,
|
||||||
SkeletonSegmentComponent,
|
SkeletonSegmentComponent,
|
||||||
@@ -195,6 +201,7 @@ import {PeriodicalDetailContentComponent} from './types/periodical/periodical-de
|
|||||||
SettingsProvider,
|
SettingsProvider,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
SCThingListItemVirtualScrollStrategyDirective,
|
||||||
DataDetailComponent,
|
DataDetailComponent,
|
||||||
DataDetailContentComponent,
|
DataDetailContentComponent,
|
||||||
DataIconPipe,
|
DataIconPipe,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Component, Input} from '@angular/core';
|
||||||
|
import {SCThings} from '@openstapps/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'data-list-item-host-default',
|
||||||
|
templateUrl: 'data-list-item-host-default.html',
|
||||||
|
})
|
||||||
|
export class DataListItemHostDefaultComponent {
|
||||||
|
@Input() item: SCThings;
|
||||||
|
}
|
||||||
24
src/app/modules/data/list/data-list-item-host-default.html
Normal file
24
src/app/modules/data/list/data-list-item-host-default.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
~ 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 <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
{{ 'name' | thingTranslate: item }}
|
||||||
|
</h2>
|
||||||
|
<p *ngIf="item.description">
|
||||||
|
<stapps-long-inline-text
|
||||||
|
[text]="'description' | thingTranslate: item"
|
||||||
|
[size]="80"
|
||||||
|
></stapps-long-inline-text>
|
||||||
|
</p>
|
||||||
84
src/app/modules/data/list/data-list-item-host.directive.ts
Normal file
84
src/app/modules/data/list/data-list-item-host.directive.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import {ComponentRef, Directive, Input, Type, ViewContainerRef} from '@angular/core';
|
||||||
|
import {SCThings, SCThingType} from '@openstapps/core';
|
||||||
|
import {BookListItemComponent} from '../types/book/book-list-item.component';
|
||||||
|
import {CatalogListItemComponent} from '../types/catalog/catalog-list-item.component';
|
||||||
|
import {DateSeriesListItemComponent} from '../types/date-series/date-series-list-item.component';
|
||||||
|
import {EventListItemComponent} from '../types/event/event-list-item.component';
|
||||||
|
import {FavoriteListItemComponent} from '../types/favorite/favorite-list-item.component';
|
||||||
|
import {MessageListItemComponent} from '../types/message/message-list-item.component';
|
||||||
|
import {OrganizationListItemComponent} from '../types/organization/organization-list-item.component';
|
||||||
|
import {PersonListItemComponent} from '../types/person/person-list-item.component';
|
||||||
|
import {PlaceListItemComponent} from '../types/place/place-list-item.component';
|
||||||
|
import {SemesterListItemComponent} from '../types/semester/semester-list-item.component';
|
||||||
|
import {VideoListItemComponent} from '../types/video/video-list-item.component';
|
||||||
|
import {PeriodicalListItemComponent} from '../types/periodical/periodical-list-item.component';
|
||||||
|
import {DataListItemHostDefaultComponent} from './data-list-item-host-default.component';
|
||||||
|
import {ArticleListItemComponent} from '../types/article/article-item.component';
|
||||||
|
import {DishListItemComponent} from '../types/dish/dish-list-item.component';
|
||||||
|
|
||||||
|
export interface DataListItem {
|
||||||
|
item: SCThings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataListItemIndex: Partial<Record<SCThingType, Type<DataListItem>>> = {
|
||||||
|
[SCThingType.Catalog]: CatalogListItemComponent,
|
||||||
|
[SCThingType.Dish]: DishListItemComponent,
|
||||||
|
[SCThingType.DateSeries]: DateSeriesListItemComponent,
|
||||||
|
[SCThingType.AcademicEvent]: EventListItemComponent,
|
||||||
|
[SCThingType.SportCourse]: DateSeriesListItemComponent,
|
||||||
|
[SCThingType.Favorite]: FavoriteListItemComponent,
|
||||||
|
[SCThingType.Message]: MessageListItemComponent,
|
||||||
|
[SCThingType.Organization]: OrganizationListItemComponent,
|
||||||
|
[SCThingType.Person]: PersonListItemComponent,
|
||||||
|
[SCThingType.Building]: PlaceListItemComponent,
|
||||||
|
[SCThingType.Floor]: PlaceListItemComponent,
|
||||||
|
[SCThingType.PointOfInterest]: PlaceListItemComponent,
|
||||||
|
[SCThingType.Room]: PlaceListItemComponent,
|
||||||
|
[SCThingType.Semester]: SemesterListItemComponent,
|
||||||
|
[SCThingType.Video]: VideoListItemComponent,
|
||||||
|
[SCThingType.Periodical]: PeriodicalListItemComponent,
|
||||||
|
[SCThingType.Book]: BookListItemComponent,
|
||||||
|
[SCThingType.Article]: ArticleListItemComponent,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[dataListItemHost]',
|
||||||
|
})
|
||||||
|
export class DataListItemHostDirective {
|
||||||
|
private type?: Type<DataListItem>;
|
||||||
|
|
||||||
|
private component?: ComponentRef<DataListItem>;
|
||||||
|
|
||||||
|
constructor(readonly viewContainerRef: ViewContainerRef) {}
|
||||||
|
|
||||||
|
@Input() set dataListItemHost(value: SCThings | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
this.viewContainerRef.clear();
|
||||||
|
delete this.type;
|
||||||
|
delete this.component;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = DataListItemIndex[value.type] || DataListItemHostDefaultComponent;
|
||||||
|
if (this.type !== type || !this.component) {
|
||||||
|
this.type = type;
|
||||||
|
this.viewContainerRef.clear();
|
||||||
|
this.component = this.viewContainerRef.createComponent(this.type);
|
||||||
|
}
|
||||||
|
this.component.instance.item = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import {DataListContext} from './data-list.component';
|
|||||||
})
|
})
|
||||||
export class DataListItemComponent {
|
export class DataListItemComponent {
|
||||||
/**
|
/**
|
||||||
* Whether or not the list item should show a thumbnail
|
* Whether the list item should show a thumbnail
|
||||||
*/
|
*/
|
||||||
@Input() hideThumbnail = false;
|
@Input() hideThumbnail = false;
|
||||||
|
|
||||||
@@ -40,6 +40,10 @@ export class DataListItemComponent {
|
|||||||
|
|
||||||
@Input() lines = 'inset';
|
@Input() lines = 'inset';
|
||||||
|
|
||||||
|
@Input() forceHeight = false;
|
||||||
|
|
||||||
|
height?: string;
|
||||||
|
|
||||||
@Input() appearance: 'normal' | 'square' = 'normal';
|
@Input() appearance: 'normal' | 'square' = 'normal';
|
||||||
|
|
||||||
@ContentChild(TemplateRef) contentTemplateRef: TemplateRef<DataListContext<SCThings>>;
|
@ContentChild(TemplateRef) contentTemplateRef: TemplateRef<DataListContext<SCThings>>;
|
||||||
|
|||||||
@@ -36,82 +36,9 @@
|
|||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ng-template #defaultContent>
|
<ng-template #defaultContent>
|
||||||
<ion-label class="ion-text-wrap" [ngSwitch]="true">
|
<ion-label class="ion-text-wrap">
|
||||||
<div>
|
<div>
|
||||||
<stapps-catalog-list-item
|
<ng-template [dataListItemHost]="item"></ng-template>
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'catalog'"
|
|
||||||
></stapps-catalog-list-item>
|
|
||||||
<stapps-date-series-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'date series'"
|
|
||||||
></stapps-date-series-list-item>
|
|
||||||
<stapps-dish-list-item [item]="$any(item)" *ngSwitchCase="item.type === 'dish'"></stapps-dish-list-item>
|
|
||||||
<stapps-event-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'academic event'"
|
|
||||||
></stapps-event-list-item>
|
|
||||||
<stapps-event-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'sport course'"
|
|
||||||
></stapps-event-list-item>
|
|
||||||
<stapps-favorite-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'favorite'"
|
|
||||||
></stapps-favorite-list-item>
|
|
||||||
<stapps-message-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'message'"
|
|
||||||
></stapps-message-list-item>
|
|
||||||
<stapps-organization-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'organization'"
|
|
||||||
></stapps-organization-list-item>
|
|
||||||
<stapps-person-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'person'"
|
|
||||||
></stapps-person-list-item>
|
|
||||||
<stapps-place-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'building'"
|
|
||||||
></stapps-place-list-item>
|
|
||||||
<stapps-place-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'floor'"
|
|
||||||
></stapps-place-list-item>
|
|
||||||
<stapps-place-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'point of interest'"
|
|
||||||
></stapps-place-list-item>
|
|
||||||
<stapps-place-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'room'"
|
|
||||||
></stapps-place-list-item>
|
|
||||||
<stapps-semester-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'semester'"
|
|
||||||
></stapps-semester-list-item>
|
|
||||||
<stapps-video-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'video'"
|
|
||||||
></stapps-video-list-item>
|
|
||||||
<stapps-book-list-item [item]="$any(item)" *ngSwitchCase="item.type === 'book'"></stapps-book-list-item>
|
|
||||||
<stapps-periodical-list-item
|
|
||||||
[item]="$any(item)"
|
|
||||||
*ngSwitchCase="item.type === 'periodical'"
|
|
||||||
></stapps-periodical-list-item>
|
|
||||||
<stapps-article-item [item]="$any(item)" *ngSwitchCase="item.type === 'article'"></stapps-article-item>
|
|
||||||
<div *ngSwitchDefault>
|
|
||||||
<h2>
|
|
||||||
{{ 'name' | thingTranslate: item }}
|
|
||||||
</h2>
|
|
||||||
<p *ngIf="item.description">
|
|
||||||
<stapps-long-inline-text
|
|
||||||
[text]="'description' | thingTranslate: item"
|
|
||||||
[size]="80"
|
|
||||||
></stapps-long-inline-text>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<stapps-action-chip-list
|
<stapps-action-chip-list
|
||||||
*ngIf="appearance !== 'square'"
|
*ngIf="appearance !== 'square'"
|
||||||
slot="end"
|
slot="end"
|
||||||
|
|||||||
@@ -15,6 +15,21 @@
|
|||||||
@import 'src/theme/util/_mixins.scss';
|
@import 'src/theme/util/_mixins.scss';
|
||||||
@import 'src/theme/common/_helper.scss';
|
@import 'src/theme/common/_helper.scss';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-item::part(native) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ion-text-wrap ::ng-deep ion-label {
|
||||||
|
white-space: normal !important;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
ion-item {
|
ion-item {
|
||||||
--border-color: transparent;
|
--border-color: transparent;
|
||||||
@include border-radius-in-parallax(var(--border-radius-default));
|
@include border-radius-in-parallax(var(--border-radius-default));
|
||||||
|
|||||||
@@ -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
|
~ 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
|
~ under the terms of the GNU General Public License as published by the Free
|
||||||
~ Software Foundation, version 3.
|
~ Software Foundation, version 3.
|
||||||
@@ -14,21 +14,17 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<ng-container *ngIf="itemStream | async as items">
|
<ng-container *ngIf="itemStream | async as items">
|
||||||
|
<ng-content select="[header]"></ng-content>
|
||||||
<cdk-virtual-scroll-viewport
|
<cdk-virtual-scroll-viewport
|
||||||
itemSize="80"
|
scThingListItemVirtualScrollStrategy
|
||||||
minBufferPx="1500"
|
|
||||||
maxBufferPx="2000"
|
|
||||||
(scrolledIndexChange)="scrolled($event)"
|
|
||||||
[style.display]="items && items.length ? 'block' : 'none'"
|
[style.display]="items && items.length ? 'block' : 'none'"
|
||||||
|
(loadMore)="notifyLoadMore()"
|
||||||
>
|
>
|
||||||
<ng-content select="[header]"></ng-content>
|
<ng-container *cdkVirtualFor="let item of items; trackBy: identifyItem">
|
||||||
<ion-list>
|
<ng-container
|
||||||
<ng-container *cdkVirtualFor="let item of items; trackBy: identifyItem">
|
*ngTemplateOutlet="listItemTemplateRef || defaultListItem; context: {$implicit: item}"
|
||||||
<ng-container
|
></ng-container>
|
||||||
*ngTemplateOutlet="listItemTemplateRef || defaultListItem; context: {$implicit: item}"
|
</ng-container>
|
||||||
></ng-container>
|
|
||||||
</ng-container>
|
|
||||||
</ion-list>
|
|
||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div [style.display]="!loading && items && items.length === 0 ? 'block' : 'none'">
|
<div [style.display]="!loading && items && items.length === 0 ? 'block' : 'none'">
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
cdk-virtual-scroll-viewport {
|
cdk-virtual-scroll-viewport {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
ion-list {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<unknown>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<unknown>;
|
||||||
|
|
||||||
|
private index$ = new Subject<number>();
|
||||||
|
|
||||||
|
private heights = new Map<unknown, number | undefined>();
|
||||||
|
|
||||||
|
private groupHeights = new Map<unknown, number | undefined>();
|
||||||
|
|
||||||
|
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<unknown, number> = new Map([[SCThingType.AcademicEvent, 139]]);
|
||||||
|
|
||||||
|
buffer = 4000;
|
||||||
|
|
||||||
|
gap = 8;
|
||||||
|
|
||||||
|
itemRenderTimeout = 1000;
|
||||||
|
|
||||||
|
heightUpdateDebounceTime = 25;
|
||||||
|
|
||||||
|
heightSetDebounceTime = 100;
|
||||||
|
|
||||||
|
loadMore = new Subject<void>();
|
||||||
|
|
||||||
|
// 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<void>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,18 +216,20 @@ export class SearchPageComponent implements OnInit, OnDestroy {
|
|||||||
const result = await this.dataProvider.search(searchOptions);
|
const result = await this.dataProvider.search(searchOptions);
|
||||||
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
|
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
|
||||||
if (append) {
|
if (append) {
|
||||||
let items = await this.items;
|
|
||||||
// append results
|
// append results
|
||||||
items = [...items, ...result.data];
|
this.items = this.items.then(it =>
|
||||||
this.items = (async () => items)();
|
// fix for some very short results
|
||||||
|
it.length === result.pagination.total ? it : [...it, ...result.data],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// override items with results
|
// override items with results
|
||||||
this.items = (async () => {
|
this.updateContextFilter(result.facets);
|
||||||
this.updateContextFilter(result.facets);
|
this.items = Promise.resolve(result.data);
|
||||||
|
|
||||||
return result.data;
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.items.then(it => {
|
||||||
|
if (it.length === result.pagination.total) console.log('final page loaded');
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(error);
|
this.logger.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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
|
* 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
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
* Software Foundation, version 3.
|
* Software Foundation, version 3.
|
||||||
@@ -15,15 +15,15 @@
|
|||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
SCFacet,
|
SCFacet,
|
||||||
|
SCFavorite,
|
||||||
|
SCIndexableThings,
|
||||||
|
SCSaveableThing,
|
||||||
SCSearchBooleanFilter,
|
SCSearchBooleanFilter,
|
||||||
SCSearchFilter,
|
SCSearchFilter,
|
||||||
SCSearchSort,
|
SCSearchSort,
|
||||||
SCSearchValueFilter,
|
SCSearchValueFilter,
|
||||||
SCThings,
|
SCThings,
|
||||||
SCFavorite,
|
|
||||||
SCSaveableThing,
|
|
||||||
SCThingType,
|
SCThingType,
|
||||||
SCIndexableThings,
|
|
||||||
SCUuid,
|
SCUuid,
|
||||||
} from '@openstapps/core';
|
} from '@openstapps/core';
|
||||||
import {StorageProvider} from '../storage/storage.provider';
|
import {StorageProvider} from '../storage/storage.provider';
|
||||||
@@ -32,7 +32,7 @@ import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
|
|||||||
import {TranslateService} from '@ngx-translate/core';
|
import {TranslateService} from '@ngx-translate/core';
|
||||||
import {ThingTranslateService} from '../../translation/thing-translate.service';
|
import {ThingTranslateService} from '../../translation/thing-translate.service';
|
||||||
import {BehaviorSubject, Observable} from 'rxjs';
|
import {BehaviorSubject, Observable} from 'rxjs';
|
||||||
import {map} from 'rxjs/operators';
|
import {debounceTime, map} from 'rxjs/operators';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service handling favorites
|
* Service handling favorites
|
||||||
@@ -48,7 +48,8 @@ export class FavoritesService {
|
|||||||
|
|
||||||
favorites = new BehaviorSubject<Map<string, SCFavorite>>(new Map<string, SCFavorite>());
|
favorites = new BehaviorSubject<Map<string, SCFavorite>>(new Map<string, SCFavorite>());
|
||||||
|
|
||||||
favoritesChanged$ = this.favorites.asObservable();
|
// using debounce time 0 allows change detection to run through async suspension
|
||||||
|
favoritesChanged$ = this.favorites.pipe(debounceTime(0));
|
||||||
|
|
||||||
static getDataFromFavorites(items: SCFavorite[]) {
|
static getDataFromFavorites(items: SCFavorite[]) {
|
||||||
return items.map(item => item.data);
|
return items.map(item => item.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user