diff --git a/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts index 8ad438cba84..67fc1447370 100644 --- a/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts +++ b/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts @@ -1,4 +1,4 @@ -import { action, makeObservable } from "mobx"; +import { action, makeObservable, override } from "mobx"; import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; @@ -32,8 +32,23 @@ function LocationSearchProviderMixin< @action showWarning() {} - supportsAutocomplete(): boolean { - return true; + async search(searchText: string, manuallyTriggered?: boolean) { + if (!this.autocompleteEnabled && !manuallyTriggered) { + this.searchResult.isSearching = false; + this.searchResult.isWaitingToStartSearch = false; + this.searchResult.message = { + content: "translate#viewModels.enterToStartSearch" + }; + + return; + } + + await super.search(searchText, manuallyTriggered); + } + + @override + get autocompleteEnabled() { + return super.autocompleteEnabled ?? true; } } diff --git a/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts index 7d58e2683e2..6a27a23e123 100644 --- a/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts +++ b/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts @@ -1,8 +1,8 @@ -import { action, makeObservable } from "mobx"; -import { fromPromise } from "mobx-utils"; +import { debounce } from "lodash-es"; +import { action, makeObservable, observable } from "mobx"; import AbstractConstructor from "../../Core/AbstractConstructor"; import Model from "../../Models/Definition/Model"; -import SearchProviderResults from "../../Models/SearchProviders/SearchProviderResults"; +import SearchProviderResult from "../../Models/SearchProviders/SearchProviderResults"; import SearchProviderTraits from "../../Traits/SearchProviders/SearchProviderTraits"; type SearchProviderModel = Model; @@ -13,36 +13,74 @@ function SearchProviderMixin< abstract class SearchProviderMixin extends Base { abstract get type(): string; + protected debounceTime = 1000; + private _debouncedSearch: ReturnType; + constructor(...args: any[]) { super(...args); makeObservable(this); + this.searchResult = new SearchProviderResult(this); + + this._debouncedSearch = debounce((searchText: string) => { + this.performSearch(searchText); + }, this.debounceTime); } + @observable + public searchResult: SearchProviderResult; + protected abstract logEvent(searchText: string): void; protected abstract doSearch( searchText: string, - results: SearchProviderResults + results: SearchProviderResult ): Promise; @action - search(searchText: string): SearchProviderResults { - const result = new SearchProviderResults(this); + cancelSearch() { + this._debouncedSearch.cancel(); + + this.searchResult.isCanceled = true; + this.searchResult = new SearchProviderResult(this); + } + + @action + async search( + searchText: string, + manuallyTriggered?: boolean + ): Promise { + this.searchResult.isWaitingToStartSearch = true; if (!this.shouldRunSearch(searchText)) { - result.resultsCompletePromise = fromPromise(Promise.resolve()); - result.message = { + this._debouncedSearch.cancel(); + + this.searchResult.isSearching = false; + this.searchResult.message = { content: "translate#viewModels.searchMinCharacters", params: { count: this.minCharacters } }; - return result; + this.searchResult.isWaitingToStartSearch = false; + return; + } + + if (manuallyTriggered) { + this._debouncedSearch.cancel(); + await this.performSearch(searchText); + } else { + await this._debouncedSearch(searchText); } + } + + @action + private async performSearch(searchText: string): Promise { this.logEvent(searchText); - result.resultsCompletePromise = fromPromise( - this.doSearch(searchText, result) - ); - return result; + this.searchResult.isWaitingToStartSearch = false; + this.searchResult.isSearching = true; + + await this.doSearch(searchText, this.searchResult); + + this.searchResult.isSearching = false; } private shouldRunSearch(searchText: string) { diff --git a/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts index 353e0e76def..d0ce5b9d661 100644 --- a/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts +++ b/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts @@ -4,7 +4,7 @@ import URI from "urijs"; import AbstractConstructor from "../../Core/AbstractConstructor"; import zoomRectangleFromPoint from "../../Map/Vector/zoomRectangleFromPoint"; import Model from "../../Models/Definition/Model"; -import SearchProviderResults from "../../Models/SearchProviders/SearchProviderResults"; +import SearchProviderResult from "../../Models/SearchProviders/SearchProviderResults"; import SearchResult from "../../Models/SearchProviders/SearchResult"; import xml2json from "../../ThirdParty/xml2json"; import WebFeatureServiceSearchProviderTraits from "../../Traits/SearchProviders/WebFeatureServiceSearchProviderTraits"; @@ -50,7 +50,7 @@ function WebFeatureServiceSearchProviderMixin< protected doSearch( searchText: string, - results: SearchProviderResults + results: SearchProviderResult ): Promise { results.results.length = 0; results.message = undefined; diff --git a/lib/Models/SearchProviders/BingMapsSearchProvider.ts b/lib/Models/SearchProviders/BingMapsSearchProvider.ts index 7ed9a8da024..504b9eaa0ce 100644 --- a/lib/Models/SearchProviders/BingMapsSearchProvider.ts +++ b/lib/Models/SearchProviders/BingMapsSearchProvider.ts @@ -16,7 +16,7 @@ import BingMapsSearchProviderTraits from "../../Traits/SearchProviders/BingMapsS import CreateModel from "../Definition/CreateModel"; import Terria from "../Terria"; import CommonStrata from "./../Definition/CommonStrata"; -import SearchProviderResults from "./SearchProviderResults"; +import SearchProviderResult from "./SearchProviderResults"; import SearchResult from "./SearchResult"; export default class BingMapsSearchProvider extends LocationSearchProviderMixin( @@ -65,7 +65,7 @@ export default class BingMapsSearchProvider extends LocationSearchProviderMixin( protected doSearch( searchText: string, - searchResults: SearchProviderResults + searchResults: SearchProviderResult ): Promise { searchResults.results.length = 0; searchResults.message = undefined; diff --git a/lib/Models/SearchProviders/CatalogSearchProvider.ts b/lib/Models/SearchProviders/CatalogSearchProvider.ts index 81063efcc85..bbc9476ae3f 100644 --- a/lib/Models/SearchProviders/CatalogSearchProvider.ts +++ b/lib/Models/SearchProviders/CatalogSearchProvider.ts @@ -1,4 +1,4 @@ -import { autorun, makeObservable, observable, runInAction } from "mobx"; +import { autorun, makeObservable, runInAction } from "mobx"; import { Category, SearchAction @@ -12,7 +12,7 @@ import CommonStrata from "../Definition/CommonStrata"; import CreateModel from "../Definition/CreateModel"; import { BaseModel } from "../Definition/Model"; import Terria from "../Terria"; -import SearchProviderResults from "./SearchProviderResults"; +import SearchProviderResult from "./SearchProviderResults"; import SearchResult from "./SearchResult"; type UniqueIdString = string; @@ -21,7 +21,7 @@ type ResultMap = Map; export function loadAndSearchCatalogRecursively( models: BaseModel[], searchTextLowercase: string, - searchResults: SearchProviderResults, + searchResults: SearchProviderResult, resultMap: ResultMap, iteration: number = 0 ): Promise { @@ -110,8 +110,7 @@ export default class CatalogSearchProvider extends CatalogSearchProviderMixin( CreateModel(CatalogSearchProviderTraits) ) { static readonly type = "catalog-search-provider"; - @observable isSearching: boolean = false; - @observable debounceDurationOnceLoaded: number = 300; + debounceTime = 300; constructor(id: string | undefined, terria: Terria) { super(id, terria); @@ -139,15 +138,15 @@ export default class CatalogSearchProvider extends CatalogSearchProviderMixin( protected async doSearch( searchText: string, - searchResults: SearchProviderResults + searchResults: SearchProviderResult ): Promise { - runInAction(() => (this.isSearching = true)); + runInAction(() => (searchResults.isSearching = true)); searchResults.results.length = 0; searchResults.message = undefined; if (searchText === undefined || /^\s*$/.test(searchText)) { - runInAction(() => (this.isSearching = false)); + runInAction(() => (searchResults.isSearching = false)); return Promise.resolve(); } @@ -179,7 +178,7 @@ export default class CatalogSearchProvider extends CatalogSearchProviderMixin( } runInAction(() => { - this.isSearching = false; + searchResults.isSearching = false; }); if (searchResults.isCanceled) { @@ -197,6 +196,7 @@ export default class CatalogSearchProvider extends CatalogSearchProviderMixin( }; } } catch (e) { + console.error(e); this.terria.raiseErrorToUser(e, { message: "An error occurred while searching", severity: TerriaErrorSeverity.Warning diff --git a/lib/Models/SearchProviders/CesiumIonSearchProvider.ts b/lib/Models/SearchProviders/CesiumIonSearchProvider.ts index 6626de42bce..ea64d4cf8ad 100644 --- a/lib/Models/SearchProviders/CesiumIonSearchProvider.ts +++ b/lib/Models/SearchProviders/CesiumIonSearchProvider.ts @@ -12,7 +12,7 @@ import LocationSearchProviderMixin from "../../ModelMixins/SearchProviders/Locat import CesiumIonSearchProviderTraits from "../../Traits/SearchProviders/CesiumIonSearchProviderTraits"; import CreateModel from "../Definition/CreateModel"; import Terria from "../Terria"; -import SearchProviderResults from "./SearchProviderResults"; +import SearchProviderResult from "./SearchProviderResults"; import SearchResult from "./SearchResult"; import CommonStrata from "../Definition/CommonStrata"; @@ -71,7 +71,7 @@ export default class CesiumIonSearchProvider extends LocationSearchProviderMixin protected async doSearch( searchText: string, - searchResults: SearchProviderResults + searchResults: SearchProviderResult ): Promise { searchResults.results.length = 0; searchResults.message = undefined; diff --git a/lib/Models/SearchProviders/MapboxSearchProvider.ts b/lib/Models/SearchProviders/MapboxSearchProvider.ts index b2d4f341be7..f2025b88136 100644 --- a/lib/Models/SearchProviders/MapboxSearchProvider.ts +++ b/lib/Models/SearchProviders/MapboxSearchProvider.ts @@ -18,7 +18,7 @@ import MapboxSearchProviderTraits from "../../Traits/SearchProviders/MapboxSearc import CommonStrata from "../Definition/CommonStrata"; import CreateModel from "../Definition/CreateModel"; import Terria from "../Terria"; -import SearchProviderResults from "./SearchProviderResults"; +import SearchProviderResult from "./SearchProviderResults"; import SearchResult from "./SearchResult"; enum MapboxGeocodeDirection { @@ -68,7 +68,7 @@ export default class MapboxSearchProvider extends LocationSearchProviderMixin( protected doSearch( searchText: string, - searchResults: SearchProviderResults + searchResults: SearchProviderResult ): Promise { searchResults.results.length = 0; searchResults.message = undefined; diff --git a/lib/Models/SearchProviders/NominatimSearchProvider.ts b/lib/Models/SearchProviders/NominatimSearchProvider.ts index 32918655097..a827cf0ca2b 100644 --- a/lib/Models/SearchProviders/NominatimSearchProvider.ts +++ b/lib/Models/SearchProviders/NominatimSearchProvider.ts @@ -12,7 +12,7 @@ import LocationSearchProviderMixin from "../../ModelMixins/SearchProviders/Locat import NominatimSearchProviderTraits from "../../Traits/SearchProviders/NominatimSearchProviderTraits"; import CreateModel from "../Definition/CreateModel"; import Terria from "../Terria"; -import SearchProviderResults from "./SearchProviderResults"; +import SearchProviderResult from "./SearchProviderResults"; import SearchResult from "./SearchResult"; export default class NominatimSearchProvider extends LocationSearchProviderMixin( @@ -44,9 +44,9 @@ export default class NominatimSearchProvider extends LocationSearchProviderMixin ); } - protected doSearch( + protected async doSearch( searchText: string, - searchResults: SearchProviderResults + searchResults: SearchProviderResult ): Promise { searchResults.results.length = 0; searchResults.message = undefined; @@ -130,10 +130,6 @@ export default class NominatimSearchProvider extends LocationSearchProviderMixin }; }); } - - supportsAutocomplete(): boolean { - return false; - } } function createZoomToFunction( diff --git a/lib/Models/SearchProviders/SearchProviderResults.ts b/lib/Models/SearchProviders/SearchProviderResults.ts index afd03f3f08e..04a3313eb00 100644 --- a/lib/Models/SearchProviders/SearchProviderResults.ts +++ b/lib/Models/SearchProviders/SearchProviderResults.ts @@ -1,9 +1,10 @@ -import { makeObservable, observable } from "mobx"; -import { IPromiseBasedObservable, fromPromise } from "mobx-utils"; +import { computed, makeObservable, observable } from "mobx"; import SearchProviderMixin from "../../ModelMixins/SearchProviders/SearchProviderMixin"; import SearchResult from "./SearchResult"; -export default class SearchProviderResults { +export default class SearchProviderResult< + SeachProviderType = SearchProviderMixin.Instance +> { @observable results: SearchResult[] = []; @observable message?: { content: string; @@ -11,16 +12,20 @@ export default class SearchProviderResults { [key: string]: string | number | undefined; }; }; + @observable isWaitingToStartSearch: boolean = false; + @observable _isSearching: boolean = false; isCanceled = false; - resultsCompletePromise: IPromiseBasedObservable = fromPromise( - Promise.resolve() - ); - constructor(readonly searchProvider: SearchProviderMixin.Instance) { + constructor(readonly searchProvider: SeachProviderType) { makeObservable(this); } + @computed get isSearching() { - return this.resultsCompletePromise.state === "pending"; + return this._isSearching; + } + + set isSearching(value: boolean) { + this._isSearching = value; } } diff --git a/lib/Models/SearchProviders/StubSearchProvider.ts b/lib/Models/SearchProviders/StubSearchProvider.ts index 304a65ddb88..8468716b359 100644 --- a/lib/Models/SearchProviders/StubSearchProvider.ts +++ b/lib/Models/SearchProviders/StubSearchProvider.ts @@ -4,7 +4,7 @@ import primitiveTrait from "../../Traits/Decorators/primitiveTrait"; import LocationSearchProviderTraits from "../../Traits/SearchProviders/LocationSearchProviderTraits"; import CreateModel from "../Definition/CreateModel"; import { ModelConstructorParameters } from "../Definition/Model"; -import SearchProviderResults from "./SearchProviderResults"; +import SearchProviderResult from "./SearchProviderResults"; export class StubSearchProviderTraits extends LocationSearchProviderTraits { @primitiveTrait({ @@ -36,7 +36,7 @@ export default class StubSearchProvider extends SearchProviderMixin( protected doSearch( _searchText: string, - _results: SearchProviderResults + _results: SearchProviderResult ): Promise { return Promise.resolve(); } diff --git a/lib/ReactViewModels/SearchState.ts b/lib/ReactViewModels/SearchState.ts index fe71584bdf1..09329683c4e 100644 --- a/lib/ReactViewModels/SearchState.ts +++ b/lib/ReactViewModels/SearchState.ts @@ -2,18 +2,15 @@ import { action, computed, IReactionDisposer, + makeObservable, observable, reaction, - makeObservable, runInAction } from "mobx"; -import filterOutUndefined from "../Core/filterOutUndefined"; +import CatalogSearchProviderMixin from "../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; import LocationSearchProviderMixin from "../ModelMixins/SearchProviders/LocationSearchProviderMixin"; -import SearchProviderMixin from "../ModelMixins/SearchProviders/SearchProviderMixin"; import CatalogSearchProvider from "../Models/SearchProviders/CatalogSearchProvider"; -import SearchProviderResults from "../Models/SearchProviders/SearchProviderResults"; import Terria from "../Models/Terria"; -import CatalogSearchProviderMixin from "../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; interface SearchStateOptions { terria: Terria; @@ -21,11 +18,9 @@ interface SearchStateOptions { } export default class SearchState { - @observable catalogSearchText: string = ""; - @observable isWaitingToStartCatalogSearch: boolean = false; + @observable private _catalogSearchText: string = ""; - @observable locationSearchText: string = ""; - @observable isWaitingToStartLocationSearch: boolean = false; + @observable private _locationSearchText: string = ""; @observable unifiedSearchText: string = ""; @observable isWaitingToStartUnifiedSearch: boolean = false; @@ -34,13 +29,6 @@ export default class SearchState { @observable showMobileLocationSearch: boolean = false; @observable showMobileCatalogSearch: boolean = false; - @observable locationSearchResults: SearchProviderResults[] = []; - @observable catalogSearchResults: SearchProviderResults | undefined; - @observable unifiedSearchResults: SearchProviderResults[] = []; - - private _catalogSearchDisposer: IReactionDisposer; - private _locationSearchDisposer: IReactionDisposer; - private _unifiedSearchDisposer: IReactionDisposer; private _workbenchItemsSubscription: IReactionDisposer; private readonly terria: Terria; @@ -56,42 +44,6 @@ export default class SearchState { new CatalogSearchProvider("catalog-search-provider", options.terria); }); - const self = this; - - this._catalogSearchDisposer = reaction( - () => self.catalogSearchText, - () => { - self.isWaitingToStartCatalogSearch = true; - if (self.catalogSearchProvider) { - self.catalogSearchResults = self.catalogSearchProvider.search(""); - } - } - ); - - this._locationSearchDisposer = reaction( - () => self.locationSearchText, - () => { - self.isWaitingToStartLocationSearch = true; - self.locationSearchResults = self.locationSearchProviders.map( - (provider) => { - return provider.search(""); - } - ); - } - ); - - this._unifiedSearchDisposer = reaction( - () => this.unifiedSearchText, - () => { - this.isWaitingToStartUnifiedSearch = true; - this.unifiedSearchResults = this.unifiedSearchProviders.map( - (provider) => { - return provider.search(""); - } - ); - } - ); - this._workbenchItemsSubscription = reaction( () => this.terria.workbench.items, () => { @@ -101,21 +53,37 @@ export default class SearchState { } dispose(): void { - this._catalogSearchDisposer(); - this._locationSearchDisposer(); - this._unifiedSearchDisposer(); this._workbenchItemsSubscription(); } @computed - get supportsAutocomplete(): boolean { - return this.locationSearchProviders.every((provider) => - provider.supportsAutocomplete() - ); + get locationSearchText() { + return this._locationSearchText; + } + + set locationSearchText(newText: string) { + this._locationSearchText = newText; + + for (const searchProvider of this.locationSearchProviders) { + searchProvider.cancelSearch(); + + if (newText.length > 0) searchProvider.search(newText, false); + } + } + + @computed get catalogSearchText() { + return this._catalogSearchText; + } + + set catalogSearchText(newText: string) { + this._catalogSearchText = newText; + + this.catalogSearchProvider?.cancelSearch(); + if (newText.length > 0) this.catalogSearchProvider?.search(newText, false); } @computed - private get locationSearchProviders(): LocationSearchProviderMixin.Instance[] { + get locationSearchProviders(): LocationSearchProviderMixin.Instance[] { return this.terria.searchBarModel.locationSearchProvidersArray; } @@ -124,57 +92,20 @@ export default class SearchState { return this.terria.searchBarModel.catalogSearchProvider; } - @computed - get unifiedSearchProviders(): SearchProviderMixin.Instance[] { - return filterOutUndefined([ - this.catalogSearchProvider, - ...this.locationSearchProviders - ]); - } - @action searchCatalog(): void { - if (this.isWaitingToStartCatalogSearch) { - this.isWaitingToStartCatalogSearch = false; - if (this.catalogSearchResults) { - this.catalogSearchResults.isCanceled = true; - } - if (this.catalogSearchProvider) { - this.catalogSearchResults = this.catalogSearchProvider.search( - this.catalogSearchText - ); - } - } - } - - @action - setCatalogSearchText(newText: string): void { - this.catalogSearchText = newText; + this.catalogSearchProvider?.search(this.catalogSearchText, true); } @action searchLocations(): void { - if (this.isWaitingToStartLocationSearch) { - this.isWaitingToStartLocationSearch = false; - this.locationSearchResults.forEach((results) => { - results.isCanceled = true; - }); - this.locationSearchResults = this.locationSearchProviders.map( - (searchProvider) => searchProvider.search(this.locationSearchText) - ); - } - } - - @action - searchUnified(): void { - if (this.isWaitingToStartUnifiedSearch) { - this.isWaitingToStartUnifiedSearch = false; - this.unifiedSearchResults.forEach((results) => { - results.isCanceled = true; - }); - this.unifiedSearchResults = this.unifiedSearchProviders.map( - (searchProvider) => searchProvider.search(this.unifiedSearchText) - ); + for (const searchProvider of this.locationSearchProviders) { + if ( + !searchProvider.autocompleteEnabled || + searchProvider.searchResult.isWaitingToStartSearch || + searchProvider.searchResult.isSearching + ) + searchProvider.search(this.locationSearchText, true); } } } diff --git a/lib/ReactViews/DataCatalog/DataCatalog.jsx b/lib/ReactViews/DataCatalog/DataCatalog.jsx index 38a21cf16d9..a50ebbf7ad3 100644 --- a/lib/ReactViews/DataCatalog/DataCatalog.jsx +++ b/lib/ReactViews/DataCatalog/DataCatalog.jsx @@ -26,24 +26,20 @@ class DataCatalog extends Component { const unfilteredItems = isSearching && catalogSearchProvider && - searchState.catalogSearchResults?.results - ? searchState.catalogSearchResults.results.map( + searchState.catalogSearchProvider?.searchResult.results + ? searchState.catalogSearchProvider.searchResult.results.map( (result) => result.catalogItem ) : this.props.items; const items = (unfilteredItems || []).filter(defined); const { t } = this.props; + return (
    {isSearching && catalogSearchProvider && ( <> - + )} {items.map( diff --git a/lib/ReactViews/ExplorerWindow/Tabs/DataCatalogTab.tsx b/lib/ReactViews/ExplorerWindow/Tabs/DataCatalogTab.tsx index 7f40a83149f..4b3e20be572 100644 --- a/lib/ReactViews/ExplorerWindow/Tabs/DataCatalogTab.tsx +++ b/lib/ReactViews/ExplorerWindow/Tabs/DataCatalogTab.tsx @@ -7,9 +7,8 @@ import { useViewState } from "../../Context"; import DataCatalog from "../../DataCatalog/DataCatalog"; import DataPreview from "../../Preview/DataPreview"; import Breadcrumbs from "../../Search/Breadcrumbs"; -import SearchBox, { DEBOUNCE_INTERVAL } from "../../Search/SearchBox"; +import SearchBox from "../../Search/SearchBox"; import Styles from "./data-catalog-tab.scss"; -import CatalogSearchProvider from "../../../Models/SearchProviders/CatalogSearchProvider"; interface DataCatalogTabProps { items?: unknown[]; @@ -52,15 +51,8 @@ const DataCatalogTab = observer(function DataCatalogTab( search()} + onDoSearch={search} placeholder={searchPlaceholder} - debounceDuration={ - terria.catalogReferencesLoaded - ? ( - searchState.catalogSearchProvider as CatalogSearchProvider - ).debounceDurationOnceLoaded - : DEBOUNCE_INTERVAL - } /> )} = observer( const [dataAttributionVisible, setDataAttributionVisible] = useState(false); const searchAttributions = searchBarModel.locationSearchProvidersArray - .map((provider) => { - return provider.attributions?.flat(); - }) - .flat() - .filter((attribution) => !!attribution); + .flatMap((provider) => provider.attributions ?? []) + .filter(Boolean); const showDataAttribution = useCallback(() => { setDataAttributionVisible(true); - }, [setDataAttributionVisible]); + }, []); const hideDataAttribution = useCallback(() => { setDataAttributionVisible(false); - }, [setDataAttributionVisible]); + }, []); useEffect(() => { return reaction( - () => currentViewer.attributions.length, - () => { - if ( - currentViewer.attributions && - currentViewer.attributions.length === 0 - ) { + () => currentViewer.attributions.length + searchAttributions.length, + (value) => { + if (value === 0) { hideDataAttribution(); } } ); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [currentViewer]); + }, [currentViewer, hideDataAttribution, searchAttributions.length]); if (currentViewer.type === "none") { return ; @@ -64,9 +57,8 @@ export const MapCredits: FC = observer( - {(currentViewer.attributions && - currentViewer.attributions.length > 0) || - currentViewer.attributions.length > 0 ? ( + {currentViewer.attributions.length > 0 || + searchAttributions.length > 0 ? ( {t("map.extraCreditLinks.credits")} diff --git a/lib/ReactViews/Mobile/MobileSearch.jsx b/lib/ReactViews/Mobile/MobileSearch.jsx index 51cc2595c0d..5624bbebfab 100644 --- a/lib/ReactViews/Mobile/MobileSearch.jsx +++ b/lib/ReactViews/Mobile/MobileSearch.jsx @@ -80,15 +80,14 @@ class MobileSearch extends Component { renderLocationResult(theme) { const searchState = this.props.viewState.searchState; - return searchState.locationSearchResults.map((search) => ( + return searchState.locationSearchProviders.map((searchProvider) => ( )); diff --git a/lib/ReactViews/ReactViewHelpers/highlightKeyword.jsx b/lib/ReactViews/ReactViewHelpers/highlightKeyword.jsx index 78c04504074..77c36adc9b9 100644 --- a/lib/ReactViews/ReactViewHelpers/highlightKeyword.jsx +++ b/lib/ReactViews/ReactViewHelpers/highlightKeyword.jsx @@ -1,6 +1,11 @@ export default function highlightKeyword(searchResult, keywordToHighlight) { if (!keywordToHighlight) return searchResult; - const parts = searchResult.split(new RegExp(`(${keywordToHighlight})`, "gi")); + const escapedKeyword = keywordToHighlight.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&" + ); + + const parts = searchResult.split(new RegExp(`(${escapedKeyword})`, "gi")); return ( <> {parts.map((part, i) => ( diff --git a/lib/ReactViews/Search/LocationSearchResults.tsx b/lib/ReactViews/Search/LocationSearchResults.tsx index c4ef59d3a8e..eaf7429007f 100644 --- a/lib/ReactViews/Search/LocationSearchResults.tsx +++ b/lib/ReactViews/Search/LocationSearchResults.tsx @@ -12,7 +12,7 @@ import styled from "styled-components"; import isDefined from "../../Core/isDefined"; import { applyTranslationIfExists } from "../../Language/languageHelpers"; import LocationSearchProviderMixin from "../../ModelMixins/SearchProviders/LocationSearchProviderMixin"; -import SearchProviderResults from "../../Models/SearchProviders/SearchProviderResults"; +import SearchProviderResult from "../../Models/SearchProviders/SearchProviderResults"; import SearchResultModel from "../../Models/SearchProviders/SearchResult"; import Terria from "../../Models/Terria"; import ViewState from "../../ReactViewModels/ViewState"; @@ -37,18 +37,16 @@ const RawButtonAndHighlight = styled(RawButton)` interface LocationSearchResultsProps { viewState: ViewState; - isWaitingForSearchToStart: boolean; terria: Terria; - search: SearchProviderResults; + searchResult: SearchProviderResult; onLocationClick: (result: SearchResultModel) => void; locationSearchText: string; } const LocationSearchResults: React.FC = observer( ({ - search, + searchResult, terria, - isWaitingForSearchToStart, locationSearchText, onLocationClick }: LocationSearchResultsProps) => { @@ -76,7 +74,7 @@ const LocationSearchResults: React.FC = observer( } const validResults = filterResults - ? search.results.filter(function (r: any) { + ? searchResult.results.filter(function (r: any) { return ( r.location.longitude > west! && r.location.longitude < east! && @@ -84,12 +82,12 @@ const LocationSearchResults: React.FC = observer( r.location.latitude < north! ); }) - : search.results; + : searchResult.results; return validResults; - }, [search.results, terria]); + }, [searchResult.results, terria]); const searchProvider: LocationSearchProviderMixin.Instance = - search.searchProvider as unknown as LocationSearchProviderMixin.Instance; + searchResult.searchProvider as unknown as LocationSearchProviderMixin.Instance; const maxResults = searchProvider.recommendedListLength || 5; const results = @@ -114,11 +112,10 @@ const LocationSearchResults: React.FC = observer( justifySpaceBetween > = observer( {isOpen && ( <> - +
      {results.map((result: SearchResultModel, i: number) => ( = observer( @@ -204,13 +197,15 @@ const NameWithLoader: FC = observer( - {`${applyTranslationIfExists(props.name, i18n)} (${ - props.length || 0 - })`} + {`${applyTranslationIfExists(props.name, i18n)} ${ + !props.search.isWaitingToStartSearch + ? `(${props.length || 0})` + : "" + }`} {!props.isOpen && - (props.search.isSearching || props.isWaitingForSearchToStart) && ( + (props.search.isSearching || props.search.isWaitingToStartSearch) && ( )} diff --git a/lib/ReactViews/Search/SearchBox.tsx b/lib/ReactViews/Search/SearchBox.tsx index 8eb43527877..c73b4a21b30 100644 --- a/lib/ReactViews/Search/SearchBox.tsx +++ b/lib/ReactViews/Search/SearchBox.tsx @@ -1,12 +1,4 @@ -import debounce from "lodash-es/debounce"; -import React, { - ChangeEvent, - KeyboardEvent, - useCallback, - useEffect, - useRef -} from "react"; -import { forwardRef } from "react"; +import React, { ChangeEvent, forwardRef } from "react"; import styled, { useTheme } from "styled-components"; import Box, { BoxSpan } from "../../Styled/Box"; import { RawButton } from "../../Styled/Button"; @@ -27,8 +19,6 @@ const SearchInput = styled.input<{ rounded?: boolean }>` -webkit-appearance: none; `; -export const DEBOUNCE_INTERVAL = 1000; - interface SearchBoxProps { onSearchTextChanged: (text: string) => void; onDoSearch: () => void; @@ -37,8 +27,6 @@ interface SearchBoxProps { placeholder?: string; onClear?: () => void; alwaysShowClear?: boolean; - debounceDuration?: number; - supportsAutocomplete?: boolean; autoFocus?: boolean; inputBoxRef?: React.Ref; } @@ -56,63 +44,24 @@ export const SearchBox: React.FC = ({ placeholder = "Search", onClear, alwaysShowClear = false, - debounceDuration, autoFocus = false, - supportsAutocomplete = true, inputBoxRef }) => { const theme = useTheme(); - const debouncedOnDoSearch = useRef>(); - - useEffect(() => { - const debounced = debounce( - () => onDoSearch(), - debounceDuration ?? DEBOUNCE_INTERVAL - ); - debouncedOnDoSearch.current = debounced; - - return () => { - debounced.flush(); - }; - }, [onDoSearch, debounceDuration]); - - useEffect(() => { - return () => debouncedOnDoSearch.current?.cancel(); - }, []); - - const search = useCallback(() => { - debouncedOnDoSearch.current?.cancel(); - onDoSearch(); - }, [onDoSearch]); const handleChange = (event: ChangeEvent) => { const value = event.target.value; - // immediately bypass debounce if we started with no value onSearchTextChanged(value); - if (!supportsAutocomplete) return; - - if (searchText.length === 0) { - search(); - } else { - debouncedOnDoSearch.current?.(); - } }; const clearSearch = () => { onSearchTextChanged(""); - search(); if (onClear) { onClear(); } }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter") { - search(); - } - }; - const hasValue = searchText.length > 0; return ( @@ -121,7 +70,7 @@ export const SearchBox: React.FC = ({ onSubmit={(event) => { event.preventDefault(); event.stopPropagation(); - search(); + onDoSearch(); }} css={` position: relative; @@ -156,7 +105,6 @@ export const SearchBox: React.FC = ({ value={searchText} onChange={handleChange} onFocus={onFocus} - onKeyDown={onKeyDown} placeholder={placeholder} autoComplete="off" autoFocus={autoFocus} diff --git a/lib/ReactViews/Search/SearchBoxAndResults.tsx b/lib/ReactViews/Search/SearchBoxAndResults.tsx index 19046d643ce..15032ceae33 100644 --- a/lib/ReactViews/Search/SearchBoxAndResults.tsx +++ b/lib/ReactViews/Search/SearchBoxAndResults.tsx @@ -1,7 +1,7 @@ import { action, runInAction } from "mobx"; import { observer } from "mobx-react"; import PropTypes from "prop-types"; -import { useEffect, useRef, type FC } from "react"; +import { useCallback, useEffect, useRef, type FC } from "react"; import { useTranslation } from "react-i18next"; import styled, { useTheme } from "styled-components"; import { addMarker, removeMarker } from "../../Models/LocationMarkerUtils"; @@ -27,10 +27,6 @@ export function SearchInDataCatalog({ fullWidth onClick={() => { const { searchState } = viewState; - // Set text here as a separate action so that it doesn't get batched up and the catalog - // search text has a chance to set isWaitingToStartCatalogSearch - searchState.setCatalogSearchText(searchState.locationSearchText); - viewState.searchInCatalog(searchState.locationSearchText); if (handleClick) { handleClick(); @@ -113,11 +109,11 @@ export const SearchBoxAndResults: FC = observer( } }; - const search = () => { + const search = useCallback(() => { viewState.searchState.searchLocations(); - }; + }, [viewState]); - const startLocationSearch = () => { + const showLocationSearchResults = () => { toggleShowLocationSearchResults(true); }; @@ -136,10 +132,9 @@ export const SearchBoxAndResults: FC = observer( ref={locationSearchRef} onSearchTextChanged={changeSearchText} onDoSearch={search} - onFocus={startLocationSearch} + onFocus={showLocationSearchResults} searchText={searchState.locationSearchText} placeholder={placeholder} - supportsAutocomplete={searchState.supportsAutocomplete} /> {/* Results */} @@ -156,9 +151,6 @@ export const SearchBoxAndResults: FC = observer( overflow: hidden; `} > - {/* search {searchterm} in data catalog */} - {/* ~TODO: Put this back once we add a MobX DataCatalogSearch Provider~ */} - {/* TODO2: Implement a more generic MobX DataCatalogSearch */} {viewState.terria.searchBarModel.showSearchInCatalog && searchState.catalogSearchProvider && ( @@ -176,30 +168,26 @@ export const SearchBoxAndResults: FC = observer( overflow-y: auto; `} > - {!searchState.isWaitingToStartLocationSearch && - searchState.locationSearchResults.map((search) => ( - { - if (!result.location) return; - addMarker(viewState.terria, { - name: result.name, - location: result.location - }); - result.clickAction?.(); - runInAction(() => { - searchState.showLocationSearchResults = false; - }); - }} - isWaitingForSearchToStart={ - searchState.isWaitingToStartLocationSearch - } - /> - ))} + {searchState.locationSearchProviders.map((searchProvider) => ( + { + if (!result.location) return; + addMarker(viewState.terria, { + name: result.name, + location: result.location + }); + result.clickAction?.(); + runInAction(() => { + searchState.showLocationSearchResults = false; + }); + }} + /> + ))} )} diff --git a/lib/ReactViews/Search/SearchHeader.tsx b/lib/ReactViews/Search/SearchHeader.tsx index 7b38b3141ac..c1dc9a6d0b3 100644 --- a/lib/ReactViews/Search/SearchHeader.tsx +++ b/lib/ReactViews/Search/SearchHeader.tsx @@ -2,34 +2,36 @@ import { observer } from "mobx-react"; import { FC } from "react"; import { useTranslation } from "react-i18next"; import { applyTranslationIfExists } from "../../Language/languageHelpers"; -import SearchProviderResults from "../../Models/SearchProviders/SearchProviderResults"; +import SearchProviderResult from "../../Models/SearchProviders/SearchProviderResults"; import { BoxSpan } from "../../Styled/Box"; import Text from "../../Styled/Text"; import Loader from "../Loader"; interface SearchHeaderProps { - searchResults: SearchProviderResults; - isWaitingForSearchToStart: boolean; + searchResult: SearchProviderResult; } const SearchHeader: FC = observer( (props: SearchHeaderProps) => { const { i18n } = useTranslation(); - if (props.searchResults.isSearching || props.isWaitingForSearchToStart) { + if ( + props.searchResult.isSearching || + props.searchResult.isWaitingToStartSearch + ) { return (
      ); - } else if (props.searchResults.message) { + } else if (props.searchResult.message) { return ( {applyTranslationIfExists( - props.searchResults.message.content, + props.searchResult.message.content, i18n, - props.searchResults.message.params + props.searchResult.message.params )} diff --git a/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts b/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts index 125f2d76b7b..fd866240613 100644 --- a/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts +++ b/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts @@ -4,9 +4,8 @@ import mixTraits from "../mixTraits"; import ModelTraits from "../ModelTraits"; import SearchProviderTraits from "./SearchProviderTraits"; -export default class LocationSearchProviderTraits extends mixTraits( - SearchProviderTraits -) { +/* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ +class LocationSearchProviderTraits extends mixTraits(SearchProviderTraits) { @primitiveTrait({ type: "string", name: "URL", @@ -45,6 +44,16 @@ export default class LocationSearchProviderTraits extends mixTraits( isNullable: true }) attributions: string[] = []; + + @primitiveTrait({ + type: "boolean", + name: "Autocomplete enabled", + description: + "Whether the autocomplete is supported for this search provider" + }) + get autocompleteEnabled() { + return true; + } } export class SearchProviderMapCenterTraits extends ModelTraits { @@ -56,3 +65,12 @@ export class SearchProviderMapCenterTraits extends ModelTraits { }) mapCenter: boolean = true; } + +/* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ +interface LocationSearchProviderTraits { + // Add traits here that you want to override from some Mixin or Model class + // without generating TS2611 type error. + autocompleteEnabled: LocationSearchProviderTraits["autocompleteEnabled"]; +} + +export default LocationSearchProviderTraits; diff --git a/lib/Traits/SearchProviders/NominatimSearchProviderTraits.ts b/lib/Traits/SearchProviders/NominatimSearchProviderTraits.ts index 16c41ec0191..5d539607d4c 100644 --- a/lib/Traits/SearchProviders/NominatimSearchProviderTraits.ts +++ b/lib/Traits/SearchProviders/NominatimSearchProviderTraits.ts @@ -8,7 +8,7 @@ export default class NominatimSearchProviderTraits extends mixTraits( LocationSearchProviderTraits, SearchProviderMapCenterTraits ) { - url: string = "//nominatim.openstreetmap.org/search"; + url: string = "https://nominatim.openstreetmap.org/search"; @primitiveTrait({ type: "string", @@ -34,4 +34,8 @@ export default class NominatimSearchProviderTraits extends mixTraits( attributions: string[] = [ "© OpenStreetMap contributors" ]; + + get autocompleteEnabled() { + return false; + } } diff --git a/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts b/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts index 4b0b50048ce..254e93e150c 100644 --- a/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts +++ b/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts @@ -9,9 +9,9 @@ class TestSearchProvider extends SearchProviderMixin( ) { type = "test"; - public override logEvent = jasmine.createSpy(); + public override logEvent = jasmine.createSpy("logEvent"); public override doSearch = jasmine - .createSpy() + .createSpy("doSearch") .and.returnValue(Promise.resolve()); } @@ -34,36 +34,36 @@ describe("SearchProviderMixin", () => { }); it(" - should not run search if searchText is undefined", () => { - const result = searchProvider.search(undefined as never); - expect(result.resultsCompletePromise).toBeDefined(); - expect(result.message).toBeDefined(); + searchProvider.search(undefined as never); + expect(searchProvider.searchResult.isSearching).toBeFalsy(); + expect(searchProvider.searchResult.message).toBeDefined(); expect(searchProvider.logEvent).not.toHaveBeenCalled(); expect(searchProvider.doSearch).not.toHaveBeenCalled(); }); it(" - should not run search if only spaces", () => { - const result = searchProvider.search(" "); - expect(result.resultsCompletePromise).toBeDefined(); - expect(result.message).toBeDefined(); + searchProvider.search(" "); + expect(searchProvider.searchResult.isSearching).toBeFalsy(); + expect(searchProvider.searchResult.message).toBeDefined(); expect(searchProvider.logEvent).not.toHaveBeenCalled(); expect(searchProvider.doSearch).not.toHaveBeenCalled(); }); it(" - should not run search if searchText less than minCharacters", () => { - const result = searchProvider.search("12"); - expect(result.resultsCompletePromise).toBeDefined(); - expect(result.message).toBeDefined(); + searchProvider.search("12"); + expect(searchProvider.searchResult.isSearching).toBeFalsy(); + expect(searchProvider.searchResult.message).toBeDefined(); expect(searchProvider.logEvent).not.toHaveBeenCalled(); expect(searchProvider.doSearch).not.toHaveBeenCalled(); }); it(" - should run search if searchText is valid", () => { - const result = searchProvider.search("1234"); - expect(result.resultsCompletePromise).toBeDefined(); - expect(result.message).not.toBeDefined(); + searchProvider.search("1234", true); + expect(searchProvider.searchResult.isSearching).toBeTruthy(); + expect(searchProvider.searchResult.message).not.toBeDefined(); expect(searchProvider.logEvent).toHaveBeenCalled(); expect(searchProvider.doSearch).toHaveBeenCalled(); diff --git a/test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts b/test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts index 47c51eb828c..b2635bd10f8 100644 --- a/test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts +++ b/test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts @@ -26,11 +26,10 @@ describe("GazetteerSearchProvider", function () { spyOn(searchProvider, "getXml").and.returnValue( Promise.resolve(wfsResponseXml) ); - const results = searchProvider.search("Fred"); - return results.resultsCompletePromise.then(() => { - expect(searchProvider.getXml).toHaveBeenCalledTimes(1); - expect(results).toBeDefined(); - expect(results.results.length > 0).toBeTruthy(); - }); + await searchProvider.search("Fred", true); + + expect(searchProvider.getXml).toHaveBeenCalledTimes(1); + expect(searchProvider.searchResult).toBeDefined(); + expect(searchProvider.searchResult.results.length > 0).toBeTruthy(); }); }); diff --git a/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts b/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts index 2d17915f212..002fa2f20d8 100644 --- a/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts +++ b/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts @@ -53,8 +53,7 @@ describe("BingMapsSearchProvider", function () { jasmine.Ajax.stubRequest(/.*/).andReturn({ responseText: JSON.stringify({ resourceSets: [] }) }); - const result = bingMapsSearchProvider.search("test"); - await result.resultsCompletePromise; + await bingMapsSearchProvider.search("test", true); const req = jasmine.Ajax.requests.mostRecent(); expect(req.url).toBe( @@ -125,12 +124,15 @@ describe("BingMapsSearchProvider", function () { }) }); - const searchResult = bingMapsSearchProvider.search("test"); - await searchResult.resultsCompletePromise; + await bingMapsSearchProvider.search("test", true); - expect(searchResult.results.length).toEqual(2); - expect(searchResult.message).toBeUndefined(); - expect(searchResult.results[0].name).toEqual("test result 2"); - expect(searchResult.results[1].name).toEqual("test result 1, Italy"); + expect(bingMapsSearchProvider.searchResult.results.length).toEqual(2); + expect(bingMapsSearchProvider.searchResult.message).toBeUndefined(); + expect(bingMapsSearchProvider.searchResult.results[0].name).toEqual( + "test result 2" + ); + expect(bingMapsSearchProvider.searchResult.results[1].name).toEqual( + "test result 1, Italy" + ); }); }); diff --git a/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts b/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts index 0850de0ca1a..83aac0d048f 100644 --- a/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts +++ b/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts @@ -40,11 +40,14 @@ describe("CesiumIonSearchProvider", () => { "api.test.com?text=test&access_token=testkey" ).andReturn({ responseText: JSON.stringify(fixture) }); - const result = searchProvider.search("test"); - await result.resultsCompletePromise; - expect(result.results.length).toBe(1); - expect(result.results[0].name).toBe("West End, Australia"); - expect(result.results[0].location?.latitude).toBe(-27.4822998046875); + await searchProvider.search("test", true); + expect(searchProvider.searchResult.results.length).toBe(1); + expect(searchProvider.searchResult.results[0].name).toBe( + "West End, Australia" + ); + expect(searchProvider.searchResult.results[0].location?.latitude).toBe( + -27.4822998046875 + ); }); it("Handles empty result", async () => { @@ -53,10 +56,9 @@ describe("CesiumIonSearchProvider", () => { ).andReturn({ responseText: JSON.stringify([]) }); - const result = searchProvider.search("test"); - await result.resultsCompletePromise; - expect(result.results.length).toBe(0); - expect(result.message?.content).toBe( + await searchProvider.search("test", true); + expect(searchProvider.searchResult.results.length).toBe(0); + expect(searchProvider.searchResult.message?.content).toBe( "translate#viewModels.searchNoLocations" ); }); @@ -67,10 +69,9 @@ describe("CesiumIonSearchProvider", () => { ).andReturn({ status: 404 }); - const result = searchProvider.search("test"); - await result.resultsCompletePromise; - expect(result.results.length).toBe(0); - expect(result.message?.content).toBe( + await searchProvider.search("test", true); + expect(searchProvider.searchResult.results.length).toBe(0); + expect(searchProvider.searchResult.message?.content).toBe( "translate#viewModels.searchErrorOccurred" ); }); diff --git a/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx b/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx index 52960d5d66f..fe73300fa3c 100644 --- a/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx +++ b/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx @@ -1,8 +1,14 @@ -import { screen } from "@testing-library/dom"; +import { screen, waitFor } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; +import i18next from "i18next"; import { runInAction } from "mobx"; +import { I18nextProvider } from "react-i18next"; import { ThemeProvider } from "styled-components"; import CommonStrata from "../../../lib/Models/Definition/CommonStrata"; import CatalogSearchProvider from "../../../lib/Models/SearchProviders/CatalogSearchProvider"; +import MapboxSearchProvider from "../../../lib/Models/SearchProviders/MapboxSearchProvider"; +import NominatimSearchProvider from "../../../lib/Models/SearchProviders/NominatimSearchProvider"; +import SearchProviderResult from "../../../lib/Models/SearchProviders/SearchProviderResults"; import Terria from "../../../lib/Models/Terria"; import ViewState from "../../../lib/ReactViewModels/ViewState"; import SearchBoxAndResults from "../../../lib/ReactViews/Search/SearchBoxAndResults"; @@ -28,7 +34,6 @@ describe("SearchBoxAndResults", function () { runInAction(() => { viewState.searchState.locationSearchText = searchText; viewState.searchState.showLocationSearchResults = false; - viewState.searchState.locationSearchResults = []; }); renderWithContexts( @@ -51,7 +56,6 @@ describe("SearchBoxAndResults", function () { runInAction(() => { viewState.searchState.locationSearchText = searchText; viewState.searchState.showLocationSearchResults = true; - viewState.searchState.locationSearchResults = []; }); renderWithContexts( @@ -73,7 +77,7 @@ describe("SearchBoxAndResults", function () { runInAction(() => { viewState.searchState.locationSearchText = searchText; viewState.searchState.showLocationSearchResults = true; - viewState.searchState.locationSearchResults = []; + viewState.terria.searchBarModel.catalogSearchProvider = undefined; }); renderWithContexts( @@ -96,7 +100,6 @@ describe("SearchBoxAndResults", function () { runInAction(() => { viewState.searchState.locationSearchText = searchText; viewState.searchState.showLocationSearchResults = true; - viewState.searchState.locationSearchResults = []; viewState.terria.searchBarModel.setTrait( CommonStrata.user, @@ -118,4 +121,301 @@ describe("SearchBoxAndResults", function () { screen.queryByText("search.searchInDataCatalog") ).not.toBeInTheDocument(); }); + + let nominatimProvider: NominatimSearchProvider; + let mapboxProvider: MapboxSearchProvider; + let nominatimSpy: jasmine.Spy; + let mapboxSpy: jasmine.Spy; + + const i18n = i18next.createInstance({ + lng: "spec", + debug: false, + resources: { + spec: { + translation: {} + } + } + }); + + beforeEach(async () => { + await i18n.init(); + + nominatimProvider = new NominatimSearchProvider("nominatim", terria); + mapboxProvider = new MapboxSearchProvider("mapbox", terria); + + // Mock the doSearch methods to avoid actual API calls. + // @ts-expect-error: doSearch method is protected + nominatimSpy = spyOn(nominatimProvider, "doSearch").and.callFake( + (searchText: string, results: SearchProviderResult) => { + return new Promise((resolve) => { + if (!results.isCanceled) { + runInAction(() => { + results.results.push({ + name: `Nominatim result for ${searchText}`, + tooltip: undefined, + isImportant: false, + clickAction: undefined, + catalogItem: undefined, + isOpen: false, + type: "", + location: undefined, + toggleOpen: jasmine.createSpy("toggleOpen") + }); + }); + } + resolve(undefined); + }); + } + ); + + // @ts-expect-error: doSearch method is protected + mapboxSpy = spyOn(mapboxProvider, "doSearch").and.callFake( + (searchText: string, results: SearchProviderResult) => { + return new Promise((resolve) => { + if (!results.isCanceled) { + runInAction(() => { + results.results.push({ + name: `Mapbox result for ${searchText}`, + tooltip: undefined, + isImportant: false, + clickAction: undefined, + catalogItem: undefined, + isOpen: false, + type: "", + location: undefined, + toggleOpen: jasmine.createSpy("toggleOpen") + }); + }); + } + resolve(undefined); + }); + } + ); + }); + + afterEach(() => { + mapboxSpy.calls.reset(); + nominatimSpy.calls.reset(); + + // Clear the search providers after each test + // @ts-expect-error: locationSearchProviders is a private property + terria.searchBarModel.locationSearchProviders.clear(); + }); + + it("should display search results from mapbox search provider", async function () { + const user = userEvent.setup(); + runInAction(() => { + terria.searchBarModel.addSearchProvider(mapboxProvider); + }); + + renderWithContexts( + + + + + , + viewState + ); + + const searchBox = screen.getByRole("textbox"); + + await user.type(searchBox, "test search"); + + await waitFor( + () => { + expect(screen.getByText("Mapbox result for")).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + + expect(mapboxProvider.searchResult.results.length).toBe(1); + + expect(mapboxSpy).toHaveBeenCalledTimes(1); + }); + + it('should not trigger search for nominatim provider until "Enter" is pressed', async function () { + const user = userEvent.setup(); + runInAction(() => { + terria.searchBarModel.addSearchProvider(nominatimProvider); + }); + + renderWithContexts( + + + + + , + viewState + ); + + const searchBox = screen.getByRole("textbox"); + + await user.type(searchBox, "test search"); + + expect(screen.queryByText("Nominatim result for")).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByText("viewModels.enterToStartSearch") + ).toBeInTheDocument(); + }); + + expect(nominatimSpy).not.toHaveBeenCalled(); + + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(screen.getByText("Nominatim result for")).toBeInTheDocument(); + }); + + expect(nominatimProvider.searchResult.results.length).toBe(1); + expect(nominatimSpy).toHaveBeenCalledTimes(1); + }); + + it("should cancel previous search when new search text is set", async function () { + const user = userEvent.setup(); + runInAction(() => { + terria.searchBarModel.addSearchProvider(nominatimProvider); + terria.searchBarModel.addSearchProvider(mapboxProvider); + }); + + renderWithContexts( + + + + + , + viewState + ); + + const searchBox = screen.getByRole("textbox"); + await user.type(searchBox, "first"); + + await user.clear(searchBox); + await user.type(searchBox, "second"); + + await waitFor( + () => { + expect(screen.getByText("second")).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + + expect(screen.queryByText("first")).not.toBeInTheDocument(); + }); + + it("should preserve individual provider state during searches", async function () { + const user = userEvent.setup(); + runInAction(() => { + terria.searchBarModel.addSearchProvider(nominatimProvider); + terria.searchBarModel.addSearchProvider(mapboxProvider); + }); + + renderWithContexts( + + + + + , + viewState + ); + + await user.type(screen.getByRole("textbox"), "test query"); + + await waitFor( + () => { + expect( + screen.getByText("viewModels.enterToStartSearch") + ).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + await waitFor( + () => { + expect(screen.getByText("Mapbox result for")).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + + await user.keyboard("{Enter}"); + + await waitFor( + () => { + expect(screen.getByText("Nominatim result for")).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + + await waitFor( + () => { + expect(screen.getByText("Mapbox result for")).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + + expect(nominatimSpy).toHaveBeenCalledTimes(1); + expect(mapboxSpy).toHaveBeenCalledTimes(1); + }); + + it("should clear results when search input is cleared", async function () { + const user = userEvent.setup(); + runInAction(() => { + terria.searchBarModel.addSearchProvider(nominatimProvider); + terria.searchBarModel.addSearchProvider(mapboxProvider); + }); + + renderWithContexts( + + + + + , + viewState + ); + + await user.type(screen.getByRole("textbox"), "test"); + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(screen.getByText("Nominatim result for")).toBeInTheDocument(); + }); + + await userEvent.clear(screen.getByRole("textbox")); + + await waitFor(() => { + expect(viewState.searchState.showLocationSearchResults).toBe(false); + }); + expect(nominatimProvider.searchResult.results.length).toBe(0); + expect(mapboxProvider.searchResult.results.length).toBe(0); + }); + + it("should handle manual vs automatic search triggering correctly", async function () { + const user = userEvent.setup(); + runInAction(() => { + terria.searchBarModel.addSearchProvider(mapboxProvider); // Has autocomplete enabled + }); + + renderWithContexts( + + + + + , + viewState + ); + + const searchBox = screen.getByRole("textbox"); + + await user.type(searchBox, "test"); + + expect(mapboxSpy).not.toHaveBeenCalled(); + + await user.keyboard("{Enter}"); + + await waitFor( + () => { + expect(mapboxSpy).toHaveBeenCalledTimes(1); + }, + { timeout: 8000 } + ); + }); }); diff --git a/wwwroot/languages/en/translation.json b/wwwroot/languages/en/translation.json index b5ea68354ab..9a0b3a180ff 100644 --- a/wwwroot/languages/en/translation.json +++ b/wwwroot/languages/en/translation.json @@ -684,7 +684,8 @@ "searchCatalogueItem": "Catalogue Items", "searchNoCatalogueItem": "Sorry, no catalogue items match your search query.", "inMultipleLocations": "In multiple locations including: ", - "inDataCatalogue": "In Data Catalogue" + "inDataCatalogue": "In Data Catalogue", + "enterToStartSearch": "Press Enter to start searching" }, "terriaViewer": { "slowWebGLAvailableTitle": "Poor WebGL performance",