From 8e12eb438ebc40221bdf667c920e44b0460d253a 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 | 66 ++++ src/Field/WfsSearch/WfsSearch.jsx | 376 +++++++++++++++++++++++ src/Field/WfsSearch/WfsSearch.spec.jsx | 169 ++++++++++ src/index.js | 2 + 4 files changed, 613 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..a0fed5bb58 --- /dev/null +++ b/src/Field/WfsSearch/WfsSearch.example.md @@ -0,0 +1,66 @@ +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..4d6f0dd107 --- /dev/null +++ b/src/Field/WfsSearch/WfsSearch.jsx @@ -0,0 +1,376 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Select, + Spin +} from 'antd'; +const Option = Select.Option; + +import Logger from '../../Util/Logger'; + +import OlMap from 'ol/map'; +import OlFormatFilter from 'ol/format/filter'; +import OlFormatGeoJSON from 'ol/format/geojson'; +import OlFormatWFS from 'ol/format/wfs'; + +/** + * The WfsSearch field. + * Implements an input field to do a WFS-GetFeature request. + * + * The GetFeature request is created with `ol.format.WFS.writeGetFeature` + * so most of the WFS specific options work like document in the corresponding + * API-docs: http://openlayers.org/en/latest/apidoc/ol.format.WFS.html#writeGetFeature + * + * @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 = { + /** + * A optional className that will be added to the input field. + */ + className: PropTypes.string, + /** + * The base URL. + * @type {String} + */ + baseUrl: PropTypes.string.isRequired, + /** + * The Property which should be shown in the inputfield when a selection was + * made. + */ + displayField: PropTypes.string, + /** + * The list of attributes that should be searched through. + */ + searchAttributes: PropTypes.arrayOf(PropTypes.string).isRequired, + /** + * The namespace URI used for features. + */ + featureNS: PropTypes.string, + /** + * The prefix for the feature namespace. + */ + featurePrefix: PropTypes.string, + /** + * 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), + /** + * 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` with + * `key={feature.id}`. + * The default will display the property `name` if existing or the `id` to. + * + * @type {function} + */ + renderOption: PropTypes.func, + /** + * An onSelect function which gets called with the selected feature as it is + * returned by server. + * The default function will create a searchlayer, adds the feature and will + * zoom to its extend. + * @type {function} + */ + onSelect: PropTypes.func, + /** + * Options which are added to the fetch-POST-request. credentials is set to + * 'same-origin' as default but can be overwritten. + */ + additionalFetchOptions: PropTypes.object + } + + static defaultProps = { + srsName: 'EPSG:3857', + outputFormat: 'application/json', + minChars: 3, + additionalFetchOptions: {}, + /** + * Create an AutoComplete.Option from the given data. + * + * @param {Object} feature The feature as returned by the server. + * @return {AutoComplete.Option} The AutoComplete.Option that will be + * rendered for each feature. + */ + renderOption: (feature) => { + return ( + + ); + }, + /** + * The default onSelect method if no onSelect prop is given. It zooms to the + * selected item. + * + * @param {object} feature The selected feature as returned by the server. + */ + onSelect: (feature, olMap) => { + if (feature) { + const olView = olMap.getView(); + const geoJsonFormat = new OlFormatGeoJSON(); + const olFeature = geoJsonFormat.readFeature(feature); + const geometry = olFeature.getGeometry(); + + if (geometry) { + olView.fit(geometry, { + duration: 500 + }); + } + } + }, + style: { + width: 200 + } + } + + /** + * Create the WfsSearch. + * + * @param {Object} props The initial props. + * @constructs WfsSearch + */ + constructor(props) { + super(props); + this.state = { + searchTerm: '', + data: [] + }; + 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} value The inputValue. Undefined if clear btn + * is pressed. + */ + onUpdateInput(value) { + this.setState({ + data: [] + }); + + if (value) { + this.setState({ + searchTerm: value || '' + }, () => { + if (this.state.searchTerm.length >= this.props.minChars) { + this.doSearch(); + } + }); + } + } + + /** + * Perform the search. + * @private + */ + doSearch() { + const { + featureNS, + featurePrefix, + featureTypes, + geometryName, + maxFeatures, + outputFormat, + propertyNames, + srsName, + baseUrl, + additionalFetchOptions + } = this.props; + + const options = { + featureNS, + featurePrefix, + featureTypes, + geometryName, + maxFeatures, + outputFormat, + propertyNames, + srsName, + filter: this.createFilter() + }; + + const wfsFormat = new OlFormatWFS(); + const featureRequest = wfsFormat.writeGetFeature(options); + this.setState({fetching: true}); + + fetch(`${baseUrl}`, { + method: 'POST', + credentials: additionalFetchOptions.credentials + ? additionalFetchOptions.credentials + : 'same-origin', + body: new XMLSerializer().serializeToString(featureRequest), + ...additionalFetchOptions + }) + .then(response => response.json()) + .then(this.onFetchSuccess.bind(this)) + .catch(this.onFetchError.bind(this)); + } + + /** + * Creates a filter fro the given searchAttributes prop and the current + * searchTerm. + * @private + */ + createFilter() { + const { + searchTerm + } = this.state; + const { + searchAttributes + } = this.props; + + const propertyFilters = searchAttributes.map(attribute => + OlFormatFilter.like(attribute, `*${searchTerm}*`, '*', '.', '!', false)); + if (searchAttributes.length > 1) { + return OlFormatFilter.or(...propertyFilters); + } else { + return propertyFilters[0]; + } + } + + /** + * This function gets called on success of the WFS GetFeature fetch request. + * It sets the response as data. + * + * @param {Array} response The found features. + */ + onFetchSuccess(response) { + const data = response.features ? response.features : []; + this.setState({ + data, + fetching: false + }); + } + + /** + * 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 WFS GetFeature: ${error}`); + this.setState({ + fetching: false + }); + } + + /** + * The function describes what to do when an item is selected. + * + * @param {String|number} value The value of the selected option. + */ + onMenuItemSelected(value) { + const { + map + } = this.props; + const selectedFeature = this.state.data.filter(i => i.id === value)[0]; + this.props.onSelect(selectedFeature, map); + } + + /** + * The render function. + */ + render() { + const { + data, + fetching + } = this.state; + + const { + additionalFetchOptions, + baseUrl, + className, + displayField, + featureNS, + featurePrefix, + featureTypes, + filter, + geometryName, + map, + maxFeatures, + minChars, + outputFormat, + onSelect, + propertyNames, + renderOption, + searchAttributes, + srsName, + ...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..7d50ca2fe3 --- /dev/null +++ b/src/Field/WfsSearch/WfsSearch.spec.jsx @@ -0,0 +1,169 @@ +/*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.data', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + wrapper.instance().onUpdateInput(); + expect(wrapper.state().data).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', () => { + // expect.assertions(1); + const wrapper = TestUtil.mountComponent(WfsSearch, { + placeholder: 'Type a countryname in its own languageā€¦', + baseUrl: 'http://ows.terrestris.de/geoserver/osm/wfs', + featureTypes: ['osm:osm-country-borders'], + searchAttributes: ['name'] + }); + wrapper.instance().doSearch = jest.fn(); + const inputValue = 'Deutsch'; + wrapper.instance().onUpdateInput(inputValue); + expect(wrapper.instance().doSearch).toHaveBeenCalled(); + wrapper.instance().doSearch.mockReset(); + }); + }); + + describe('#onFetchSuccess', () => { + it('sets the response features as state.data', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const features = [{ + id: '752526', + properties: { + name: 'Deutschland' + } + }]; + wrapper.instance().onFetchSuccess({features}); + expect(wrapper.state().data).toEqual(features); + }); + }); + + describe('#onFetchError', () => { + it('sets the response as state.data', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const loggerSpy = jest.spyOn(Logger, 'error'); + wrapper.instance().onFetchError('Peter'); + expect(loggerSpy).toHaveBeenCalled(); + expect(loggerSpy).toHaveBeenCalledWith('Error while requesting WFS GetFeature: Peter'); + loggerSpy.mockReset(); + loggerSpy.mockRestore(); + }); + }); + + describe('#onMenuItemSelected', () => { + it('calls this.props.onSelect with the selected item', () => { + //SETUP + const data = [{ + id: '752526', + properties: { + name: '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({ + data: data + }); + wrapper.instance().onMenuItemSelected('752526'); + expect(selectSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith(data[0], map); + + selectSpy.mockReset(); + selectSpy.mockRestore(); + }); + }); + + describe('default #onSelect', () => { + it('zooms to the selected feature', () => { + jest.useFakeTimers(); + //SETUP + const feature = { + type: 'Feature', + id: '752526', + properties: { + name: 'Peter', + }, + geometry: { + type: 'Polygon', + coordinates: [[[10, 40],[40, 40],[40, 10],[10, 10],[10, 40]]] + } + }; + 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(feature, map); + expect(fitSpy).toHaveBeenCalled(); + setTimeout(() => { + expect(map.getView().getCenter()).toEqual([25, 25]); + expect(map.getView().getZoom()).toEqual(2); + }, 501); + jest.runAllTimers(); + fitSpy.mockReset(); + fitSpy.mockRestore(); + }); + }); + + describe('#renderOption', () => { + it('returns a Select.Option', () => { + const wrapper = TestUtil.mountComponent(WfsSearch); + const feature = { + id: '752526', + properties: { + name: 'Deutschland' + } + }; + const option = wrapper.props().renderOption(feature); + expect(option.key).toBe(feature.id); + expect(option.props.children).toBe(feature.properties.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,