From 0fa5b043b22791def9868d06517eda600df403cb Mon Sep 17 00:00:00 2001 From: Kai Volland Date: Mon, 9 Apr 2018 09:12:53 +0200 Subject: [PATCH] Introduces WfsSearch --- src/Field/WfsSearch/WfsSearch.example.md | 64 +++++ src/Field/WfsSearch/WfsSearch.jsx | 290 +++++++++++++++++++++++ src/Field/WfsSearch/WfsSearch.spec.jsx | 179 ++++++++++++++ src/index.js | 2 + 4 files changed, 535 insertions(+) create mode 100644 src/Field/WfsSearch/WfsSearch.example.md create mode 100644 src/Field/WfsSearch/WfsSearch.jsx create mode 100644 src/Field/WfsSearch/WfsSearch.spec.jsx diff --git a/src/Field/WfsSearch/WfsSearch.example.md b/src/Field/WfsSearch/WfsSearch.example.md new file mode 100644 index 0000000000..6a62c0b4fe --- /dev/null +++ b/src/Field/WfsSearch/WfsSearch.example.md @@ -0,0 +1,64 @@ +This demonstrates the usage of the WfsSearch. + +```jsx +const React = require('react'); +const OlMap = require('ol/map').default; +const OlView = require('ol/view').default; +const OlLayerTile = require('ol/layer/tile').default; +const OlSourceOsm = require('ol/source/osm').default; +const OlProj = require('ol/proj').default; + +class WfsSearchExample extends React.Component { + + constructor(props) { + + super(props); + + this.mapDivId = `map-${Math.random()}`; + + this.map = new OlMap({ + layers: [ + new OlLayerTile({ + name: 'OSM', + source: new OlSourceOsm() + }) + ], + view: new OlView({ + center: OlProj.fromLonLat([37.40570, 8.81566]), + zoom: 4 + }) + }); + } + + componentDidMount() { + this.map.setTarget(this.mapDivId); + } + + render() { + return( +
+
+ +
+
+
+ ) + } +} + + +``` diff --git a/src/Field/WfsSearch/WfsSearch.jsx b/src/Field/WfsSearch/WfsSearch.jsx new file mode 100644 index 0000000000..f279f4342a --- /dev/null +++ b/src/Field/WfsSearch/WfsSearch.jsx @@ -0,0 +1,290 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + AutoComplete, +} from 'antd'; +const Option = AutoComplete.Option; + +import Logger from '../../Util/Logger'; +import UrlUtil from '../../Util/UrlUtil/UrlUtil'; +import olProj from 'ol/proj'; +import OlMap from 'ol/map'; + +/** + * The WfsSearch. + * + * @class WfsSearch + * @extends React.Component + */ +export class WfsSearch extends React.Component { + + /** + * The className added to this component. + * @type {String} + * @private + */ + className = 'react-geo-wfssearch' + + static propTypes = { + className: PropTypes.string, + /** + * The base URL. + * @type {String} + */ + baseUrl: PropTypes.string.isRequired, + /** + * The list of attributes that should be searched through. + */ + searchAttributes: PropTypes.arrayOf(PropTypes.string).isRequired, + /** + * The namespace URI used for features. Required. + */ + featureNS: PropTypes.string.isRequired, + /** + * The prefix for the feature namespace. Required. + */ + featurePrefix: PropTypes.string.isRequired, + /** + * The feature type names. Required. + */ + featureTypes: PropTypes.arrayOf(PropTypes.string).isRequired, + /** + * SRS name. No srsName attribute will be set on geometries when this is not + * provided. + */ + srsName: PropTypes.string, + /** + * Ther output format of the response. + */ + outputFormat: PropTypes.string, + /** + * Maximum number of features to fetch. + */ + maxFeatures: PropTypes.number, + /** + * Geometry name to use in a BBOX filter. + */ + geometryName: PropTypes.string, + /** + * Optional list of property names to serialize. + */ + propertyNames: PropTypes.arrayOf(PropTypes.string), + /** + * Wheather to use paging or not. + */ + paging: PropTypes.bool, + /** + * Start index to use for WFS paging. This is a WFS 2.0 feature backported + * to WFS 1.1.0 by some Web Feature Services. + */ + pagingStartIndex: PropTypes.number, + /** + * Number of features to retrieve when paging. This is a WFS 2.0 feature + * backported to WFS 1.1.0 by some Web Feature Services. Please note that + * some Web Feature Services have repurposed maxfeatures instead. + */ + pagingCount: PropTypes.number, + /** + * Filter condition. See http://openlayers.org/en/latest/apidoc/ol.format.filter.html + * for more information. + */ + filter: PropTypes.object, + /** + * The ol.map where the map will zoom to. + * + * @type {Object} + */ + map: PropTypes.instanceOf(OlMap).isRequired, + /** + * The minimal amount of characters entered in the input to start a search. + * @type {Number} + */ + minChars: PropTypes.number, + /** + * A render function which gets called with the selected item as it is + * returned by the server. It must return an `AutoComplete.Option`. + * + * @type {function} + */ + renderOption: PropTypes.func, + /** + * An onSelect function which gets called with the selected item as it is + * returned by nominatim. + * @type {function} + */ + onSelect: PropTypes.func + } + + static defaultProps = { + paging: true, + pagingStartIndex: 0, + pagingCount: 20, + srsName: 'EPSG:3857', + outputFormat: 'application/json', + minChars: 3, + /** + * Create an AutoComplete.Option from the given data. + * + * @param {Object} item The tuple as an object. + * @return {AutoComplete.Option} The returned option + */ + renderOption: (item) => { + return ( + + ); + }, + /** + * The default onSelect method if no onSelect prop is given. It zooms to the + * selected item. + * + * @param {object} selected The selected item as it is returned by nominatim. + */ + onSelect: (selected, olMap) => { + if (selected && selected.boundingbox) { + const olView = olMap.getView(); + debugger + + extent = extent.map(function(coord) { + return parseFloat(coord); + }); + + extent = olProj.transformExtent(extent, 'EPSG:4326', + olView.getProjection().getCode()); + + olView.fit(extent, { + duration: 500 + }); + } + }, + style: { + width: 200 + } + } + + /** + * Create the WfsSearch. + * + * @param {Object} props The initial props. + * @constructs WfsSearch + */ + constructor(props) { + super(props); + this.state = { + searchTerm: '', + dataSource: [] + }; + this.onUpdateInput = this.onUpdateInput.bind(this); + this.onMenuItemSelected = this.onMenuItemSelected.bind(this); + } + + /** + * Called if the input of the AutoComplete is being updated. It sets the + * current inputValue as searchTerm and starts a search if the inputValue has + * a length of at least `this.props.minChars` (default 3). + * + * @param {String|undefined} inputValue The inputValue. Undefined if clear btn + * is pressed. + */ + onUpdateInput(inputValue) { + this.setState({ + dataSource: [] + }); + + this.setState({ + searchTerm: inputValue || '' + }, () => { + if (this.state.searchTerm.length >= this.props.minChars) { + this.doSearch(); + } + }); + } + + /** + * Perform the search. + */ + doSearch() { + this.state.searchTerm; + + const featureRequest = new ol.format.WFS().writeGetFeature(this.props.options); + const getRequestParams = UrlUtil.objectToRequestString(baseParams); + + fetch(`${this.props.baseUrl}${getRequestParams}`) + .then(response => response.json()) + .then(this.onFetchSuccess.bind(this)) + .catch(this.onFetchError.bind(this)); + } + + /** + * This function gets called on success of the nominatim fetch. + * It sets the response as dataSource. + * + * @param {Array} response The found features. + */ + onFetchSuccess(response) { + this.setState({ + dataSource: response + }); + } + + /** + * This function gets called when the nomintim fetch returns an error. + * It logs the error to the console. + * + * @param {String} error The errorstring. + */ + onFetchError(error) { + Logger.error(`Error while requesting Nominatim: ${error}`); + } + + /** + * The function describes what to do when an item is selected. + * + * @param {value} key The key of the selected option. + */ + onMenuItemSelected(key) { + const selected = this.state.dataSource.filter(i => i.place_id === key)[0]; + this.props.onSelect(selected, this.props.map); + } + + /** + * The render function. + */ + render() { + const { + className, + nominatimBaseUrl, + format, + viewbox, + bounded, + polygon_geojson, + addressdetails, + limit, + countrycodes, + map, + onSelect, + renderOption, + ...passThroughProps + } = this.props; + + const finalClassName = className + ? `${className} ${this.className}` + : this.className; + + return ( + + ); + } +} + +export default WfsSearch; diff --git a/src/Field/WfsSearch/WfsSearch.spec.jsx b/src/Field/WfsSearch/WfsSearch.spec.jsx new file mode 100644 index 0000000000..627c4d770a --- /dev/null +++ b/src/Field/WfsSearch/WfsSearch.spec.jsx @@ -0,0 +1,179 @@ +/*eslint-env jest*/ + +import OlMap from 'ol/map'; +import OlView from 'ol/view'; +import OlLayerTile from 'ol/layer/tile'; +import OlSourceOsm from 'ol/source/osm'; + +import TestUtil from '../../Util/TestUtil'; +import Logger from '../../Util/Logger'; + +import {WfsSearch} from '../../index'; + +describe('', () => { + it('is defined', () => { + expect(WfsSearch).not.toBeUndefined(); + }); + + it('can be rendered', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + expect(wrapper).not.toBeUndefined(); + }); + + describe('#onUpdateInput', () => { + it('resets state.dataSource', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + wrapper.instance().onUpdateInput(); + expect(wrapper.state().dataSource).toEqual([]); + }); + + it('sets the inputValue as state.searchTerm', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const inputValue = 'a'; + wrapper.instance().onUpdateInput(inputValue); + expect(wrapper.state().searchTerm).toBe(inputValue); + }); + + it('sends a request if input is as long as props.minChars', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const fetchSpy = jest.spyOn(window, 'fetch'); + const inputValue = 'Bonn'; + wrapper.instance().onUpdateInput(inputValue); + expect(fetchSpy).toHaveBeenCalled(); + fetchSpy.mockReset(); + fetchSpy.mockRestore(); + }); + }); + + describe('#doSearch', () => { + it('sends a request with appropriate parts', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const fetchSpy = jest.spyOn(window, 'fetch'); + const inputValue = 'Bonn'; + wrapper.setState({searchTerm: inputValue}); + wrapper.instance().doSearch(); + expect(fetchSpy).toHaveBeenCalled(); + + const fetchUrl = fetchSpy.mock.calls[0][0]; + + const expectations = [ + wrapper.props().nominatimBaseUrl, + encodeURIComponent(wrapper.props().format), + encodeURIComponent(wrapper.props().viewbox), + encodeURIComponent(wrapper.props().bounded), + encodeURIComponent(wrapper.props().polygon_geojson), + encodeURIComponent(wrapper.props().addressdetails), + encodeURIComponent(wrapper.props().limit), + encodeURIComponent(wrapper.props().countrycodes), + encodeURIComponent(inputValue) + ]; + expectations.forEach(expectation => { + expect(fetchUrl).toMatch(expectation); + }); + fetchSpy.mockReset(); + fetchSpy.mockRestore(); + }); + }); + + describe('#onFetchSuccess', () => { + it('sets the response as state.dataSource', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + wrapper.instance().onFetchSuccess(['Peter']); + expect(wrapper.state().dataSource).toEqual(['Peter']); + }); + }); + + describe('#onFetchError', () => { + it('sets the response as state.dataSource', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const loggerSpy = jest.spyOn(Logger, 'error'); + wrapper.instance().onFetchError('Peter'); + expect(loggerSpy).toHaveBeenCalled(); + expect(loggerSpy).toHaveBeenCalledWith('Error while requesting Nominatim: Peter'); + loggerSpy.mockReset(); + loggerSpy.mockRestore(); + }); + }); + + describe('#onMenuItemSelected', () => { + it('calls this.props.onSelect with the selected item', () => { + //SETUP + const dataSource = [{ + place_id: '752526', + display_name: 'Böen, Löningen, Landkreis Cloppenburg, Niedersachsen, Deutschland' + }]; + const map = new OlMap({ + layers: [new OlLayerTile({name: 'OSM', source: new OlSourceOsm()})], + view: new OlView({ + projection: 'EPSG:4326', + center: [37.40570, 8.81566], + zoom: 4 + }) + }); + //SETUP END + + const selectSpy = jest.fn(); + const wrapper = TestUtil.mountComponent(WfsSearch, { + onSelect: selectSpy, + map + }); + wrapper.setState({ + dataSource: dataSource + }); + wrapper.instance().onMenuItemSelected('752526'); + expect(selectSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith(dataSource[0], map); + + selectSpy.mockReset(); + selectSpy.mockRestore(); + }); + }); + + describe('#onSelect', () => { + it('zooms to the boundingbox of the selected entry', () => { + //SETUP + const bbox = ['52.7076346', '52.7476346', '7.7702617', '7.8102617']; + const transformedExtent = [ + parseFloat(bbox[2]), + parseFloat(bbox[0]), + parseFloat(bbox[3]), + parseFloat(bbox[1]) + ]; + const item = { + place_id: '752526', + boundingbox: bbox + }; + const map = new OlMap({ + layers: [new OlLayerTile({name: 'OSM', source: new OlSourceOsm()})], + view: new OlView({ + projection: 'EPSG:4326', + center: [37.40570, 8.81566], + zoom: 4 + }) + }); + //SETUP END + + const wrapper = TestUtil.mountComponent(WfsSearch, {map}); + const fitSpy = jest.spyOn(map.getView(), 'fit'); + wrapper.props().onSelect(item, map); + expect(fitSpy).toHaveBeenCalled(); + expect(fitSpy).toHaveBeenCalledWith(transformedExtent, expect.any(Object) ); + fitSpy.mockReset(); + fitSpy.mockRestore(); + }); + }); + + describe('#renderOption', () => { + it('returns an AutoComplete.Option', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const item = { + place_id: '752526', + display_name: 'Böen, Löningen, Landkreis Cloppenburg, Niedersachsen, Deutschland' + }; + const option = wrapper.props().renderOption(item); + expect(option.key).toBe(item.place_id); + expect(option.props.children).toBe(item.display_name); + }); + }); + +}); diff --git a/src/index.js b/src/index.js index 9120effae6..b91ed0074e 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ import ZoomButton from './Button/ZoomButton/ZoomButton.jsx'; import ZoomToExtentButton from './Button/ZoomToExtentButton/ZoomToExtentButton.jsx'; import CoordinateReferenceSystemCombo from './Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.jsx'; import NominatimSearch from './Field/NominatimSearch/NominatimSearch.jsx'; +import WfsSearch from './Field/WfsSearch/WfsSearch.jsx'; import ScaleCombo from './Field/ScaleCombo/ScaleCombo.jsx'; import LayerTree from './LayerTree/LayerTree.jsx'; import LayerTreeNode from './LayerTreeNode/LayerTreeNode.jsx'; @@ -89,6 +90,7 @@ export { MeasureUtil, CoordinateReferenceSystemCombo, NominatimSearch, + WfsSearch, ObjectUtil, ProjectionUtil, StringUtil,