From 7425b0df0c071014420b1918f19b6437c16cba07 Mon Sep 17 00:00:00 2001 From: kappu <kappu72gmail.com> Date: Thu, 18 May 2017 12:01:08 +0200 Subject: [PATCH] Add ui to configure text search services --- .../actions/__tests__/searchconfig-test.js | 51 +++ web/client/actions/searchconfig.js | 53 +++ web/client/api/Nominatim.js | 3 +- .../searchservicesconfig/ResultsProps.jsx | 76 ++++ .../searchservicesconfig/SearchServices.css | 403 ++++++++++++++++++ .../searchservicesconfig/ServicesList.jsx | 81 ++++ .../searchservicesconfig/WFSOptionalProps.jsx | 67 +++ .../searchservicesconfig/WFSServiceProps.jsx | 97 +++++ .../__tests__/SearchServiceConfig-test.jsx | 48 +++ web/client/localConfig.json | 2 +- web/client/plugins/Save.jsx | 8 +- web/client/plugins/SaveAs.jsx | 8 +- web/client/plugins/Search.jsx | 19 +- web/client/plugins/SearchServicesConfig.jsx | 211 +++++++++ .../searchservicesconfig/ToggleButton.jsx | 43 ++ web/client/product/plugins.js | 3 +- .../reducers/__tests__/searchconfig-test.js | 78 ++++ web/client/reducers/searchconfig.js | 45 ++ web/client/translations/data.en-US | 26 +- web/client/translations/data.it-IT | 26 +- 20 files changed, 1334 insertions(+), 14 deletions(-) create mode 100644 web/client/actions/__tests__/searchconfig-test.js create mode 100644 web/client/actions/searchconfig.js create mode 100644 web/client/components/mapcontrols/searchservicesconfig/ResultsProps.jsx create mode 100644 web/client/components/mapcontrols/searchservicesconfig/SearchServices.css create mode 100644 web/client/components/mapcontrols/searchservicesconfig/ServicesList.jsx create mode 100644 web/client/components/mapcontrols/searchservicesconfig/WFSOptionalProps.jsx create mode 100644 web/client/components/mapcontrols/searchservicesconfig/WFSServiceProps.jsx create mode 100644 web/client/components/mapcontrols/searchservicesconfig/__tests__/SearchServiceConfig-test.jsx create mode 100644 web/client/plugins/SearchServicesConfig.jsx create mode 100644 web/client/plugins/searchservicesconfig/ToggleButton.jsx create mode 100644 web/client/reducers/__tests__/searchconfig-test.js create mode 100644 web/client/reducers/searchconfig.js diff --git a/web/client/actions/__tests__/searchconfig-test.js b/web/client/actions/__tests__/searchconfig-test.js new file mode 100644 index 0000000000..e375c09a0a --- /dev/null +++ b/web/client/actions/__tests__/searchconfig-test.js @@ -0,0 +1,51 @@ +/** +* 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. +*/ + +var expect = require('expect'); +var { + SET_SEARCH_CONFIG_PROP, + RESET_SEARCH_CONFIG, + UPDATE_SERVICE, + setSearchConfigProp, + restServiceConfig, + updateService +} = require('../searchconfig'); + +describe('Test correctness of the searchconfig actions', () => { + + + it('resetServiceConfig', () => { + const testPage = 1; + var retval = restServiceConfig(testPage); + + expect(retval).toExist(); + expect(retval.type).toBe(RESET_SEARCH_CONFIG); + expect(retval.page).toBe(testPage); + }); + + it('setSearchConfigProp', () => { + const testProperty = 'prop'; + const testValue = 'val'; + var retval = setSearchConfigProp(testProperty, testValue); + + expect(retval).toExist(); + expect(retval.type).toBe(SET_SEARCH_CONFIG_PROP); + expect(retval.property).toBe(testProperty); + expect(retval.value).toBe(testValue); + }); + + it('updateService', () => { + const testService = "service"; + const testIdx = 1; + var retval = updateService(testService, testIdx); + expect(retval).toExist(); + expect(retval.type).toBe(UPDATE_SERVICE); + expect(retval.service).toBe(testService); + expect(retval.idx).toBe(testIdx); + }); +}); diff --git a/web/client/actions/searchconfig.js b/web/client/actions/searchconfig.js new file mode 100644 index 0000000000..7c14dcc7a8 --- /dev/null +++ b/web/client/actions/searchconfig.js @@ -0,0 +1,53 @@ +/** + * 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 SET_SEARCH_CONFIG_PROP = 'SET_SEARCH_CONFIG_PROP'; +const RESET_SEARCH_CONFIG = 'RESET_SEARCH_CONFIG'; +const UPDATE_SERVICE = 'UPDATE_SERVICE'; + +/** +* Sets a property +* @memberof actions.search +* @param {string} property the property to set +* @param {string|number|boolean|object} value the value to set or to check for toggling +* @return {object} of type `SET_SEARCH_CONFIG_PROP` with property and value params +*/ +function setSearchConfigProp(property, value) { + return { + type: SET_SEARCH_CONFIG_PROP, + property, + value + }; +} + +function restServiceConfig(page = 0 ) { + return { + type: RESET_SEARCH_CONFIG, + page + }; +} +function updateService(service, idx = -1) { + return { + type: UPDATE_SERVICE, + service, + idx + }; +} + +/** +* Actions for search +* @name actions.searchconfig +*/ +module.exports = { + SET_SEARCH_CONFIG_PROP, + RESET_SEARCH_CONFIG, + UPDATE_SERVICE, + setSearchConfigProp, + restServiceConfig, + updateService +}; diff --git a/web/client/api/Nominatim.js b/web/client/api/Nominatim.js index 474e11994b..11f2639bee 100644 --- a/web/client/api/Nominatim.js +++ b/web/client/api/Nominatim.js @@ -13,7 +13,8 @@ const DEFAULT_REVERSE_URL = 'nominatim.openstreetmap.org/reverse'; const defaultOptions = { format: 'json', bounded: 0, - polygon_geojson: 1 + polygon_geojson: 1, + priority: 5 }; /** * API for local config diff --git a/web/client/components/mapcontrols/searchservicesconfig/ResultsProps.jsx b/web/client/components/mapcontrols/searchservicesconfig/ResultsProps.jsx new file mode 100644 index 0000000000..5a7dfc6359 --- /dev/null +++ b/web/client/components/mapcontrols/searchservicesconfig/ResultsProps.jsx @@ -0,0 +1,76 @@ +/** + * 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 React = require('react'); +const {FormGroup, ControlLabel, FormControl, Label} = require('react-bootstrap'); +const Message = require('../../I18N/Message'); +const Slider = require('react-nouislider'); +const assign = require('object-assign'); +function validate(service = {}) { + return service.displayName && service.displayName.length > 0; +} +const ResultsProps = React.createClass({ + propTypes: { + service: React.PropTypes.object, + onPropertyChange: React.PropTypes.func + }, + getDefaultProps() { + return { + service: {}, + onPropertyChange: () => {} + }; + }, + render() { + const {service} = this.props; + return ( + <form> + <span className="wfs-required-props-title"><Message msgId="search.s_result_props_label" /></span> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_title" /> + </ControlLabel> + <FormControl + value={service.displayName} + key="displayName" + type="text" + placeholder={'e.g. "${properties.name}\"'} + onChange={this.updateProp.bind(null, "displayName")}/> + </FormGroup> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_description" /> + </ControlLabel> + <FormControl + value={service.subTitle} + key="subTitle" + type="text" + onChange={this.updateProp.bind(null, "subTitle")}/> + </FormGroup> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_priority" /> + <Label key="priority-labeel" className="slider-label">{parseInt(service.priority || 1, 10)}</Label> + </ControlLabel> + <Slider key="priority" start={[service.priority || 1]} + range={{min: 1, max: 10}} + onSlide={this.updatePriority} + /> + <span className="priority-info"><Message msgId="search.s_priority_info" /></span> + </FormGroup> + </form>); + }, + updateProp(prop, event) { + const value = event.target.value; + this.props.onPropertyChange("service", assign({}, this.props.service, {[prop]: value})); + }, + updatePriority(val) { + this.props.onPropertyChange("service", assign({}, this.props.service, {priority: parseFloat(val[0], 10)})); + } +}); + +module.exports = { Element: ResultsProps, validate}; diff --git a/web/client/components/mapcontrols/searchservicesconfig/SearchServices.css b/web/client/components/mapcontrols/searchservicesconfig/SearchServices.css new file mode 100644 index 0000000000..4b583fa98d --- /dev/null +++ b/web/client/components/mapcontrols/searchservicesconfig/SearchServices.css @@ -0,0 +1,403 @@ +/* all this css rules are related to the slider, copy paste from advanced layertree example */ + +.noUi-target, +.noUi-target * { +-webkit-touch-callout: none; +-webkit-user-select: none; +-ms-touch-action: none; + touch-action: none; +-ms-user-select: none; +-moz-user-select: none; +-moz-box-sizing: border-box; + box-sizing: border-box; +} +.noUi-target { + position: relative; + direction: ltr; +} +.noUi-base { + width: 100%; + height: 100%; + position: relative; + z-index: 1; /* Fix 401 */ +} +.noUi-origin { + position: absolute; + right: 0; + top: 0; + left: 0; + bottom: 0; +} +.noUi-handle { + position: relative; + z-index: 1; +} +.noUi-stacking .noUi-handle { +/* This class is applied to the lower origin when + its values is > 50%. */ + z-index: 10; +} +.noUi-state-tap .noUi-origin { +-webkit-transition: left 0.3s, top 0.3s; + transition: left 0.3s, top 0.3s; +} +.noUi-state-drag * { + cursor: inherit !important; +} + +/* Painting and performance; + * Browsers can paint handles in their own layer. + */ +.noUi-base { + -webkit-transform: translate3d(0,0,0); + transform: translate3d(0,0,0); +} + +/* Slider size and handle placement; + */ +.noUi-horizontal { + height: 18px; +} +.noUi-horizontal .noUi-handle { + width: 34px; + height: 28px; + left: -17px; + top: -6px; +} +.noUi-vertical { + width: 18px; +} +.noUi-vertical .noUi-handle { + width: 28px; + height: 34px; + left: -6px; + top: -17px; +} + +/* Styling; + */ +.noUi-background { + background: #FAFAFA; + box-shadow: inset 0 1px 1px #f0f0f0; +} +.noUi-connect { + background: #3FB8AF; + box-shadow: inset 0 0 3px rgba(51,51,51,0.45); +-webkit-transition: background 450ms; + transition: background 450ms; +} +.noUi-origin { + border-radius: 2px; +} +.noUi-target { + border-radius: 4px; + border: 1px solid #D3D3D3; + box-shadow: inset 0 1px 1px #F0F0F0, 0 3px 6px -5px #BBB; +} +.noUi-target.noUi-connect { + box-shadow: inset 0 0 3px rgba(51,51,51,0.45), 0 3px 6px -5px #BBB; +} + +/* Handles and cursors; + */ +.noUi-draggable { + cursor: w-resize; +} +.noUi-vertical .noUi-draggable { + cursor: n-resize; +} +.noUi-handle { + border: 1px solid #D9D9D9; + border-radius: 3px; + background: #FFF; + cursor: default; + box-shadow: inset 0 0 1px #FFF, + inset 0 1px 7px #EBEBEB, + 0 3px 6px -3px #BBB; +} +.noUi-active { + box-shadow: inset 0 0 1px #FFF, + inset 0 1px 7px #DDD, + 0 3px 6px -3px #BBB; +} + +/* Handle stripes; + */ +.noUi-handle:before, +.noUi-handle:after { + content: ""; + display: block; + position: absolute; + height: 14px; + width: 1px; + background: #E8E7E6; + left: 14px; + top: 6px; +} +.noUi-handle:after { + left: 17px; +} +.noUi-vertical .noUi-handle:before, +.noUi-vertical .noUi-handle:after { + width: 14px; + height: 1px; + left: 6px; + top: 14px; +} +.noUi-vertical .noUi-handle:after { + top: 17px; +} + +/* Disabled state; + */ +[disabled].noUi-connect, +[disabled] .noUi-connect { + background: #B8B8B8; +} +[disabled].noUi-origin, +[disabled] .noUi-handle { + cursor: not-allowed; +} + +.noUi-tooltip { + display: block; + position: absolute; + border: 1px solid #D9D9D9; + border-radius: 3px; + background: #fff; + padding: 5px; + left: -9px; + text-align: center; + width: 50px; +} + +.noUi-handle-lower .noUi-tooltip { + top: -32px; +} + +.noUi-handle-upper .noUi-tooltip { + bottom: -32px; +} + +/* Pips + */ +.noUi-pips, +.noUi-pips * { +-moz-box-sizing: border-box; + box-sizing: border-box; +} +.noUi-pips { + position: absolute; + color: #999; +} + +/* Values; + * + */ +.noUi-value { + width: 40px; + position: absolute; + text-align: center; +} +.noUi-value-sub { + color: #ccc; + font-size: 10px; +} + +/* Markings; + * + */ +.noUi-marker { + position: absolute; + background: #CCC; +} +.noUi-marker-sub { + background: #AAA; +} +.noUi-marker-large { + background: #AAA; +} + +/* Horizontal layout; + * + */ +.noUi-pips-horizontal { + padding: 10px 0; + height: 50px; + top: 100%; + left: 0; + width: 100%; +} +.noUi-value-horizontal { + margin-left: -20px; + padding-top: 20px; +} +.noUi-value-horizontal.noUi-value-sub { + padding-top: 15px; +} + +.noUi-marker-horizontal.noUi-marker { + margin-left: -1px; + width: 2px; + height: 5px; +} +.noUi-marker-horizontal.noUi-marker-sub { + height: 10px; +} +.noUi-marker-horizontal.noUi-marker-large { + height: 15px; +} + +/* Vertical layout; + * + */ +.noUi-pips-vertical { + padding: 0 10px; + height: 100%; + top: 0; + left: 100%; +} +.noUi-value-vertical { + width: 15px; + margin-left: 20px; + margin-top: -5px; +} + +.noUi-marker-vertical.noUi-marker { + width: 5px; + height: 2px; + margin-top: -1px; +} +.noUi-marker-vertical.noUi-marker-sub { + width: 10px; +} +.noUi-marker-vertical.noUi-marker-large { + width: 15px; +} + + +#search-serivices-ui .noUi-background { + background:#CCC; +} + +#search-serivices-ui .noUi-horizontal { + height: 2px; + width: 260px; + margin:auto; +} +#search-serivices-ui .noUi-target { + position: relative; + direction: ltr; +} + +#search-serivices-ui .noUi-base { + width: 100%; + height: 100%; + position: relative; + z-index: 1; +} + +#search-serivices-ui .noUi-horizontal .noUi-handle { + width: 23px; + height: 23px; + top: -10px; +} + +#search-serivices-ui .noUi-handle { + background: #078AA3; + cursor: default; + border-radius:50px; +} + +#search-serivices-ui .noUi-handle { + position: relative; + z-index: 1; +} +#search-serivices-ui .noUi-target, .noUi-target * { + -webkit-touch-callout: none; + -webkit-user-select: none; + -ms-touch-action: none; + touch-action: none; + -ms-user-select: none; + -moz-user-select: none; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +#search-serivices-ui .noUi-target { + position: relative; + direction: ltr; +} + +#search-serivices-ui .noUi-target { + width: 94%; + margin-bottom: 10px; + margin-top: 10px +} + +#search-serivices-ui .noUi-handle:before, #search-serivices-ui .noUi-handle:after { + background: none; +} +#search-serivices-ui .service-radio{ + padding-left: 0 +} +#search-serivices-ui .services-list{ + height: 130px; + border-radius: 6px; + overflow: auto; + border: 1px solid rgba(128, 128, 128, 0.55); +} +.search-service-btn-group { + margin-bottom: 10px; + margin-top: 10px; +} +.services-panel .panel-body{ + padding-top: 0px; +} +.services-list .glyphicon{ + cursor: pointer; + padding: 2px; + color: #078aa3; + float: right; +} +.services-list .search-serivce-name{ + text-transform: capitalize; + padding-left: 4px; +} +.search-service-item { + margin: 4px; + margin-left: 6px; + margin-right: 10px; +} +#search-serivices-ui .checkbox{ + padding-left: 0px; +} +#search-serivices-ui .wfs-required-props-title{ + margin-bottom: 15px; + display: inline-block; + margin-left: -10px; + margin-right: -10px; + width: calc(100% + 20px); + border-bottom: 1px solid gray; + font-size: larger; +} +.confirm-close{ + position: absolute; + right: 10px; + top: 4px; + border-radius: 6px; +} +#search-serivices-ui .slider-label { + margin-left: 10px; + width: 20px; +} +.list-remove-btn { + float: right; + border: 0px; + background-color: transparent; +} +#search-serivices-ui .priority-info { + font-size: smaller; + display: inline-block; + padding-left: 2px; +} \ No newline at end of file diff --git a/web/client/components/mapcontrols/searchservicesconfig/ServicesList.jsx b/web/client/components/mapcontrols/searchservicesconfig/ServicesList.jsx new file mode 100644 index 0000000000..9c9bde3238 --- /dev/null +++ b/web/client/components/mapcontrols/searchservicesconfig/ServicesList.jsx @@ -0,0 +1,81 @@ +/** + * 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 React = require('react'); +const {FormGroup, Checkbox, ControlLabel, Glyphicon} = require('react-bootstrap'); +const Message = require('../../I18N/Message'); +const ConfirmButton = require('../../buttons/ConfirmButton'); + +function validate() { + return true; +} + +const ServicesList = React.createClass({ + propTypes: { + services: React.PropTypes.array, + override: React.PropTypes.bool, + service: React.PropTypes.object, + onPropertyChange: React.PropTypes.func + }, + contextTypes: { + messages: React.PropTypes.object + }, + getDefaultProps() { + return { + services: [], + override: false, + onPropertyChange: () => {} + }; + }, + getOptions() { + return this.props.services.map((s, idx) => { + return ( + <div className="search-service-item" key={idx}> + <span className="search-serivce-name"> + {s.name} + </span> + <ConfirmButton className="list-remove-btn" onConfirm={() => this.remove(idx)} text={<Glyphicon glyph="remove-circle" />} confirming={{className: "text-warning list-remove-btn", text: <Message msgId="search.confirmremove" />}}/> + <Glyphicon onClick={() => this.edit(s, idx)} glyph="pencil"/> + </div>); + }); + }, + render() { + const {override} = this.props; + return ( + <form> + <FormGroup> + <ControlLabel> + <Message msgId="search.serviceslistlabel" /> + </ControlLabel> + <div className="services-list"> + {this.getOptions()} + </div> + </FormGroup> + <Checkbox checked={override} onChange={this.toggleOverride}> + <Message msgId="search.overriedservice" /> + </Checkbox> + </form>); + }, + edit(s, idx) { + this.props.onPropertyChange("init_service_values", s); + this.props.onPropertyChange("service", s); + this.props.onPropertyChange("editIdx", idx); + this.props.onPropertyChange("page", 1); + }, + toggleOverride() { + const {services, override} = this.props; + this.props.onPropertyChange("textSearchConfig", {services, override: !override}); + }, + remove(idx) { + const {services, override} = this.props; + const newServices = services.filter((el, i) => i !== idx); + this.props.onPropertyChange("textSearchConfig", {services: newServices, override}); + } +}); + +module.exports = {Element: ServicesList, validate}; diff --git a/web/client/components/mapcontrols/searchservicesconfig/WFSOptionalProps.jsx b/web/client/components/mapcontrols/searchservicesconfig/WFSOptionalProps.jsx new file mode 100644 index 0000000000..a3a4ca6f6f --- /dev/null +++ b/web/client/components/mapcontrols/searchservicesconfig/WFSOptionalProps.jsx @@ -0,0 +1,67 @@ +/** + * 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 React = require('react'); +const {FormGroup, ControlLabel, FormControl, Label} = require('react-bootstrap'); +const Message = require('../../I18N/Message'); +const Slider = require('react-nouislider'); +const assign = require('object-assign'); +function validate() { + return true; +} +const WFSOptionalProps = React.createClass({ + propTypes: { + service: React.PropTypes.object, + onPropertyChange: React.PropTypes.func + }, + getDefaultProps() { + return { + service: {}, + onPropertyChange: () => {} + }; + }, + render() { + const {service} = this.props; + const {options = {}} = service; + return ( + <form> + <span className="wfs-required-props-title"><Message msgId="search.s_wfs_opt_props_label" /></span> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_sort" /> + </ControlLabel> + <FormControl + value={options.sortBy} + key="sortBy" + type="text" + onChange={this.updateProp.bind(null, "sortBy")}/> + </FormGroup> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_max_features" /> + </ControlLabel> + <Slider key="maxFeatures" start={[options.maxFeatures || 1]} + range={{min: 1, max: 50}} + onSlide={this.updateMaxFeatures} + /> + <Label key="maxFeatures-labeel" className="slider-label" >{options.maxFeatures || 1}</Label> + </FormGroup> + </form>); + }, + updateProp(prop, event) { + const value = event.target.value; + const options = assign({}, this.props.service.options, {[prop]: value}); + this.props.onPropertyChange("service", assign({}, this.props.service, {options})); + }, + updateMaxFeatures(val) { + const options = assign({}, this.props.service.options, {maxFeatures: parseInt(val[0], 10)}); + this.props.onPropertyChange("service", assign({}, this.props.service, {options})); + } +}); + +module.exports = {Element: WFSOptionalProps, validate}; diff --git a/web/client/components/mapcontrols/searchservicesconfig/WFSServiceProps.jsx b/web/client/components/mapcontrols/searchservicesconfig/WFSServiceProps.jsx new file mode 100644 index 0000000000..e4433d7576 --- /dev/null +++ b/web/client/components/mapcontrols/searchservicesconfig/WFSServiceProps.jsx @@ -0,0 +1,97 @@ +/** + * 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 React = require('react'); +const {FormGroup, ControlLabel, FormControl} = require('react-bootstrap'); +const Message = require('../../I18N/Message'); +const assign = require('object-assign'); +// const weburl = new RegExp(/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/); +function validate(service = {}) { + const {options = {}, name = ''} = service; + const {url = '', typeName = '', queriableAttributes = ''} = options; + // const p = url.search(weburl); + return name.length > 0 && url.length > 0 && typeName.length > 0 && queriableAttributes.length > 0; +} + +const WFSServiceProps = React.createClass({ + propTypes: { + service: React.PropTypes.object, + onPropertyChange: React.PropTypes.func + }, + getDefaultProps() { + return { + service: {}, + onPropertyChange: () => {} + }; + }, + render() { + const {service} = this.props; + const {options = {}} = service; + return ( + <form> + <span className="wfs-required-props-title"><Message msgId="search.s_wfs_props_label" /></span> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_name" /> + </ControlLabel> + <FormControl + value={service.name} + key="name" + type="text" + onChange={this.updateName}/> + </FormGroup> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_url" /> + </ControlLabel> + <FormControl + value={options.url} + key="url" + type="text" + onChange={this.updateProp.bind(null, "url")}/> + </FormGroup> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_layer" /> + </ControlLabel> + <FormControl + value={options.typeName} + key="typeName" + type="text" + onChange={this.updateProp.bind(null, "typeName")}/> + </FormGroup> + <FormGroup> + <ControlLabel> + <Message msgId="search.s_attributes" /> + </ControlLabel> + <FormControl + value={([options.queriableAttributes] || []).join(",")} + key="queriableAttributes" + type="text" + onChange={this.updateProp.bind(null, "queriableAttributes")}/> + </FormGroup> + </form>); + }, + updateProp(prop, event) { + let value = event.target.value; + if (prop === "queriableAttributes") { + value = value.split(","); + } + const options = assign({}, this.props.service.options, {[prop]: value} ); + this.props.onPropertyChange("service", assign({}, this.props.service, {options})); + }, + updateName(event) { + const value = event.target.value; + this.props.onPropertyChange("service", assign({}, this.props.service, {name: value})); + }, + updateMaxFeatures(val) { + const options = assign({}, this.props.service.options, {maxFeatures: parseFloat(val[0], 10)}); + this.props.onPropertyChange("service", assign({}, this.props.service, {options})); + } +}); + +module.exports = { Element: WFSServiceProps, validate}; diff --git a/web/client/components/mapcontrols/searchservicesconfig/__tests__/SearchServiceConfig-test.jsx b/web/client/components/mapcontrols/searchservicesconfig/__tests__/SearchServiceConfig-test.jsx new file mode 100644 index 0000000000..4f86908c99 --- /dev/null +++ b/web/client/components/mapcontrols/searchservicesconfig/__tests__/SearchServiceConfig-test.jsx @@ -0,0 +1,48 @@ +/** +* 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. +*/ + +var expect = require('expect'); + +var React = require('react'); +var ReactDOM = require('react-dom'); +var ResultsProps = require('../ResultsProps'); +var ServicesList = require('../ServicesList'); +var WFSOptionalProps = require('../WFSOptionalProps'); +var WFSServiceProps = require('../WFSServiceProps'); + + +describe("test text search service config components", () => { + beforeEach((done) => { + document.body.innerHTML = '<div id="container"></div>'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test Result Props creation', () => { + const tb = ReactDOM.render(<ResultsProps.Element/>, document.getElementById("container")); + expect(tb).toExist(); + }); + it('test Services List creation', () => { + const tb = ReactDOM.render(<ServicesList.Element/>, document.getElementById("container")); + expect(tb).toExist(); + }); + it('test WFS optional props creation', () => { + const tb = ReactDOM.render(<WFSOptionalProps.Element/>, document.getElementById("container")); + expect(tb).toExist(); + }); + it('test WFS serivece props creation', () => { + const tb = ReactDOM.render(<WFSServiceProps.Element/>, document.getElementById("container")); + expect(tb).toExist(); + }); + +}); diff --git a/web/client/localConfig.json b/web/client/localConfig.json index f82e0dacd0..b7f057ec54 100644 --- a/web/client/localConfig.json +++ b/web/client/localConfig.json @@ -196,7 +196,7 @@ } } }, - "OmniBar", "Login", "Save", "SaveAs", "BurgerMenu", "Expander", "Undo", "Redo", "FullScreen", "GlobeViewSwitcher" + "OmniBar", "Login", "Save", "SaveAs", "BurgerMenu", "Expander", "Undo", "Redo", "FullScreen", "GlobeViewSwitcher", "SearchServicesConfig" ], "embedded": [{ "name": "Map", diff --git a/web/client/plugins/Save.jsx b/web/client/plugins/Save.jsx index 4f041710fe..b5e142d4b0 100644 --- a/web/client/plugins/Save.jsx +++ b/web/client/plugins/Save.jsx @@ -29,7 +29,8 @@ const selector = createSelector(mapSelector, stateSelector, layersSelector, (map show: state.controls && state.controls.save && state.controls.save.enabled, map, mapId: map && map.mapId, - layers + layers, + textSearchConfig: state.searchconfig && state.searchconfig.textSearchConfig })); const Save = React.createClass({ @@ -41,7 +42,8 @@ const Save = React.createClass({ loadMapInfo: React.PropTypes.func, map: React.PropTypes.object, layers: React.PropTypes.array, - params: React.PropTypes.object + params: React.PropTypes.object, + textSearchConfig: React.PropTypes.object }, getDefaultProps() { return { @@ -91,7 +93,7 @@ const Save = React.createClass({ let resultingmap = { version: 2, // layers are defined inside the map object - map: assign({}, map, {layers}) + map: assign({}, map, {layers, text_serch_config: this.props.textSearchConfig}) }; this.props.onMapSave(this.props.mapId, JSON.stringify(resultingmap)); this.props.onClose(); diff --git a/web/client/plugins/SaveAs.jsx b/web/client/plugins/SaveAs.jsx index d2b7038f42..e17f1b42df 100644 --- a/web/client/plugins/SaveAs.jsx +++ b/web/client/plugins/SaveAs.jsx @@ -33,7 +33,8 @@ const selector = createSelector(mapSelector, stateSelector, layersSelector, (map user: state.security && state.security.user, currentMap: state.currentMap, metadata: state.maps.metadata, - layers + layers, + textSearchConfig: state.searchconfig && state.searchconfig.textSearchConfig })); const SaveAs = React.createClass({ @@ -57,7 +58,8 @@ const SaveAs = React.createClass({ resetCurrentMap: React.PropTypes.func, metadataChanged: React.PropTypes.func, onMapSave: React.PropTypes.func, - loadMapInfo: React.PropTypes.func + loadMapInfo: React.PropTypes.func, + textSearchConfig: React.PropTypes.object }, contextTypes: { router: React.PropTypes.object @@ -127,7 +129,7 @@ const SaveAs = React.createClass({ let resultingmap = { version: 2, // layers are defined inside the map object - map: assign({}, map, {layers}) + map: assign({}, map, {layers, text_serch_config: this.props.textSearchConfig}) }; return resultingmap; }, diff --git a/web/client/plugins/Search.jsx b/web/client/plugins/Search.jsx index f7a7b587d8..65278967ee 100644 --- a/web/client/plugins/Search.jsx +++ b/web/client/plugins/Search.jsx @@ -124,21 +124,25 @@ const ToggleButton = require('./searchbar/ToggleButton'); const SearchPlugin = connect((state) => ({ enabled: state.controls && state.controls.search && state.controls.search.enabled || false, selectedServices: state && state.search && state.search.selectedServices, - selectedItems: state && state.search && state.search.selectedItems + selectedItems: state && state.search && state.search.selectedItems, + textSearchConfig: state && state.searchconfig && state.searchconfig.textSearchConfig }))(React.createClass({ propTypes: { fitResultsToMapSize: React.PropTypes.bool, searchOptions: React.PropTypes.object, selectedItems: React.PropTypes.array, selectedServices: React.PropTypes.array, + userServices: React.PropTypes.array, withToggle: React.PropTypes.oneOfType([React.PropTypes.bool, React.PropTypes.array]), - enabled: React.PropTypes.bool + enabled: React.PropTypes.bool, + textSearchConfig: React.PropTypes.object }, getDefaultProps() { return { searchOptions: { services: [{type: "nominatim"}] }, + userServices: [], fitResultsToMapSize: true, withToggle: false, enabled: true @@ -147,8 +151,17 @@ const SearchPlugin = connect((state) => ({ getServiceOverrides( propSelector ) { return this.props.selectedItems && this.props.selectedItems[this.props.selectedItems.length - 1] && get(this.props.selectedItems[this.props.selectedItems.length - 1], propSelector); }, + getSearchOptions() { + const{ searchOptions, textSearchConfig} = this.props; + if (textSearchConfig && textSearchConfig.services && textSearchConfig.services.length > 0) { + return textSearchConfig.override ? assign({}, searchOptions, {services: textSearchConfig.services}) : assign({}, searchOptions, {services: searchOptions.services.concat(textSearchConfig.services)}); + } + return searchOptions; + }, getCurrentServices() { - return this.props.selectedServices && this.props.selectedServices.length > 0 ? assign({}, this.props.searchOptions, {services: this.props.selectedServices}) : this.props.searchOptions; + const {selectedServices} = this.props; + const searchOptions = this.getSearchOptions(); + return selectedServices && selectedServices.length > 0 ? assign({}, searchOptions, {services: selectedServices}) : searchOptions; }, getSearchAndToggleButton() { const search = (<SearchBar diff --git a/web/client/plugins/SearchServicesConfig.jsx b/web/client/plugins/SearchServicesConfig.jsx new file mode 100644 index 0000000000..7a6de0d123 --- /dev/null +++ b/web/client/plugins/SearchServicesConfig.jsx @@ -0,0 +1,211 @@ +/** +* 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 React = require('react'); +const {connect} = require('react-redux'); +const assign = require('object-assign'); +const {Glyphicon, Button} = require('react-bootstrap'); +const ConfirmButton = require('../components/buttons/ConfirmButton'); +const Dialog = require('../components//misc/Dialog'); +const Portal = require('../components/misc/Portal'); +const Message = require('./locale/Message'); +const {isEqual} = require('lodash'); +const {toggleControl} = require('../actions/controls'); +const {setSearchConfigProp, updateService, restServiceConfig} = require('../actions/searchconfig'); + +require('../components/mapcontrols/searchservicesconfig/SearchServices.css'); + +const SearchServicesButton = require('./searchservicesconfig/ToggleButton'); +const ServiceList = require('../components/mapcontrols/searchservicesconfig/ServicesList.jsx'); +const WFSServiceProps = require('../components/mapcontrols/searchservicesconfig/WFSServiceProps.jsx'); +const ResultsProps = require('../components/mapcontrols/searchservicesconfig/ResultsProps.jsx'); +const WFSOptionalProps = require('../components/mapcontrols/searchservicesconfig/WFSOptionalProps.jsx'); + + +const SearchServicesConfigPanel = React.createClass({ + propTypes: { + id: React.PropTypes.string, + enabled: React.PropTypes.bool, + panelStyle: React.PropTypes.object, + panelClassName: React.PropTypes.string, + closeGlyph: React.PropTypes.string, + titleText: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element]), + toggleControl: React.PropTypes.func, + pages: React.PropTypes.arrayOf(React.PropTypes.shape({ + Element: React.PropTypes.func.isRequired, + validate: React.PropTypes.func.isRequired + })), + page: React.PropTypes.number, + service: React.PropTypes.object, + initServiceValues: React.PropTypes.object, + onPropertyChange: React.PropTypes.func, + newService: React.PropTypes.object.isRequired, + updateService: React.PropTypes.func, + restServiceConfig: React.PropTypes.func, + textSearchConfig: React.PropTypes.object, + editIdx: React.PropTypes.number + }, + getDefaultProps() { + return { + id: "SearchServicesConfigUI", + enabled: false, + panelStyle: { + minWidth: "400px", + zIndex: 2000, + position: "absolute", + // overflow: "auto", + top: "100px", + minHeight: "300px", + left: "calc(50% - 150px)", + backgroundColor: "white" + }, + panelClassName: "toolbar-panel", + closeGlyph: "1-close", + titleText: <Message msgId="search.configpaneltitle" />, + closePanel: () => {}, + onPropertyChange: () => {}, + page: 0, + newService: { + type: "wfs", + name: "", + displayName: "", + subTitle: "", + priority: 1, + options: { + url: "", + typeName: "", + queriableAttributes: "", + sortBy: "", + maxFeatures: 5, + srsName: "EPSG:4326"} + } + }; + }, + canProceed() { + const {page, pages, service} = this.props; + return pages[page].validate(service); + }, + isDirty() { + const {service, initServiceValues} = this.props; + return !isEqual(service, initServiceValues); + }, + renderFooter() { + const {page, pages} = this.props; + if (page === 0) { + return ( + <span role="footer"> + <Button onClick={this.addService} bsStyle="primary"> + <Message msgId="search.addbtn" /> + </Button> + </span>); + }else if (page === pages.length - 1) { + return ( + <span role="footer"> + <Button onClick={this.prev} bsStyle="primary"> + <Message msgId="search.prevbtn" /> + </Button> + <Button disabled={!this.canProceed()} onClick={this.update} bsStyle="primary"> + <Message msgId="search.savebtn" /> + </Button> + </span>); + } + return ( + <span role="footer"> + {page === 1 && this.isDirty() ? ( + <ConfirmButton onConfirm={this.prev} bsStyle="primary" + confirming={{text: <Message msgId="search.cancelconfirm" />}} + text={(<Message msgId="search.cancelbtn" />)}/> + ) : ( + <Button onClick={this.prev} bsStyle="primary"> + <Message msgId={page === 1 ? "search.cancelbtn" : "search.prevbtn"} /> + </Button>) + } + <Button disabled={!this.canProceed()} onClick={this.next} bsStyle="primary"> + <Message msgId="search.nextbtn" /> + </Button> + </span>); + }, + render() { + const {enabled, pages, page, id, panelStyle, panelClassName, titleText, closeGlyph, onPropertyChange, service, textSearchConfig = {}} = this.props; + const Section = pages && pages[page] || null; + return enabled ? ( + <Portal> + <Dialog id={id} style={panelStyle} className={panelClassName}> + <span role="header"> + <span>{titleText}</span> + { this.isDirty() ? ( + <ConfirmButton className="close" confirming={{ + text: <Message msgId="search.cancelbtn" />, className: "btn btn-sm btn-warning confirm-close"}} onConfirm={this.onClose} bsStyle="primary" text={(<Glyphicon glyph={closeGlyph}/>)}/>) : <button onClick={this.onClose} className="close">{closeGlyph ? <Glyphicon glyph={closeGlyph}/> : <span>×</span>}</button>} + } + </span> + <div role="body" id="search-serivices-ui"> + <Section.Element services={textSearchConfig.services} override={textSearchConfig.override} onPropertyChange={onPropertyChange} service={service}/> + </div> + {this.renderFooter()} + </Dialog> + </Portal>) : null; + }, + onClose() { + this.props.toggleControl("searchservicesconfig"); + this.props.restServiceConfig(0); + this.props.toggleControl("settings"); + }, + addService() { + const {newService} = this.props; + this.props.onPropertyChange("init_service_values", newService); + this.props.onPropertyChange("service", newService); + this.props.onPropertyChange("page", 1); + }, + prev() { + const {page} = this.props; + if (page > 1) { + this.props.onPropertyChange("page", page - 1); + }else if (page === 1 ) { + this.props.restServiceConfig(0); + } + }, + next() { + const {page, pages} = this.props; + if (page < pages.length - 1) { + this.props.onPropertyChange("page", page + 1); + } + }, + update() { + const {service, editIdx} = this.props; + this.props.updateService(service, editIdx); + } +}); + +const SearchServicesPlugin = connect(({controls = {}, searchconfig = {}}) => ({ + enabled: controls.searchservicesconfig && controls.searchservicesconfig.enabled || false, + pages: [ServiceList, WFSServiceProps, ResultsProps, WFSOptionalProps], + page: searchconfig && searchconfig.page || 0, + service: searchconfig && searchconfig.service, + initServiceValues: searchconfig && searchconfig.init_service_values, + textSearchConfig: searchconfig && searchconfig.textSearchConfig, + editIdx: searchconfig && searchconfig.editIdx + }), { + toggleControl, + onPropertyChange: setSearchConfigProp, + restServiceConfig, + updateService})(SearchServicesConfigPanel); + +module.exports = { + SearchServicesConfigPlugin: assign(SearchServicesPlugin, { + Settings: { + tool: + <SearchServicesButton + text={<Message msgId="search.searchservicesbutton" />} + />, + position: 4 + } + }), + reducers: { + searchconfig: require('../reducers/searchconfig') + } +}; diff --git a/web/client/plugins/searchservicesconfig/ToggleButton.jsx b/web/client/plugins/searchservicesconfig/ToggleButton.jsx new file mode 100644 index 0000000000..b52ed3bb46 --- /dev/null +++ b/web/client/plugins/searchservicesconfig/ToggleButton.jsx @@ -0,0 +1,43 @@ +/** + * 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 React = require('react'); +const {connect} = require('react-redux'); +const {toggleControl} = require('../../actions/controls'); +const {FormGroup} = require('react-bootstrap'); +const ToggleBtn = require('../../components/buttons/ToggleButton'); +const ToggleServicesConfig = React.createClass({ + propTypes: { + toggleControl: React.PropTypes.func, + enabled: React.PropTypes.bool + }, + onClick() { + if (!this.props.enabled) { + this.props.toggleControl("settings"); + this.props.toggleControl("searchservicesconfig"); + } + }, + render() { + return ( + <FormGroup> + <ToggleBtn key="searchservicesconfig" + isButton={true} {...this.props} onClick={this.onClick}/> + </FormGroup>); + } +}); + +module.exports = connect((state) => ({ + enabled: state.controls && state.controls.searchservicesconfig && state.controls.searchservicesconfig.enabled || false, + pressedStyle: "default", + defaultStyle: "primary", + btnConfig: { + bsSize: "small"} +}), { + toggleControl: toggleControl +})(ToggleServicesConfig); + + diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index d059277912..9cc9334787 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -68,7 +68,8 @@ module.exports = { ScrollTopPlugin: require('../plugins/ScrollTop'), GoFull: require('../plugins/GoFull'), GlobeViewSwitcherPlugin: require('../plugins/GlobeViewSwitcher'), - VersionPlugin: require('../plugins/Version') + VersionPlugin: require('../plugins/Version'), + SearchServicesConfigPlugin: require('../plugins/SearchServicesConfig') }, requires: { ReactSwipe: require('react-swipeable-views').default, diff --git a/web/client/reducers/__tests__/searchconfig-test.js b/web/client/reducers/__tests__/searchconfig-test.js new file mode 100644 index 0000000000..580d875e64 --- /dev/null +++ b/web/client/reducers/__tests__/searchconfig-test.js @@ -0,0 +1,78 @@ +/** +* 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 searchconfig = require('../searchconfig'); +const {SET_SEARCH_CONFIG_PROP, RESET_SEARCH_CONFIG, UPDATE_SERVICE} = require('../../actions/searchconfig'); + +describe('Test the searchconfig reducer', () => { + it('Map config loaded with textSearchConfig', () => { + + const action = {type: 'MAP_CONFIG_LOADED', + config: { version: 2, map: {layers: [], text_serch_config: {override: true}}}}; + + const state = searchconfig({}, action); + expect(state.textSearchConfig).toExist(); + expect(state.textSearchConfig.override).toBe(true); + }); + it('Map config loaded without textSearchConfig', () => { + + const action = {type: 'MAP_CONFIG_LOADED', + config: { version: 2, map: {layers: []}}}; + + const state = searchconfig({}, action); + expect(state.textSearchConfig).toBe(undefined); + }); + it('reset searchconfig state', () => { + const state = searchconfig( + {service: "test", page: 1, init_service_values: "test", editIdx: 2, textSearchConfig: {}} + , { + type: RESET_SEARCH_CONFIG, + page: 0 + }); + expect(state.page).toBe(0); + expect(state.service).toBe(undefined); + expect(state.init_service_values).toBe(undefined); + expect(state.editIdx).toBe(undefined); + expect(state.textSearchConfig).toExist(); + }); + + it('test service update', () => { + const state = searchconfig({ + textSearchConfig: {services: [{name: "test"}]} + }, { + type: UPDATE_SERVICE, + service: {name: "changed"}, + idx: 0 + }); + expect(state.textSearchConfig).toExist(); + expect(state.textSearchConfig.services[0].name).toBe("changed"); + }); + it('test service add', () => { + const state = searchconfig({ + textSearchConfig: {services: [{name: "test"}]} + }, { + type: UPDATE_SERVICE, + service: {name: "changed"}, + idx: -1 + }); + expect(state.textSearchConfig).toExist(); + expect(state.textSearchConfig.services[1].name).toBe("changed"); + }); + + it('set a search config property', () => { + const state = searchconfig({}, { + type: SET_SEARCH_CONFIG_PROP, + property: "prop", + value: 'val' + }); + expect(state.prop).toExist(); + expect(state.prop).toBe('val'); + }); + +}); diff --git a/web/client/reducers/searchconfig.js b/web/client/reducers/searchconfig.js new file mode 100644 index 0000000000..d4fd9726c0 --- /dev/null +++ b/web/client/reducers/searchconfig.js @@ -0,0 +1,45 @@ +/** +* 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. +*/ +var { + SET_SEARCH_CONFIG_PROP, RESET_SEARCH_CONFIG, + UPDATE_SERVICE} = require('../actions/searchconfig'); +var {RESET_CONTROLS} = require('../actions/controls'); +var {MAP_CONFIG_LOADED} = require('../actions/config'); +const assign = require('object-assign'); + +function searchconfig(state = null, action) { + switch (action.type) { + case SET_SEARCH_CONFIG_PROP: + return assign({}, state, { + [action.property]: action.value + }); + case MAP_CONFIG_LOADED: { + const textSearchConfig = action.config.map.text_serch_config; + return assign({}, state, {textSearchConfig}); + } + case RESET_CONTROLS: + case RESET_SEARCH_CONFIG: { + return assign({}, state, {service: undefined, page: action.page, init_service_values: undefined, editIdx: undefined}); + } + case UPDATE_SERVICE: { + let newServices = (state.textSearchConfig && state.textSearchConfig.services || []).slice(); + // Convert priority to int + const newService = assign({}, action.service, {priority: parseInt(action.service.priority, 10)}); + if (action.idx !== -1) { + newServices[action.idx] = newService; + }else { + newServices.push(newService); + } + return assign({}, state, {service: undefined, page: 0, init_service_values: undefined, editIdx: undefined, textSearchConfig: {services: newServices, override: state.textSearchConfig && state.textSearchConfig.override || false}}); + } + default: + return state; + } +} + +module.exports = searchconfig; diff --git a/web/client/translations/data.en-US b/web/client/translations/data.en-US index fae566375f..9d09cd0dba 100644 --- a/web/client/translations/data.en-US +++ b/web/client/translations/data.en-US @@ -266,7 +266,31 @@ "bearingLabel": "Bearing" }, "search":{ - "placeholder": "Search by location name or coordinates ..." + "placeholder": "Search by location name or coordinates ...", + "searchservicesbutton": "Configure search services", + "configpaneltitle": "Create/edit a search service", + "serviceslistlabel": "Available services", + "overriedservice": "Overried services", + "addbtn": "Add", + "nextbtn": "Next", + "prevbtn": "Back", + "savebtn": "Update", + "cancelbtn": "Cancel", + "confirmremove": "Delete?", + "cancelconfirm": "Confirm Cancel?", + "s_name": "Name", + "s_title": "Title", + "s_description": "Description", + "s_priority": "Priority", + "s_url": "Service url", + "s_layer": "Layer", + "s_attributes": "Attributes", + "s_sort": "Sort by", + "s_max_features": "Max features", + "s_wfs_props_label" : "WFS service props", + "s_wfs_opt_props_label" : "Optional props", + "s_result_props_label": "Result display properties", + "s_priority_info": "Used to sort search results, higher values first. Nominatim results have priority = 5" }, "drawLocal": { "draw": { diff --git a/web/client/translations/data.it-IT b/web/client/translations/data.it-IT index 57f5414aca..b976a16246 100644 --- a/web/client/translations/data.it-IT +++ b/web/client/translations/data.it-IT @@ -266,7 +266,31 @@ "bearingLabel": "Direzione" }, "search":{ - "placeholder": "Cerca un indirizzo o inserisci coordinate..." + "placeholder": "Cerca un indirizzo o inserisci coordinate...", + "searchservicesbutton": "Configura servizi di ricerca", + "configpaneltitle": "Aggiungi/modifica un servizio di ricerca", + "serviceslistlabel": "Servizi disponibili", + "overriedservice": "Sovrascrivi servizi", + "addbtn": "Aggiungi", + "cancelbtn": "Annulla", + "confirmremove": "Elimina?", + "cancelconfirm": "Confermi annulla?", + "nextbtn": "Avanti", + "prevbtn": "Precedente", + "savebtn": "Aggiorna", + "s_name": "Name", + "s_title": "Titolo", + "s_description": "Descrizione", + "s_priority": "Priorità", + "s_url": "Url servizio", + "s_layer": "Nome layer", + "s_attributes": "Attributi", + "s_sort": "Ordina per", + "s_max_features": "Max features", + "s_wfs_props_label" : "Proprietà WFS service", + "s_wfs_opt_props_label" : "Proprietà facoltative", + "s_result_props_label": "Proprietà di visualizzazione dei risultati", + "s_priority_info": "Usato per oridnare resultati, valori più alti per primi. Nominatim ha priorità = 5" }, "drawLocal": { "draw": {