/* * 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, Input, OnDestroy, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {Keyboard} from '@capacitor/keyboard'; import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular'; import {Capacitor} from '@capacitor/core'; import { SCFacet, SCFeatureConfiguration, SCSearchFilter, SCSearchQuery, SCSearchSort, SCThings, } from '@openstapps/core'; import {NGXLogger} from 'ngx-logger'; import {combineLatest, Subject, Subscription} from 'rxjs'; import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators'; import {ContextMenuService} from '../../menu/context/context-menu.service'; import {SettingsProvider} from '../../settings/settings.provider'; import {DataRoutingService} from '../data-routing.service'; import {DataProvider} from '../data.provider'; import {PositionService} from '../../map/position.service'; import {ConfigProvider} from '../../config/config.provider'; import {searchPageSwitchAnimation} from './search-page-switch-animation'; /** * SearchPageComponent queries things and shows list of things as search results and filter as context menu */ @Component({ selector: 'stapps-search-page', templateUrl: 'search-page.html', styleUrls: ['search-page.scss'], providers: [ContextMenuService], }) export class SearchPageComponent implements OnInit, OnDestroy { title = 'search.title'; isHebisAvailable = false; /** * Signalizes that the data is being loaded */ loading = false; /** * Display the navigation between default and library search */ @Input() showNavigation = true; /** * Show default data (e.g. when there is user interaction) */ @Input() showDefaultData = false; /** * Show the navigation drawer */ @Input() showDrawer = true; /** * Show "universal search" toolbar */ @Input() showTopToolbar = true; /** * Api query filter */ filterQuery: SCSearchFilter | undefined; /** * Filters the search should be initialized with */ @Input() forcedFilter?: SCSearchFilter; /** * If routing should be done if the user clicks on an item */ @Input() itemRouting? = true; /** * Thing counter to start query the next page from */ from = 0; /** * Container for queried things */ items: Promise; /** * Page size of queries */ pageSize = 30; /** * Search value from search bar */ queryText: string; /** * Emits when there is a change in the query (search, sort or filter changed) */ queryChanged = new Subject(); /** * Subject to handle search text changes */ queryTextChanged = new Subject(); /** * Time to wait for search query if search text is changing */ searchQueryDueTime = 1000; /** * Search response only ever contains a single SCThingType */ singleTypeResponse = false; /** * Api query sorting */ sortQuery: SCSearchSort[] | undefined; /** * Array of all subscriptions to Observables */ subscriptions: Subscription[] = []; routeAnimation: AnimationBuilder; /** * Injects the providers and creates subscriptions * * @param alertController AlertController * @param dataProvider DataProvider * @param contextMenuService ContextMenuService * @param settingsProvider SettingsProvider * @param logger An angular logger * @param dataRoutingService DataRoutingService * @param router Router * @param route ActivatedRoute * @param positionService PositionService * @param configProvider ConfigProvider */ constructor( protected readonly alertController: AlertController, protected dataProvider: DataProvider, protected readonly contextMenuService: ContextMenuService, protected readonly settingsProvider: SettingsProvider, protected readonly logger: NGXLogger, protected dataRoutingService: DataRoutingService, protected router: Router, private readonly route: ActivatedRoute, protected positionService: PositionService, private readonly configProvider: ConfigProvider, animationController: AnimationController, ) { this.routeAnimation = searchPageSwitchAnimation(animationController); } /** * Fetches items with set query configuration * * @param append If true fetched data gets appended to existing, override otherwise (default false) */ protected async fetchAndUpdateItems(append = false): Promise { // build query search options const searchOptions: SCSearchQuery = { from: this.from, size: this.pageSize, }; const filters: SCSearchFilter[] = []; if (this.queryText && this.queryText.length > 0) { // add query string searchOptions.query = this.queryText; } if (this.sortQuery) { // add query sorting searchOptions.sort = this.sortQuery; } for (const filter of [this.forcedFilter, this.filterQuery]) { if (typeof filter !== 'undefined') { filters.push(filter); } } if (filters.length > 0) { searchOptions.filter = { arguments: { filters: filters, operation: 'and', }, type: 'boolean', }; } this.loading = !append; try { const result = await this.dataProvider.search(searchOptions); this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1; if (append) { let items = await this.items; // append results items = [...items, ...result.data]; this.items = (async () => items)(); } else { // override items with results this.items = (async () => { this.updateContextFilter(result.facets); return result.data; })(); } } catch (error) { this.logger.error(error); } finally { this.loading = false; } } /** * Hides keyboard in native app environments */ hideKeyboard() { if (Capacitor.isNativePlatform()) { Keyboard.hide(); } } /** * Set starting values (e.g. forced filter, which can be set in components inheriting this one) */ // eslint-disable-next-line class-methods-use-this initialize() { // nothing to do here } /** * Loads next page of things */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async loadMore(): Promise { this.from += this.pageSize; await this.fetchAndUpdateItems(true); } /** * Search event of search bar */ searchStringChanged(queryValue: string) { this.router.navigate([], { relativeTo: this.route, queryParams: {query: queryValue}, queryParamsHandling: 'merge', }); this.queryTextChanged.next(queryValue); } /** * Updates the possible filter options in ContextMenuService with facets */ updateContextFilter(facets: SCFacet[]) { this.contextMenuService.updateContextFilter(facets); } ngOnInit() { this.initialize(); this.contextMenuService.setContextSort({ name: 'sort', reversed: false, value: 'relevance', values: [ { reversible: false, value: 'relevance', }, { reversible: true, value: 'name', }, { reversible: true, value: 'type', }, ], }); 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; if (typeof this.filterQuery !== 'undefined' || this.queryText?.length > 0 || this.showDefaultData) { 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]); } }), ); try { const features = this.configProvider.getValue('features') as SCFeatureConfiguration; this.isHebisAvailable = !!features.plugins?.['hebis-plugin']?.urlPath; } catch (error) { this.logger.error(error); } } /** * Initialize */ async ionViewWillEnter() { const term = this.route.snapshot.queryParamMap.get('query') || undefined; if (term) { this.queryText = term; this.searchStringChanged(term); } } ngOnDestroy() { for (const subscription of this.subscriptions) { subscription.unsubscribe(); } } }