diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e724b6f7..3d737bab 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -23,8 +23,8 @@ import localeDe from '@angular/common/locales/de'; import {APP_INITIALIZER, NgModule, Provider} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {RouteReuseStrategy} from '@angular/router'; -import {SplashScreen} from '@ionic-native/splash-screen/ngx'; import {Diagnostic} from '@ionic-native/diagnostic/ngx'; +import {SplashScreen} from '@ionic-native/splash-screen/ngx'; import {StatusBar} from '@ionic-native/status-bar/ngx'; import {IonicModule, IonicRouteStrategy} from '@ionic/angular'; import { @@ -54,6 +54,7 @@ import {fakeBackendProvider} from './_helpers/fake-backend.interceptor'; import {UtilModule} from './util/util.module'; import {initLogger} from './_helpers/ts-logger'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {FavoritesModule} from './modules/favorites/favorites.module'; registerLocaleData(localeDe); @@ -140,6 +141,7 @@ const providers: Provider[] = [ ConfigModule, DataModule, IonicModule.forRoot(), + FavoritesModule, MapModule, MenuModule, NewsModule, diff --git a/src/app/modules/data/data.module.ts b/src/app/modules/data/data.module.ts index 544f7412..92a532d6 100644 --- a/src/app/modules/data/data.module.ts +++ b/src/app/modules/data/data.module.ts @@ -80,6 +80,7 @@ import {VideoListItemComponent} from './types/video/video-list-item.component'; import {OriginInListComponent} from './elements/origin-in-list.component'; import {CoordinatedSearchProvider} from './coordinated-search.provider'; import {Geolocation} from '@ionic-native/geolocation/ngx'; +import {FavoriteButtonComponent} from './elements/favorite-button.component'; /** * Module for handling data @@ -105,6 +106,7 @@ import {Geolocation} from '@ionic-native/geolocation/ngx'; DishListItemComponent, EventDetailContentComponent, EventListItemComponent, + FavoriteButtonComponent, FavoriteDetailContentComponent, FavoriteListItemComponent, FoodDataListComponent, diff --git a/src/app/modules/data/data.provider.ts b/src/app/modules/data/data.provider.ts index e871d3f0..73268dc8 100644 --- a/src/app/modules/data/data.provider.ts +++ b/src/app/modules/data/data.provider.ts @@ -15,18 +15,20 @@ import {Injectable} from '@angular/core'; import {Client} from '@openstapps/api/lib/client'; import { + SCFacet, SCIndexableThings, SCMultiSearchRequest, SCMultiSearchResponse, SCSearchRequest, SCSearchResponse, SCSearchValueFilter, + SCThing, SCThingOriginType, SCThings, SCThingsField, SCThingType, + SCSaveableThing, } from '@openstapps/core'; -import {SCSaveableThing} from '@openstapps/core'; import {chunk, fromPairs, toPairs} from 'lodash-es'; import {environment} from '../../../environments/environment'; import {StorageProvider} from '../storage/storage.provider'; @@ -110,6 +112,31 @@ export class DataProvider { }; } + /** + * Create a facet from data + * + * @param items Data to generate facet for + * @param field Field for which to generate facet + */ + static facetForField(items: SCThing[], field: SCThingsField): SCFacet { + const bucketMap = new Map(); + const facet: SCFacet = {buckets: [], field: field}; + + for (const item of items) { + const value = + typeof bucketMap.get(item.type) === 'undefined' + ? 1 + : (bucketMap.get(item.type) as number) + 1; + bucketMap.set(item.type, value); + } + + for (const [key, value] of bucketMap.entries()) { + facet.buckets.push({key: key, count: value}); + } + + return facet; + } + /** * TODO * @@ -128,6 +155,28 @@ export class DataProvider { this.storageProvider = storageProvider; } + /** + * Create savable thing from an indexable thing + * + * @param item An indexable to create savable thing from + * @param type The type (falls back to the type of the indexable thing) + */ + static createSaveable( + item: SCIndexableThings, + type?: SCThingType, + ): SCSaveableThing { + return { + data: item, + name: item.name, + origin: { + created: new Date().toISOString(), + type: SCThingOriginType.User, + }, + type: typeof type === 'undefined' ? item.type : type, + uid: item.uid, + }; + } + /** * Delete a data item * @@ -234,28 +283,12 @@ export class DataProvider { /** * Save a data item * - * @param item Data item that needs to be saved - * @param [type] Savable type (e.g. 'favorite'); if nothing is provided then type of the thing is used + * @param item An item that needs to be saved */ - async put( - item: SCIndexableThings, - type?: SCThingType, - ): Promise { - const savableItem: SCSaveableThing = { - data: item, - name: item.name, - origin: { - created: new Date().toISOString(), - type: SCThingOriginType.User, - }, - type: typeof type === 'undefined' ? item.type : type, - uid: item.uid, - }; - - // @TODO: Implementation for saving item into the backend (user's account) + async put(item: SCIndexableThings): Promise { return this.storageProvider.put( this.getDataKey(item.uid), - savableItem, + DataProvider.createSaveable(item, item.type), ); } diff --git a/src/app/modules/data/detail/data-detail.component.spec.ts b/src/app/modules/data/detail/data-detail.component.spec.ts index c9945323..5fe45990 100644 --- a/src/app/modules/data/detail/data-detail.component.spec.ts +++ b/src/app/modules/data/detail/data-detail.component.spec.ts @@ -109,7 +109,8 @@ describe('DataDetailComponent', () => { ); }); - it('should get a data item when component is accessed', async () => { + it('should get a data item when the view is entered', () => { + comp.ionViewWillEnter(); expect(DataDetailComponent.prototype.getItem).toHaveBeenCalledWith( sampleThing.uid, ); diff --git a/src/app/modules/data/detail/data-detail.component.ts b/src/app/modules/data/detail/data-detail.component.ts index f5a87e5d..ec9e5ed3 100644 --- a/src/app/modules/data/detail/data-detail.component.ts +++ b/src/app/modules/data/detail/data-detail.component.ts @@ -12,18 +12,20 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, OnInit} from '@angular/core'; +import {Component} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; import {Network} from '@ionic-native/network/ngx'; import {IonRefresher} from '@ionic/angular'; import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import { SCLanguageCode, - SCSaveableThing, SCThings, SCUuid, + SCSaveableThing, } from '@openstapps/core'; import {DataProvider, DataScope} from '../data.provider'; +import {FavoritesService} from '../../favorites/favorites.service'; +import {take} from 'rxjs/operators'; /** * A Component to display an SCThing detailed @@ -33,7 +35,7 @@ import {DataProvider, DataScope} from '../data.provider'; styleUrls: ['data-detail.scss'], templateUrl: 'data-detail.html', }) -export class DataDetailComponent implements OnInit { +export class DataDetailComponent { /** * The associated item * @@ -60,12 +62,14 @@ export class DataDetailComponent implements OnInit { * @param route the route the page was accessed from * @param dataProvider the data provider * @param network the network provider - * @param translateService the translation service + * @param favoritesService the favorites provider + * @param translateService he translate provider */ constructor( private readonly route: ActivatedRoute, private readonly dataProvider: DataProvider, private readonly network: Network, + private readonly favoritesService: FavoritesService, translateService: TranslateService, ) { this.language = translateService.currentLang as SCLanguageCode; @@ -99,8 +103,20 @@ export class DataDetailComponent implements OnInit { /** * Initialize */ - ngOnInit() { - void this.getItem(this.route.snapshot.paramMap.get('uid') ?? ''); + async ionViewWillEnter() { + const uid = this.route.snapshot.paramMap.get('uid') || ''; + await this.getItem(uid ?? ''); + // fallback to the saved item (from favorites) + if (this.item === null) { + this.favoritesService + .get(uid) + .pipe(take(1)) + .subscribe(item => { + if (typeof item !== undefined) { + this.item = item; + } + }); + } } /** diff --git a/src/app/modules/data/detail/data-detail.html b/src/app/modules/data/detail/data-detail.html index be74b780..eea8dd8f 100644 --- a/src/app/modules/data/detail/data-detail.html +++ b/src/app/modules/data/detail/data-detail.html @@ -5,6 +5,12 @@ {{ 'data.detail.TITLE' | translate }} + + + diff --git a/src/app/modules/data/elements/favorite-button.component.html b/src/app/modules/data/elements/favorite-button.component.html new file mode 100644 index 00000000..e4a6fc07 --- /dev/null +++ b/src/app/modules/data/elements/favorite-button.component.html @@ -0,0 +1,7 @@ + + + diff --git a/src/app/modules/data/elements/favorite-button.component.scss b/src/app/modules/data/elements/favorite-button.component.scss new file mode 100644 index 00000000..14386030 --- /dev/null +++ b/src/app/modules/data/elements/favorite-button.component.scss @@ -0,0 +1,11 @@ +:host { + ion-button { + width: 50px; + height: 50px; + --border-radius: 50%; + } + + ion-icon.filled { + color: #FBC02D; + } +} diff --git a/src/app/modules/data/elements/favorite-button.component.ts b/src/app/modules/data/elements/favorite-button.component.ts new file mode 100644 index 00000000..97702ef8 --- /dev/null +++ b/src/app/modules/data/elements/favorite-button.component.ts @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2021 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, Input} from '@angular/core'; +import {SCIndexableThings} from '@openstapps/core'; +import {FavoritesService} from '../../favorites/favorites.service'; +import {Observable} from 'rxjs'; +import {map, take} from 'rxjs/operators'; + +/** + * The button to add or remove a thing from favorites + */ +@Component({ + selector: 'stapps-favorite-button', + templateUrl: './favorite-button.component.html', + styleUrls: ['./favorite-button.component.scss'], +}) +export class FavoriteButtonComponent { + /** + * Item getter + */ + get item(): SCIndexableThings { + return this._item; + } + + /** + * An item to show (setter is used as there were issues assigning the distance to the right place in a list) + */ + @Input() set item(item: SCIndexableThings) { + this._item = item; + this.isFavorite$ = this.favoritesService.get(this.item.uid).pipe( + map(favorite => { + return typeof favorite !== 'undefined'; + }), + ); + } + + /** + * An item to show + */ + private _item: SCIndexableThings; + + /** + * The thing already in favorites or not + */ + isFavorite$: Observable; + + constructor(private favoritesService: FavoritesService) {} + + /** + * Add or remove the thing from favorites (depending on its current status) + * + * @param event A click event + */ + async toggle(event: Event) { + // prevent additional effects e.g. router to be activated + event.stopPropagation(); + + this.isFavorite$.pipe(take(1)).subscribe(enabled => { + enabled + ? this.favoritesService.delete(this.item) + : this.favoritesService.put(this.item); + }); + } +} diff --git a/src/app/modules/data/list/data-list-item.html b/src/app/modules/data/list/data-list-item.html index 1dae60a2..5517e0bd 100644 --- a/src/app/modules/data/list/data-list-item.html +++ b/src/app/modules/data/list/data-list-item.html @@ -87,4 +87,5 @@ > + diff --git a/src/app/modules/data/list/data-list.component.ts b/src/app/modules/data/list/data-list.component.ts index 639a232b..5d88a4f5 100644 --- a/src/app/modules/data/list/data-list.component.ts +++ b/src/app/modules/data/list/data-list.component.ts @@ -135,8 +135,10 @@ export class DataListComponent implements OnChanges, OnInit, OnDestroy { */ scrolled(_index: number) { if ( + // first condition prevents "load more" to be executed before event having initial items + this.items && (this.items?.length ?? 0) - this.viewPort.getRenderedRange().end <= - (this.items?.length ?? 0) * this.reloadThreshold + (this.items?.length ?? 0) * this.reloadThreshold ) { this.notifyLoadMore(); } diff --git a/src/app/modules/data/list/search-page.component.ts b/src/app/modules/data/list/search-page.component.ts index 7d1e9772..894176ec 100644 --- a/src/app/modules/data/list/search-page.component.ts +++ b/src/app/modules/data/list/search-page.component.ts @@ -12,7 +12,7 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {Component, Input} from '@angular/core'; import {Router} from '@angular/router'; import {AlertController} from '@ionic/angular'; import { @@ -39,7 +39,7 @@ import {PositionService} from '../../map/position.service'; templateUrl: 'search-page.html', providers: [ContextMenuService], }) -export class SearchPageComponent implements OnInit, OnDestroy { +export class SearchPageComponent { /** * Api query filter */ @@ -115,6 +115,7 @@ export class SearchPageComponent implements OnInit, OnDestroy { * @param logger An angular logger * @param dataRoutingService DataRoutingService * @param router Router + * @param positionService PositionService */ constructor( protected readonly alertController: AlertController, @@ -127,49 +128,6 @@ export class SearchPageComponent implements OnInit, OnDestroy { protected positionService: PositionService, ) { this.initialize(); - - combineLatest([ - this.queryTextChanged.pipe( - debounceTime(this.searchQueryDueTime), - distinctUntilChanged(), - startWith(this.queryText), - ), - this.contextMenuService.filterQueryChanged$.pipe( - startWith(this.filterQuery), - ), - this.contextMenuService.sortQueryChanged$.pipe(startWith(this.sortQuery)), - ]).subscribe(async query => { - this.queryText = query[0]; - this.filterQuery = query[1]; - this.sortQuery = query[2]; - this.from = 0; - await this.fetchAndUpdateItems(); - this.queryChanged.next(); - }); - - this.fetchAndUpdateItems(); - - /** - * Subscribe to 'settings.changed' events - */ - this.subscriptions.push( - this.settingsProvider.settingsActionChanged$.subscribe( - ({type, payload}) => { - if (type === 'stapps.settings.changed') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {category, name, value} = payload!; - this.logger.log(`received event "settings.changed" with category: - ${category}, name: ${name}, value: ${JSON.stringify(value)}`); - } - }, - ), - this.dataRoutingService.itemSelectListener().subscribe(item => { - void this.router.navigate(['data-detail', item.uid]); - }), - this.positionService - .watchCurrentLocation({maximumAge: 30_000}) - .subscribe(), - ); } /** @@ -259,60 +217,20 @@ export class SearchPageComponent implements OnInit, OnDestroy { } /** - * Unsubscribe from Observables + * Search event of search bar */ - ngOnDestroy() { - for (const subscription of this.subscriptions) { - subscription.unsubscribe(); - } + searchStringChanged(queryValue: string) { + this.queryTextChanged.next(queryValue); } /** - * Initialises the possible sort options in ContextMenuService + * Updates the possible filter options in ContextMenuService with facets */ - ngOnInit(): void { - combineLatest([ - this.queryTextChanged.pipe( - debounceTime(this.searchQueryDueTime), - distinctUntilChanged(), - startWith(this.queryText), - ), - this.contextMenuService.filterQueryChanged$.pipe( - startWith(this.filterQuery), - ), - this.contextMenuService.sortQueryChanged$.pipe(startWith(this.sortQuery)), - ]).subscribe(async query => { - this.queryText = query[0]; - this.filterQuery = query[1]; - this.sortQuery = query[2]; - this.from = 0; - await this.fetchAndUpdateItems(); - this.queryChanged.next(); - }); - - void this.fetchAndUpdateItems(); - - /** - * Subscribe to 'settings.changed' events - */ - this.subscriptions.push( - this.settingsProvider.settingsActionChanged$.subscribe( - ({type, payload}) => { - if (type === 'stapps.settings.changed') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {category, name, value} = payload!; - this.logger.log(`received event "settings.changed" with category: - ${category}, name: ${name}, value: ${JSON.stringify(value)}`); - } - }, - ), - this.dataRoutingService.itemSelectListener().subscribe(item => { - if (this.itemRouting) { - void this.router.navigate(['data-detail', item.uid]); - } - }), - ); + updateContextFilter(facets: SCFacet[]) { + this.contextMenuService.updateContextFilter(facets); + } + ionViewWillEnter() { this.contextMenuService.setContextSort({ name: 'sort', reversed: false, @@ -332,19 +250,49 @@ export class SearchPageComponent implements OnInit, OnDestroy { }, ], }); + + this.subscriptions.push( + combineLatest([ + this.queryTextChanged.pipe( + debounceTime(this.searchQueryDueTime), + distinctUntilChanged(), + startWith(this.queryText), + ), + this.contextMenuService.filterQueryChanged$.pipe( + startWith(this.filterQuery), + ), + this.contextMenuService.sortQueryChanged$.pipe( + startWith(this.sortQuery), + ), + ]).subscribe(async query => { + this.queryText = query[0]; + this.filterQuery = query[1]; + this.sortQuery = query[2]; + this.from = 0; + await this.fetchAndUpdateItems(); + this.queryChanged.next(); + }), + this.settingsProvider.settingsActionChanged$.subscribe( + ({type, payload}) => { + if (type === 'stapps.settings.changed') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const {category, name, value} = payload!; + this.logger.log(`received event "settings.changed" with category: + ${category}, name: ${name}, value: ${JSON.stringify(value)}`); + } + }, + ), + this.dataRoutingService.itemSelectListener().subscribe(item => { + if (this.itemRouting) { + void this.router.navigate(['data-detail', item.uid]); + } + }), + ); } - /** - * Search event of search bar - */ - searchStringChanged(queryValue: string) { - this.queryTextChanged.next(queryValue); - } - - /** - * Updates the possible filter options in ContextMenuService with facets - */ - updateContextFilter(facets: SCFacet[]) { - this.contextMenuService.updateContextFilter(facets); + ionViewWillLeave() { + for (const subscription of this.subscriptions) { + subscription.unsubscribe(); + } } } diff --git a/src/app/modules/favorites/favorites-page.component.scss b/src/app/modules/favorites/favorites-page.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/favorites/favorites-page.component.ts b/src/app/modules/favorites/favorites-page.component.ts new file mode 100644 index 00000000..05aa149c --- /dev/null +++ b/src/app/modules/favorites/favorites-page.component.ts @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2021 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'; +import {FavoritesService} from './favorites.service'; +import {DataRoutingService} from '../data/data-routing.service'; +import {Router} from '@angular/router'; +import {ContextMenuService} from '../menu/context/context-menu.service'; +import {SearchPageComponent} from '../data/list/search-page.component'; +import {AlertController} from '@ionic/angular'; +import {DataProvider} from '../data/data.provider'; +import {SettingsProvider} from '../settings/settings.provider'; +import {NGXLogger} from 'ngx-logger'; +import {PositionService} from '../map/position.service'; +import {take} from 'rxjs/operators'; + +/** + * The page for showing favorites + */ +@Component({ + templateUrl: '../data/list/search-page.html', + providers: [ContextMenuService], + styleUrls: ['./favorites-page.component.scss'], +}) +export class FavoritesPageComponent extends SearchPageComponent { + constructor( + alertController: AlertController, + dataProvider: DataProvider, + contextMenuService: ContextMenuService, + settingsProvider: SettingsProvider, + logger: NGXLogger, + dataRoutingService: DataRoutingService, + router: Router, + positionService: PositionService, + private favoritesService: FavoritesService, + ) { + super( + alertController, + dataProvider, + contextMenuService, + settingsProvider, + logger, + dataRoutingService, + router, + positionService, + ); + } + + ionViewWillEnter() { + super.ionViewWillEnter(); + this.subscriptions.push( + this.favoritesService.favoritesChanged$.subscribe(_favoritesMap => { + this.fetchAndUpdateItems(); + }), + ); + } + + /** + * Fetches/updates the favorites (search page component's method override) + */ + async fetchAndUpdateItems() { + this.favoritesService + .search(this.queryText, this.filterQuery, this.sortQuery) + .pipe(take(1)) + .subscribe(result => { + this.items = (async () => result.data)(); + this.updateContextFilter(result.facets); + }); + } +} diff --git a/src/app/modules/favorites/favorites.module.ts b/src/app/modules/favorites/favorites.module.ts new file mode 100644 index 00000000..0ff8652e --- /dev/null +++ b/src/app/modules/favorites/favorites.module.ts @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2021 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 {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {IonicModule} from '@ionic/angular'; +import {FavoritesPageComponent} from './favorites-page.component'; +import {RouterModule, Routes} from '@angular/router'; +import {MenuModule} from '../menu/menu.module'; +import {TranslateModule} from '@ngx-translate/core'; +import {DataModule} from '../data/data.module'; + +const favoritesRoutes: Routes = [ + { + path: 'favorites', + component: FavoritesPageComponent, + }, +]; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + RouterModule.forChild(favoritesRoutes), + MenuModule, + TranslateModule, + DataModule, + ], + declarations: [FavoritesPageComponent], +}) +export class FavoritesModule {} diff --git a/src/app/modules/favorites/favorites.service.ts b/src/app/modules/favorites/favorites.service.ts new file mode 100644 index 00000000..9dce2608 --- /dev/null +++ b/src/app/modules/favorites/favorites.service.ts @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2021 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 {Injectable} from '@angular/core'; +import { + SCFacet, + SCSearchBooleanFilter, + SCSearchFilter, + SCSearchSort, + SCSearchValueFilter, + SCThings, + SCFavorite, + SCSaveableThing, + SCThingType, + SCIndexableThings, + SCUuid, +} from '@openstapps/core'; +import {StorageProvider} from '../storage/storage.provider'; +import {DataProvider} from '../data/data.provider'; +import {ThingTranslatePipe} from '../../translation/thing-translate.pipe'; +import {TranslateService} from '@ngx-translate/core'; +import {ThingTranslateService} from '../../translation/thing-translate.service'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +/** + * Service handling favorites + */ +@Injectable({ + providedIn: 'root', +}) +export class FavoritesService { + /** + * Translation pipe + */ + thingTranslatePipe: ThingTranslatePipe; + + favorites = new BehaviorSubject>( + new Map(), + ); + + favoritesChanged$ = this.favorites.asObservable(); + + static getDataFromFavorites(items: SCFavorite[]) { + return items.map(item => item.data); + } + + /** + * Provides the type value from a filter + * + * @param filter Filter to get the type from + */ + static getFilterType(filter: SCSearchBooleanFilter | SCSearchValueFilter) { + let value: string | undefined; + if (filter.type === 'boolean') { + for (const internalFilter of filter.arguments.filters) { + value = FavoritesService.getFilterType( + internalFilter as SCSearchBooleanFilter | SCSearchValueFilter, + ); + } + } else { + value = filter.arguments.value as string; + } + + return value; + } + + /** + * Provides all the saved favorites + */ + async getAll() { + return this.storageProvider.search(this.storagePrefix); + } + + /** + * Sorts provided items by the provided field + * + * @param items Items to sort + * @param field The field to use for sorting the items + * @param sortType In which order to sort the provided textual field + */ + sortItems( + items: SCIndexableThings[], + field: 'name' | 'type', + sortType: 'asc' | 'desc', + ) { + const reverse = sortType === 'asc' ? 1 : -1; + + return items.sort((a, b) => { + return ( + new Intl.Collator(this.translate.currentLang).compare( + this.thingTranslatePipe.transform(field, a), + this.thingTranslatePipe.transform(field, b), + ) * reverse + ); + }); + } + + /** + * Gets storage prefix text + */ + get storagePrefix(): string { + return this._storagePrefix; + } + + /** + * Sets storage prefix text + */ + set storagePrefix(storagePrefix) { + this._storagePrefix = storagePrefix; + } + + /** + * Storage prefix text + */ + private _storagePrefix = 'stapps.favorites'; + + constructor( + private storageProvider: StorageProvider, + private readonly translate: TranslateService, + private readonly thingTranslate: ThingTranslateService, + ) { + this.thingTranslatePipe = new ThingTranslatePipe( + this.translate, + this.thingTranslate, + ); + void this.emitAll(); + } + + /** + * Provides key for storing data into the local database + * + * @param uid Unique identifier of a resource + */ + getStorageKey(uid: string): string { + return `${this.storagePrefix}.${uid}`; + } + + /** + * Removes an item from favorites + * + * @param item Data item that needs to be deleted + */ + async delete(item: SCIndexableThings): Promise { + await this.storageProvider.delete(this.getStorageKey(item.uid)); + void (await this.emitAll()); + } + + /** + * Save an item as a favorite + * + * @param item Data item that needs to be saved + */ + async put(item: SCIndexableThings): Promise { + const favorite = DataProvider.createSaveable(item, SCThingType.Favorite); + await this.storageProvider.put( + this.getStorageKey(item.uid), + favorite, + ); + void (await this.emitAll()); + } + + async emitAll() { + this.favorites.next(await this.getAll()); + } + + get(uid: SCUuid): Observable { + return this.favoritesChanged$.pipe( + map(favoritesMap => { + return favoritesMap.get(this.getStorageKey(uid)); + }), + ); + } + + /** + * Search through the (saved) favorites + * + * @param queryText Text to filter the data with + * @param filterQuery Filters to apply on the data + * @param sortQuery Sort to apply on the data + */ + search( + queryText?: string, + filterQuery?: SCSearchFilter, + sortQuery?: SCSearchSort, + ): Observable<{data: SCThings[]; facets: SCFacet[]}> { + return this.favoritesChanged$.pipe( + map(favoritesMap => { + let items = [...favoritesMap.values()].map(favorite => favorite.data); + if (typeof queryText !== 'undefined') { + const textFilteredItems: SCIndexableThings[] = []; + for (const item of items) { + if ( + this.thingTranslatePipe + .transform('name', item) + .toLowerCase() + .includes(queryText.toLowerCase()) + ) { + textFilteredItems.push(item); + } + } + items = textFilteredItems; + } + + if (typeof filterQuery !== 'undefined') { + const filterType = FavoritesService.getFilterType( + filterQuery as SCSearchBooleanFilter | SCSearchValueFilter, + ); + const filteredItems: SCIndexableThings[] = []; + for (const item of items) { + if (item.type === filterType) { + filteredItems.push(item); + } + } + items = filteredItems; + } + + if (typeof sortQuery !== 'undefined') { + items = this.sortItems( + items, + sortQuery.arguments.field as 'name' | 'type', + sortQuery.order, + ); + } + + return { + data: items, + facets: [DataProvider.facetForField(items, 'type')], + }; + }), + ); + } +} diff --git a/src/app/modules/menu/context/context-menu.service.ts b/src/app/modules/menu/context/context-menu.service.ts index bc1b1d43..927349ff 100644 --- a/src/app/modules/menu/context/context-menu.service.ts +++ b/src/app/modules/menu/context/context-menu.service.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 StApps + * Copyright (C) 2020-2021 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. diff --git a/src/app/modules/menu/context/context-type.ts b/src/app/modules/menu/context/context-type.ts index 9acfbcf2..2abe8021 100644 --- a/src/app/modules/menu/context/context-type.ts +++ b/src/app/modules/menu/context/context-type.ts @@ -14,6 +14,8 @@ */ import {SCFacet, SCFacetBucket} from '@openstapps/core'; +export type ContextType = FilterContext | SortContext; + /** * A sort context */ diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 96b5598e..e33a1112 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -79,6 +79,11 @@ } } }, + "favorites": { + "page": { + "TITLE": "Favoriten" + } + }, "map": { "page": { "TITLE": "Karte", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 6d7c86ba..6bc1a2da 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -79,6 +79,11 @@ } } }, + "favorites": { + "page": { + "TITLE": "Favorites" + } + }, "map": { "page": { "TITLE": "Map",