diff --git a/architecture/0011-configurable-search-providers.md b/architecture/0011-configurable-search-providers.md new file mode 100644 index 00000000000..2779eaf5a97 --- /dev/null +++ b/architecture/0011-configurable-search-providers.md @@ -0,0 +1,36 @@ +# 11. Configuration of search providers + +Date: 2021-01-19 + +## Status + +Proposed + +## Context + +Ticket. +https://github.com/TerriaJS/terriajs/issues/5141. + +### Intro + +The existing approach to the definition of SearchProviders requires the development team's involvement and rebuild of the application, which can be undesired behavior in highly dynamic environments. +It's much better to enable the administrators to maintain the search providers. + +## Proposal + +- SearchProviders could greatly use the benefits of the new model system used for Catalog. +- Create a simple base Mixin (`SearchProviderMixin`) to attach SearchProviders to the Model system and enable easier creation of new search providers. +- Make SearchProviders configurable from `config.json`. +- Provide sensible defaults for everything. +- Typescript everything. +- Make everything translateable (administrator can specify i18next keys for all names) + +## Benefits + +- Much easier to implement new search providers. +- Much easier to update existing search providers, `urls` and `keys`. +- Offer administrators an option to decide wheter they want to load group members using `CatalogSearchProvider`. + +## Consequences + +This is quite a large change and should be thoroughly tested to avoid the possible bugs in the search providers migration. diff --git a/doc/customizing/client-side-config.md b/doc/customizing/client-side-config.md index af88359aa9b..908d4f82a7a 100644 --- a/doc/customizing/client-side-config.md +++ b/doc/customizing/client-side-config.md @@ -67,7 +67,7 @@ Specifies various options for configuring TerriaJS: | `defaultMaximumShownFeatureInfos` | no | **number** | `100` | The maximum number of "feature info" boxes that can be displayed when clicking a point. | | `regionMappingDefinitionsUrl` | no | **string** | | **Deprecated** please use `regionMappingDefinitionsUrls` array instead. If this is defined, it will override `regionMappingDefinitionsUrls` | | `regionMappingDefinitionsUrls` | no | **string[]** | `["build/TerriaJS/data/regionMapping.json"]` | URLs of JSON files that define region mapping for Tabular data (eg CSV). This option only needs to be changed in unusual deployments. It has to be changed if deploying as static site, for instance. It multiple URLs are provided then the first matching region will be used (in order of URLs) | -| `catalogIndexUrl` | no | **string** | | URL of the JSON file that contains index of catalog. See [CatalogIndex](#catalogindex) | +| `catalogIndexUrl` | no | **string** | | URL of the JSON file that contains index of catalog. See [CatalogIndex](search-providers.md#catalogindex) | | `proj4ServiceBaseUrl` | no | **string** | `"proj4def/"` | URL of Proj4 projection lookup service (part of TerriaJS-Server). This option only needs to be changed in unusual deployments. It has to be changed if deploying as static site, for instance. | | `corsProxyBaseUrl` | no | **string** | `"proxy/"` | URL of CORS proxy service (part of TerriaJS-Server). This option only needs to be changed in unusual deployments. It has to be changed if deploying as static site, for instance. | | `proxyableDomainsUrl` | no | **string** | `"proxyabledomains/"` | Deprecated, will be determined from serverconfig. | @@ -115,6 +115,8 @@ Specifies various options for configuring TerriaJS: | `storyVideo.videoUrl` | no | **string** | https://www.youtube-nocookie.com/embed/fbiQawV8IYY | Video to show in Story Editor panel under Getting Started. | | `relatedMaps` | no | **[RelatedMap](#relatedmap)[]** | See [`lib/Models/RelatedMaps.ts`](../../lib/Models/RelatedMaps.ts) | Maps to show in "Related Maps" menu panel | | `aboutButtonHrefUrl` | no | **string** | `"about.html"` | About button URL. If set to `null`, then the About button will not be shown | +| `searchBar` | no | **[SearchBar](#searchbar)** | `new SearchBar()` | Search bar configuration | +| `searchProviders` | no | \*\*[SearchProviders](search-providers.md) | `[]` | Search providers that will be used for search | ### MagdaReferenceHeaders @@ -249,30 +251,15 @@ Credits/Attribution shown at the bottom of the map. Supports internationalizatio --- -### CatalogIndex +### SearchBar -If your TerriaMap has many (>50) dynamic groups (groups which need to be loaded - for example CKAN, WMS-group...) it may be worth generating a static catalog index JSON file. This file will contain ID, name and description fields of all catalog items, which can be used to search through the catalog very quickly without needing to load dynamic references/groups (for example `MagdaReference` -> `WebMapServiceCatalogGroup` -> `WebMapServiceCatalogItem`). +Configuration for the search bar. Some of the values will be used as default for +search provider values. -The https://github.com/nextapps-de/flexsearch library is used to index and search the catalog index file. - -To generate the catalog index: - -- `yarn build-tools` -- `node .\build\generateCatalogIndex.js config-url base-url` where - - - `config-url` is URL to client-side-config file - - `base-url` is URL to terriajs-server (this is used to load `server-config` and to proxy requests) - - For example `node .\build\generateCatalogIndex.js http://localhost:3001/config.json http://localhost:3001` - -- This will output three files - - `catalog-index.json` - - `catalog-index-errors.json` with any error messages which occurred while loading catalog members - - `catalog-index-errors-stack.json` with errors stack -- Set `catalogIndexUrl` config parameter to URL to `catalog-index.json` - -This file will have to be re-generated manually every time the catalog structure changes - for example: - -- if items are renamed, or moved -- dynamic groups are updated (for example, WMS server publishes new layers) - -For more details see [/buildprocess/generateCatalogIndex.ts](/buildprocess/generateCatalogIndex.ts) +| Name | Required | Type | Default | Description | +| --------------------- | -------- | ------------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | +| placeholder | no | **string** | `translate#search.placeholder` | Input text field placeholder shown when no input has been given yet. The string is translateable. | +| recommendedListLength | no | **number** | `5` | Maximum amount of entries in the suggestion list. | +| flightDurationSeconds | no | **number** | `1.5` | The duration of the camera flight to an entered location, in seconds. | +| minCharacters | no | **number** | 3 | Minimum number of characters required for search to start | +| boundingBoxLimit | no | **Rectangle** | `Cesium.Rectangle.MAX_VALUE` | Bounding box limits for the search results {west, south, east, north} | diff --git a/doc/customizing/search-providers.md b/doc/customizing/search-providers.md new file mode 100644 index 00000000000..3077a7e3fb5 --- /dev/null +++ b/doc/customizing/search-providers.md @@ -0,0 +1,159 @@ +# Search providers + +Terriajs supports 2 types of search providers + +1. Catalog search provider +2. Location search providers + +Each search provider can be configured using following options + +| Name | Required | Type | Default | Description | +| ------------- | -------- | ---------- | ------------------------------------------- | ---------------------------------------------------------- | +| name | no | **string** | `unknown` | Name of the search provider. | +| minCharacters | no | **number** | `catalogParameters.searchBar.minCharacters` | Minimum number of characters required for search to start. | + +## Catalog search provider + +`type: catalog-search-provider` + +Catalog search provider is used to find the desired dataset. Catalog search provider can be used with or without static catalog index JSON file. Without catalog index each catalog group and item will be dynamically fetched from remote servers in the moment of the search, and for bigger catalog this will cause poor performance of search. For example when having WMS-group in catalog searching in that catalog will cause catalog to issue `getCapabilities` request, wait for response and then perform the search. TerriaJS supports only search provider of type `catalog-search-provider` + +### CatalogIndex + +If your TerriaMap has many (>50) dynamic groups (groups which need to be loaded - for example CKAN, WMS-group...) it may be worth generating a static catalog index JSON file. This file will contain ID, name and description fields of all catalog items, which can be used to search through the catalog very quickly without needing to load dynamic references/groups (for example `MagdaReference` -> `WebMapServiceCatalogGroup` -> `WebMapServiceCatalogItem`). + +The [flexsearch](https://github.com/nextapps-de/flexsearch) library is used to index and search the catalog index file. + +**Note** NodeJS v10 is not supported, please use v12 or v14. + +To generate the catalog index: + +- `yarn build-tools` +- `node .\build\generateCatalogIndex.js config-url base-url` where + + - `config-url` is URL to client-side-config file + - `base-url` is URL to terriajs-server (this is used to load `server-config` and to proxy requests) + - For example `node .\build\generateCatalogIndex.js http://localhost:3001/config.json http://localhost:3001` + +- This will output three files + - `catalog-index.json` + - `catalog-index-errors.json` with any error messages which occurred while loading catalog members + - `catalog-index-errors-stack.json` with errors stack +- Set `catalogIndexUrl` config parameter to URL to `catalog-index.json` + +This file will have to be re-generated manually every time the catalog structure changes - for example: + +- if items are renamed, or moved +- dynamic groups are updated (for example, WMS server publishes new layers) + +For more details see [/buildprocess/generateCatalogIndex.ts](/buildprocess/generateCatalogIndex.ts) + +## Location search providers + +Location search providers are used to search for locations on the map. TerriaJS currently supports two implementations of search providers: + +- [`BingMapsSearchProvider`](#bingmapssearchprovider) - implementation which in background uses Bing Map search API +- [`CesiumIonSearchProvider`](#cesiumionsearchprovider) - implementation which in background use CesiumIon geocoding API +- [`AustralianGazetteerSearchProvider`](#australiangazetteersearchprovider) - uses `WebFeatureServiceSearchProvider` + +Each `LocationSearchProvider support following confing options + +| Name | Required | Type | Default | Description | +| --------------------- | -------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| url | yes | **string** | `""` | The URL of search provider. | +| recommendedListLength | no | **number** | `5` | Default amount of entries in the suggestion list. | +| flightDurationSeconds | no | **number** | `1.5` | Time to move to the result location. | +| isOpen | no | **boolean** | `true` | True if the search results of this search provider are visible by default; otherwise, false (user manually open search results). | + +### BingMapsSearchProvider + +`type: bing-maps-search-provider` + +Bing maps search provider is based on commercial API which is provided by BingMaps. To enable it, it is necessary to add an apropriate Bing Maps API key as config parameter. This search provider as results returns addresses and a place name locations. + +| Name | Required | Type | Default | Description | +| -------------- | -------- | ----------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `key` | no | **string** | `configParameters.bingMapsKey` | The Bing Maps key. | +| primaryCountry | no | **string** | `Australia` | Name of the country to prioritize the search results. | +| `culture` | no | **string** | `en-au` | Use the culture parameter to specify a culture for your request.The culture parameter provides the result in the language of the culture. For a list of supported cultures, see [Supported Culture Codes](https://docs.microsoft.com/en-us/bingmaps/rest-services/common-parameters-and-types/supported-culture-codes) | +| `mapCenter` | no | **boolean** | `true` | Whether the current location of the map center is supplied with search request | + +It provides a default value for `url: https://dev.virtualearth.net/` + +**Example** + +```json +{ + "id": "search-provider/bing-maps", + "type": "bing-maps-search-provider", + "name": "translate#viewModels.searchLocations", + "url": "https://dev.virtualearth.net/", + "flightDurationSeconds": 1.5, + "minCharacters": 5, + "isOpen": true +}, +``` + +### CesiumIonSearchProvider + +`type: cesium-ion-search-provider` + +CesiumIon search provider is based on CesiumIon geocoding API provided by Cesium. To enable it it is necessary to add appropriate cesium API key as config parameter. + +| Name | Required | Type | Default | Description | +| ----- | -------- | ---------- | --------------------------------------- | ------------------ | +| `key` | no | **string** | `configParameters.cesiumIonAccessToken` | The CesiumIon key. | + +It provides a default value for `url: https://api.cesium.com/v1/geocode/search/` + +**Example** + +```json +{ + "id": "search-provider/cesium-ion", + "type": "cesium-ion-search-provider", + "name": "translate#viewModels.searchLocations", + "url": "https://api.cesium.com/v1/geocode/search/", + "flightDurationSeconds": 1.5, + "minCharacters": 5, + "isOpen": true +}, +``` + +### AustralianGazetteerSearchProvider + +`type: australian-gazetteer-search-provider` + +Australian gazzetteer search provider is based on web feature service that uses an official place names of Australia. It is based on `WebFeatureServiceProvider`. +It can be configured using following options + +| Name | Required | Type | Default | Description | +| ------------------------ | -------- | ---------- | ----------- | --------------------------------------------- | +| `searchPropertyName` | yes | **string** | `undefined` | Which property to look for the search text in | +| `searchPropertyTypeName` | yes | **string** | `undefined` | Type of the properties to search | + +**Example** + +```json +{ + "id": "search-provider/australian-gazetteer", + "type": "australian-gazetteer-search-provider", + "name": "translate#viewModels.searchPlaceNames", + "url": "http://services.ga.gov.au/gis/services/Australian_Gazetteer/MapServer/WFSServer", + "searchPropertyName": "Australian_Gazetteer:NameU", + "searchPropertyTypeName": "Australian_Gazetteer:Gazetteer_of_Australia", + "flightDurationSeconds": 1.5, + "minCharacters": 3, + "recommendedListLength": 3, + "isOpen": false +} +``` + +### Implementing new location search provider + +Implementing new location search provider is similar to implementing new `CatalogItems` and `CatalogGroups`. Each of them should be based on the usage of one of the mixins + +- `LocationSearchProviderMixin` - should be used for API based location search providers. Example of such search provider is `BingMapSearchProvider`. +- `WebFeatureServiceSearchProviderMixin` - should be used for location search providers that will rely on data provided by `WebFeatureService`. Example of such search provider is `AustralianGazetteerSearchProvider`. + +Each new `SearchProvider` should be registered inside `registerSearchProvider` so they can be properly upserted from json definition provider in config file. diff --git a/doc/mkdocs.yml b/doc/mkdocs.yml index b6e06bebf45..dfd28b71452 100644 --- a/doc/mkdocs.yml +++ b/doc/mkdocs.yml @@ -20,6 +20,7 @@ nav: - Cloning and Building: customizing/cloning-and-building.md - Skinning: customizing/skinning.md - Translation guide: customizing/translation-guide.md + - Search Providers: customizing/search-providers.md - Connecting to Data: - Overview: connecting-to-data/README.md - Cross-Origin Resource Sharing: connecting-to-data/cross-origin-resource-sharing.md diff --git a/lib/Core/loadJsonp.js b/lib/Core/loadJsonp.js deleted file mode 100644 index 4b666d219fd..00000000000 --- a/lib/Core/loadJsonp.js +++ /dev/null @@ -1,25 +0,0 @@ -const Resource = require("terriajs-cesium/Source/Core/Resource").default; - -function loadJsonp(urlOrResource, callbackParameterName) { - var resource = Resource.createIfNeeded(urlOrResource); - return resource.fetchJsonp(callbackParameterName); -} - -Object.defineProperties(loadJsonp, { - loadAndExecuteScript: { - get: function () { - return Resource._Implementations.loadAndExecuteScript; - }, - set: function (value) { - Resource._Implementations.loadAndExecuteScript = value; - } - }, - - defaultLoadAndExecuteScript: { - get: function () { - return Resource._DefaultImplementations.loadAndExecuteScript; - } - } -}); - -module.exports = loadJsonp; diff --git a/lib/Core/loadJsonp.ts b/lib/Core/loadJsonp.ts new file mode 100644 index 00000000000..8a4eece71c5 --- /dev/null +++ b/lib/Core/loadJsonp.ts @@ -0,0 +1,30 @@ +import Resource from "terriajs-cesium/Source/Core/Resource"; + +export function loadJsonp( + urlOrResource: Resource, + callbackParameterName: string +): Promise { + const resource = + typeof urlOrResource === "string" + ? new Resource({ url: urlOrResource }) + : urlOrResource; + + return resource.fetchJsonp(callbackParameterName)!; +} + +Object.defineProperties(loadJsonp, { + loadAndExecuteScript: { + get: function () { + return (Resource as any)._Implementations.loadAndExecuteScript; + }, + set: function (value) { + (Resource as any)._Implementations.loadAndExecuteScript = value; + } + }, + + defaultLoadAndExecuteScript: { + get: function () { + return (Resource as any)._DefaultImplementations.loadAndExecuteScript; + } + } +}); diff --git a/lib/Language/languageHelpers.ts b/lib/Language/languageHelpers.ts index a4868f24c85..4e6f2de4d24 100644 --- a/lib/Language/languageHelpers.ts +++ b/lib/Language/languageHelpers.ts @@ -1,4 +1,4 @@ -import { i18n } from "i18next"; +import { TOptions, i18n } from "i18next"; import { isJsonString } from "../Core/Json"; export const TRANSLATE_KEY_PREFIX = "translate#"; @@ -17,12 +17,12 @@ export const TRANSLATE_KEY_PREFIX = "translate#"; export function applyTranslationIfExists( keyOrString: string, i18n: i18n, - options?: { [key: string]: string } + options?: TOptions ): string { // keyOrString could be undefined in some cases even if we type it as string if (isJsonString(keyOrString as unknown)) { if (keyOrString.indexOf(TRANSLATE_KEY_PREFIX) === 0) { - const translationKey = keyOrString.substr(TRANSLATE_KEY_PREFIX.length); + const translationKey = keyOrString.substring(TRANSLATE_KEY_PREFIX.length); return i18n.exists(translationKey) ? i18n.t(translationKey, options) : translationKey; diff --git a/lib/ModelMixins/SearchProviders/CatalogSearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/CatalogSearchProviderMixin.ts new file mode 100644 index 00000000000..d2c0209c731 --- /dev/null +++ b/lib/ModelMixins/SearchProviders/CatalogSearchProviderMixin.ts @@ -0,0 +1,44 @@ +import { computed, makeObservable } from "mobx"; +import { fromPromise } from "mobx-utils"; +import AbstractConstructor from "../../Core/AbstractConstructor"; +import isDefined from "../../Core/isDefined"; +import Model from "../../Models/Definition/Model"; +import SearchProviderTraits from "../../Traits/SearchProviders/SearchProviderTraits"; +import SearchProviderMixin from "./SearchProviderMixin"; + +type CatalogSearchProviderModel = Model; + +function CatalogSearchProviderMixin< + T extends AbstractConstructor +>(Base: T) { + abstract class CatalogSearchProviderMixin extends SearchProviderMixin(Base) { + constructor(...args: any[]) { + super(...args); + makeObservable(this); + } + + @computed get resultsAreReferences() { + return ( + isDefined(this.terria.catalogIndex?.loadPromise) && + fromPromise(this.terria.catalogIndex!.loadPromise).state === "fulfilled" + ); + } + + get hasCatalogSearchProviderMixin() { + return true; + } + } + + return CatalogSearchProviderMixin; +} + +namespace CatalogSearchProviderMixin { + export interface Instance + extends InstanceType> {} + + export function isMixedInto(model: any): model is Instance { + return model && model.hasCatalogSearchProviderMixin; + } +} + +export default CatalogSearchProviderMixin; diff --git a/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts new file mode 100644 index 00000000000..d1bd12985e7 --- /dev/null +++ b/lib/ModelMixins/SearchProviders/LocationSearchProviderMixin.ts @@ -0,0 +1,72 @@ +import { action, makeObservable } 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"; +import AbstractConstructor from "../../Core/AbstractConstructor"; +import CommonStrata from "../../Models/Definition/CommonStrata"; +import Model from "../../Models/Definition/Model"; +import Terria from "../../Models/Terria"; +import LocationSearchProviderTraits from "../../Traits/SearchProviders/LocationSearchProviderTraits"; +import SearchProviderMixin from "./SearchProviderMixin"; + +type LocationSearchProviderModel = Model; + +function LocationSearchProviderMixin< + T extends AbstractConstructor +>(Base: T) { + abstract class LocationSearchProviderMixin extends SearchProviderMixin(Base) { + constructor(...args: any[]) { + super(...args); + makeObservable(this); + } + + get hasLocationSearchProviderMixin() { + return true; + } + + @action + toggleOpen(stratumId: CommonStrata = CommonStrata.user) { + this.setTrait(stratumId, "isOpen", !this.isOpen); + } + + @action + showWarning() {} + } + + return LocationSearchProviderMixin; +} + +interface MapCenter { + longitude: number; + latitude: number; +} + +export function getMapCenter(terria: Terria): MapCenter { + const view = terria.currentViewer.getCurrentCameraView(); + if (view.position !== undefined) { + const cameraPositionCartographic = Ellipsoid.WGS84.cartesianToCartographic( + view.position + ); + return { + longitude: CesiumMath.toDegrees(cameraPositionCartographic.longitude), + latitude: CesiumMath.toDegrees(cameraPositionCartographic.latitude) + }; + } else { + const center = Rectangle.center(view.rectangle); + return { + longitude: CesiumMath.toDegrees(center.longitude), + latitude: CesiumMath.toDegrees(center.latitude) + }; + } +} + +namespace LocationSearchProviderMixin { + export interface Instance + extends InstanceType> {} + + export function isMixedInto(model: any): model is Instance { + return model && model.hasLocationSearchProviderMixin; + } +} + +export default LocationSearchProviderMixin; diff --git a/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts new file mode 100644 index 00000000000..7d58e2683e2 --- /dev/null +++ b/lib/ModelMixins/SearchProviders/SearchProviderMixin.ts @@ -0,0 +1,76 @@ +import { action, makeObservable } from "mobx"; +import { fromPromise } from "mobx-utils"; +import AbstractConstructor from "../../Core/AbstractConstructor"; +import Model from "../../Models/Definition/Model"; +import SearchProviderResults from "../../Models/SearchProviders/SearchProviderResults"; +import SearchProviderTraits from "../../Traits/SearchProviders/SearchProviderTraits"; + +type SearchProviderModel = Model; + +function SearchProviderMixin< + T extends AbstractConstructor +>(Base: T) { + abstract class SearchProviderMixin extends Base { + abstract get type(): string; + + constructor(...args: any[]) { + super(...args); + makeObservable(this); + } + + protected abstract logEvent(searchText: string): void; + + protected abstract doSearch( + searchText: string, + results: SearchProviderResults + ): Promise; + + @action + search(searchText: string): SearchProviderResults { + const result = new SearchProviderResults(this); + if (!this.shouldRunSearch(searchText)) { + result.resultsCompletePromise = fromPromise(Promise.resolve()); + result.message = { + content: "translate#viewModels.searchMinCharacters", + params: { + count: this.minCharacters + } + }; + return result; + } + this.logEvent(searchText); + result.resultsCompletePromise = fromPromise( + this.doSearch(searchText, result) + ); + return result; + } + + private shouldRunSearch(searchText: string) { + if ( + searchText === undefined || + /^\s*$/.test(searchText) || + (this.minCharacters && searchText.length < this.minCharacters) + ) { + return false; + } + return true; + } + + get hasSearchProviderMixin() { + return true; + } + } + + return SearchProviderMixin; +} + +namespace SearchProviderMixin { + export interface Instance + extends InstanceType> {} + + export function isMixedInto(model: any): model is Instance { + return model && model.hasSearchProviderMixin; + } +} + +export default SearchProviderMixin; diff --git a/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts b/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts new file mode 100644 index 00000000000..622a5bddb57 --- /dev/null +++ b/lib/ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin.ts @@ -0,0 +1,232 @@ +import { makeObservable, runInAction } from "mobx"; +import Resource from "terriajs-cesium/Source/Core/Resource"; +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 SearchResult from "../../Models/SearchProviders/SearchResult"; +import xml2json from "../../ThirdParty/xml2json"; +import WebFeatureServiceSearchProviderTraits from "../../Traits/SearchProviders/WebFeatureServiceSearchProviderTraits"; +import LocationSearchProviderMixin from "./LocationSearchProviderMixin"; + +function WebFeatureServiceSearchProviderMixin< + T extends AbstractConstructor> +>(Base: T) { + abstract class WebFeatureServiceSearchProviderMixin extends LocationSearchProviderMixin( + Base + ) { + constructor(...args: any[]) { + super(...args); + makeObservable(this); + } + + protected abstract featureToSearchResultFunction: ( + feature: any + ) => SearchResult; + protected abstract transformSearchText: + | ((searchText: string) => string) + | undefined; + protected abstract searchResultFilterFunction: + | ((feature: any) => boolean) + | undefined; + protected abstract searchResultScoreFunction: + | ((feature: any, searchText: string) => number) + | undefined; + + cancelRequest?: () => void; + + private _waitingForResults: boolean = false; + + getXml(url: string): Promise { + const resource = new Resource({ url }); + this._waitingForResults = true; + const xmlPromise = resource.fetchXML()!; + this.cancelRequest = resource.request.cancelFunction; + return xmlPromise.finally(() => { + this._waitingForResults = false; + }); + } + + protected doSearch( + searchText: string, + results: SearchProviderResults + ): Promise { + results.results.length = 0; + results.message = undefined; + + if (this._waitingForResults) { + // There's been a new search! Cancel the previous one. + if (this.cancelRequest !== undefined) { + this.cancelRequest(); + this.cancelRequest = undefined; + } + this._waitingForResults = false; + } + + const originalSearchText = searchText; + + searchText = searchText.trim(); + if (this.transformSearchText !== undefined) { + searchText = this.transformSearchText(searchText); + } + if (searchText.length < 2) { + return Promise.resolve(); + } + + // Support for matchCase="false" is patchy, but we try anyway + const filter = ` + ${this.searchPropertyName} + *${searchText}*`; + + const _wfsServiceUrl = new URI(this.url); + _wfsServiceUrl.setSearch({ + service: "WFS", + request: "GetFeature", + typeName: this.searchPropertyTypeName, + version: "1.1.0", + srsName: "urn:ogc:def:crs:EPSG::4326", // srsName must be formatted like this for correct lat/long order >:( + filter: filter + }); + + return this.getXml(_wfsServiceUrl.toString()) + .then((xml: any) => { + let json: any = xml2json(xml); + let features: any[]; + if (json === undefined) { + results.message = { + content: "translate#viewModels.searchErrorOccurred" + }; + return; + } + + if (json.member !== undefined) { + features = json.member; + } else if (json.featureMember !== undefined) { + features = json.featureMember; + } else { + results.message = { + content: "translate#viewModels.searchNoPlaceNames" + }; + return; + } + + // if there's only one feature, make it an array + if (!Array.isArray(features)) { + features = [features]; + } + + const resultSet = new Set(); + + runInAction(() => { + if (this.searchResultFilterFunction !== undefined) { + features = features.filter(this.searchResultFilterFunction); + } + + if (features.length === 0) { + results.message = { + content: "translate#viewModels.searchNoPlaceNames" + }; + return; + } + + if (this.searchResultScoreFunction !== undefined) { + features = features.sort( + (featureA, featureB) => + this.searchResultScoreFunction!( + featureB, + originalSearchText + ) - + this.searchResultScoreFunction!(featureA, originalSearchText) + ); + } + + let searchResults = features + .map(this.featureToSearchResultFunction) + .map((result) => { + result.clickAction = createZoomToFunction( + this, + result.location + ); + return result; + }); + + // If we don't have a scoring function, sort the search results now + // We can't do this earlier because we don't know what the schema of the unprocessed feature looks like + if (this.searchResultScoreFunction === undefined) { + // Put shorter results first + // They have a larger percentage of letters that the user actually typed in them + searchResults = searchResults.sort( + (featureA, featureB) => + featureA.name.length - featureB.name.length + ); + } + + // Remove results that have the same name and are close to each other + searchResults = searchResults.filter((result) => { + const hash = `${result.name},${result.location?.latitude.toFixed( + 1 + )},${result.location?.longitude.toFixed(1)}`; + if (resultSet.has(hash)) { + return false; + } + resultSet.add(hash); + return true; + }); + + // append new results to all results + results.results.push(...searchResults); + }); + }) + .catch((e) => { + if (results.isCanceled) { + // A new search has superseded this one, so ignore the result. + return; + } + results.message = { + content: "translate#viewModels.searchErrorOccurred" + }; + }); + } + + get isWebFeatureServiceSearchProviderMixin() { + return true; + } + } + + return WebFeatureServiceSearchProviderMixin; +} + +namespace WebFeatureServiceSearchProviderMixin { + export interface Instance + extends InstanceType< + ReturnType + > {} + + export function isMixedInto(model: any): model is Instance { + return model && model.isWebFeatureServiceSearchProviderMixin; + } +} +export default WebFeatureServiceSearchProviderMixin; + +function createZoomToFunction( + model: WebFeatureServiceSearchProviderMixin.Instance, + location: any +) { + // Server does not return information of a bounding box, just a location. + // bboxSize is used to expand a point + var bboxSize = 0.2; + var rectangle = zoomRectangleFromPoint( + location.latitude, + location.longitude, + bboxSize + ); + + const flightDurationSeconds: number = + model.flightDurationSeconds || + model.terria.searchBarModel.flightDurationSeconds; + + return function () { + model.terria.currentViewer.zoomTo(rectangle, flightDurationSeconds); + }; +} diff --git a/lib/Models/Catalog/Ows/WebFeatureServiceSearchProvider.ts b/lib/Models/Catalog/Ows/WebFeatureServiceSearchProvider.ts deleted file mode 100644 index 8599934aed9..00000000000 --- a/lib/Models/Catalog/Ows/WebFeatureServiceSearchProvider.ts +++ /dev/null @@ -1,220 +0,0 @@ -import i18next from "i18next"; -import { runInAction } from "mobx"; -import URI from "urijs"; -import zoomRectangleFromPoint from "../../../Map/Vector/zoomRectangleFromPoint"; -import xml2json from "../../../ThirdParty/xml2json"; -import SearchProvider from "../../SearchProviders/SearchProvider"; -import SearchProviderResults from "../../SearchProviders/SearchProviderResults"; -import SearchResult from "../../SearchProviders/SearchResult"; -import Terria from "../../Terria"; -import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; -import Resource from "terriajs-cesium/Source/Core/Resource"; - -export interface WebFeatureServiceSearchProviderOptions { - /** Base url for the service */ - wfsServiceUrl: string; - /** Which property to look for the search text in */ - searchPropertyName: string; - /** Type of the properties to search */ - searchPropertyTypeName: string; - /** Convert a WFS feature to a search result */ - featureToSearchResultFunction: (feature: any) => SearchResult; - terria: Terria; - /** How long it takes to zoom in when a search result is clicked */ - flightDurationSeconds?: number; - /** Apply a function to search text before it gets passed to the service. Useful for changing case */ - transformSearchText?: (searchText: string) => string; - /** Return true if a feature should be included in search results */ - searchResultFilterFunction?: (feature: any) => boolean; - /** Return a score that gets used to sort results (in descending order) */ - searchResultScoreFunction?: (feature: any, searchText: string) => number; - /** name of the search provider */ - name: string; -} - -export default class WebFeatureServiceSearchProvider extends SearchProvider { - private _wfsServiceUrl: uri.URI; - private _searchPropertyName: string; - private _searchPropertyTypeName: string; - private _featureToSearchResultFunction: (feature: any) => SearchResult; - flightDurationSeconds: number; - readonly terria: Terria; - private _transformSearchText: ((searchText: string) => string) | undefined; - private _searchResultFilterFunction: ((feature: any) => boolean) | undefined; - private _searchResultScoreFunction: - | ((feature: any, searchText: string) => number) - | undefined; - cancelRequest?: () => void; - private _waitingForResults: boolean = false; - - constructor(options: WebFeatureServiceSearchProviderOptions) { - super(); - this._wfsServiceUrl = new URI(options.wfsServiceUrl); - this._searchPropertyName = options.searchPropertyName; - this._searchPropertyTypeName = options.searchPropertyTypeName; - this._featureToSearchResultFunction = options.featureToSearchResultFunction; - this.terria = options.terria; - this.flightDurationSeconds = defaultValue( - options.flightDurationSeconds, - 1.5 - ); - this._transformSearchText = options.transformSearchText; - this._searchResultFilterFunction = options.searchResultFilterFunction; - this._searchResultScoreFunction = options.searchResultScoreFunction; - this.name = options.name; - } - - getXml(): Promise { - const resource = new Resource({ url: this._wfsServiceUrl.toString() }); - this._waitingForResults = true; - const xmlPromise = resource.fetchXML()!; - this.cancelRequest = resource.request.cancelFunction; - return xmlPromise.finally(() => { - this._waitingForResults = false; - }); - } - - protected doSearch( - searchText: string, - results: SearchProviderResults - ): Promise { - results.results.length = 0; - results.message = undefined; - - if (this._waitingForResults) { - // There's been a new search! Cancel the previous one. - if (this.cancelRequest !== undefined) { - this.cancelRequest(); - this.cancelRequest = undefined; - } - this._waitingForResults = false; - } - - const originalSearchText = searchText; - - searchText = searchText.trim(); - if (this._transformSearchText !== undefined) { - searchText = this._transformSearchText(searchText); - } - if (searchText.length < 2) { - return Promise.resolve(); - } - - // Support for matchCase="false" is patchy, but we try anyway - const filter = ` - ${this._searchPropertyName} - *${searchText}*`; - - this._wfsServiceUrl.setSearch({ - service: "WFS", - request: "GetFeature", - typeName: this._searchPropertyTypeName, - version: "1.1.0", - srsName: "urn:ogc:def:crs:EPSG::4326", // srsName must be formatted like this for correct lat/long order >:( - filter: filter - }); - - return this.getXml() - .then((xml: any) => { - let json: any = xml2json(xml); - let features: any[]; - if (json === undefined) { - results.message = i18next.t("viewModels.searchErrorOccurred"); - return; - } - - if (json.member !== undefined) { - features = json.member; - } else if (json.featureMember !== undefined) { - features = json.featureMember; - } else { - results.message = i18next.t("viewModels.searchNoPlaceNames"); - return; - } - - // if there's only one feature, make it an array - if (!Array.isArray(features)) { - features = [features]; - } - - const resultSet = new Set(); - - runInAction(() => { - if (this._searchResultFilterFunction !== undefined) { - features = features.filter(this._searchResultFilterFunction); - } - - if (features.length === 0) { - results.message = i18next.t("viewModels.searchNoPlaceNames"); - return; - } - - if (this._searchResultScoreFunction !== undefined) { - features = features.sort( - (featureA, featureB) => - this._searchResultScoreFunction!(featureB, originalSearchText) - - this._searchResultScoreFunction!(featureA, originalSearchText) - ); - } - - let searchResults = features - .map(this._featureToSearchResultFunction) - .map((result) => { - result.clickAction = createZoomToFunction(this, result.location); - return result; - }); - - // If we don't have a scoring function, sort the search results now - // We can't do this earlier because we don't know what the schema of the unprocessed feature looks like - if (this._searchResultScoreFunction === undefined) { - // Put shorter results first - // They have a larger percentage of letters that the user actually typed in them - searchResults = searchResults.sort( - (featureA, featureB) => - featureA.name.length - featureB.name.length - ); - } - - // Remove results that have the same name and are close to each other - searchResults = searchResults.filter((result) => { - const hash = `${result.name},${result.location?.latitude.toFixed( - 1 - )},${result.location?.longitude.toFixed(1)}`; - if (resultSet.has(hash)) { - return false; - } - resultSet.add(hash); - return true; - }); - - // append new results to all results - results.results.push(...searchResults); - }); - }) - .catch((e) => { - if (results.isCanceled) { - // A new search has superseded this one, so ignore the result. - return; - } - results.message = i18next.t("viewModels.searchErrorOccurred"); - }); - } -} - -function createZoomToFunction( - model: WebFeatureServiceSearchProvider, - location: any -) { - // Server does not return information of a bounding box, just a location. - // bboxSize is used to expand a point - var bboxSize = 0.2; - var rectangle = zoomRectangleFromPoint( - location.latitude, - location.longitude, - bboxSize - ); - - return function () { - model.terria.currentViewer.zoomTo(rectangle, model.flightDurationSeconds); - }; -} diff --git a/lib/Models/SearchProviders/AustralianGazetteerSearchProvider.ts b/lib/Models/SearchProviders/AustralianGazetteerSearchProvider.ts index 37bd354c6b1..2072841b206 100644 --- a/lib/Models/SearchProviders/AustralianGazetteerSearchProvider.ts +++ b/lib/Models/SearchProviders/AustralianGazetteerSearchProvider.ts @@ -1,7 +1,13 @@ -import i18next from "i18next"; +import { makeObservable } from "mobx"; +import { + Category, + SearchAction +} from "../../Core/AnalyticEvents/analyticEvents"; +import WebFeatureServiceSearchProviderMixin from "../../ModelMixins/SearchProviders/WebFeatureServiceSearchProviderMixin"; +import WebFeatureServiceSearchProviderTraits from "../../Traits/SearchProviders/WebFeatureServiceSearchProviderTraits"; +import CreateModel from "../Definition/CreateModel"; +import { ModelConstructorParameters } from "../Definition/Model"; import SearchResult from "./SearchResult"; -import Terria from "../Terria"; -import WebFeatureServiceSearchProvider from "../Catalog/Ows/WebFeatureServiceSearchProvider"; const featureCodesToNamesMap = new Map([ ["AF", "Aviation"], @@ -220,23 +226,36 @@ const searchResultScoreFunction = function ( return score; }; -const WFS_SERVICE_URL = - "http://services.ga.gov.au/gis/services/Australian_Gazetteer/MapServer/WFSServer"; -const SEARCH_PROPERTY_NAME = "Australian_Gazetteer:NameU"; -const SEARCH_PROPERTY_TYPE_NAME = "Australian_Gazetteer:Gazetteer_of_Australia"; - -export default function createAustralianGazetteerSearchProvider( - terria: Terria +export default class AustralianGazetteerSearchProvider extends WebFeatureServiceSearchProviderMixin( + CreateModel(WebFeatureServiceSearchProviderTraits) ) { - return new WebFeatureServiceSearchProvider({ - terria, - featureToSearchResultFunction, - wfsServiceUrl: WFS_SERVICE_URL, - searchPropertyName: SEARCH_PROPERTY_NAME, - searchPropertyTypeName: SEARCH_PROPERTY_TYPE_NAME, - transformSearchText: (searchText) => searchText.toUpperCase(), - name: i18next.t("viewModels.searchPlaceNames"), - searchResultFilterFunction: searchResultFilterFunction, - searchResultScoreFunction: searchResultScoreFunction - }); + static readonly type = "australian-gazetteer-search-provider"; + + constructor(...args: ModelConstructorParameters) { + super(...args); + makeObservable(this); + } + + get type() { + return AustralianGazetteerSearchProvider.type; + } + + protected logEvent(searchText: string) { + this.terria.analytics?.logEvent( + Category.search, + SearchAction.gazetteer, + searchText + ); + } + + featureToSearchResultFunction: (feature: any) => SearchResult = + featureToSearchResultFunction; + transformSearchText: ((searchText: string) => string) | undefined = ( + searchText + ) => searchText.toUpperCase(); + searchResultFilterFunction: ((feature: any) => boolean) | undefined = + searchResultFilterFunction; + searchResultScoreFunction: + | ((feature: any, searchText: string) => number) + | undefined = searchResultScoreFunction; } diff --git a/lib/Models/SearchProviders/BingMapsSearchProvider.ts b/lib/Models/SearchProviders/BingMapsSearchProvider.ts index 5a011c5a31c..b9ebb6b0ab7 100644 --- a/lib/Models/SearchProviders/BingMapsSearchProvider.ts +++ b/lib/Models/SearchProviders/BingMapsSearchProvider.ts @@ -1,66 +1,68 @@ -import { observable, runInAction, makeObservable } from "mobx"; -import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; -import defined from "terriajs-cesium/Source/Core/defined"; -import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid"; -import CesiumMath from "terriajs-cesium/Source/Core/Math"; +import i18next from "i18next"; +import { makeObservable, override, runInAction } from "mobx"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import Resource from "terriajs-cesium/Source/Core/Resource"; -import loadJsonp from "../../Core/loadJsonp"; -import SearchProvider from "./SearchProvider"; -import SearchResult from "./SearchResult"; -import Terria from "../Terria"; -import SearchProviderResults from "./SearchProviderResults"; -import i18next from "i18next"; +import defined from "terriajs-cesium/Source/Core/defined"; import { Category, SearchAction } from "../../Core/AnalyticEvents/analyticEvents"; +import { loadJsonp } from "../../Core/loadJsonp"; +import { applyTranslationIfExists } from "../../Language/languageHelpers"; +import LocationSearchProviderMixin, { + getMapCenter +} from "../../ModelMixins/SearchProviders/LocationSearchProviderMixin"; +import BingMapsSearchProviderTraits from "../../Traits/SearchProviders/BingMapsSearchProviderTraits"; +import CreateModel from "../Definition/CreateModel"; +import Terria from "../Terria"; +import CommonStrata from "./../Definition/CommonStrata"; +import SearchProviderResults from "./SearchProviderResults"; +import SearchResult from "./SearchResult"; -interface BingMapsSearchProviderOptions { - terria: Terria; - url?: string; - key?: string; - flightDurationSeconds?: number; - primaryCountry?: string; - culture?: string; -} +export default class BingMapsSearchProvider extends LocationSearchProviderMixin( + CreateModel(BingMapsSearchProviderTraits) +) { + static readonly type = "bing-maps-search-provider"; -export default class BingMapsSearchProvider extends SearchProvider { - readonly terria: Terria; - @observable url: string; - @observable key: string | undefined; - @observable flightDurationSeconds: number; - @observable primaryCountry: string; - @observable culture: string; + get type() { + return BingMapsSearchProvider.type; + } - constructor(options: BingMapsSearchProviderOptions) { - super(); + constructor(uniqueId: string | undefined, terria: Terria) { + super(uniqueId, terria); makeObservable(this); - this.terria = options.terria; - this.name = i18next.t("viewModels.searchLocations"); - this.url = defaultValue(options.url, "https://dev.virtualearth.net/"); - if (this.url.length > 0 && this.url[this.url.length - 1] !== "/") { - this.url += "/"; - } - this.key = options.key; - this.flightDurationSeconds = defaultValue( - options.flightDurationSeconds, - 1.5 - ); - this.primaryCountry = defaultValue(options.primaryCountry, "Australia"); - this.culture = defaultValue(options.culture, "en-au"); + runInAction(() => { + if (!!this.terria.configParameters.bingMapsKey) { + this.setTrait( + CommonStrata.defaults, + "key", + this.terria.configParameters.bingMapsKey + ); + } + }); + } - if (!this.key) { + @override + override showWarning() { + if (!this.key || this.key === "") { console.warn( - "The " + - this.name + - " geocoder will always return no results because a Bing Maps key has not been provided. Please get a Bing Maps key from bingmapsportal.com and add it to parameters.bingMapsKey in config.json." + `The ${applyTranslationIfExists(this.name, i18next)}(${ + this.type + }) geocoder will always return no results because a Bing Maps key has not been provided. Please get a Bing Maps key from bingmapsportal.com and add it to parameters.bingMapsKey in config.json.` ); } } + protected logEvent(searchText: string) { + this.terria.analytics?.logEvent( + Category.search, + SearchAction.gazetteer, + searchText + ); + } + protected doSearch( searchText: string, searchResults: SearchProviderResults @@ -68,52 +70,25 @@ export default class BingMapsSearchProvider extends SearchProvider { searchResults.results.length = 0; searchResults.message = undefined; - if (searchText === undefined || /^\s*$/.test(searchText)) { - return Promise.resolve(); - } - - this.terria.analytics?.logEvent( - Category.search, - SearchAction.bing, - searchText - ); - - let longitudeDegrees; - let latitudeDegrees; - - const view = this.terria.currentViewer.getCurrentCameraView(); - if (view.position !== undefined) { - const cameraPositionCartographic = - Ellipsoid.WGS84.cartesianToCartographic(view.position); - longitudeDegrees = CesiumMath.toDegrees( - cameraPositionCartographic.longitude - ); - latitudeDegrees = CesiumMath.toDegrees( - cameraPositionCartographic.latitude - ); - } else { - const center = Rectangle.center(view.rectangle); - longitudeDegrees = CesiumMath.toDegrees(center.longitude); - latitudeDegrees = CesiumMath.toDegrees(center.latitude); + const searchQuery = new Resource({ + url: this.url + "REST/v1/Locations", + queryParameters: { + culture: this.culture, + query: searchText, + key: this.key, + maxResults: this.maxResults + } + }); + + if (this.mapCenter) { + const mapCenter = getMapCenter(this.terria); + + searchQuery.appendQueryParameters({ + userLocation: `${mapCenter.latitude}, ${mapCenter.longitude}` + }); } - const promise: Promise = loadJsonp( - new Resource({ - url: - this.url + - "REST/v1/Locations?culture=" + - this.culture + - "&userLocation=" + - latitudeDegrees + - "," + - longitudeDegrees, - queryParameters: { - query: searchText, - key: this.key - } - }), - "jsonp" - ); + const promise: Promise = loadJsonp(searchQuery, "jsonp"); return promise .then((result) => { @@ -123,69 +98,31 @@ export default class BingMapsSearchProvider extends SearchProvider { } if (result.resourceSets.length === 0) { - searchResults.message = i18next.t("viewModels.searchNoLocations"); + searchResults.message = { + content: "translate#viewModels.searchNoLocations" + }; return; } - var resourceSet = result.resourceSets[0]; + const resourceSet = result.resourceSets[0]; if (resourceSet.resources.length === 0) { - searchResults.message = i18next.t("viewModels.searchNoLocations"); + searchResults.message = { + content: "translate#viewModels.searchNoLocations" + }; return; } - const primaryCountryLocations: any[] = []; - const otherLocations: any[] = []; - - // Locations in the primary country go on top, locations elsewhere go undernearth and we add - // the country name to them. - for (let i = 0; i < resourceSet.resources.length; ++i) { - const resource = resourceSet.resources[i]; - - let name = resource.name; - if (!defined(name)) { - continue; - } - - let list = primaryCountryLocations; - let isImportant = true; - - const country = resource.address - ? resource.address.countryRegion - : undefined; - if (defined(this.primaryCountry) && country !== this.primaryCountry) { - // Add this location to the list of other locations. - list = otherLocations; - isImportant = false; - - // Add the country to the name, if it's not already there. - if ( - defined(country) && - name.lastIndexOf(country) !== name.length - country.length - ) { - name += ", " + country; - } - } - - list.push( - new SearchResult({ - name: name, - isImportant: isImportant, - clickAction: createZoomToFunction(this, resource), - location: { - latitude: resource.point.coordinates[0], - longitude: resource.point.coordinates[1] - } - }) - ); - } - runInAction(() => { - searchResults.results.push(...primaryCountryLocations); - searchResults.results.push(...otherLocations); + const locations = this.sortByPriority(resourceSet.resources); + + searchResults.results.push(...locations.primaryCountry); + searchResults.results.push(...locations.other); }); if (searchResults.results.length === 0) { - searchResults.message = i18next.t("viewModels.searchNoLocations"); + searchResults.message = { + content: "translate#viewModels.searchNoLocations" + }; } }) .catch(() => { @@ -194,9 +131,64 @@ export default class BingMapsSearchProvider extends SearchProvider { return; } - searchResults.message = i18next.t("viewModels.searchErrorOccurred"); + searchResults.message = { + content: "translate#viewModels.searchErrorOccurred" + }; }); } + + protected sortByPriority(resources: any[]) { + const primaryCountryLocations: any[] = []; + const otherLocations: any[] = []; + + // Locations in the primary country go on top, locations elsewhere go undernearth and we add + // the country name to them. + for (let i = 0; i < resources.length; ++i) { + const resource = resources[i]; + + let name = resource.name; + if (!defined(name)) { + continue; + } + + let list = primaryCountryLocations; + let isImportant = true; + + const country = resource.address + ? resource.address.countryRegion + : undefined; + if (defined(this.primaryCountry) && country !== this.primaryCountry) { + // Add this location to the list of other locations. + list = otherLocations; + isImportant = false; + + // Add the country to the name, if it's not already there. + if ( + defined(country) && + name.lastIndexOf(country) !== name.length - country.length + ) { + name += ", " + country; + } + } + + list.push( + new SearchResult({ + name: name, + isImportant: isImportant, + clickAction: createZoomToFunction(this, resource), + location: { + latitude: resource.point.coordinates[0], + longitude: resource.point.coordinates[1] + } + }) + ); + } + + return { + primaryCountry: primaryCountryLocations, + other: otherLocations + }; + } } function createZoomToFunction(model: BingMapsSearchProvider, resource: any) { diff --git a/lib/Models/SearchProviders/CatalogIndex.ts b/lib/Models/SearchProviders/CatalogIndex.ts index 5450fdeb2c3..bf3a79bfcd1 100644 --- a/lib/Models/SearchProviders/CatalogIndex.ts +++ b/lib/Models/SearchProviders/CatalogIndex.ts @@ -1,5 +1,5 @@ import { Document } from "flexsearch"; -import { action, observable, runInAction, makeObservable } from "mobx"; +import { action, makeObservable, observable, runInAction } from "mobx"; import { isJsonObject, isJsonString, isJsonStringArray } from "../../Core/Json"; import loadBlob, { isZip, parseZipJsonBlob } from "../../Core/loadBlob"; import loadJson from "../../Core/loadJson"; diff --git a/lib/Models/SearchProviders/CatalogSearchProvider.ts b/lib/Models/SearchProviders/CatalogSearchProvider.ts index db887c27409..81063efcc85 100644 --- a/lib/Models/SearchProviders/CatalogSearchProvider.ts +++ b/lib/Models/SearchProviders/CatalogSearchProvider.ts @@ -1,30 +1,23 @@ -import { - autorun, - computed, - observable, - runInAction, - makeObservable -} from "mobx"; -import { fromPromise } from "mobx-utils"; +import { autorun, makeObservable, observable, runInAction } from "mobx"; import { Category, SearchAction } from "../../Core/AnalyticEvents/analyticEvents"; -import isDefined from "../../Core/isDefined"; import { TerriaErrorSeverity } from "../../Core/TerriaError"; import GroupMixin from "../../ModelMixins/GroupMixin"; import ReferenceMixin from "../../ModelMixins/ReferenceMixin"; +import CatalogSearchProviderMixin from "../../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; +import CatalogSearchProviderTraits from "../../Traits/SearchProviders/CatalogSearchProviderTraits"; +import CommonStrata from "../Definition/CommonStrata"; +import CreateModel from "../Definition/CreateModel"; import { BaseModel } from "../Definition/Model"; import Terria from "../Terria"; -import SearchProvider from "./SearchProvider"; import SearchProviderResults from "./SearchProviderResults"; import SearchResult from "./SearchResult"; -interface CatalogSearchProviderOptions { - terria: Terria; -} type UniqueIdString = string; type ResultMap = Map; + export function loadAndSearchCatalogRecursively( models: BaseModel[], searchTextLowercase: string, @@ -79,7 +72,7 @@ export function loadAndSearchCatalogRecursively( if (referencesAndGroupsToLoad.length === 0) { return Promise.resolve(); } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { autorun((reaction) => { Promise.all( referencesAndGroupsToLoad.map(async (model) => { @@ -92,41 +85,55 @@ export function loadAndSearchCatalogRecursively( // return model.loadMembers(); // } }) - ).then(() => { - // Then call this function again to see if new child references were loaded in - resolve( - loadAndSearchCatalogRecursively( - models, - searchTextLowercase, - searchResults, - resultMap, - iteration + 1 - ) - ); - }); + ) + .then(() => { + // Then call this function again to see if new child references were loaded in + resolve( + loadAndSearchCatalogRecursively( + models, + searchTextLowercase, + searchResults, + resultMap, + iteration + 1 + ) + ); + }) + .catch((error) => { + reject(error); + }); reaction.dispose(); }); }); } -export default class CatalogSearchProvider extends SearchProvider { - readonly terria: Terria; +export default class CatalogSearchProvider extends CatalogSearchProviderMixin( + CreateModel(CatalogSearchProviderTraits) +) { + static readonly type = "catalog-search-provider"; @observable isSearching: boolean = false; @observable debounceDurationOnceLoaded: number = 300; - constructor(options: CatalogSearchProviderOptions) { - super(); + constructor(id: string | undefined, terria: Terria) { + super(id, terria); makeObservable(this); - this.terria = options.terria; - this.name = "Catalog Items"; + this.setTrait( + CommonStrata.defaults, + "minCharacters", + terria.searchBarModel.minCharacters + ); + } + + get type() { + return CatalogSearchProvider.type; } - @computed get resultsAreReferences() { - return ( - isDefined(this.terria.catalogIndex?.loadPromise) && - fromPromise(this.terria.catalogIndex!.loadPromise).state === "fulfilled" + protected logEvent(searchText: string) { + this.terria.analytics?.logEvent( + Category.search, + SearchAction.catalog, + searchText ); } @@ -156,11 +163,6 @@ export default class CatalogSearchProvider extends SearchProvider { } } - this.terria.analytics?.logEvent( - Category.search, - SearchAction.catalog, - searchText - ); const resultMap: ResultMap = new Map(); try { @@ -190,7 +192,9 @@ export default class CatalogSearchProvider extends SearchProvider { }); if (searchResults.results.length === 0) { - searchResults.message = "Sorry, no locations match your search query."; + searchResults.message = { + content: "translate#viewModels.searchNoCatalogueItem" + }; } } catch (e) { this.terria.raiseErrorToUser(e, { @@ -202,8 +206,9 @@ export default class CatalogSearchProvider extends SearchProvider { return; } - searchResults.message = - "An error occurred while searching. Please check your internet connection or try again later."; + searchResults.message = { + content: "translate#viewModels.searchErrorOccurred" + }; } } } diff --git a/lib/Models/SearchProviders/CesiumIonSearchProvider.ts b/lib/Models/SearchProviders/CesiumIonSearchProvider.ts index e0e958c6f68..b1e39b33605 100644 --- a/lib/Models/SearchProviders/CesiumIonSearchProvider.ts +++ b/lib/Models/SearchProviders/CesiumIonSearchProvider.ts @@ -1,23 +1,20 @@ -import SearchProvider from "./SearchProvider"; -import { observable, makeObservable, runInAction } from "mobx"; -import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; -import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; import i18next from "i18next"; -import Terria from "../Terria"; -import SearchProviderResults from "./SearchProviderResults"; -import SearchResult from "./SearchResult"; -import loadJson from "../../Core/loadJson"; +import { makeObservable, override, runInAction } from "mobx"; +import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; + import { Category, SearchAction } from "../../Core/AnalyticEvents/analyticEvents"; - -interface CesiumIonSearchProviderOptions { - terria: Terria; - url?: string; - key: string; - flightDurationSeconds?: number; -} +import loadJson from "../../Core/loadJson"; +import { applyTranslationIfExists } from "../../Language/languageHelpers"; +import LocationSearchProviderMixin from "../../ModelMixins/SearchProviders/LocationSearchProviderMixin"; +import CesiumIonSearchProviderTraits from "../../Traits/SearchProviders/CesiumIonSearchProviderTraits"; +import CreateModel from "../Definition/CreateModel"; +import Terria from "../Terria"; +import SearchProviderResults from "./SearchProviderResults"; +import SearchResult from "./SearchResult"; +import CommonStrata from "../Definition/CommonStrata"; interface CesiumIonGeocodeResultFeature { bbox: [number, number, number, number]; @@ -28,51 +25,56 @@ interface CesiumIonGeocodeResult { features: CesiumIonGeocodeResultFeature[]; } -export default class CesiumIonSearchProvider extends SearchProvider { - readonly terria: Terria; - @observable key: string | undefined; - @observable flightDurationSeconds: number; - @observable url: string; +export default class CesiumIonSearchProvider extends LocationSearchProviderMixin( + CreateModel(CesiumIonSearchProviderTraits) +) { + static readonly type = "cesium-ion-search-provider"; - constructor(options: CesiumIonSearchProviderOptions) { - super(); + get type() { + return CesiumIonSearchProvider.type; + } + + constructor(uniqueId: string | undefined, terria: Terria) { + super(uniqueId, terria); makeObservable(this); - this.terria = options.terria; - this.name = i18next.t("viewModels.searchLocations"); - this.url = defaultValue( - options.url, - "https://api.cesium.com/v1/geocode/search" - ); - this.key = options.key; - this.flightDurationSeconds = defaultValue( - options.flightDurationSeconds, - 1.5 - ); + runInAction(() => { + if (!!this.terria.configParameters.cesiumIonAccessToken) { + this.setTrait( + CommonStrata.defaults, + "key", + this.terria.configParameters.cesiumIonAccessToken + ); + } + }); + } - if (!this.key) { + @override + override showWarning() { + if (!this.key || this.key === "") { console.warn( - "The " + - this.name + - " geocoder will always return no results because a CesiumIon key has not been provided. Please get a CesiumIon key from ion.cesium.com, ensure it has geocoding permission and add it to parameters.cesiumIonAccessToken in config.json." + `The ${applyTranslationIfExists(this.name, i18next)}(${ + this.type + }) geocoder will always return no results because a CesiumIon key has not been provided. Please get a CesiumIon key from ion.cesium.com, ensure it has geocoding permission and add it to searchProvider.key or parameters.cesiumIonAccessToken in config.json.` ); } } - protected async doSearch( - searchText: string, - searchResults: SearchProviderResults - ): Promise { - if (searchText === undefined || /^\s*$/.test(searchText)) { - return Promise.resolve(); - } - + protected logEvent(searchText: string): void { this.terria.analytics?.logEvent( Category.search, SearchAction.cesium, searchText ); + } + + protected async doSearch( + searchText: string, + searchResults: SearchProviderResults + ): Promise { + searchResults.results.length = 0; + searchResults.message = undefined; let response: CesiumIonGeocodeResult; try { @@ -80,22 +82,20 @@ export default class CesiumIonSearchProvider extends SearchProvider { `${this.url}?text=${searchText}&access_token=${this.key}` ); } catch (e) { - runInAction(() => { - searchResults.message = i18next.t("viewModels.searchErrorOccurred"); - }); + searchResults.message = { + content: "translate#viewModels.searchErrorOccurred" + }; return; } runInAction(() => { - if (!response.features) { - searchResults.message = i18next.t("viewModels.searchNoLocations"); + if (!response.features || response.features.length === 0) { + searchResults.message = { + content: "translate#viewModels.searchNoLocations" + }; return; } - if (response.features.length === 0) { - searchResults.message = i18next.t("viewModels.searchNoLocations"); - } - searchResults.results = response.features.map((feature) => { const [w, s, e, n] = feature.bbox; const rectangle = Rectangle.fromDegrees(w, s, e, n); diff --git a/lib/Models/SearchProviders/SearchBarModel.ts b/lib/Models/SearchProviders/SearchBarModel.ts new file mode 100644 index 00000000000..3dc1a21a0a9 --- /dev/null +++ b/lib/Models/SearchProviders/SearchBarModel.ts @@ -0,0 +1,121 @@ +import { + action, + computed, + isObservableArray, + makeObservable, + observable +} from "mobx"; +import { JsonObject } from "protomaps"; +import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; +import RuntimeError from "terriajs-cesium/Source/Core/RuntimeError"; +import Result from "../../Core/Result"; +import TerriaError from "../../Core/TerriaError"; +import CatalogSearchProviderMixin from "../../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; +import LocationSearchProviderMixin from "../../ModelMixins/SearchProviders/LocationSearchProviderMixin"; +import { SearchBarTraits } from "../../Traits/SearchProviders/SearchBarTraits"; +import SearchProviderTraits from "../../Traits/SearchProviders/SearchProviderTraits"; +import CommonStrata from "../Definition/CommonStrata"; +import CreateModel from "../Definition/CreateModel"; +import { BaseModel } from "../Definition/Model"; +import ModelPropertiesFromTraits from "../Definition/ModelPropertiesFromTraits"; +import updateModelFromJson from "../Definition/updateModelFromJson"; +import Terria from "../Terria"; +import SearchProviderFactory from "./SearchProviderFactory"; +import upsertSearchProviderFromJson from "./upsertSearchProviderFromJson"; + +export class SearchBarModel extends CreateModel(SearchBarTraits) { + private locationSearchProviders = observable.map(); + + @observable + catalogSearchProvider: CatalogSearchProviderMixin.Instance | undefined; + + constructor(readonly terria: Terria) { + super("search-bar-model", terria); + + makeObservable(this); + } + + updateModelConfig(config?: ModelPropertiesFromTraits) { + if (config) { + updateModelFromJson( + this, + CommonStrata.definition, + config as never as JsonObject + ); + } + return this; + } + + initializeSearchProviders( + searchProviders: ModelPropertiesFromTraits[] + ) { + const errors: TerriaError[] = []; + + if (!isObservableArray(searchProviders)) { + errors.push( + new TerriaError({ + sender: SearchProviderFactory, + title: "SearchProviders", + message: { key: "searchProvider.noSearchProviders" } + }) + ); + } + searchProviders?.forEach((searchProvider) => { + const loadedModel = upsertSearchProviderFromJson( + SearchProviderFactory, + this.terria, + CommonStrata.definition, + searchProvider + ).pushErrorTo(errors); + + if (LocationSearchProviderMixin.isMixedInto(loadedModel)) { + loadedModel.showWarning(); + } + }); + + return new Result( + undefined, + TerriaError.combine( + errors, + "An error occurred while loading search providers" + ) + ); + } + + /** + * Add new SearchProvider to the list of SearchProviders. + */ + @action + addSearchProvider(model: BaseModel) { + if (model.uniqueId === undefined) { + throw new DeveloperError( + "A SearchProvider without a `uniqueId` cannot be added." + ); + } + + if (this.locationSearchProviders.has(model.uniqueId)) { + throw new RuntimeError( + "A SearchProvider with the specified ID already exists." + ); + } + + if (!LocationSearchProviderMixin.isMixedInto(model)) { + throw new RuntimeError( + "SearchProvider must be a LocationSearchProvider." + ); + } + + this.locationSearchProviders.set(model.uniqueId, model); + } + + @computed + get locationSearchProvidersArray() { + return [...this.locationSearchProviders.entries()] + .filter((entry) => { + return LocationSearchProviderMixin.isMixedInto(entry[1]); + }) + .map(function (entry) { + return entry[1] as LocationSearchProviderMixin.Instance; + }); + } +} diff --git a/lib/Models/SearchProviders/SearchModelFactory.ts b/lib/Models/SearchProviders/SearchModelFactory.ts new file mode 100644 index 00000000000..e5f05182d8c --- /dev/null +++ b/lib/Models/SearchProviders/SearchModelFactory.ts @@ -0,0 +1,12 @@ +import LocationSearchProviderMixin from "../../ModelMixins/SearchProviders/LocationSearchProviderMixin"; +import { ModelConstructor } from "../Definition/Model"; +import ModelFactory from "../Definition/ModelFactory"; + +export class SearchModelFactory extends ModelFactory { + register( + type: string, + constructor: ModelConstructor + ) { + super.register(type, constructor); + } +} diff --git a/lib/Models/SearchProviders/SearchProvider.ts b/lib/Models/SearchProviders/SearchProvider.ts deleted file mode 100644 index 2af5f6c9081..00000000000 --- a/lib/Models/SearchProviders/SearchProvider.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { action, observable, makeObservable } from "mobx"; -import { fromPromise } from "mobx-utils"; -import SearchProviderResults from "./SearchProviderResults"; - -export default abstract class SearchProvider { - constructor() { - makeObservable(this); - } - - /** If search results are References to models in terria.models - set this to true. - * This is so groups/references are opened and loaded properly - */ - get resultsAreReferences(): boolean { - return false; - } - - @observable name = "Unknown"; - @observable isOpen = true; - - @action - toggleOpen() { - this.isOpen = !this.isOpen; - } - - @action - search(searchText: string): SearchProviderResults { - const result = new SearchProviderResults(this); - result.resultsCompletePromise = fromPromise( - this.doSearch(searchText, result) - ); - return result; - } - - protected abstract doSearch( - searchText: string, - results: SearchProviderResults - ): Promise; -} diff --git a/lib/Models/SearchProviders/SearchProviderFactory.ts b/lib/Models/SearchProviders/SearchProviderFactory.ts new file mode 100644 index 00000000000..e42059bbb78 --- /dev/null +++ b/lib/Models/SearchProviders/SearchProviderFactory.ts @@ -0,0 +1,4 @@ +import { SearchModelFactory } from "./SearchModelFactory"; + +const SearchProviderFactory = new SearchModelFactory(); +export default SearchProviderFactory; diff --git a/lib/Models/SearchProviders/SearchProviderResults.ts b/lib/Models/SearchProviders/SearchProviderResults.ts index ad1c24e95bd..afd03f3f08e 100644 --- a/lib/Models/SearchProviders/SearchProviderResults.ts +++ b/lib/Models/SearchProviders/SearchProviderResults.ts @@ -1,17 +1,22 @@ -import { observable, makeObservable } from "mobx"; -import SearchResult from "./SearchResult"; +import { makeObservable, observable } from "mobx"; import { IPromiseBasedObservable, fromPromise } from "mobx-utils"; -import SearchProvider from "./SearchProvider"; +import SearchProviderMixin from "../../ModelMixins/SearchProviders/SearchProviderMixin"; +import SearchResult from "./SearchResult"; export default class SearchProviderResults { @observable results: SearchResult[] = []; - @observable message: string | undefined; + @observable message?: { + content: string; + params?: { + [key: string]: string | number | undefined; + }; + }; isCanceled = false; resultsCompletePromise: IPromiseBasedObservable = fromPromise( Promise.resolve() ); - constructor(readonly searchProvider: SearchProvider) { + constructor(readonly searchProvider: SearchProviderMixin.Instance) { makeObservable(this); } diff --git a/lib/Models/SearchProviders/SearchResult.ts b/lib/Models/SearchProviders/SearchResult.ts index 5c1ad61107a..55495726876 100644 --- a/lib/Models/SearchProviders/SearchResult.ts +++ b/lib/Models/SearchProviders/SearchResult.ts @@ -1,8 +1,8 @@ -import { BaseModel } from "../Definition/Model"; -import { observable, action, makeObservable } from "mobx"; +import { action, makeObservable, observable } from "mobx"; import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; import defined from "terriajs-cesium/Source/Core/defined"; import GroupMixin from "../../ModelMixins/GroupMixin"; +import { BaseModel } from "../Definition/Model"; export interface SearchResultOptions { name?: string; diff --git a/lib/Models/SearchProviders/StubSearchProvider.ts b/lib/Models/SearchProviders/StubSearchProvider.ts new file mode 100644 index 00000000000..6ba2399bff7 --- /dev/null +++ b/lib/Models/SearchProviders/StubSearchProvider.ts @@ -0,0 +1,43 @@ +import { makeObservable } from "mobx"; +import SearchProviderMixin from "../../ModelMixins/SearchProviders/SearchProviderMixin"; +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"; + +export class StubSearchProviderTraits extends LocationSearchProviderTraits { + @primitiveTrait({ + type: "boolean", + name: "Is experiencing issues", + description: + "Whether the search provider is experiencing issues which may cause search results to be unavailable" + }) + isExperiencingIssues: boolean = true; +} + +export default class StubSearchProvider extends SearchProviderMixin( + CreateModel(StubSearchProviderTraits) +) { + static readonly type = "stub-search-provider"; + + constructor(...args: ModelConstructorParameters) { + super(...args); + makeObservable(this); + } + + get type(): string { + return StubSearchProvider.type; + } + + protected logEvent(searchText: string) { + return; + } + + protected doSearch( + searchText: string, + results: SearchProviderResults + ): Promise { + return Promise.resolve(); + } +} diff --git a/lib/Models/SearchProviders/createStubSearchProvider.ts b/lib/Models/SearchProviders/createStubSearchProvider.ts new file mode 100644 index 00000000000..9f8b9a74c88 --- /dev/null +++ b/lib/Models/SearchProviders/createStubSearchProvider.ts @@ -0,0 +1,27 @@ +import CommonStrata from "../Definition/CommonStrata"; +import { BaseModel } from "../Definition/Model"; +import Terria from "../Terria"; +import StubSearchProvider from "./StubSearchProvider"; + +const getUniqueStubSearchProviderName = (terria: Terria) => { + const stubName = "[StubSearchProvider]"; + let uniqueId = stubName; + let idIncrement = 1; + while (terria.getModelById(BaseModel, uniqueId) !== undefined) { + uniqueId = stubName + " (" + idIncrement + ")"; + idIncrement++; + } + return uniqueId; +}; + +export default function createStubSearchProvider( + terria: Terria, + uniqueId?: string +): StubSearchProvider { + const idToUse = uniqueId || getUniqueStubSearchProviderName(terria); + const stub = new StubSearchProvider(idToUse, terria); + + stub.setTrait(CommonStrata.underride, "name", stub.uniqueId); + terria.searchBarModel.addSearchProvider(stub); + return stub; +} diff --git a/lib/Models/SearchProviders/registerSearchProviders.ts b/lib/Models/SearchProviders/registerSearchProviders.ts new file mode 100644 index 00000000000..5eb5a1e1fdd --- /dev/null +++ b/lib/Models/SearchProviders/registerSearchProviders.ts @@ -0,0 +1,21 @@ +import AustralianGazetteerSearchProvider from "./AustralianGazetteerSearchProvider"; +import BingMapsSearchProvider from "./BingMapsSearchProvider"; +import CesiumIonSearchProvider from "./CesiumIonSearchProvider"; +import SearchProviderFactory from "./SearchProviderFactory"; + +export default function registerSearchProviders() { + SearchProviderFactory.register( + BingMapsSearchProvider.type, + BingMapsSearchProvider + ); + + SearchProviderFactory.register( + CesiumIonSearchProvider.type, + CesiumIonSearchProvider + ); + + SearchProviderFactory.register( + AustralianGazetteerSearchProvider.type, + AustralianGazetteerSearchProvider + ); +} diff --git a/lib/Models/SearchProviders/upsertSearchProviderFromJson.ts b/lib/Models/SearchProviders/upsertSearchProviderFromJson.ts new file mode 100644 index 00000000000..81c863b1ad0 --- /dev/null +++ b/lib/Models/SearchProviders/upsertSearchProviderFromJson.ts @@ -0,0 +1,106 @@ +import i18next from "i18next"; +import { runInAction } from "mobx"; +import Result from "../../Core/Result"; +import TerriaError from "../../Core/TerriaError"; +import { applyTranslationIfExists } from "../../Language/languageHelpers"; +import SearchProviderMixin from "../../ModelMixins/SearchProviders/SearchProviderMixin"; +import CommonStrata from "../Definition/CommonStrata"; +import { BaseModel } from "../Definition/Model"; +import updateModelFromJson from "../Definition/updateModelFromJson"; +import Terria from "../Terria"; +import { SearchModelFactory } from "./SearchModelFactory"; +import StubSearchProvider from "./StubSearchProvider"; +import createStubSearchProvider from "./createStubSearchProvider"; + +export default function upsertSearchProviderFromJson( + factory: SearchModelFactory, + terria: Terria, + stratumName: string, + json: any +): Result { + const errors: TerriaError[] = []; + + let uniqueId = json.id; + if (uniqueId === undefined) { + const id = json.localId || json.name; + if (id === undefined) { + return Result.error( + new TerriaError({ + title: i18next.t("models.catalog.idForMatchingErrorTitle"), + message: i18next.t("models.catalog.idForMatchingErrorMessage") + }) + ); + } + uniqueId = id; + } + + let model = terria.getModelById(BaseModel, uniqueId); + + if (model === undefined) { + model = factory.create(json.type, uniqueId, terria); + if (model === undefined) { + errors.push( + new TerriaError({ + title: i18next.t("searchProvider.models.unsupportedTypeTitle"), + message: i18next.t("searchProvider.models.unsupportedTypeMessage", { + type: json.type + }) + }) + ); + model = createStubSearchProvider(terria, uniqueId); + const stub = model; + stub.setTrait(CommonStrata.underride, "isExperiencingIssues", true); + stub.setTrait(CommonStrata.override, "name", `${uniqueId} (Stub)`); + } + + if (model.type !== StubSearchProvider.type) { + try { + model.terria.searchBarModel.addSearchProvider(model); + } catch (error) { + errors.push(TerriaError.from(error)); + } + } + } + + setDefaultTraits(model); + + updateModelFromJson(model, stratumName, json).catchError((error) => { + errors.push(error); + model!.setTrait(CommonStrata.underride, "isExperiencingIssues", true); + }); + + return new Result( + model, + TerriaError.combine( + errors, + `Error upserting search provider JSON: \`${applyTranslationIfExists( + uniqueId, + i18next + )}\`` + ) + ); +} + +function setDefaultTraits(model: BaseModel) { + const terria = model.terria; + + runInAction(() => { + model.setTrait( + CommonStrata.defaults, + "flightDurationSeconds", + terria.searchBarModel.flightDurationSeconds + ); + + model.setTrait( + CommonStrata.defaults, + "minCharacters", + terria.searchBarModel.minCharacters + ); + + model.setTrait( + CommonStrata.defaults, + "recommendedListLength", + terria.searchBarModel.recommendedListLength + ); + }); +} diff --git a/lib/Models/Terria.ts b/lib/Models/Terria.ts index 38a7eed4086..24773609c63 100644 --- a/lib/Models/Terria.ts +++ b/lib/Models/Terria.ts @@ -117,12 +117,16 @@ import MapInteractionMode from "./MapInteractionMode"; import NoViewer from "./NoViewer"; import { defaultRelatedMaps, RelatedMap } from "./RelatedMaps"; import CatalogIndex from "./SearchProviders/CatalogIndex"; +import { SearchBarModel } from "./SearchProviders/SearchBarModel"; import ShareDataService from "./ShareDataService"; import { StoryVideoSettings } from "./StoryVideoSettings"; import TimelineStack from "./TimelineStack"; import { isViewerMode, setViewerMode } from "./ViewerMode"; import Workbench from "./Workbench"; import SelectableDimensionWorkflow from "./Workflows/SelectableDimensionWorkflow"; +import { SearchBarTraits } from "../Traits/SearchProviders/SearchBarTraits"; +import ModelPropertiesFromTraits from "./Definition/ModelPropertiesFromTraits"; +import SearchProviderTraits from "../Traits/SearchProviders/SearchProviderTraits"; // import overrides from "../Overrides/defaults.jsx"; @@ -340,6 +344,12 @@ export interface ConfigParameters { plugins?: Record; aboutButtonHrefUrl?: string | null; + + /** + * The search bar allows requesting information from various search services at once. + */ + searchBarConfig?: ModelPropertiesFromTraits; + searchProviders: ModelPropertiesFromTraits[]; } interface StartOptions { @@ -435,6 +445,7 @@ export default class Terria { readonly overlays = new Workbench(); readonly catalog = new Catalog(this); readonly baseMapsModel = new BaseMapsModel("basemaps", this); + readonly searchBarModel = new SearchBarModel(this); readonly timelineClock = new Clock({ shouldAnimate: false }); // readonly overrides: any = overrides; // TODO: add options.functionOverrides like in master @@ -552,7 +563,9 @@ export default class Terria { googleAnalyticsOptions: undefined, relatedMaps: defaultRelatedMaps, aboutButtonHrefUrl: "about.html", - plugins: undefined + plugins: undefined, + searchBarConfig: undefined, + searchProviders: [] }; @observable @@ -986,6 +999,7 @@ export default class Terria { if (isJsonObject(config) && isJsonObject(config.parameters)) { this.updateParameters(config.parameters); } + if (this.configParameters.errorService) { this.setupErrorServiceProvider(this.configParameters.errorService); } @@ -1043,6 +1057,15 @@ export default class Terria { ) ); + this.searchBarModel + .updateModelConfig(this.configParameters.searchBarConfig) + .initializeSearchProviders(this.configParameters.searchProviders) + .catchError((error) => + this.raiseErrorToUser( + TerriaError.from(error, "Failed to initialize searchProviders") + ) + ); + if (typeof options.beforeRestoreAppState === "function") { try { await options.beforeRestoreAppState(); @@ -1060,6 +1083,7 @@ export default class Terria { this ); } + this.loadPersistedMapSettings(); } diff --git a/lib/ReactViewModels/SearchState.ts b/lib/ReactViewModels/SearchState.ts index 90f76241dd1..cae172c8b99 100644 --- a/lib/ReactViewModels/SearchState.ts +++ b/lib/ReactViewModels/SearchState.ts @@ -1,30 +1,26 @@ -// import CatalogItemNameSearchProviderViewModel from "../ViewModels/CatalogItemNameSearchProviderViewModel"; import { + action, + computed, + IReactionDisposer, observable, reaction, - IReactionDisposer, - computed, - action, - makeObservable + makeObservable, + runInAction } from "mobx"; -import Terria from "../Models/Terria"; -import SearchProviderResults from "../Models/SearchProviders/SearchProviderResults"; -import SearchProvider from "../Models/SearchProviders/SearchProvider"; import filterOutUndefined from "../Core/filterOutUndefined"; +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; - catalogSearchProvider?: CatalogSearchProvider; - locationSearchProviders?: SearchProvider[]; + catalogSearchProvider?: CatalogSearchProviderMixin.Instance; } export default class SearchState { - @observable - catalogSearchProvider: SearchProvider | undefined; - - @observable locationSearchProviders: SearchProvider[]; - @observable catalogSearchText: string = ""; @observable isWaitingToStartCatalogSearch: boolean = false; @@ -46,28 +42,36 @@ export default class SearchState { private _locationSearchDisposer: IReactionDisposer; private _unifiedSearchDisposer: IReactionDisposer; + private readonly terria: Terria; + constructor(options: SearchStateOptions) { makeObservable(this); - this.catalogSearchProvider = - options.catalogSearchProvider || - new CatalogSearchProvider({ terria: options.terria }); - this.locationSearchProviders = options.locationSearchProviders || []; + + this.terria = options.terria; + + runInAction(() => { + this.terria.searchBarModel.catalogSearchProvider = + options.catalogSearchProvider || + new CatalogSearchProvider("catalog-search-provider", options.terria); + }); + + const self = this; this._catalogSearchDisposer = reaction( - () => this.catalogSearchText, + () => self.catalogSearchText, () => { - this.isWaitingToStartCatalogSearch = true; - if (this.catalogSearchProvider) { - this.catalogSearchResults = this.catalogSearchProvider.search(""); + self.isWaitingToStartCatalogSearch = true; + if (self.catalogSearchProvider) { + self.catalogSearchResults = self.catalogSearchProvider.search(""); } } ); this._locationSearchDisposer = reaction( - () => this.locationSearchText, + () => self.locationSearchText, () => { - this.isWaitingToStartLocationSearch = true; - this.locationSearchResults = this.locationSearchProviders.map( + self.isWaitingToStartLocationSearch = true; + self.locationSearchResults = self.locationSearchProviders.map( (provider) => { return provider.search(""); } @@ -95,7 +99,17 @@ export default class SearchState { } @computed - get unifiedSearchProviders(): SearchProvider[] { + private get locationSearchProviders(): LocationSearchProviderMixin.Instance[] { + return this.terria.searchBarModel.locationSearchProvidersArray; + } + + @computed + get catalogSearchProvider(): CatalogSearchProviderMixin.Instance | undefined { + return this.terria.searchBarModel.catalogSearchProvider; + } + + @computed + get unifiedSearchProviders(): SearchProviderMixin.Instance[] { return filterOutUndefined([ this.catalogSearchProvider, ...this.locationSearchProviders diff --git a/lib/ReactViewModels/ViewState.ts b/lib/ReactViewModels/ViewState.ts index caaa9449bcf..0c7b80b4240 100644 --- a/lib/ReactViewModels/ViewState.ts +++ b/lib/ReactViewModels/ViewState.ts @@ -38,6 +38,7 @@ import { } from "./defaultTourPoints"; import DisclaimerHandler from "./DisclaimerHandler"; import SearchState from "./SearchState"; +import CatalogSearchProviderMixin from "../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; export const DATA_CATALOG_NAME = "data-catalog"; export const USER_DATA_NAME = "my-data"; @@ -48,8 +49,7 @@ export const WORKBENCH_RESIZE_ANIMATION_DURATION = 500; interface ViewStateOptions { terria: Terria; - catalogSearchProvider: any; - locationSearchProviders: any[]; + catalogSearchProvider: CatalogSearchProviderMixin.Instance | undefined; errorHandlingProvider?: any; } @@ -376,9 +376,8 @@ export default class ViewState { makeObservable(this); const terria = options.terria; this.searchState = new SearchState({ - terria: terria, - catalogSearchProvider: options.catalogSearchProvider, - locationSearchProviders: options.locationSearchProviders + terria, + catalogSearchProvider: options.catalogSearchProvider }); this.errorProvider = options.errorHandlingProvider diff --git a/lib/ReactViews/Search/search-box.scss.d.ts b/lib/ReactViews/Custom/Chart/legends.scss.d.ts similarity index 61% rename from lib/ReactViews/Search/search-box.scss.d.ts rename to lib/ReactViews/Custom/Chart/legends.scss.d.ts index d7e28504b25..30cfe1352bb 100644 --- a/lib/ReactViews/Search/search-box.scss.d.ts +++ b/lib/ReactViews/Custom/Chart/legends.scss.d.ts @@ -1,10 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'formLabel': string; - 'searchClear': string; - 'searchData': string; - 'searchField': string; + 'legends': string; } declare var cssExports: CssExports; export = cssExports; diff --git a/lib/ReactViews/DataCatalog/DataCatalog.jsx b/lib/ReactViews/DataCatalog/DataCatalog.jsx index e0f10c0cc9f..75ae5184a38 100644 --- a/lib/ReactViews/DataCatalog/DataCatalog.jsx +++ b/lib/ReactViews/DataCatalog/DataCatalog.jsx @@ -1,15 +1,11 @@ import React from "react"; import { observer } from "mobx-react"; - import PropTypes from "prop-types"; import { withTranslation } from "react-i18next"; - import defined from "terriajs-cesium/Source/Core/defined"; - -import DataCatalogMember from "./DataCatalogMember"; import SearchHeader from "../Search/SearchHeader"; - import Styles from "./data-catalog.scss"; +import DataCatalogMember from "./DataCatalogMember"; // Displays the data catalog. @observer @@ -43,7 +39,7 @@ class DataCatalog extends React.Component { <> = (props: PropsType) => { - const { message, t, boxProps, textProps, ...rest }: PropsType = props; + const { message, t, boxProps, textProps, hideMessage, ...rest }: PropsType = + props; return ( - {message || t("loader.loadingMessage")} + {!hideMessage && (message || t("loader.loadingMessage"))} ); diff --git a/lib/ReactViews/Search/location-search-result.scss.d.ts b/lib/ReactViews/Map/MapNavigation/zoom_control.scss.d.ts similarity index 50% rename from lib/ReactViews/Search/location-search-result.scss.d.ts rename to lib/ReactViews/Map/MapNavigation/zoom_control.scss.d.ts index d53b3b8dd6a..57772fcb940 100644 --- a/lib/ReactViews/Search/location-search-result.scss.d.ts +++ b/lib/ReactViews/Map/MapNavigation/zoom_control.scss.d.ts @@ -1,13 +1,11 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'footer': string; - 'heading': string; - 'isOpen': string; - 'items': string; - 'light': string; - 'provider-result': string; - 'providerResult': string; + 'decrease': string; + 'increase': string; + 'list': string; + 'refresh': string; + 'zoomControl': string; } declare var cssExports: CssExports; export = cssExports; diff --git a/lib/ReactViews/Map/map-navigation.scss.d.ts b/lib/ReactViews/Map/map-navigation.scss.d.ts new file mode 100644 index 00000000000..4c8f98f91e4 --- /dev/null +++ b/lib/ReactViews/Map/map-navigation.scss.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'control': string; + 'controls': string; + 'map-navigation': string; + 'mapNavigation': string; + 'navs': string; + 'with-time-series-controls': string; + 'withTimeSeriesControls': string; +} +declare var cssExports: CssExports; +export = cssExports; diff --git a/lib/ReactViews/Mobile/MobileHeader.jsx b/lib/ReactViews/Mobile/MobileHeader.jsx index b95adb30105..9600c11404c 100644 --- a/lib/ReactViews/Mobile/MobileHeader.jsx +++ b/lib/ReactViews/Mobile/MobileHeader.jsx @@ -5,6 +5,7 @@ import PropTypes from "prop-types"; import React from "react"; import { withTranslation } from "react-i18next"; import styled, { withTheme } from "styled-components"; +import { applyTranslationIfExists } from "../../Language/languageHelpers"; import { removeMarker } from "../../Models/LocationMarkerUtils"; import Box from "../../Styled/Box"; import { RawButton } from "../../Styled/Button"; @@ -127,16 +128,19 @@ class MobileHeader extends React.Component { } renderSearch() { - const { t } = this.props; + const { t, viewState } = this.props; - const searchState = this.props.viewState.searchState; + const searchState = viewState.searchState;
{searchState.showMobileLocationSearch && ( ` - &:hover, &:focus { - background-color: ${p.theme.greyLighter}; - ${StyledIcon} { - fill-opacity: 1; - } - }`} -`; - -const MAX_RESULTS_BEFORE_TRUNCATING = 5; - -@observer -class LocationSearchResults extends React.Component { - static propTypes = { - viewState: PropTypes.object.isRequired, - isWaitingForSearchToStart: PropTypes.bool, - terria: PropTypes.object.isRequired, - search: PropTypes.object.isRequired, - onLocationClick: PropTypes.func.isRequired, - theme: PropTypes.string, - locationSearchText: PropTypes.string, - t: PropTypes.func.isRequired - }; - - static defaultProps = { - theme: "light" - }; - - constructor(props) { - super(props); - this.state = { - isOpen: true, - isExpanded: false - }; - } - - toggleIsOpen() { - this.setState({ - isOpen: !this.state.isOpen - }); - } - - toggleExpand() { - this.setState({ - isExpanded: !this.state.isExpanded - }); - } - - renderResultsFooter() { - const { t } = this.props; - if (this.state.isExpanded) { - return t("search.viewLess", { - name: this.props.search.searchProvider.name - }); - } - return t("search.viewMore", { - name: this.props.search.searchProvider.name - }); - } - - render() { - const search = this.props.search; - const { isOpen, isExpanded } = this.state; - const searchProvider = search.searchProvider; - const locationSearchBoundingBox = - this.props.terria.configParameters.locationSearchBoundingBox; - - const validResults = isDefined(locationSearchBoundingBox) - ? search.results.filter(function (r) { - return ( - r.location.longitude > locationSearchBoundingBox[0] && - r.location.longitude < locationSearchBoundingBox[2] && - r.location.latitude > locationSearchBoundingBox[1] && - r.location.latitude < locationSearchBoundingBox[3] - ); - }) - : search.results; - - const results = - validResults.length > MAX_RESULTS_BEFORE_TRUNCATING - ? isExpanded - ? validResults - : validResults.slice(0, MAX_RESULTS_BEFORE_TRUNCATING) - : validResults; - - return ( -
- this.toggleIsOpen()} - > - - {`${search.searchProvider.name} (${validResults?.length})`} - - - - - -
    - {results.map((result, i) => ( - - ))} -
- {isOpen && validResults.length > MAX_RESULTS_BEFORE_TRUNCATING && ( - - this.toggleExpand()}> - - {this.renderResultsFooter()} - - - - )} -
-
- ); - } -} - -module.exports = withTranslation()(LocationSearchResults); diff --git a/lib/ReactViews/Search/LocationSearchResults.tsx b/lib/ReactViews/Search/LocationSearchResults.tsx new file mode 100644 index 00000000000..191e7db1b33 --- /dev/null +++ b/lib/ReactViews/Search/LocationSearchResults.tsx @@ -0,0 +1,229 @@ +/** + Initially this was written to support various location search providers in master, + however we only have a single location provider at the moment, and how we merge + them in the new design is yet to be resolved, see: + https://github.com/TerriaJS/nsw-digital-twin/issues/248#issuecomment-599919318 + */ + +import { action, computed, makeObservable, observable } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; +import { + useTranslation, + withTranslation, + WithTranslation +} from "react-i18next"; +import styled, { DefaultTheme } 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 Terria from "../../Models/Terria"; +import SearchResultModel from "../../Models/SearchProviders/SearchResult"; +import ViewState from "../../ReactViewModels/ViewState"; +import Box, { BoxSpan } from "../../Styled/Box"; +import { RawButton } from "../../Styled/Button"; +import Icon, { StyledIcon } from "../../Styled/Icon"; +import Ul from "../../Styled/List"; +import Text, { TextSpan } from "../../Styled/Text"; +import Loader from "../Loader"; +import SearchHeader from "./SearchHeader"; +import SearchResult from "./SearchResult"; + +const RawButtonAndHighlight = styled(RawButton)` + ${(p) => ` + &:hover, &:focus { + background-color: ${p.theme.greyLighter}; + ${StyledIcon} { + fill-opacity: 1; + } + }`} +`; + +interface PropsType extends WithTranslation { + viewState: ViewState; + isWaitingForSearchToStart: boolean; + terria: Terria; + search: SearchProviderResults; + onLocationClick: (result: SearchResultModel) => void; + theme: DefaultTheme; + locationSearchText: string; +} + +@observer +class LocationSearchResults extends React.Component { + @observable isExpanded = false; + + constructor(props: PropsType) { + super(props); + makeObservable(this); + } + + @action.bound + toggleExpand() { + this.isExpanded = !this.isExpanded; + } + + @computed + get validResults() { + const { search, terria } = this.props; + const locationSearchBoundingBox = terria.searchBarModel.boundingBoxLimit; + let filterResults = false; + let west: number | undefined, + east: number | undefined, + south: number | undefined, + north: number | undefined; + if (locationSearchBoundingBox) { + ({ west, east, south, north } = locationSearchBoundingBox); + + filterResults = + isDefined(west) && + isDefined(east) && + isDefined(south) && + isDefined(north); + } + + const validResults = filterResults + ? search.results.filter(function (r: any) { + return ( + r.location.longitude > west! && + r.location.longitude < east! && + r.location.latitude > south! && + r.location.latitude < north! + ); + }) + : search.results; + return validResults; + } + + render() { + const { search } = this.props; + const searchProvider: LocationSearchProviderMixin.Instance = + search.searchProvider as unknown as LocationSearchProviderMixin.Instance; + + const maxResults = searchProvider.recommendedListLength || 5; + const validResults = this.validResults; + const results = + validResults.length > maxResults + ? this.isExpanded + ? validResults + : validResults.slice(0, maxResults) + : validResults; + const isOpen = searchProvider.isOpen; + return ( + + searchProvider.toggleOpen()} + > + + + + + + + {isOpen && ( + <> + +
    + {results.map((result: SearchResultModel, i: number) => ( + + ))} +
+ {validResults.length > maxResults && ( + + this.toggleExpand()}> + + + + + + )} + + )} +
+
+ ); + } +} + +interface SearchResultsFooterProps { + isExpanded: boolean; + name: string; +} + +const SearchResultsFooter: React.FC = ( + props: SearchResultsFooterProps +) => { + const { t, i18n } = useTranslation(); + if (props.isExpanded) { + return t("search.viewLess", { + name: applyTranslationIfExists(props.name, i18n) + }); + } + return t("search.viewMore", { + name: applyTranslationIfExists(props.name, i18n) + }); +}; + +interface NameWithLoaderProps { + name: string; + length?: number; + isOpen: boolean; + search: SearchProviderResults; + isWaitingForSearchToStart: boolean; +} + +const NameWithLoader: React.FC = observer( + (props: NameWithLoaderProps) => { + const { i18n } = useTranslation(); + return ( + + + {`${applyTranslationIfExists( + props.name, + i18n + )} (${props.length || 0})`} + + {!props.isOpen && + (props.search.isSearching || props.isWaitingForSearchToStart) && ( + + )} + + ); + } +); +export default withTranslation()(LocationSearchResults); diff --git a/lib/ReactViews/Search/SearchBox.jsx b/lib/ReactViews/Search/SearchBox.jsx index 45f758b5824..df6b103daff 100644 --- a/lib/ReactViews/Search/SearchBox.jsx +++ b/lib/ReactViews/Search/SearchBox.jsx @@ -1,12 +1,12 @@ -import React from "react"; -import PropTypes from "prop-types"; import createReactClass from "create-react-class"; import debounce from "lodash-es/debounce"; -import Icon, { StyledIcon } from "../../Styled/Icon"; +import PropTypes from "prop-types"; +import React from "react"; import styled, { withTheme } from "styled-components"; import Box, { BoxSpan } from "../../Styled/Box"; -import Text from "../../Styled/Text"; import { RawButton } from "../../Styled/Button"; +import Icon, { StyledIcon } from "../../Styled/Icon"; +import Text from "../../Styled/Text"; const SearchInput = styled.input` box-sizing: border-box; diff --git a/lib/ReactViews/Search/SearchBoxAndResults.jsx b/lib/ReactViews/Search/SearchBoxAndResults.jsx index 2116884da06..390b655f3fc 100644 --- a/lib/ReactViews/Search/SearchBoxAndResults.jsx +++ b/lib/ReactViews/Search/SearchBoxAndResults.jsx @@ -1,26 +1,21 @@ -import React from "react"; -import { removeMarker } from "../../Models/LocationMarkerUtils"; import { reaction, runInAction } from "mobx"; -import { Trans } from "react-i18next"; -import PropTypes from "prop-types"; import { observer } from "mobx-react"; +import PropTypes from "prop-types"; +import React from "react"; +import { useTranslation } from "react-i18next"; import styled from "styled-components"; -// import { ThemeContext } from "styled-components"; - -import SearchBox from "../Search/SearchBox"; -// import SidebarSearch from "../Search/SidebarSearch"; -import LocationSearchResults from "../Search/LocationSearchResults"; -import Icon, { StyledIcon } from "../../Styled/Icon"; - +import { addMarker, removeMarker } from "../../Models/LocationMarkerUtils"; import Box from "../../Styled/Box"; -import Text from "../../Styled/Text"; -import Spacing from "../../Styled/Spacing"; import { RawButton } from "../../Styled/Button"; - -import { addMarker } from "../../Models/LocationMarkerUtils"; +import Icon, { StyledIcon } from "../../Styled/Icon"; +import Spacing from "../../Styled/Spacing"; +import Text from "../../Styled/Text"; +import LocationSearchResults from "../Search/LocationSearchResults"; +import SearchBox from "../Search/SearchBox"; export function SearchInDataCatalog({ viewState, handleClick }) { const locationSearchText = viewState.searchState.locationSearchText; + const { t } = useTranslation(); return ( - - Search {locationSearchText} in the Data Catalogue - + {t("search.searchInDataCatalog", { + locationSearchText: locationSearchText + })} ); } + SearchInDataCatalog.propTypes = { handleClick: PropTypes.func.isRequired, viewState: PropTypes.object.isRequired @@ -71,11 +64,13 @@ const PresentationBox = styled(Box).attrs({ `; export const LOCATION_SEARCH_INPUT_NAME = "LocationSearchInput"; + export class SearchBoxAndResultsRaw extends React.Component { constructor(props) { super(props); this.locationSearchRef = React.createRef(); } + componentDidMount() { this.props.viewState.updateAppRef( LOCATION_SEARCH_INPUT_NAME, @@ -111,6 +106,7 @@ export class SearchBoxAndResultsRaw extends React.Component { this._nowViewingChangeSubscription = undefined; } } + changeSearchText(newText) { runInAction(() => { this.props.viewState.searchState.locationSearchText = newText; @@ -131,17 +127,21 @@ export class SearchBoxAndResultsRaw extends React.Component { }); } } + search() { this.props.viewState.searchState.searchLocations(); } + toggleShowLocationSearchResults(bool) { runInAction(() => { this.props.viewState.searchState.showLocationSearchResults = bool; }); } + startLocationSearch() { this.toggleShowLocationSearchResults(true); } + render() { const { viewState, placeholder } = this.props; const searchState = viewState.searchState; @@ -197,27 +197,25 @@ export class SearchBoxAndResultsRaw extends React.Component { overflow-y: auto; `} > - {this.props.viewState.searchState.locationSearchResults.map( - (search) => ( - { - addMarker(this.props.terria, result); - result.clickAction(); - runInAction(() => { - searchState.showLocationSearchResults = false; - }); - }} - isWaitingForSearchToStart={ - searchState.isWaitingToStartLocationSearch - } - /> - ) - )} + {searchState.locationSearchResults.map((search) => ( + { + addMarker(this.props.terria, result); + result.clickAction(); + runInAction(() => { + searchState.showLocationSearchResults = false; + }); + }} + isWaitingForSearchToStart={ + searchState.isWaitingToStartLocationSearch + } + /> + ))} )} diff --git a/lib/ReactViews/Search/SearchHeader.jsx b/lib/ReactViews/Search/SearchHeader.jsx deleted file mode 100644 index 6856db1931b..00000000000 --- a/lib/ReactViews/Search/SearchHeader.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import Loader from "../Loader"; -import { observer } from "mobx-react"; -import React from "react"; -import PropTypes from "prop-types"; -import Styles from "./search-header.scss"; - -/** Renders either a loader or a message based off search state. */ -@observer -class SearchHeader extends React.Component { - static propTypes = { - searchResults: PropTypes.object.isRequired, - isWaitingForSearchToStart: PropTypes.bool - }; - - render() { - if ( - this.props.searchResults.isSearching || - this.props.isWaitingForSearchToStart - ) { - return ( -
- -
- ); - } else if (this.props.searchResults.message) { - return ( -
- {this.props.searchResults.message} -
- ); - } else { - return null; - } - } -} - -export default SearchHeader; diff --git a/lib/ReactViews/Search/SearchHeader.tsx b/lib/ReactViews/Search/SearchHeader.tsx new file mode 100644 index 00000000000..383ad5bc7b0 --- /dev/null +++ b/lib/ReactViews/Search/SearchHeader.tsx @@ -0,0 +1,43 @@ +import { observer } from "mobx-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { applyTranslationIfExists } from "../../Language/languageHelpers"; +import SearchProviderResults from "../../Models/SearchProviders/SearchProviderResults"; +import { BoxSpan } from "../../Styled/Box"; +import Text from "../../Styled/Text"; +import Loader from "../Loader"; + +interface SearchHeaderProps { + searchResults: SearchProviderResults; + isWaitingForSearchToStart: boolean; +} + +const SearchHeader: React.FC = observer( + (props: SearchHeaderProps) => { + const { i18n } = useTranslation(); + + if (props.searchResults.isSearching || props.isWaitingForSearchToStart) { + return ( +
+ +
+ ); + } else if (props.searchResults.message) { + return ( + + + {applyTranslationIfExists( + props.searchResults.message.content, + i18n, + props.searchResults.message.params + )} + + + ); + } else { + return null; + } + } +); + +export default SearchHeader; diff --git a/lib/ReactViews/Search/SearchResult.jsx b/lib/ReactViews/Search/SearchResult.jsx deleted file mode 100644 index f0375cc5217..00000000000 --- a/lib/ReactViews/Search/SearchResult.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import styled, { withTheme } from "styled-components"; -import createReactClass from "create-react-class"; -import Icon, { StyledIcon } from "../../Styled/Icon"; - -import Box, { BoxSpan } from "../../Styled/Box"; -import { RawButton } from "../../Styled/Button"; -import { TextSpan } from "../../Styled/Text"; -import Hr from "../../Styled/Hr"; -import Spacing, { SpacingSpan } from "../../Styled/Spacing"; - -import highlightKeyword from "../ReactViewHelpers/highlightKeyword"; - -// Not sure how to generalise this or if it should be kept in stlyed/Button.jsx - -// Initially had this as border bottom on the button, but need a HR given it's not a full width border -// // ${p => !p.isLastResult && `border-bottom: 1px solid ${p.theme.greyLighter};`} -const RawButtonAndHighlight = styled(RawButton)` - ${(p) => ` - &:hover, &:focus { - background-color: ${p.theme.greyLighter}; - ${StyledIcon} { - fill-opacity: 1; - } - }`} -`; - -// A Location item when doing Bing map searvh or Gazetter search -export const SearchResult = createReactClass({ - propTypes: { - name: PropTypes.string.isRequired, - clickAction: PropTypes.func.isRequired, - isLastResult: PropTypes.bool, - locationSearchText: PropTypes.string, - icon: PropTypes.string, - theme: PropTypes.object, - searchResultTheme: PropTypes.string - }, - - getDefaultProps() { - return { - icon: false, - searchResultTheme: "light" - }; - }, - - render() { - const { - searchResultTheme, - theme, - name, - locationSearchText, - icon - // isLastResult - } = this.props; - const isDarkTheme = searchResultTheme === "dark"; - const isLightTheme = searchResultTheme === "light"; - const highlightedResultName = highlightKeyword(name, locationSearchText); - return ( -
  • - - - {/* {!isLastResult && ( */} - - -
    - -
    - {/* )} */} - - - {icon && ( - - )} - - - - {highlightedResultName} - - - - - -
    -
    -
  • - ); - } -}); - -export default withTheme(SearchResult); diff --git a/lib/ReactViews/Search/SearchResult.tsx b/lib/ReactViews/Search/SearchResult.tsx new file mode 100644 index 00000000000..69a53600b0e --- /dev/null +++ b/lib/ReactViews/Search/SearchResult.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import styled, { useTheme } from "styled-components"; +import { Li } from "../../Styled/List"; +import Icon, { StyledIcon } from "../../Styled/Icon"; +import highlightKeyword from "../ReactViewHelpers/highlightKeyword"; +import Box, { BoxSpan } from "../../Styled/Box"; +import { TextSpan } from "../../Styled/Text"; +import Spacing, { SpacingSpan } from "../../Styled/Spacing"; +import { RawButton } from "../../Styled/Button"; +import Hr from "../../Styled/Hr"; + +// Not sure how to generalise this or if it should be kept in stlyed/Button.jsx + +// Initially had this as border bottom on the button, but need a HR given it's not a full width border +// // ${p => !p.isLastResult && `border-bottom: 1px solid ${p.theme.greyLighter};`} +const RawButtonAndHighlight = styled(RawButton)` + ${(p) => ` + &:hover, &:focus { + background-color: ${p.theme.greyLighter}; + ${StyledIcon} { + fill-opacity: 1; + } + }`} +`; + +interface SearchResultProps { + name: string; + clickAction(): void; + isLastResult: boolean; + locationSearchText: string; + icon: string; +} + +const SearchResult: React.FC = ( + props: SearchResultProps +) => { + const theme = useTheme(); + const highlightedResultName = highlightKeyword( + props.name, + props.locationSearchText + ); + const isLightTheme = true; + const isDarkTheme = false; + return ( +
  • + + + {/* {!isLastResult && ( */} + + +
    + +
    + {/* )} */} + + + {props.icon && ( + + )} + + + + {highlightedResultName} + + + + + +
    +
    +
  • + ); +}; + +export default SearchResult; diff --git a/lib/ReactViews/Search/location-search-result.scss b/lib/ReactViews/Search/location-search-result.scss deleted file mode 100644 index b6455bd65e3..00000000000 --- a/lib/ReactViews/Search/location-search-result.scss +++ /dev/null @@ -1,83 +0,0 @@ -@import "../../Sass/common/mixins"; -@import "~terriajs-variables"; - -.heading { - composes: btn from "../../Sass/common/_buttons.scss"; - padding: $padding; - position: relative; - width: 100%; - text-align: left; - font-weight: bold; - &, - &:hover, - &:focus { - box-shadow: inset 0 -1px 0 0 rgba(255, 255, 255, 0.15); - } - @media (min-width: $md) { - color: $text-light; - } - svg { - height: 10px; - width: 10px; - position: absolute; - right: $padding + $padding-small; - top: $padding + $padding-small; - } -} - -.footer { - composes: btn from "../../Sass/common/_buttons.scss"; - padding: $padding; - font-size: 12px; - position: relative; - width: 100%; - text-align: left; - box-shadow: none; - @media (min-width: $md) { - color: $text-light; - } - svg { - height: 20px; - width: 20px; - position: absolute; - right: $padding; - top: $padding; - } -} - -.provider-result { - margin-top: $padding-small; - @media (min-width: $md) { - color: $text-darker; - } - // background-color: $dark-with-overlay; - .items { - display: none; - } -} - -// on mobile, we don't want the gap between search results -.light { - // background: #fff; - margin-top: 0; - svg { - // fill: #000; - } -} - -.dark { - svg { - // fill: #ffffff; - } -} - -.isOpen { - .items { - display: block; - } -} - -.items { - composes: clearfix from "../../Sass/common/_base.scss"; - composes: list-reset from "../../Sass/common/_base.scss"; -} diff --git a/lib/ReactViews/Search/search-box.scss b/lib/ReactViews/Search/search-box.scss deleted file mode 100644 index 204fec49fd4..00000000000 --- a/lib/ReactViews/Search/search-box.scss +++ /dev/null @@ -1,60 +0,0 @@ -@import "~terriajs-variables"; -@import "../../Sass/common/mixins"; - -.formLabel { - position: absolute; - svg { - // height: $input-height; - // width: $input-height; - height: 20px; - width: 20px; - fill: $charcoal-grey; - padding: $padding; - fill-opacity: 0.5; - } -} - -.searchField { - composes: field from "../../Sass/common/_form.scss"; - font-family: $font-base; -} - -.searchData { - position: relative; - width: 100%; -} - -input[type="text"].searchField { - padding-left: $input-height; - padding-right: $input-height; - color: $text-dark; - width: 100%; - overflow: hidden; - border-color: transparent; // if you need to remove borders, always use transparent "X"px borders instead of 0 borders for a11y - @include placeholder { - text-align: center; - @include transition(all, 0.25s, linear); - } - - &:focus { - @include placeholder { - padding-left: 0; - } - } -} - -.searchClear { - composes: btn from "../../Sass/common/_buttons.scss"; - right: 0px; - top: 0px; - position: absolute; - height: $input-height; - width: $input-height; - svg { - height: 15px; - width: 15px; - margin: 0 auto; - fill: $charcoal-grey; - fill-opacity: 0.5; - } -} diff --git a/lib/ReactViews/Search/search-result.scss b/lib/ReactViews/Search/search-result.scss deleted file mode 100644 index 618d47f3cce..00000000000 --- a/lib/ReactViews/Search/search-result.scss +++ /dev/null @@ -1,95 +0,0 @@ -@import "~terriajs-variables"; - -.search-result { - span { - overflow-wrap: break-word; - word-wrap: break-word; - } -} - -.search-result.light .btn { - // border-bottom: 1px solid rgba($grey, 0.4); - background: transparent; - &:hover { - // border-bottom: 1px solid $color-primary; - color: $color-primary; - } - svg { - fill: #000; - } -} - -.search-result.dark .btn { - color: #fff; - border: 0; - box-shadow: inset 0 -1px 0 0 rgba(255, 255, 255, 0.15); - margin-bottom: 0; - svg { - fill-opacity: 0.5; - } - &:hover { - svg { - fill: #fff; - } - } -} - -.icon { - position: absolute; - top: 5px; - bottom: 0; - // TODO: better icon management - left: -3px; - height: 21px; - width: 17px; - margin: 7px 0; -} - -.resultName { - padding-left: 20px; -} - -.arrow-icon { - position: absolute; - top: 5px; - bottom: 0; - right: 1px; - height: 10px; - width: 10px; - width: 14px; - margin: 8px; - // margin: $padding; - - svg { - fill-opacity: 0; - } -} - -.btn { - composes: btn from "../../Sass/common/_buttons.scss"; - &.btn { - position: relative; - padding: $padding; - padding-left: 0; - margin-bottom: $padding-mini; - padding-right: $padding; - width: 100%; - border: 1px solid transparent; - } - &:hover { - background-color: $grey-lighter; - .arrow-icon svg { - fill-opacity: 1; - } - } - &.btnLocationName { - padding: $padding $padding * 3; - } -} -.btnWithBorderBottom { - &.btnWithBorderBottom { - @media (min-width: $sm) { - border-bottom: 1px solid $grey; - } - } -} diff --git a/lib/ReactViews/Search/search-result.scss.d.ts b/lib/ReactViews/Search/search-result.scss.d.ts deleted file mode 100644 index ba4c634fc8a..00000000000 --- a/lib/ReactViews/Search/search-result.scss.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file is automatically generated. -// Please do not change this file! -interface CssExports { - 'arrow-icon': string; - 'arrowIcon': string; - 'btn': string; - 'btnLocationName': string; - 'btnWithBorderBottom': string; - 'dark': string; - 'icon': string; - 'light': string; - 'resultName': string; - 'search-result': string; - 'searchResult': string; -} -declare var cssExports: CssExports; -export = cssExports; diff --git a/lib/ReactViews/SidePanel/SidePanel.tsx b/lib/ReactViews/SidePanel/SidePanel.tsx index 58c06eaa18e..d020e0bdb01 100644 --- a/lib/ReactViews/SidePanel/SidePanel.tsx +++ b/lib/ReactViews/SidePanel/SidePanel.tsx @@ -13,6 +13,7 @@ import { useRefForTerria } from "../Hooks/useRefForTerria"; import SearchBoxAndResults from "../Search/SearchBoxAndResults"; import { withViewState } from "../Context"; import Workbench from "../Workbench/Workbench"; +import { applyTranslationIfExists } from "../../Language/languageHelpers"; const BoxHelpfulHints = styled(Box)``; @@ -132,7 +133,7 @@ interface SidePanelProps { const SidePanel = observer>( ({ viewState, theme, refForExploreMapData, refForUploadData }) => { const terria = viewState.terria; - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const onAddDataClicked: React.MouseEventHandler = ( e ) => { @@ -162,7 +163,10 @@ const SidePanel = observer>( diff --git a/lib/ReactViews/Workbench/Controls/concept-viewer.scss.d.ts b/lib/ReactViews/Workbench/Controls/concept-viewer.scss.d.ts new file mode 100644 index 00000000000..251738aaa6a --- /dev/null +++ b/lib/ReactViews/Workbench/Controls/concept-viewer.scss.d.ts @@ -0,0 +1,21 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'btnGroup': string; + 'btnToggleActive': string; + 'btnToggleOpen': string; + 'children-list': string; + 'childrenList': string; + 'hasChildren': string; + 'header': string; + 'inner': string; + 'is-loading': string; + 'isLoading': string; + 'isSelectable': string; + 'items': string; + 'root': string; + 'section': string; + 'unSelectable': string; +} +declare var cssExports: CssExports; +export = cssExports; diff --git a/lib/ReactViews/Workbench/Controls/display-as-percent.scss.d.ts b/lib/ReactViews/Workbench/Controls/display-as-percent.scss.d.ts new file mode 100644 index 00000000000..34bdd776387 --- /dev/null +++ b/lib/ReactViews/Workbench/Controls/display-as-percent.scss.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'btn': string; +} +declare var cssExports: CssExports; +export = cssExports; diff --git a/lib/Styled/List.tsx b/lib/Styled/List.tsx index de219333569..dfa9f7c2228 100644 --- a/lib/Styled/List.tsx +++ b/lib/Styled/List.tsx @@ -10,6 +10,7 @@ interface IUlProps { export const Ul = styled(Box).attrs({ as: "ul" })` + padding-left: 0; list-style: none; margin: 0; ${(props) => @@ -27,7 +28,6 @@ export const Ul = styled(Box).attrs({ padding-top: 5px; } `} - ${(props) => props.lined && css` diff --git a/lib/Styled/Select.tsx b/lib/Styled/Select.tsx index 1628570dc65..a502347597a 100644 --- a/lib/Styled/Select.tsx +++ b/lib/Styled/Select.tsx @@ -27,7 +27,7 @@ or with overrides on icon import React from "react"; import styled, { useTheme } from "styled-components"; -const Box: any = require("./Box").default; +import Box from "./Box"; import { GLYPHS, StyledIcon } from "./Icon"; const StyledSelect = styled.select` diff --git a/lib/Traits/SearchProviders/BingMapsSearchProviderTraits.ts b/lib/Traits/SearchProviders/BingMapsSearchProviderTraits.ts new file mode 100644 index 00000000000..4ec8f1a0f41 --- /dev/null +++ b/lib/Traits/SearchProviders/BingMapsSearchProviderTraits.ts @@ -0,0 +1,42 @@ +import primitiveTrait from "../Decorators/primitiveTrait"; +import mixTraits from "../mixTraits"; +import LocationSearchProviderTraits, { + SearchProviderMapCenterTraits +} from "./LocationSearchProviderTraits"; + +export default class BingMapsSearchProviderTraits extends mixTraits( + LocationSearchProviderTraits, + SearchProviderMapCenterTraits +) { + url: string = "https://dev.virtualearth.net/"; + + @primitiveTrait({ + type: "string", + name: "Key", + description: "The Bing Maps key." + }) + key?: string; + + @primitiveTrait({ + type: "string", + name: "Primary country", + description: "Name of the country to prioritize the search results." + }) + primaryCountry: string = "Australia"; + + @primitiveTrait({ + type: "string", + name: "Culture", + description: `Use the culture parameter to specify a culture for your request. + The culture parameter provides the result in the language of the culture. + For a list of supported cultures, see [Supported Culture Codes](https://docs.microsoft.com/en-us/bingmaps/rest-services/common-parameters-and-types/supported-culture-codes)` + }) + culture: string = "en-au"; + + @primitiveTrait({ + type: "number", + name: "Max results", + description: "The maximum number of results to return." + }) + maxResults: number = 5; +} diff --git a/lib/Traits/SearchProviders/CatalogSearchProviderTraits.ts b/lib/Traits/SearchProviders/CatalogSearchProviderTraits.ts new file mode 100644 index 00000000000..bac19734806 --- /dev/null +++ b/lib/Traits/SearchProviders/CatalogSearchProviderTraits.ts @@ -0,0 +1,14 @@ +import primitiveTrait from "../Decorators/primitiveTrait"; +import mixTraits from "../mixTraits"; +import SearchProviderTraits from "./SearchProviderTraits"; + +export default class CatalogSearchProviderTraits extends mixTraits( + SearchProviderTraits +) { + @primitiveTrait({ + type: "string", + name: "Name", + description: "Name of the search provider." + }) + name: string = "Catalog items"; +} diff --git a/lib/Traits/SearchProviders/CesiumIonSearchProviderTraits.ts b/lib/Traits/SearchProviders/CesiumIonSearchProviderTraits.ts new file mode 100644 index 00000000000..350a3f9ec04 --- /dev/null +++ b/lib/Traits/SearchProviders/CesiumIonSearchProviderTraits.ts @@ -0,0 +1,20 @@ +import primitiveTrait from "../Decorators/primitiveTrait"; +import mixTraits from "../mixTraits"; +import LocationSearchProviderTraits, { + SearchProviderMapCenterTraits +} from "./LocationSearchProviderTraits"; + +export default class CesiumIonSearchProviderTraits extends mixTraits( + LocationSearchProviderTraits, + SearchProviderMapCenterTraits +) { + url: string = "https://api.cesium.com/v1/geocode/search"; + + @primitiveTrait({ + type: "string", + name: "Key", + description: + "The Cesium ION key. If not provided, will try to use the global cesium ion key." + }) + key?: string; +} diff --git a/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts b/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts new file mode 100644 index 00000000000..8c21cf5f93b --- /dev/null +++ b/lib/Traits/SearchProviders/LocationSearchProviderTraits.ts @@ -0,0 +1,49 @@ +import primitiveTrait from "../Decorators/primitiveTrait"; +import mixTraits from "../mixTraits"; +import ModelTraits from "../ModelTraits"; +import SearchProviderTraits from "./SearchProviderTraits"; + +export default class LocationSearchProviderTraits extends mixTraits( + SearchProviderTraits +) { + @primitiveTrait({ + type: "string", + name: "URL", + description: "The URL of search provider." + }) + url: string = ""; + + @primitiveTrait({ + type: "number", + name: "recommendedListLength", + description: "Maximum amount of entries in the suggestion list." + }) + recommendedListLength: number = 5; + + @primitiveTrait({ + type: "number", + name: "URL", + description: "Time to move to the result location.", + isNullable: true + }) + flightDurationSeconds?: number = 1.5; + + @primitiveTrait({ + type: "boolean", + name: "Is open", + description: + "True if the search results of this search provider are visible; otherwise, false.", + isNullable: true + }) + isOpen: boolean = true; +} + +export class SearchProviderMapCenterTraits extends ModelTraits { + @primitiveTrait({ + type: "boolean", + name: "Map center", + description: + "Whether the current location of the map center is supplied with search request" + }) + mapCenter: boolean = true; +} diff --git a/lib/Traits/SearchProviders/SearchBarTraits.ts b/lib/Traits/SearchProviders/SearchBarTraits.ts new file mode 100644 index 00000000000..85c9bbb2da3 --- /dev/null +++ b/lib/Traits/SearchProviders/SearchBarTraits.ts @@ -0,0 +1,45 @@ +import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; +import objectTrait from "../Decorators/objectTrait"; +import primitiveTrait from "../Decorators/primitiveTrait"; +import ModelTraits from "../ModelTraits"; +import { RectangleTraits } from "../TraitsClasses/MappableTraits"; + +export class SearchBarTraits extends ModelTraits { + @primitiveTrait({ + type: "string", + name: "placeholder", + description: + "Input text field placeholder shown when no input has been given yet. The string is translateable." + }) + placeholder: string = "translate#search.placeholder"; + + @primitiveTrait({ + type: "number", + name: "Recommended list length", + description: "Maximum amount of entries in the suggestion list." + }) + recommendedListLength: number = 5; + + @primitiveTrait({ + type: "number", + name: "Flight duration seconds", + description: + "The duration of the camera flight to an entered location, in seconds." + }) + flightDurationSeconds: number = 1.5; + + @primitiveTrait({ + type: "number", + name: "Minimum characters", + description: "Minimum number of characters required for search to start" + }) + minCharacters: number = 3; + + @objectTrait({ + type: RectangleTraits, + name: "Bounding box limit", + description: + "Bounding box limits for the search results {west, south, east, north}" + }) + boundingBoxLimit?: RectangleTraits = Rectangle.MAX_VALUE; +} diff --git a/lib/Traits/SearchProviders/SearchProviderTraits.ts b/lib/Traits/SearchProviders/SearchProviderTraits.ts new file mode 100644 index 00000000000..175ed48a22e --- /dev/null +++ b/lib/Traits/SearchProviders/SearchProviderTraits.ts @@ -0,0 +1,19 @@ +import ModelTraits from "../ModelTraits"; +import primitiveTrait from "../Decorators/primitiveTrait"; + +export default class SearchProviderTraits extends ModelTraits { + @primitiveTrait({ + type: "string", + name: "Name", + description: "Name of the search provider." + }) + name: string = "unknown"; + + @primitiveTrait({ + type: "number", + name: "Minimum characters", + description: "Minimum number of characters required for search to start", + isNullable: true + }) + minCharacters?: number; +} diff --git a/lib/Traits/SearchProviders/WebFeatureServiceSearchProviderTraits.ts b/lib/Traits/SearchProviders/WebFeatureServiceSearchProviderTraits.ts new file mode 100644 index 00000000000..636585348d3 --- /dev/null +++ b/lib/Traits/SearchProviders/WebFeatureServiceSearchProviderTraits.ts @@ -0,0 +1,21 @@ +import primitiveTrait from "../Decorators/primitiveTrait"; +import mixTraits from "../mixTraits"; +import LocationSearchProviderTraits from "./LocationSearchProviderTraits"; + +export default class WebFeatureServiceSearchProviderTraits extends mixTraits( + LocationSearchProviderTraits +) { + @primitiveTrait({ + type: "string", + name: "Search property name", + description: "Which property to look for the search text in" + }) + searchPropertyName?: string; + + @primitiveTrait({ + type: "string", + name: "Search property type name", + description: "Type of the properties to search" + }) + searchPropertyTypeName?: string; +} diff --git a/test/Map/StyledHtmlSpec.tsx b/test/Map/StyledHtmlSpec.tsx index e363ed5811e..861619da303 100644 --- a/test/Map/StyledHtmlSpec.tsx +++ b/test/Map/StyledHtmlSpec.tsx @@ -22,8 +22,7 @@ describe("StyledHtml", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts b/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts new file mode 100644 index 00000000000..44227b87351 --- /dev/null +++ b/test/ModelMixins/SearchProviders/SearchProviderMixinSpec.ts @@ -0,0 +1,75 @@ +import SearchProviderMixin from "../../../lib/ModelMixins/SearchProviders/SearchProviderMixin"; +import CommonStrata from "../../../lib/Models/Definition/CommonStrata"; +import CreateModel from "../../../lib/Models/Definition/CreateModel"; +import Terria from "../../../lib/Models/Terria"; +import BingMapsSearchProviderTraits from "../../../lib/Traits/SearchProviders/BingMapsSearchProviderTraits"; + +class TestSearchProvider extends SearchProviderMixin( + CreateModel(BingMapsSearchProviderTraits) +) { + type = "test"; + + constructor(uniqueId: string | undefined, terria: Terria) { + super(uniqueId, terria); + } + + public override logEvent = jasmine.createSpy(); + public override doSearch = jasmine + .createSpy() + .and.returnValue(Promise.resolve()); +} + +describe("SearchProviderMixin", () => { + let terria: Terria; + let searchProvider: TestSearchProvider; + + beforeEach(() => { + terria = new Terria({ + baseUrl: "./" + }); + searchProvider = new TestSearchProvider("test", terria); + searchProvider.setTrait(CommonStrata.definition, "minCharacters", 3); + searchProvider.logEvent.calls.reset(); + searchProvider.doSearch.calls.reset(); + }); + + it(" - properly mixed", () => { + expect(SearchProviderMixin.isMixedInto(searchProvider)).toBeTruthy(); + }); + + it(" - should not run search if searchText is undefined", () => { + const result = searchProvider.search(undefined as never); + expect(result.resultsCompletePromise).toBeDefined(); + expect(result.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(); + + 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(); + + 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(); + + expect(searchProvider.logEvent).toHaveBeenCalled(); + expect(searchProvider.doSearch).toHaveBeenCalled(); + }); +}); diff --git a/test/Models/MapNavigation/MapNavigationModelSpec.ts b/test/Models/MapNavigation/MapNavigationModelSpec.ts index 0d22e88c45b..0a7e88ded3f 100644 --- a/test/Models/MapNavigation/MapNavigationModelSpec.ts +++ b/test/Models/MapNavigation/MapNavigationModelSpec.ts @@ -20,8 +20,7 @@ describe("MapNavigationModel", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); item1 = { id: "item1", diff --git a/test/Models/AustralianGazetteerSearchProviderSpec.ts b/test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts similarity index 55% rename from test/Models/AustralianGazetteerSearchProviderSpec.ts rename to test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts index 9304d9158ca..a58fcdf1883 100644 --- a/test/Models/AustralianGazetteerSearchProviderSpec.ts +++ b/test/Models/SearchProviders/AustralianGazetteerSearchProviderSpec.ts @@ -1,9 +1,8 @@ import { configure } from "mobx"; -import createAustralianGazetteerSearchProvider from "../../lib/Models/SearchProviders/AustralianGazetteerSearchProvider"; -import Terria from "../../lib/Models/Terria"; -import WebFeatureServiceSearchProvider from "../../lib/Models/Catalog/Ows/WebFeatureServiceSearchProvider"; +import AustralianGazetteerSearchProvider from "../../../lib/Models/SearchProviders/AustralianGazetteerSearchProvider"; +import Terria from "../../../lib/Models/Terria"; -const wfsResponseXml = require("raw-loader!../../wwwroot/test/WFS/getWithFilter.xml"); +const wfsResponseXml = require("raw-loader!../../../wwwroot/test/WFS/getWithFilter.xml"); configure({ enforceActions: "observed", @@ -11,10 +10,18 @@ configure({ }); describe("GazetteerSearchProvider", function () { - let searchProvider: WebFeatureServiceSearchProvider; + let searchProvider: AustralianGazetteerSearchProvider; beforeEach(function () { - searchProvider = createAustralianGazetteerSearchProvider(new Terria()); + searchProvider = new AustralianGazetteerSearchProvider( + "test", + new Terria() + ); + }); + + it(" type", function () { + expect(searchProvider.type).toEqual(AustralianGazetteerSearchProvider.type); }); + it("queries the web feature service and returns search results", async function () { spyOn(searchProvider, "getXml").and.returnValue( Promise.resolve(wfsResponseXml) diff --git a/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts b/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts new file mode 100644 index 00000000000..557fe5b848e --- /dev/null +++ b/test/Models/SearchProviders/BingMapsSearchProviderSpec.ts @@ -0,0 +1,138 @@ +import { runInAction } from "mobx"; +import Resource from "terriajs-cesium/Source/Core/Resource"; +import LocationSearchProviderMixin from "../../../lib/ModelMixins/SearchProviders/LocationSearchProviderMixin"; +import BingMapsSearchProvider from "../../../lib/Models/SearchProviders/BingMapsSearchProvider"; +import Terria from "../../../lib/Models/Terria"; + +import * as loadJsonp from "../../../lib/Core/loadJsonp"; +import CommonStrata from "../../../lib/Models/Definition/CommonStrata"; + +describe("BingMapsSearchProvider", function () { + let terria: Terria; + let bingMapsSearchProvider: BingMapsSearchProvider; + + beforeEach(async function () { + terria = new Terria({ + baseUrl: "./" + }); + bingMapsSearchProvider = new BingMapsSearchProvider("test", terria); + }); + + it(" - properly mixed", () => { + expect( + LocationSearchProviderMixin.isMixedInto(bingMapsSearchProvider) + ).toBeTruthy(); + }); + + it(" - propperly defines default url", () => { + expect(bingMapsSearchProvider.url).toEqual("https://dev.virtualearth.net/"); + }); + + it(" - propperly sets query parameters", () => { + runInAction(() => { + bingMapsSearchProvider.setTrait( + CommonStrata.definition, + "key", + "test-key" + ); + bingMapsSearchProvider.setTrait( + CommonStrata.definition, + "minCharacters", + 3 + ); + bingMapsSearchProvider.setTrait( + CommonStrata.definition, + "mapCenter", + false + ); + }); + const test = spyOn(loadJsonp, "loadJsonp").and.returnValue( + Promise.resolve({ + resourceSets: [] + }) + ); + + const searchResult = bingMapsSearchProvider.search("test"); + + expect(test).toHaveBeenCalledWith( + new Resource({ + url: "https://dev.virtualearth.net/REST/v1/Locations", + queryParameters: { + culture: "en-au", + query: "test", + key: "test-key", + maxResults: 5 + } + }), + "jsonp" + ); + }); + + it(" - propperly sort the search results", async () => { + runInAction(() => { + bingMapsSearchProvider.setTrait( + CommonStrata.definition, + "key", + "test-key" + ); + bingMapsSearchProvider.setTrait( + CommonStrata.definition, + "minCharacters", + 3 + ); + bingMapsSearchProvider.setTrait( + CommonStrata.definition, + "mapCenter", + false + ); + }); + const test = spyOn(loadJsonp, "loadJsonp").and.returnValue( + Promise.resolve({ + resourceSets: [ + { + resources: [ + { + name: "test result 1", + address: { + countryRegion: "Italy" + }, + point: { + type: "Point", + coordinates: [46.06452179, 12.08810234] + }, + bbox: [ + 46.06022262573242, 12.072776794433594, 46.06576919555664, + 12.101446151733398 + ] + }, + { + name: "test result 2", + address: { + countryRegion: "Australia" + }, + point: { + type: "Point", + coordinates: [46.06452179, 12.08810234] + }, + bbox: [ + 45.96084213256836, 11.978724479675293, 46.09341049194336, + 12.2274169921875 + ] + }, + { + name: undefined + } + ] + } + ] + }) + ); + + const searchResult = await bingMapsSearchProvider.search("test"); + + 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"); + }); +}); diff --git a/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts b/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts index f000088d6f9..e0858fc3542 100644 --- a/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts +++ b/test/Models/SearchProviders/CesiumIonSearchProviderSpec.ts @@ -1,6 +1,7 @@ import CesiumIonSearchProvider from "../../../lib/Models/SearchProviders/CesiumIonSearchProvider"; import Terria from "../../../lib/Models//Terria"; import * as loadJson from "../../../lib/Core/loadJson"; +import CommonStrata from "../../../lib/Models/Definition/CommonStrata"; const fixture = { features: [ @@ -17,15 +18,18 @@ const fixture = { }; describe("CesiumIonSearchProvider", () => { - const searchProvider = new CesiumIonSearchProvider({ - key: "testkey", - url: "api.test.com", - terria: { - currentViewer: { - zoomTo: () => {} - } - } as unknown as Terria + let terria: Terria; + let searchProvider: CesiumIonSearchProvider; + + beforeEach(async function () { + terria = new Terria({ + baseUrl: "./" + }); + searchProvider = new CesiumIonSearchProvider("test-cesium-ion", terria); + searchProvider.setTrait(CommonStrata.definition, "key", "testkey"); + searchProvider.setTrait(CommonStrata.definition, "url", "api.test.com"); }); + it("Handles valid results", async () => { spyOn(loadJson, "default").and.returnValue( new Promise((resolve) => resolve(fixture)) @@ -47,13 +51,17 @@ describe("CesiumIonSearchProvider", () => { const result = await searchProvider.search("test"); console.log(result); expect(result.results.length).toBe(0); - expect(result.message).toBe("viewModels.searchNoLocations"); + expect(result.message?.content).toBe( + "translate#viewModels.searchNoLocations" + ); }); it("Handles error", async () => { spyOn(loadJson, "default").and.throwError("error"); const result = await searchProvider.search("test"); expect(result.results.length).toBe(0); - expect(result.message).toBe("viewModels.searchErrorOccurred"); + expect(result.message?.content).toBe( + "translate#viewModels.searchErrorOccurred" + ); }); }); diff --git a/test/Models/SearchProviders/LocationSearchProviderSpec.ts b/test/Models/SearchProviders/LocationSearchProviderSpec.ts new file mode 100644 index 00000000000..0f744648c79 --- /dev/null +++ b/test/Models/SearchProviders/LocationSearchProviderSpec.ts @@ -0,0 +1,32 @@ +import LocationSearchProviderMixin from "../../../lib/ModelMixins/SearchProviders/LocationSearchProviderMixin"; +import BingMapsSearchProvider from "../../../lib/Models/SearchProviders/BingMapsSearchProvider"; +import Terria from "../../../lib/Models/Terria"; + +describe("LocationSearchProvider", function () { + let terria: Terria; + let bingMapsSearchProvider: BingMapsSearchProvider; + beforeEach(async function () { + terria = new Terria({ + baseUrl: "./" + }); + bingMapsSearchProvider = new BingMapsSearchProvider("test", terria); + }); + + it(" - properly mixed", function () { + expect( + LocationSearchProviderMixin.isMixedInto(bingMapsSearchProvider) + ).toBeTruthy(); + }); + + it(" - propperly defines default recommendedListLength", function () { + expect(bingMapsSearchProvider.recommendedListLength).toEqual(5); + }); + + it(" - propperly defines default flightDurationSeconds", function () { + expect(bingMapsSearchProvider.flightDurationSeconds).toEqual(1.5); + }); + + it(" - propperly defines default isOpen", function () { + expect(bingMapsSearchProvider.isOpen).toEqual(true); + }); +}); diff --git a/test/Models/TerriaSpec.ts b/test/Models/TerriaSpec.ts index d48ccf7526a..ed096187703 100644 --- a/test/Models/TerriaSpec.ts +++ b/test/Models/TerriaSpec.ts @@ -584,8 +584,7 @@ describe("Terria", function () { newTerria = new Terria({ appBaseHref: "/", baseUrl: "./" }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); UrlToCatalogMemberMapping.register( @@ -801,8 +800,7 @@ describe("Terria", function () { newTerria = new Terria({ baseUrl: "./" }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); await Promise.all( @@ -913,8 +911,7 @@ describe("Terria", function () { viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); newTerria = new Terria({ baseUrl: "./" }); diff --git a/test/Models/Workflows/SelectableDimensionWorkflowSpec.ts b/test/Models/Workflows/SelectableDimensionWorkflowSpec.ts index 385fd974197..dcac113c67f 100644 --- a/test/Models/Workflows/SelectableDimensionWorkflowSpec.ts +++ b/test/Models/Workflows/SelectableDimensionWorkflowSpec.ts @@ -14,8 +14,7 @@ describe("SelectableDimensionWorkflow", function () { terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViewModels/ViewStateSpec.ts b/test/ReactViewModels/ViewStateSpec.ts index ff04eeb9e6b..b403493c6b8 100644 --- a/test/ReactViewModels/ViewStateSpec.ts +++ b/test/ReactViewModels/ViewStateSpec.ts @@ -14,8 +14,7 @@ describe("ViewState", function () { terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/BottomDock/BottomDockSpec.tsx b/test/ReactViews/BottomDock/BottomDockSpec.tsx index 91fc7851835..d89936a04c5 100644 --- a/test/ReactViews/BottomDock/BottomDockSpec.tsx +++ b/test/ReactViews/BottomDock/BottomDockSpec.tsx @@ -15,8 +15,7 @@ describe("BottomDock", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/BottomDock/MapDataCountSpec.tsx b/test/ReactViews/BottomDock/MapDataCountSpec.tsx index b8b00cfe534..fa8a2ee5242 100644 --- a/test/ReactViews/BottomDock/MapDataCountSpec.tsx +++ b/test/ReactViews/BottomDock/MapDataCountSpec.tsx @@ -19,8 +19,7 @@ describe("MapDataCount", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/DataCatalog/DataCatalogItemSpec.tsx b/test/ReactViews/DataCatalog/DataCatalogItemSpec.tsx index 2d18745ee9b..16ed60e92ff 100644 --- a/test/ReactViews/DataCatalog/DataCatalogItemSpec.tsx +++ b/test/ReactViews/DataCatalog/DataCatalogItemSpec.tsx @@ -26,8 +26,7 @@ describe("DataCatalogItem", () => { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); wmsItem = new WebMapServiceCatalogItem("test", terria); diff --git a/test/ReactViews/DisclaimerSpec.tsx b/test/ReactViews/DisclaimerSpec.tsx index b9672dd256b..49ed398fb0c 100644 --- a/test/ReactViews/DisclaimerSpec.tsx +++ b/test/ReactViews/DisclaimerSpec.tsx @@ -19,8 +19,7 @@ describe("Disclaimer", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/FeatureInfoPanelSpec.tsx b/test/ReactViews/FeatureInfoPanelSpec.tsx index 8abc2b9d572..b4d632648fc 100644 --- a/test/ReactViews/FeatureInfoPanelSpec.tsx +++ b/test/ReactViews/FeatureInfoPanelSpec.tsx @@ -37,8 +37,7 @@ describe("FeatureInfoPanel", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/FeatureInfoSectionSpec.tsx b/test/ReactViews/FeatureInfoSectionSpec.tsx index 349b78e67b3..f773fb1025d 100644 --- a/test/ReactViews/FeatureInfoSectionSpec.tsx +++ b/test/ReactViews/FeatureInfoSectionSpec.tsx @@ -66,8 +66,7 @@ describe("FeatureInfoSection", function () { viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); const properties = { name: "Kay", diff --git a/test/ReactViews/Generic/PromptSpec.tsx b/test/ReactViews/Generic/PromptSpec.tsx index 31b7fa6f65b..d44afbf3cd7 100644 --- a/test/ReactViews/Generic/PromptSpec.tsx +++ b/test/ReactViews/Generic/PromptSpec.tsx @@ -19,8 +19,7 @@ describe("HelpPrompt", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Map/Navigation/Compass/CompassSpec.tsx b/test/ReactViews/Map/Navigation/Compass/CompassSpec.tsx index d88e6da7f4f..5daafb1a59a 100644 --- a/test/ReactViews/Map/Navigation/Compass/CompassSpec.tsx +++ b/test/ReactViews/Map/Navigation/Compass/CompassSpec.tsx @@ -21,8 +21,7 @@ describe("Compass", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Map/Navigation/Compass/GyroscopeGuidanceSpec.tsx b/test/ReactViews/Map/Navigation/Compass/GyroscopeGuidanceSpec.tsx index f7883198777..fa870e14cff 100644 --- a/test/ReactViews/Map/Navigation/Compass/GyroscopeGuidanceSpec.tsx +++ b/test/ReactViews/Map/Navigation/Compass/GyroscopeGuidanceSpec.tsx @@ -18,8 +18,7 @@ describe("GyroscopeGuidance", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Map/Panels/HelpPanel/HelpPanelSpec.tsx b/test/ReactViews/Map/Panels/HelpPanel/HelpPanelSpec.tsx index 46f29ea5b71..cb81e15d93e 100644 --- a/test/ReactViews/Map/Panels/HelpPanel/HelpPanelSpec.tsx +++ b/test/ReactViews/Map/Panels/HelpPanel/HelpPanelSpec.tsx @@ -23,8 +23,7 @@ describe("HelpPanel", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Map/Panels/HelpPanel/VideoGuideSpec.tsx b/test/ReactViews/Map/Panels/HelpPanel/VideoGuideSpec.tsx index 884d9ffeaf5..0674dea8b45 100644 --- a/test/ReactViews/Map/Panels/HelpPanel/VideoGuideSpec.tsx +++ b/test/ReactViews/Map/Panels/HelpPanel/VideoGuideSpec.tsx @@ -23,8 +23,7 @@ describe("VideoGuide", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Map/Panels/LangPanel/LangPanelSpec.tsx b/test/ReactViews/Map/Panels/LangPanel/LangPanelSpec.tsx index ba81a1c5ed5..2003f843a51 100644 --- a/test/ReactViews/Map/Panels/LangPanel/LangPanelSpec.tsx +++ b/test/ReactViews/Map/Panels/LangPanel/LangPanelSpec.tsx @@ -20,8 +20,7 @@ describe("LangPanel", function () { viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Map/Panels/SharePanel/BuildShareLinkSpec.ts b/test/ReactViews/Map/Panels/SharePanel/BuildShareLinkSpec.ts index fef5d94cac1..87b5027a8bb 100644 --- a/test/ReactViews/Map/Panels/SharePanel/BuildShareLinkSpec.ts +++ b/test/ReactViews/Map/Panels/SharePanel/BuildShareLinkSpec.ts @@ -36,8 +36,7 @@ beforeEach(function () { viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Search/BreadcrumbsSpec.tsx b/test/ReactViews/Search/BreadcrumbsSpec.tsx index f85ce9a5278..08d15e39fe9 100644 --- a/test/ReactViews/Search/BreadcrumbsSpec.tsx +++ b/test/ReactViews/Search/BreadcrumbsSpec.tsx @@ -25,8 +25,7 @@ describe("Breadcrumbs", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); catalogGroup = new CatalogGroup("group-of-geospatial-cats", terria); terria.addModel(catalogGroup); diff --git a/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx b/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx index f62f0fd3a6d..20f7a276921 100644 --- a/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx +++ b/test/ReactViews/Search/SearchBoxAndResultsSpec.tsx @@ -9,6 +9,7 @@ import SearchBoxAndResults, { } from "../../../lib/ReactViews/Search/SearchBoxAndResults"; import { ThemeProvider } from "styled-components"; import { terriaTheme } from "../../../lib/ReactViews/StandardUserInterface"; +import CatalogSearchProvider from "../../../lib/Models/SearchProviders/CatalogSearchProvider"; describe("SearchBoxAndResults", function () { let terria: Terria; @@ -22,12 +23,7 @@ describe("SearchBoxAndResults", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] - }); - - runInAction(() => { - (viewState as any).searchState.catalogSearchProvider = true; + catalogSearchProvider: new CatalogSearchProvider("catalog", terria) }); }); @@ -89,7 +85,7 @@ describe("SearchBoxAndResults", function () { viewState.searchState.locationSearchText = searchText; viewState.searchState.showLocationSearchResults = true; viewState.searchState.locationSearchResults = []; - (viewState as any).searchState.catalogSearchProvider = false; + viewState.terria.searchBarModel.catalogSearchProvider = undefined; }); act(() => { testRenderer = create( diff --git a/test/ReactViews/Search/SearchBoxSpec.tsx b/test/ReactViews/Search/SearchBoxSpec.tsx index bb60465a479..6c285caa4b1 100644 --- a/test/ReactViews/Search/SearchBoxSpec.tsx +++ b/test/ReactViews/Search/SearchBoxSpec.tsx @@ -18,8 +18,7 @@ describe("SearchBox", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/SidePanel/BrandingSpec.tsx b/test/ReactViews/SidePanel/BrandingSpec.tsx index fbe4094a574..dab452d71ee 100644 --- a/test/ReactViews/SidePanel/BrandingSpec.tsx +++ b/test/ReactViews/SidePanel/BrandingSpec.tsx @@ -15,8 +15,7 @@ describe("Branding", function () { terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/StandardUserInterface/TrainerBar/TrainerBarSpec.tsx b/test/ReactViews/StandardUserInterface/TrainerBar/TrainerBarSpec.tsx index 7bba0db2c75..d05c06f4652 100644 --- a/test/ReactViews/StandardUserInterface/TrainerBar/TrainerBarSpec.tsx +++ b/test/ReactViews/StandardUserInterface/TrainerBar/TrainerBarSpec.tsx @@ -20,8 +20,7 @@ describe("TrainerBar", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/ToolButtonSpec.tsx b/test/ReactViews/ToolButtonSpec.tsx index c0d52244919..35cdbd1820d 100644 --- a/test/ReactViews/ToolButtonSpec.tsx +++ b/test/ReactViews/ToolButtonSpec.tsx @@ -15,8 +15,7 @@ describe("ToolButton", function () { const terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); }); diff --git a/test/ReactViews/ToolSpec.tsx b/test/ReactViews/ToolSpec.tsx index f9a14750a76..97eafd33d5f 100644 --- a/test/ReactViews/ToolSpec.tsx +++ b/test/ReactViews/ToolSpec.tsx @@ -14,8 +14,7 @@ describe("Tool", function () { const terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Tools/ItemSearchTool/ItemSearchToolSpec.tsx b/test/ReactViews/Tools/ItemSearchTool/ItemSearchToolSpec.tsx index d02a6666e75..84286cc1966 100644 --- a/test/ReactViews/Tools/ItemSearchTool/ItemSearchToolSpec.tsx +++ b/test/ReactViews/Tools/ItemSearchTool/ItemSearchToolSpec.tsx @@ -58,8 +58,7 @@ describe("ItemSearchTool", function () { const terria: Terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); item = new MockSearchableItem("test", terria); item.setTrait(CommonStrata.user, "search", { diff --git a/test/ReactViews/Tour/TourPortalSpec.tsx b/test/ReactViews/Tour/TourPortalSpec.tsx index 4d0d1108d72..67f5a0576cf 100644 --- a/test/ReactViews/Tour/TourPortalSpec.tsx +++ b/test/ReactViews/Tour/TourPortalSpec.tsx @@ -23,8 +23,7 @@ describe("TourPortal", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/WelcomeMessageSpec.tsx b/test/ReactViews/WelcomeMessageSpec.tsx index ce7fa35d34d..646ceecb493 100644 --- a/test/ReactViews/WelcomeMessageSpec.tsx +++ b/test/ReactViews/WelcomeMessageSpec.tsx @@ -20,8 +20,7 @@ describe("WelcomeMessage", function () { }); viewState = new ViewState({ terria: terria, - catalogSearchProvider: null, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Workbench/Controls/IdealZoomSpec.tsx b/test/ReactViews/Workbench/Controls/IdealZoomSpec.tsx index 34d2e06ed29..bb553cb84f8 100644 --- a/test/ReactViews/Workbench/Controls/IdealZoomSpec.tsx +++ b/test/ReactViews/Workbench/Controls/IdealZoomSpec.tsx @@ -30,8 +30,7 @@ describe("Ideal Zoom", function () { const options = { terria: terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }; viewState = new ViewState(options); }); diff --git a/test/ReactViews/Workbench/Controls/ViewingControlsSpec.tsx b/test/ReactViews/Workbench/Controls/ViewingControlsSpec.tsx index 3df68ffd857..b271fb5f5ce 100644 --- a/test/ReactViews/Workbench/Controls/ViewingControlsSpec.tsx +++ b/test/ReactViews/Workbench/Controls/ViewingControlsSpec.tsx @@ -16,8 +16,7 @@ describe("ViewingControls", function () { terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ReactViews/Workflows/WorkflowPanelSpec.tsx b/test/ReactViews/Workflows/WorkflowPanelSpec.tsx index 48bf6dd2242..28f81461995 100644 --- a/test/ReactViews/Workflows/WorkflowPanelSpec.tsx +++ b/test/ReactViews/Workflows/WorkflowPanelSpec.tsx @@ -17,8 +17,7 @@ describe("WorkflowPanel", function () { "./data/regionMapping.json"; viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ViewModels/FeatureInfoPanelSpec.ts b/test/ViewModels/FeatureInfoPanelSpec.ts index b82f45d109e..388979f9c84 100644 --- a/test/ViewModels/FeatureInfoPanelSpec.ts +++ b/test/ViewModels/FeatureInfoPanelSpec.ts @@ -12,8 +12,7 @@ describe("FeatureInfoPanel", function () { terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ViewModels/MapNavigation/MapToolbarSpec.ts b/test/ViewModels/MapNavigation/MapToolbarSpec.ts index 3df9e1a316e..7e2b533e00b 100644 --- a/test/ViewModels/MapNavigation/MapToolbarSpec.ts +++ b/test/ViewModels/MapNavigation/MapToolbarSpec.ts @@ -11,8 +11,7 @@ describe("MapToolbar", function () { terria = new Terria(); viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); }); diff --git a/test/ViewModels/ViewingControlsMenuSpec.ts b/test/ViewModels/ViewingControlsMenuSpec.ts index fa00d863487..4e77287f0e0 100644 --- a/test/ViewModels/ViewingControlsMenuSpec.ts +++ b/test/ViewModels/ViewingControlsMenuSpec.ts @@ -11,8 +11,7 @@ describe("ViewingControlsMenu", function () { const terria = new Terria(); const viewState = new ViewState({ terria, - catalogSearchProvider: undefined, - locationSearchProviders: [] + catalogSearchProvider: undefined }); expect(viewState.globalViewingControlOptions.length).toEqual(0); const generateFunction = (item: CatalogMemberMixin.Instance) => ({ diff --git a/wwwroot/languages/en/translation.json b/wwwroot/languages/en/translation.json index 0e7d6bce3c4..2fad91ec8b1 100644 --- a/wwwroot/languages/en/translation.json +++ b/wwwroot/languages/en/translation.json @@ -448,7 +448,7 @@ "resultsLabel": "Search Results", "done": "Done", "data": "Data", - "searchInDataCatalog": "Search <1>'{{locationSearchText}}' in the Data Catalogue", + "searchInDataCatalog": "Search '{{locationSearchText}}' in the Data Catalogue", "search": "Search '{{searchText}}' in the Data Catalogue", "viewLess": "View less {{name}} results", "viewMore": "View more {{name}} results", @@ -638,6 +638,8 @@ "viewModels": { "searchNoLocations": "Sorry, no locations match your search query.", "searchErrorOccurred": "An error occurred while searching. Please check your internet connection or try again later.", + "searchMinCharacters": "You need to enter minimum {{count}} character", + "searchMinCharacters_plural": "You need to enter minimum {{count}} characters", "searchAddresses": "Addresses", "searchPlaceNames": "Official Place Names", "searchNoPlaceNames": "Sorry, no official place names match your search query.", @@ -2025,6 +2027,15 @@ } } }, + "searchProvider": { + "noSearchProviders": "There is no configured search providers", + "models": { + "unsupportedTypeTitle": "Unknown type", + "unsupportedTypeMessage": "Could not create unknown model type {{type}}.", + "idForMatchingErrorTitle": "Missing property", + "idForMatchingErrorMessage": "Model objects must have an `id`, `localId`, or `name` property." + } + }, "deltaTool": { "titlePrefix": "Change Detection", "description": "This tool visualizes the difference between imagery captured at two discrete points in time.",