Skip to content

Commit

Permalink
Implementation of nested search services #1122 (#1523)
Browse files Browse the repository at this point in the history
* Implementation of nested search services

Nested services can be configured to be used when a result from a
service is already selected, to refine the search.

 - You can use mixed services
 - You can display selected items
 - You can populate searchbar with some text when an item is selected (useful also for nominatim)
 - Can change the text search placeholder
 - Improved search result to support templating system (displayName and subTitle)
 - Every string can be defined using a template from the selected item

* Fixed eslint
  • Loading branch information
offtherailz authored Mar 7, 2017
1 parent 47bdb98 commit 6f57ada
Show file tree
Hide file tree
Showing 17 changed files with 618 additions and 52 deletions.
22 changes: 21 additions & 1 deletion web/client/actions/__tests__/search-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ var {
TEXT_SEARCH_ERROR,
TEXT_SEARCH_STARTED,
TEXT_SEARCH_ITEM_SELECTED,
TEXT_SEARCH_NESTED_SERVICES_SELECTED,
TEXT_SEARCH_CANCEL_ITEM,
searchResultLoaded,
searchTextLoading,
searchResultError,
textSearch,
selectSearchItem
selectSearchItem,
selectNestedService,
cancelSelectedItem
} = require('../search');

describe('Test correctness of the search actions', () => {
Expand Down Expand Up @@ -58,5 +62,21 @@ describe('Test correctness of the search actions', () => {
expect(retval.item).toEqual("A");
expect(retval.mapConfig).toBe("B");
});
it('serch item cancelled', () => {
const retval = cancelSelectedItem("ITEM");
expect(retval).toExist();
expect(retval.type).toBe(TEXT_SEARCH_CANCEL_ITEM);
expect(retval.item).toEqual("ITEM");
});
it('serch nested service selected', () => {
const items = [{text: "TEXT"}];
const services = [{type: "wfs"}, {type: "wms"}];
const retval = selectNestedService(services, items, "TEST");
expect(retval).toExist();
expect(retval.type).toBe(TEXT_SEARCH_NESTED_SERVICES_SELECTED);
expect(retval.items).toEqual(items);
expect(retval.services).toEqual(services);
});


});
23 changes: 21 additions & 2 deletions web/client/actions/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const TEXT_SEARCH_RESET = 'TEXT_SEARCH_RESET';
const TEXT_SEARCH_ADD_MARKER = 'TEXT_SEARCH_ADD_MARKER';
const TEXT_SEARCH_TEXT_CHANGE = 'TEXT_SEARCH_TEXT_CHANGE';
const TEXT_SEARCH_LOADING = 'TEXT_SEARCH_LOADING';
const TEXT_SEARCH_NESTED_SERVICES_SELECTED = 'TEXT_SEARCH_NESTED_SERVICE_SELECTED';
const TEXT_SEARCH_ERROR = 'TEXT_SEARCH_ERROR';

const TEXT_SEARCH_CANCEL_ITEM = 'TEXT_SEARCH_CANCEL_ITEM';
const TEXT_SEARCH_ITEM_SELECTED = 'TEXT_SEARCH_ITEM_SELECTED';

function searchResultLoaded(results, append=false, services) {
Expand Down Expand Up @@ -83,7 +84,21 @@ function selectSearchItem(item, mapConfig) {
};

}
function selectNestedService(services, items, searchText) {
return {
type: TEXT_SEARCH_NESTED_SERVICES_SELECTED,
searchText,
services,
items
};
}

function cancelSelectedItem(item) {
return {
type: TEXT_SEARCH_CANCEL_ITEM,
item
};
}

module.exports = {
TEXT_SEARCH_STARTED,
Expand All @@ -96,6 +111,8 @@ module.exports = {
TEXT_SEARCH_ADD_MARKER,
TEXT_SEARCH_TEXT_CHANGE,
TEXT_SEARCH_ITEM_SELECTED,
TEXT_SEARCH_NESTED_SERVICES_SELECTED,
TEXT_SEARCH_CANCEL_ITEM,
searchTextLoading,
searchResultError,
searchResultLoaded,
Expand All @@ -104,5 +121,7 @@ module.exports = {
resetSearch,
addMarker,
searchTextChanged,
selectSearchItem
selectNestedService,
selectSearchItem,
cancelSelectedItem
};
6 changes: 3 additions & 3 deletions web/client/api/searchText.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ const toNominatim = (fc) =>
*/

module.exports = {
nominatim: (searchText, {options = null} = {}) =>
nominatim: (searchText, options = {}) =>
require('./Nominatim')
.geocode(searchText, options)
.then( res => GeoCodeUtils.nominatimToGeoJson(res.data)),
wfs: (searchText, {url, typeName, queriableAttributes, outputFormat="application/json", predicate ="ILIKE", ...params }) => {
wfs: (searchText, {url, typeName, queriableAttributes, outputFormat="application/json", predicate ="ILIKE", staticFilter="", ...params }) => {
return WFS
.getFeatureSimple(url, assign({
maxFeatures: 10,
startIndex: 0,
typeName,
outputFormat,
cql_filter: queriableAttributes.map( attr => `${attr} ${predicate} '%${searchText}%'`).join(' OR ')
cql_filter: queriableAttributes.map( attr => `${attr} ${predicate} '%${searchText}%'`).join(' OR ').concat(staticFilter)
}, params))
.then( response => response.features );
}
Expand Down
49 changes: 43 additions & 6 deletions web/client/components/mapcontrols/search/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ let SearchBar = React.createClass({
onSearchReset: React.PropTypes.func,
onPurgeResults: React.PropTypes.func,
onSearchTextChange: React.PropTypes.func,
onCancelSelectedItem: React.PropTypes.func,
placeholder: React.PropTypes.string,
placeholderMsgId: React.PropTypes.string,
delay: React.PropTypes.number,
hideOnBlur: React.PropTypes.bool,
blurResetDelay: React.PropTypes.number,
typeAhead: React.PropTypes.bool,
searchText: React.PropTypes.string,
selectedItems: React.PropTypes.array,
autoFocusOnSelect: React.PropTypes.bool,
loading: React.PropTypes.bool,
error: React.PropTypes.object,
style: React.PropTypes.object,
Expand All @@ -54,14 +57,27 @@ let SearchBar = React.createClass({
onSearchReset: () => {},
onPurgeResults: () => {},
onSearchTextChange: () => {},
onCancelSelectedItem: () => {},
selectedItems: [],
placeholderMsgId: "search.placeholder",
delay: 1000,
blurResetDelay: 300,
autoFocusOnSelect: true,
hideOnBlur: true,
typeAhead: true,
searchText: ""
};
},
componentDidUpdate(prevProps) {
let shouldFocus = this.props.autoFocusOnSelect && this.props.selectedItems &&
(
(prevProps.selectedItems && prevProps.selectedItems.length < this.props.selectedItems.length)
|| (!prevProps.selectedItems && this.props.selectedItems.length === 1)
);
if (shouldFocus) {
this.focusToInput();
}
},
onChange(e) {
var text = e.target.value;
this.props.onSearchTextChange(text);
Expand All @@ -70,8 +86,16 @@ let SearchBar = React.createClass({
}
},
onKeyDown(event) {
if (event.keyCode === 13) {
this.search();
switch (event.keyCode) {
case 13:
this.search();
break;
case 8:
if (!this.props.searchText && this.props.selectedItems && this.props.selectedItems.length > 0) {
this.props.onCancelSelectedItem(this.props.selectedItems[this.props.selectedItems.length - 1]);
}
break;
default:
}
},
onFocus() {
Expand All @@ -85,9 +109,14 @@ let SearchBar = React.createClass({
delay(() => {this.props.onPurgeResults(); }, this.props.blurResetDelay);
}
},
renderAddonBefore() {
return this.props.selectedItems && this.props.selectedItems.map((item, index) =>
<span key={"selected-item" + index} className="input-group-addon"><div className="selectedItem-text">{item.text}</div></span>
);
},
renderAddonAfter() {
const remove = <Glyphicon className="searchclear" glyph="remove" onClick={this.clearSearch}/>;
var showRemove = this.props.searchText !== "";
var showRemove = this.props.searchText !== "" || (this.props.selectedItems && this.props.selectedItems.length > 0);
let addonAfter = showRemove ? [remove] : [<Glyphicon glyph="search"/>];
if (this.props.loading) {
addonAfter = [<Spinner style={{
Expand Down Expand Up @@ -117,10 +146,13 @@ let SearchBar = React.createClass({
return (
<div id="map-search-bar" style={this.props.style} className={"MapSearchBar" + (this.props.className ? " " + this.props.className : "")}>
<FormGroup>
<div className="input-group"><FormControl
<div className="input-group">
{this.renderAddonBefore()}
<FormControl
key="search-input"
placeholder={placeholder}
type="text"
inputRef={ref => { this.input = ref; }}
style={{
textOverflow: "ellipsis"
}}
Expand All @@ -138,14 +170,19 @@ let SearchBar = React.createClass({
},
search() {
var text = this.props.searchText;
if (text === undefined || text === "") {
if ((text === undefined || text === "") && (!this.props.selectedItems || this.props.selectedItems.length === 0)) {
this.props.onSearchReset();
} else {
this.props.onSearch(text, this.props.searchOptions);
}

},

focusToInput() {
let node = this.input;
if (node && node.focus instanceof Function) {
setTimeout( () => node.focus(), 200);
}
},
clearSearch() {
this.props.onSearchReset();
}
Expand Down
15 changes: 13 additions & 2 deletions web/client/components/mapcontrols/search/SearchResult.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,20 @@
const React = require('react');
const {get} = require('lodash');

const {generateTemplateString} = require('../../../utils/TemplateUtils');

let SearchResult = React.createClass({
propTypes: {
/* field name or template.
* e.g. "properties.subTitle"
* e.g. "This is a subtitle for ${properties.subTitle}"
*/
subTitle: React.PropTypes.string,
item: React.PropTypes.object,
/* field name or template.
* e.g. "properties.displayName"
* e.g. "This is a title for ${properties.title}"
*/
displayName: React.PropTypes.string,
idField: React.PropTypes.string,
icon: React.PropTypes.string,
Expand All @@ -35,9 +45,10 @@ let SearchResult = React.createClass({
}
let item = this.props.item;
return (
<div key={item.osm_id} className="search-result NominatimResult" onClick={this.onClick}>
<div key={item.osm_id} className="search-result" onClick={this.onClick}>
<div className="icon"> <img src={item.icon} /></div>
{get(item, this.props.displayName) }
<div className="text-result-title">{get(item, this.props.displayName) || generateTemplateString(this.props.displayName || "")(item) }</div>
<small className="text-info">{this.props.subTitle && get(item, this.props.subTitle) || generateTemplateString(this.props.subTitle || "")(item) }</small>
</div>
);
}
Expand Down
9 changes: 5 additions & 4 deletions web/client/components/mapcontrols/search/SearchResultList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ let SearchResultList = React.createClass({
return this.props.results.map((item, idx)=> {
const service = this.findService(item) || {};
return (<SearchResult
subTitle={service.subTitle}
idField={service.idField}
displayName={service.displayName}
key={item.osm_id || "res_" + idx} item={item} onItemClick={this.onItemClick}/>);
Expand All @@ -59,8 +60,8 @@ let SearchResultList = React.createClass({
},
findService(item) {
const services = this.props.searchOptions && this.props.searchOptions.services;
if (services && item.__SERVICE__ !== null) {
if ( typeof item.__SERVICE__ === "string" ) {
if (item.__SERVICE__ !== null) {
if (services && typeof item.__SERVICE__ === "string" ) {
for (let i = 0; i < services.length; i++) {
if (services[i] && services[i].id === item.__SERVICE__) {
return services[i];
Expand All @@ -72,8 +73,8 @@ let SearchResultList = React.createClass({
}
}

} else {
return services[item.__SERVICE__];
} else if (typeof item.__SERVICE__ === "object") {
return item.__SERVICE__;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ describe("test the SearchBar", () => {
done();
}, 50);
});
it('test autofocus on selected items', (done) => {
let tb = ReactDOM.render(<SearchBar searchText="test" delay={0} typeAhead={true} blurResetDelay={0} />, document.getElementById("container"));
let input = TestUtils.scryRenderedDOMComponentsWithTag(tb, "input")[0];
expect(input).toExist();
let spyOnFocus = expect.spyOn(input, 'focus');
input = ReactDOM.findDOMNode(input);
input.value = "test";
TestUtils.Simulate.blur(input);
ReactDOM.render(<SearchBar searchText="test" delay={0} typeAhead={true} blurResetDelay={0} selectedItems={[{text: "TEST"}]}/>, document.getElementById("container"));
setTimeout(() => {
expect(spyOnFocus.calls.length).toEqual(1);
done();
}, 210);
});

it('test that options are passed to search action', () => {
var tb;
Expand Down Expand Up @@ -163,4 +177,24 @@ describe("test the SearchBar", () => {
let error = ReactDOM.findDOMNode(TestUtils.scryRenderedDOMComponentsWithClass(tb, "searcherror")[0]);
expect(error).toExist();
});

it('test cancel items', (done) => {
const testHandlers = {
onCancelSelectedItem: () => {}
};

const spy = expect.spyOn(testHandlers, 'onCancelSelectedItem');
let tb = ReactDOM.render(<SearchBar searchText="test" delay={0} typeAhead={true} blurResetDelay={0} onCancelSelectedItem={testHandlers.onCancelSelectedItem} />, document.getElementById("container"));
let input = TestUtils.scryRenderedDOMComponentsWithTag(tb, "input")[0];
expect(input).toExist();
input = ReactDOM.findDOMNode(input);

// backspace with empty searchText causes trigger of onCancelSelectedItem
ReactDOM.render(<SearchBar searchText="" delay={0} typeAhead={true} blurResetDelay={0} onCancelSelectedItem={testHandlers.onCancelSelectedItem} selectedItems={[{text: "TEST"}]}/>, document.getElementById("container"));
TestUtils.Simulate.keyDown(input, {key: "Backspace", keyCode: 8, which: 8});
setTimeout(() => {
expect(spy.calls.length).toEqual(1);
done();
}, 10);
});
});
Loading

0 comments on commit 6f57ada

Please sign in to comment.