From 6e126f1ff61bf3280c481dff9e100a7c9132548c Mon Sep 17 00:00:00 2001 From: Matteo Velludini Date: Thu, 30 Mar 2017 16:13:22 +0200 Subject: [PATCH] fixed #1634 added support to custom search services and minor fixes --- docma-config.json | 1 - docs/developer-guide/map-plugin.md | 2 +- web/client/api/searchText.js | 93 +++++++++--- .../mapcontrols/search/SearchBar.jsx | 6 +- web/client/epics/__tests__/search-test.js | 133 ++++++++++++++---- web/client/epics/search.js | 116 ++++++++------- web/client/plugins/Map.jsx | 2 +- web/client/plugins/Search.jsx | 11 +- 8 files changed, 253 insertions(+), 111 deletions(-) diff --git a/docma-config.json b/docma-config.json index eb40f9dd6a..e43df8879f 100644 --- a/docma-config.json +++ b/docma-config.json @@ -76,7 +76,6 @@ "label": "Download", "href": "index.html", "items": [ - { "label": "mvn clean install" }, { "label": "MapStore 2 Releases", "href": "https://github.com/geosolutions-it/MapStore2/releases", diff --git a/docs/developer-guide/map-plugin.md b/docs/developer-guide/map-plugin.md index 82621d8f94..b49c871245 100644 --- a/docs/developer-guide/map-plugin.md +++ b/docs/developer-guide/map-plugin.md @@ -90,7 +90,7 @@ module.exports = { }], "toolsOptions": { "test": { - "label": "ciao" + "label": "Hello" } ... } diff --git a/web/client/api/searchText.js b/web/client/api/searchText.js index 9f0034cd31..a32dc32ed9 100644 --- a/web/client/api/searchText.js +++ b/web/client/api/searchText.js @@ -9,40 +9,91 @@ const WFS = require('./WFS'); const assign = require('object-assign'); const GeoCodeUtils = require('../utils/GeoCodeUtils'); const {generateTemplateString} = require('../utils/TemplateUtils'); -/* -const toNominatim = (fc) => - fc.features && fc.features.map( (f) => ({ - boundingbox: f.properties.bbox, - lat: 1, - lon: 1, - display_name: `${f.properties.STATE_NAME} (${f.properties.STATE_ABBR})` - })); -*/ - -module.exports = { +const axios = require('axios'); +const urlUtil = require('url'); +let Services = { nominatim: (searchText, options = {}) => require('./Nominatim') .geocode(searchText, options) .then( res => GeoCodeUtils.nominatimToGeoJson(res.data)), - wfs: (searchText, {url, typeName, queriableAttributes, outputFormat="application/json", predicate ="ILIKE", staticFilter="", blacklist = [], item, ...params }) => { + wfs: (searchText, {url, typeName, queriableAttributes = [], outputFormat="application/json", predicate ="ILIKE", staticFilter="", blacklist = [], item, ...params }) => { // split into words and remove blacklisted words const staticFilterParsed = generateTemplateString(staticFilter || "")(item); let searchWords = searchText.split(" ").filter(w => w).filter( w => blacklist.indexOf(w.toLowerCase()) < 0 ); - // if the searchtext is empty use the full searchText + // if the array searchWords is empty, then use the full searchText if (searchWords.length === 0 ) { - searchWords = [searchText]; + searchWords = !!searchText ? [searchText] : []; + } + let filter; + if (searchWords.length > 0 ) { + filter = "(".concat( searchWords.map( (w) => queriableAttributes.map( attr => `${attr} ${predicate} '%${w.replace("'", "''")}%'`).join(" OR ")).join(') AND (')).concat(")"); } + + filter = filter ? filter.concat(staticFilterParsed) : staticFilterParsed || null; + return WFS .getFeatureSimple(url, assign({ - maxFeatures: 10, - startIndex: 0, - typeName, - outputFormat, - // create a filter like : `(ATTR ilike '%word1%') AND (ATTR ilike '%word2%')` - cql_filter: "(".concat( searchWords.map( (w) => queriableAttributes.map( attr => `${attr} ${predicate} '%${w.replace("'", "''")}%'`).join(" OR ")).join(') AND (')).concat(")") .concat(staticFilterParsed) - }, params)) + maxFeatures: 10, + typeName, + outputFormat, + // create a filter like : `(ATTR ilike '%word1%') AND (ATTR ilike '%word2%')` + cql_filter: filter + }, params)) .then( response => response.features ); + }, + bzVie: (searchText, {pathname, lang}) => { + let params = assign({}, {query: searchText, lang}); + let url = urlUtil.format({ + pathname, + query: params + }); + return axios.post(url).then( (res) => { + if (res && res.data && res.data.success) { + return res.data.vie.map((item) => { + return { + "type": "Feature", + "properties": { + "code": item.codice, + "desc": item.descrizione + } + }; + }); + } + return []; + }); + }, + bzCivico: (searchText, {pathname, item}) => { + let params = assign({}, {query: searchText, idVia: item.properties.code}); + let url = urlUtil.format({ + pathname, + query: params + }); + return axios.post(url).then( (res) => { + if (res && res.data && res.data.success) { + return res.data.vie.map((nestedItem) => { + return { + "type": "Feature", + "properties": { + "code": nestedItem.codice, + "desc": nestedItem.descrizione + } + }; + }); + } + return []; + }); } }; + +const Utils = { + setService: (type, fun) => { + Services[type] = fun; + }, + getService: (type) => { + return !!Services[type] ? Services[type] : null; + } +}; + +module.exports = {API: {Services, Utils}}; diff --git a/web/client/components/mapcontrols/search/SearchBar.jsx b/web/client/components/mapcontrols/search/SearchBar.jsx index 3e7ba314d7..2bbb2f21fb 100644 --- a/web/client/components/mapcontrols/search/SearchBar.jsx +++ b/web/client/components/mapcontrols/search/SearchBar.jsx @@ -40,7 +40,7 @@ require('./searchbar.css'); * @prop {number} blurResetDelay time to wait before to trigger onPurgeResults after blur event, if `hideOnBlur` is true * @prop {searchText} the text to display in the component * @prop {object[]} selectedItems the items selected. Must have `text` property to display - * @prop {boolean} autoFocusOnSelect if true, the comonent gets focus when items are added, or deleted but some item is still selected. Useful for continue writing after selecting an item (with nested services for instance) + * @prop {boolean} autoFocusOnSelect if true, the component gets focus when items are added, or deleted but some item is still selected. Useful for continue writing after selecting an item (with nested services for instance) * @prop {boolean} loading if true, shows the loading tool * @prop {object} error if not null, an error icon will be display * @prop {object} style css style to apply to the component @@ -154,9 +154,9 @@ let SearchBar = React.createClass({ }, render() { // const innerGlyphicon = ; - let placeholder; + let placeholder = "search.placeholder"; if (!this.props.placeholder && this.context.messages) { - let placeholderLocMessage = LocaleUtils.getMessageById(this.context.messages, this.props.placeholderMsgId); + let placeholderLocMessage = LocaleUtils.getMessageById(this.context.messages, this.props.placeholderMsgId || placeholder); if (placeholderLocMessage) { placeholder = placeholderLocMessage; } diff --git a/web/client/epics/__tests__/search-test.js b/web/client/epics/__tests__/search-test.js index 1e3d33b0ff..586a7d02aa 100644 --- a/web/client/epics/__tests__/search-test.js +++ b/web/client/epics/__tests__/search-test.js @@ -18,6 +18,37 @@ const rootEpic = combineEpics(searchEpic, searchItemSelected); const epicMiddleware = createEpicMiddleware(rootEpic); const mockStore = configureMockStore([epicMiddleware]); +const SEARCH_NESTED = 'SEARCH NESTED'; +const TEST_NESTED_PLACEHOLDER = 'TEST_NESTED_PLACEHOLDER'; +const STATE_NAME = 'STATE_NAME'; + +const nestedService = { + nestedPlaceholder: TEST_NESTED_PLACEHOLDER +}; +const TEXT = "Dinagat Islands"; +const item = { + "type": "Feature", + "bbox": [125, 10, 126, 11], + "geometry": { + "type": "Point", + "coordinates": [125.6, 10.1] + }, + "properties": { + "name": TEXT + }, + "__SERVICE__": { + searchTextTemplate: "${properties.name}", + displayName: "${properties.name}", + type: "wfs", + options: { + staticFilter: "${properties.name}" + }, + nestedPlaceholder: SEARCH_NESTED, + nestedPlaceholderMsgId: TEST_NESTED_PLACEHOLDER, + then: [nestedService] + } +}; + describe('search Epics', () => { let store; beforeEach(() => { @@ -36,7 +67,7 @@ describe('search Epics', () => { options: { url: 'base/web/client/test-resources/wfs/Wyoming.json', typeName: 'topp:states', - queriableAttributes: ['STATE_NAME'] + queriableAttributes: [STATE_NAME] } }] }; @@ -81,31 +112,6 @@ describe('search Epics', () => { }); it('searchItemSelected epic with nested services', () => { - let nestedService = { - nestedPlaceholder: "TEST_NESTED_PLACEHOLDER" - }; - const TEXT = "Dinagat Islands"; - const item = { - "type": "Feature", - "bbox": [125, 10, 126, 11], - "geometry": { - "type": "Point", - "coordinates": [125.6, 10.1] - }, - "properties": { - "name": TEXT - }, - "__SERVICE__": { - searchTextTemplate: "${properties.name}", - displayName: "${properties.name}", - type: "wfs", - options: { - staticFilter: "${properties.name}" - }, - nestedPlaceholder: "SEARCH NESTED", - then: [nestedService] - } - }; let action = selectSearchItem(item, { size: { width: 200, @@ -129,11 +135,82 @@ describe('search Epics', () => { } }); expect(actions[4].items).toEqual({ - placeholder: "SEARCH NESTED", + placeholder: SEARCH_NESTED, + placeholderMsgId: TEST_NESTED_PLACEHOLDER, text: TEXT }); expect(actions[5].type).toBe(TEXT_SEARCH_TEXT_CHANGE); - expect(actions[5].searchText).toBe("Dinagat Islands"); + expect(actions[5].searchText).toBe(TEXT); + }); + it('searchItemSelected with geomService', () => { + const itemWithoutGeom = { + "type": "Feature", + "bbox": [125, 10, 126, 11], + "properties": { + "name": TEXT + }, + "__SERVICE__": { + searchTextTemplate: "${properties.name}", + displayName: "${properties.name}", + type: "wfs", + options: { + staticFilter: "${properties.name}" + }, + "geomService": { + type: 'wfs', + options: { + url: 'base/web/client/test-resources/wfs/Wyoming.json', + typeName: 'topp:states', + queriableAttributes: [STATE_NAME] + } + }, + nestedPlaceholder: SEARCH_NESTED, + nestedPlaceholderMsgId: TEST_NESTED_PLACEHOLDER + } + }; + + let action = selectSearchItem(itemWithoutGeom, { + size: { + width: 200, + height: 200 + }, + services: [{ + type: 'wfs', + options: { + url: 'base/web/client/test-resources/wfs/Wyoming.json', + typeName: 'topp:states', + queriableAttributes: [STATE_NAME] + } + }], + projection: "EPSG:4326" + }); + + store.dispatch( action ); + setTimeout(() => { + let actions = store.getActions(); + expect(actions.length).toBe(6); + expect(actions[1].type).toBe(CHANGE_MAP_VIEW); + expect(actions[2].type).toBe(TEXT_SEARCH_ADD_MARKER); + expect(actions[3].type).toBe(TEXT_SEARCH_RESULTS_PURGE); + expect(actions[4].type).toBe(TEXT_SEARCH_NESTED_SERVICES_SELECTED); + expect(actions[5].type).toBe(TEXT_SEARCH_TEXT_CHANGE); + + expect(actions[4].services[0]).toEqual({ + ...nestedService, + options: { + item + } + }); + expect(actions[4].services[0].geometry).toExist(); + expect(actions[4].items).toEqual({ + placeholder: SEARCH_NESTED, + placeholderMsgId: TEST_NESTED_PLACEHOLDER, + text: TEXT + }); + expect(actions[5].searchText).toBe(TEXT); + expect(actions[5].type).toBe(TEXT_SEARCH_TEXT_CHANGE); + + }, 400); }); }); diff --git a/web/client/epics/search.js b/web/client/epics/search.js index a439b92731..b0e01acbf9 100644 --- a/web/client/epics/search.js +++ b/web/client/epics/search.js @@ -6,8 +6,6 @@ * LICENSE file in the root directory of this source tree. */ -// var GeoCodingApi = require('../api/Nominatim'); - const {TEXT_SEARCH_STARTED, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_RESET, @@ -20,14 +18,15 @@ const {TEXT_SEARCH_STARTED, searchTextChanged, resultsPurge } = require('../actions/search'); + const mapUtils = require('../utils/MapUtils'); const CoordinatesUtils = require('../utils/CoordinatesUtils'); const Rx = require('rxjs'); -const services = require('../api/searchText'); +const {API} = require('../api/searchText'); const {changeMapView} = require('../actions/map'); -const pointOnSurface = require('turf-point-on-surface'); const toBbox = require('turf-bbox'); const {generateTemplateString} = require('../utils/TemplateUtils'); +const assign = require('object-assign'); const {get} = require('lodash'); @@ -44,21 +43,26 @@ const searchEpic = action$ => .debounceTime(250) .switchMap( action => // create a stream of streams from array - Rx.Observable.from((action.services || [ {type: "nominatim"} ]) - // Create an stream for each Promise - .map( (service) => Rx.Observable.defer(() => services[service.type](action.searchText, service.options) - .then( (response= []) => response.map(result => ({...result, __SERVICE__: service, __PRIORITY__: service.priority || 0})) )) - .retryWhen(errors => errors.delay(200).scan((count, err) => { - if ( count >= 2) { - throw err; - } - return count + 1; - }, 0)) - )) + Rx.Observable.from( + (action.services || [ {type: "nominatim"} ]) + // Create an stream for each Service + .map( + (service) => Rx.Observable.defer(() => API.Utils.getService(service.type)(action.searchText, service.options) + .then( + (response= []) => response.map(result => ({...result, __SERVICE__: service, __PRIORITY__: service.priority || 0})) + )) + .retryWhen(errors => errors.delay(200).scan((count, err) => { + if ( count >= 2) { + throw err; + } + return count + 1; + }, 0)) + ) // map + ) // from // merge all results from the streams .mergeAll() .scan( (oldRes, newRes) => [...oldRes, ...newRes].sort( (a, b) => get(b, "__PRIORITY__") - get(a, "__PRIORITY__") ) .slice(0, 15)) - .map((results) => searchResultLoaded(results, false, services)) + .map((results) => searchResultLoaded(results, false, API.Services)) .takeUntil(action$.ofType([ TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_RESET, TEXT_SEARCH_ITEM_SELECTED])) .startWith(searchTextLoading(true)) .concat([searchTextLoading(false)]) @@ -78,42 +82,50 @@ const searchEpic = action$ => * @memberof epics.search * @return {Observable} */ + const searchItemSelected = action$ => action$.ofType(TEXT_SEARCH_ITEM_SELECTED) .switchMap(action => { - const item = action.item; + let itemSelectionStream = Rx.Observable.of(action.item) + // retrieve geometry from geomService or pass the item directly + .concatMap((item) => { + if (item && item.__SERVICE__ && item.__SERVICE__.geomService) { + let staticFilter = generateTemplateString(item.__SERVICE__.geomService.options.staticFilter || "")(item); + return Rx.Observable.fromPromise( + API.Utils.getService(item.__SERVICE__.geomService.type)("", assign( {}, item.__SERVICE__.geomService.options, { staticFilter } )) + .then(res => assign({}, item, {geometry: res[0].geometry} ) ) + ); + } + return Rx.Observable.of(action.item); + }).mergeMap((item) => { + let bbox = item.bbox || item.properties.bbox || toBbox(item); + let mapSize = action.mapConfig.size; + // zoom by the max. extent defined in the map's config + let newZoom = mapUtils.getZoomForExtent(CoordinatesUtils.reprojectBbox(bbox, "EPSG:4326", action.mapConfig.projection), mapSize, 0, 21, null); - let mapSize = action.mapConfig.size; - // zoom by the max. extent defined in the map's config - let bbox = item.bbox || item.properties.bbox || toBbox(item); - var newZoom = mapUtils.getZoomForExtent(CoordinatesUtils.reprojectBbox(bbox, "EPSG:4326", action.mapConfig.projection), mapSize, 0, 21, null); + // center by the max. extent defined in the map's config + let newCenter = mapUtils.getCenterForExtent(bbox, "EPSG:4326"); + let actions = [ + changeMapView(newCenter, newZoom, { + bounds: { + minx: bbox[0], + miny: bbox[1], + maxx: bbox[2], + maxy: bbox[3] + }, + crs: "EPSG:4326", + rotation: 0 + }, action.mapConfig.size, null, action.mapConfig.projection), + addMarker(item) + ]; + return actions; + }); - // center by the max. extent defined in the map's config - let newCenter = mapUtils.getCenterForExtent(bbox, "EPSG:4326"); - // let markerCoordinates = {lat: newCenter.y, lng: newCenter.x}; - const point = pointOnSurface(item); - if (point && point.geometry && point.geometry.coordinates) { - // markerCoordinates = {lat: point.geometry.coordinates[1], lng: point.geometry.coordinates[0]}; - } - let actions = [ - changeMapView(newCenter, newZoom, { - bounds: { - minx: bbox[0], - miny: bbox[1], - maxx: bbox[2], - maxy: bbox[3] - }, - crs: "EPSG:4326", - rotation: 0 - }, action.mapConfig.size, null, action.mapConfig.projection), - addMarker(item), - resultsPurge()]; + const item = action.item; let nestedServices = item && item.__SERVICE__ && item.__SERVICE__.then; - // if a nested service is present, select the item and the nested service - if (nestedServices) { - actions.push(selectNestedService( + let nestedServicesStream = nestedServices ? Rx.Observable.of(selectNestedService( nestedServices.map((nestedService) => ({ ...nestedService, options: { @@ -122,19 +134,19 @@ const searchItemSelected = action$ => } })), { text: generateTemplateString(item.__SERVICE__.displayName || "")(item), - placeholder: item.__SERVICE__.nestedPlaceholder && generateTemplateString(item.__SERVICE__.nestedPlaceholder || "")(item) + placeholder: item.__SERVICE__.nestedPlaceholder && generateTemplateString(item.__SERVICE__.nestedPlaceholder || "")(item), + placeholderMsgId: item.__SERVICE__.nestedPlaceholderMsgId && generateTemplateString(item.__SERVICE__.nestedPlaceholderMsgId || "")(item) }, - generateTemplateString(item.__SERVICE__.searchTextTemplate || "")(item) - )); - } + generateTemplateString(item.__SERVICE__.searchTextTemplate || "")(item) + )) : Rx.Observable.empty(); // if the service has a searchTextTemplate, use it to modify the search text to display let searchTextTemplate = item.__SERVICE__ && item.__SERVICE__.searchTextTemplate; - if ( searchTextTemplate ) { - actions.push(searchTextChanged(generateTemplateString(searchTextTemplate)(item))); - } - return Rx.Observable.from(actions); + let searchTextStream = searchTextTemplate ? Rx.Observable.of(searchTextChanged(generateTemplateString(searchTextTemplate)(item))) : Rx.Observable.empty(); + + return Rx.Observable.merge(itemSelectionStream, Rx.Observable.of(resultsPurge()), nestedServicesStream, searchTextStream); }); + /** * Actions for search * @name epics.search diff --git a/web/client/plugins/Map.jsx b/web/client/plugins/Map.jsx index f352c6e2ae..1ad394b0d0 100644 --- a/web/client/plugins/Map.jsx +++ b/web/client/plugins/Map.jsx @@ -97,7 +97,7 @@ let plugins; * }], * "toolsOptions": { * "test": { - * "label": "ciao" + * "label": "Hello" * } * ... * } diff --git a/web/client/plugins/Search.jsx b/web/client/plugins/Search.jsx index eb18c20af7..6ac6f90be0 100644 --- a/web/client/plugins/Search.jsx +++ b/web/client/plugins/Search.jsx @@ -1,5 +1,5 @@ /* - * Copyright 2016, GeoSolutions Sas. + * Copyright 2017, GeoSolutions Sas. * All rights reserved. * * This source code is licensed under the BSD-style license found in the @@ -25,8 +25,7 @@ const searchSelector = createSelector([ error: searchState && searchState.error, loading: searchState && searchState.loading, searchText: searchState ? searchState.searchText : "", - selectedItems: searchState && searchState.selectedItems, - selectedServices: searchState && searchState.selectedServices + selectedItems: searchState && searchState.selectedItems })); const SearchBar = connect(searchSelector, { @@ -102,8 +101,11 @@ const ToggleButton = require('./searchbar/ToggleButton'); * "blackist": [... an array of strings to exclude from the final search filter ] * }, * "nestedPlaceholder": "Write other text to refine the search...", - * "then": [ ... an array of services to use when one item of this service is selected] + * "nestedPlaceholderMsgId": "id contained in the localization files i.e. search.nestedplaceholder", + * "then": [ ... an array of services to use when one item of this service is selected], + * "geomService": { optional service to retrieve the geometry} * } + * * ``` * The typical nested service needs to have some additional parameters: * ``` @@ -151,6 +153,7 @@ const SearchPlugin = connect((state) => ({ {...this.props} searchOptions={this.getCurrentServices()} placeholder={this.getServiceOverrides("placeholder")} + placeholderMsgId={this.getServiceOverrides("placeholderMsgId")} />); if (this.props.withToggle === true) { return [].concat(this.props.enabled ? [search] : null);