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/__tests__/searchText-test.js b/web/client/api/__tests__/searchText-test.js new file mode 100644 index 0000000000..62ab8701d5 --- /dev/null +++ b/web/client/api/__tests__/searchText-test.js @@ -0,0 +1,42 @@ +/** + * Copyright 2017, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +const expect = require('expect'); +const {API} = require('../searchText'); +const axios = require('axios'); + +describe('Test correctness of the searchText APIs', () => { + + const myFun = (param) => { + // do stuff + return param; + }; + function fun() { + return axios.get('base/web/client/test-resources/featureCollectionZone.js'); + } + + it('setter and getter services', (done) => { + let servName = "myService"; + API.Utils.setService(servName, myFun); + try { + expect(API.Services).toExist(); + expect(API.Services[servName]).toExist(); + expect(API.Utils.getService(servName)).toExist(); + done(); + } catch(ex) { + done(ex); + } + }); + + let serviceType = 'myCustomService'; + it('setService', (done) => { + API.Utils.setService(serviceType, fun); + expect(API.Utils.getService(serviceType)).toBe(fun); + done(); + }); +}); diff --git a/web/client/api/searchText.js b/web/client/api/searchText.js index 9f0034cd31..d643ae3c64 100644 --- a/web/client/api/searchText.js +++ b/web/client/api/searchText.js @@ -9,40 +9,47 @@ 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 = { +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 ); } }; + +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..254e0a82a5 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,17 +112,44 @@ describe('search Epics', () => { }); it('searchItemSelected epic with nested services', () => { - let nestedService = { - nestedPlaceholder: "TEST_NESTED_PLACEHOLDER" - }; - const TEXT = "Dinagat Islands"; - const item = { + let action = selectSearchItem(item, { + size: { + width: 200, + height: 200 + }, + projection: "EPSG:4326" + }); + + store.dispatch( action ); + + let actions = store.getActions(); + expect(actions.length).toBe(6); + let expectedActions = [CHANGE_MAP_VIEW, TEXT_SEARCH_ADD_MARKER, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_NESTED_SERVICES_SELECTED, TEXT_SEARCH_TEXT_CHANGE ]; + let actionsType = actions.map(a => a.type); + + expectedActions.forEach((a) => { + expect(actionsType.indexOf(a)).toNotBe(-1); + }); + + let testSearchNestedServicesSelectedAction = actions.filter(m => m.type === TEXT_SEARCH_NESTED_SERVICES_SELECTED)[0]; + expect(testSearchNestedServicesSelectedAction.services[0]).toEqual({ + ...nestedService, + options: { + item + } + }); + expect(testSearchNestedServicesSelectedAction.items).toEqual({ + placeholder: SEARCH_NESTED, + placeholderMsgId: TEST_NESTED_PLACEHOLDER, + text: TEXT + }); + expect(actions.filter(m => m.type === TEXT_SEARCH_TEXT_CHANGE)[0].searchText).toBe(TEXT); + }); + + it('testing the geometry service', (done) => { + // use the done function for asynchronus calls + const itemWithoutGeom = { "type": "Feature", - "bbox": [125, 10, 126, 11], - "geometry": { - "type": "Point", - "coordinates": [125.6, 10.1] - }, "properties": { "name": TEXT }, @@ -102,11 +160,19 @@ describe('search Epics', () => { options: { staticFilter: "${properties.name}" }, - nestedPlaceholder: "SEARCH NESTED", - then: [nestedService] + "geomService": { + type: 'wfs', + options: { + url: 'base/web/client/test-resources/wfs/Wyoming.json', + typeName: 'topp:states', + queriableAttributes: [STATE_NAME] + } + } } }; - let action = selectSearchItem(item, { + + // needed for the changeMapView action + let action = selectSearchItem(itemWithoutGeom, { size: { width: 200, height: 200 @@ -115,25 +181,17 @@ describe('search Epics', () => { }); store.dispatch( action ); + // a set timeout is needed in order to dispatch the actions + setTimeout(() => { + let actions = store.getActions(); + expect(actions.length).toBe(5); + let addMarkerAction = actions.filter(m => m.type === TEXT_SEARCH_ADD_MARKER)[0]; - 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[4].services[0]).toEqual({ - ...nestedService, - options: { - item - } - }); - expect(actions[4].items).toEqual({ - placeholder: "SEARCH NESTED", - text: TEXT - }); - expect(actions[5].type).toBe(TEXT_SEARCH_TEXT_CHANGE); - expect(actions[5].searchText).toBe("Dinagat Islands"); + expect(addMarkerAction).toExist(); + expect(addMarkerAction.markerPosition.geometry).toExist(); + done(); + // setting 0 as delay arises script error + }, 100); }); }); diff --git a/web/client/epics/search.js b/web/client/epics/search.js index a439b92731..a1e8a6380e 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'); @@ -43,27 +42,31 @@ const searchEpic = action$ => action$.ofType(TEXT_SEARCH_STARTED) .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})) )) + // create a stream of streams from array + 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)) - )) - // 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)) - .takeUntil(action$.ofType([ TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_RESET, TEXT_SEARCH_ITEM_SELECTED])) - .startWith(searchTextLoading(true)) - .concat([searchTextLoading(false)]) - .catch(e => Rx.Observable.from([searchResultError(e), searchTextLoading(false)])) - + ) // 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)) + .takeUntil(action$.ofType([ TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_RESET, TEXT_SEARCH_ITEM_SELECTED])) + .startWith(searchTextLoading(true)) + .concat([searchTextLoading(false)]) + .catch(e => Rx.Observable.from([searchResultError(e), searchTextLoading(false)])) ); /** @@ -78,42 +81,51 @@ const searchEpic = action$ => * @memberof epics.search * @return {Observable} */ + const searchItemSelected = action$ => action$.ofType(TEXT_SEARCH_ITEM_SELECTED) .switchMap(action => { - const item = action.item; + // itemSelectionStream --> emits actions for zoom and marker add + let itemSelectionStream = Rx.Observable.of(action.item) + .concatMap((item) => { + if (item && item.__SERVICE__ && item.__SERVICE__.geomService) { + let staticFilter = generateTemplateString(item.__SERVICE__.geomService.options.staticFilter || "")(item); + // retrieve geometry from geomService or pass the item directly + 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); + }).concatMap((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);