Skip to content

Commit

Permalink
Text search epic refactor (#1479)
Browse files Browse the repository at this point in the history
* Text search epics re-implementation

This is a first example of how to implement async functionalities using redux-observable.
Added also a test with redux-mock-store.

* Search Text now supports nominatim + WFS search
* Improved error management
* Added loading status
* Debouncing of search events
* Support cancellation on purge
* Merge result and sort by priority
* add redux-mock-store to test redux-observable
  • Loading branch information
offtherailz authored Feb 16, 2017
1 parent f2a8d82 commit 7d03732
Show file tree
Hide file tree
Showing 14 changed files with 544 additions and 25 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"redux-devtools-dock-monitor": "1.1.1",
"redux-devtools-log-monitor": "1.0.11",
"redux-immutable-state-invariant": "1.2.3",
"redux-mock-store": "1.2.2",
"rimraf": "2.5.2",
"simple-git": "1.33.1",
"style-loader": "0.12.4",
Expand Down
34 changes: 28 additions & 6 deletions web/client/actions/__tests__/search-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,44 @@
var expect = require('expect');
var {
TEXT_SEARCH_RESULTS_LOADED,
searchResultLoaded
TEXT_SEARCH_LOADING,
TEXT_SEARCH_ERROR,
TEXT_SEARCH_STARTED,
searchResultLoaded,
searchTextLoading,
searchResultError,
searchTextStarted
} = require('../search');

describe('Test correctness of the searc actions', () => {
it('search results loaded', () => {
const testVal = {data: ['result1', 'result2']};
describe('Test correctness of the search actions', () => {

it('text search started', () => {
const action = searchTextStarted(true);
expect(action.type).toBe(TEXT_SEARCH_STARTED);
});
it('text search loading', () => {
const action = searchTextLoading(true);
expect(action.loading).toBe(true);
expect(action.type).toBe(TEXT_SEARCH_LOADING);
});
it('text search error', () => {
const action = searchResultError({message: "MESSAGE"});
expect(action.error).toExist();
expect(action.error.message).toBe("MESSAGE");
expect(action.type).toBe(TEXT_SEARCH_ERROR);
});
it('serch results', () => {
const testVal = ['result1', 'result2'];
const retval = searchResultLoaded(testVal);
expect(retval).toExist();
expect(retval.type).toBe(TEXT_SEARCH_RESULTS_LOADED);
expect(retval.results).toEqual(testVal.data);
expect(retval.results).toEqual(testVal);
expect(retval.append).toBe(false);

const retval2 = searchResultLoaded(testVal, true);
expect(retval2).toExist();
expect(retval2.type).toBe(TEXT_SEARCH_RESULTS_LOADED);
expect(retval2.results).toEqual(testVal.data);
expect(retval2.results).toEqual(testVal);
expect(retval2.append).toBe(true);
});

Expand Down
33 changes: 31 additions & 2 deletions web/client/actions/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@
*/

var GeoCodingApi = require('../api/Nominatim');

const TEXT_SEARCH_STARTED = 'TEXT_SEARCH_STARTED';
const TEXT_SEARCH_RESULTS_LOADED = 'TEXT_SEARCH_RESULTS_LOADED';
const TEXT_SEARCH_PERFORMED = 'TEXT_SEARCH_PERFORMED';
const TEXT_SEARCH_RESULTS_PURGE = 'TEXT_SEARCH_RESULTS_PURGE';
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_ERROR = 'TEXT_SEARCH_ERROR';

function searchResultLoaded(results, append=false) {
return {
type: TEXT_SEARCH_RESULTS_LOADED,
results: results.data,
results: results,
append: append
};
}
Expand All @@ -30,6 +32,26 @@ function searchTextChanged(text) {
};
}

function searchTextStarted(searchText) {
return {
type: TEXT_SEARCH_STARTED,
searchText
};
}
function searchTextLoading(loading) {
return {
type: TEXT_SEARCH_LOADING,
loading
};
}
function searchResultError(error) {
return {
type: TEXT_SEARCH_ERROR,
error
};
}


function resultsPurge() {
return {
type: TEXT_SEARCH_RESULTS_PURGE
Expand All @@ -51,6 +73,7 @@ function addMarker(itemPosition) {

function textSearch(text) {
return (dispatch) => {
dispatch(searchTextStarted(text));
GeoCodingApi.geocode(text).then((response) => {
dispatch(searchResultLoaded(response));
}).catch((e) => {
Expand All @@ -61,12 +84,18 @@ function textSearch(text) {


module.exports = {
TEXT_SEARCH_STARTED,
TEXT_SEARCH_LOADING,
TEXT_SEARCH_ERROR,
TEXT_SEARCH_RESULTS_LOADED,
TEXT_SEARCH_PERFORMED,
TEXT_SEARCH_RESULTS_PURGE,
TEXT_SEARCH_RESET,
TEXT_SEARCH_ADD_MARKER,
TEXT_SEARCH_TEXT_CHANGE,
searchTextStarted,
searchTextLoading,
searchResultError,
searchResultLoaded,
textSearch,
resultsPurge,
Expand Down
18 changes: 18 additions & 0 deletions web/client/api/WFS.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ const urlUtil = require('url');
const assign = require('object-assign');

const Api = {
/**
* Simple getFeature using http GET method with json format
*/
getFeatureSimple: function(baseUrl, params) {
return axios.get(baseUrl + '?service=WFS&version=1.1.0&request=GetFeature', {
params: assign({
service: "WFS",
version: "1.1.0",
request: "GetFeature",
format: "application/json"
}, params)
}).then((response) => {
if (typeof response.data !== 'object') {
return JSON.parse(response.data);
}
return response.data;
});
},
describeFeatureType: function(url, typeName) {
const parsed = urlUtil.parse(url, true);
const describeLayerUrl = urlUtil.format(assign({}, parsed, {
Expand Down
34 changes: 34 additions & 0 deletions web/client/api/searchText.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 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 WFS = require('./WFS');
const assign = require('object-assign');


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 = {
nominatim: (searchText) => require('./Nominatim').geocode(searchText).then( res => res.data),
wfs: (searchText, {url, typeName, queriableAttributes, outputFormat="application/json", predicate ="ILIKE", ...params }) => {
return WFS.getFeatureSimple(url,
assign({
maxFeatures: 10,
startIndex: 0,
typeName,
outputFormat,
cql_filter: queriableAttributes.map( attr => `${attr} ${predicate} '%${searchText}%'`).join(' OR ')
}, params)).then( response => toNominatim(response ));
}
};
28 changes: 24 additions & 4 deletions web/client/components/mapcontrols/search/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
*/

var React = require('react');
var {Input, Glyphicon} = require('react-bootstrap');
var {Input, Glyphicon, OverlayTrigger, Tooltip} = require('react-bootstrap');
var LocaleUtils = require('../../../utils/LocaleUtils');
var Spinner = require('react-spinkit');


var delay = (
function() {
Expand Down Expand Up @@ -38,6 +40,8 @@ let SearchBar = React.createClass({
blurResetDelay: React.PropTypes.number,
typeAhead: React.PropTypes.bool,
searchText: React.PropTypes.string,
loading: React.PropTypes.bool,
error: React.PropTypes.object,
style: React.PropTypes.object,
searchOptions: React.PropTypes.object
},
Expand Down Expand Up @@ -81,10 +85,25 @@ let SearchBar = React.createClass({
delay(() => {this.props.onPurgeResults(); }, this.props.blurResetDelay);
}
},
render() {
// const innerGlyphicon = <Button onClick={this.search}></Button>;
renderAddonAfter() {
const remove = <Glyphicon className="searchclear" glyph="remove" onClick={this.clearSearch}/>;
var showRemove = this.props.searchText !== "";
let addonAfter = showRemove ? [remove] : [<Glyphicon glyph="search"/>];
if (this.props.loading) {
addonAfter = [<Spinner style={{
position: "absolute",
right: "14px",
top: "8px"
}} spinnerName="pulse" noFadeIn/>, addonAfter];
}
if (this.props.error) {
let tooltip = <Tooltip id="tooltip">{this.props.error && this.props.error.message || null}</Tooltip>;
addonAfter.push(<OverlayTrigger placement="bottom" overlay={tooltip}><Glyphicon style={{color: "#b94a48"}} className="searcherror" glyph="warning-sign" onClick={this.clearSearch}/></OverlayTrigger>);
}
return addonAfter;
},
render() {
// const innerGlyphicon = <Button onClick={this.search}></Button>;
let placeholder;
if (!this.props.placeholder && this.context.messages) {
let placeholderLocMessage = LocaleUtils.getMessageById(this.context.messages, this.props.placeholderMsgId);
Expand All @@ -94,6 +113,7 @@ let SearchBar = React.createClass({
} else {
placeholder = this.props.placeholder;
}

return (
<div id="map-search-bar" style={this.props.style} className={"MapSearchBar" + (this.props.className ? " " + this.props.className : "")}>
<Input
Expand All @@ -105,7 +125,7 @@ let SearchBar = React.createClass({
}}
value={this.props.searchText}
ref="input"
addonAfter={showRemove ? remove : <Glyphicon glyph="search"/>}
addonAfter={this.renderAddonAfter()}
onKeyDown={this.onKeyDown}
onBlur={this.onBlur}
onFocus={this.onFocus}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,11 @@ describe("test the SearchBar", () => {
expect(spy.calls.length).toEqual(1);
expect(spy).toHaveBeenCalledWith('test', searchOptions);
});
it('test error and loading status', () => {
var TestUtils = React.addons.TestUtils;
const tb = ReactDOM.render(<SearchBar loading={true} error={{message: "TEST_ERROR"}}/>, document.getElementById("container"));
expect(tb).toExist();
let error = ReactDOM.findDOMNode(TestUtils.scryRenderedDOMComponentsWithClass(tb, "searcherror")[0]);
expect(error).toExist();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ let ResultList = React.createClass({
this.props.afterItemClick();
},
renderResults() {
return this.props.results.map((item)=> {return <NominatimResult key={item.osm_id} item={item} onItemClick={this.onItemClick}/>; });
return this.props.results.map((item, idx)=> {return <NominatimResult key={item.osm_id || "res_" + idx} item={item} onItemClick={this.onItemClick}/>; });
},
render() {
var notFoundMessage = this.props.notFoundMessage;
Expand Down
54 changes: 54 additions & 0 deletions web/client/epics/__tests__/search-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

/**
* Copyright 2015, 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');

const configureMockStore = require('redux-mock-store').default;
const { createEpicMiddleware } = require('redux-observable');
const { searchTextStarted, TEXT_SEARCH_RESULTS_LOADED, TEXT_SEARCH_LOADING } = require('../../actions/search');
const {searchEpic} = require('../search');
const epicMiddleware = createEpicMiddleware(searchEpic);
const mockStore = configureMockStore([epicMiddleware]);

describe('searchEpic', () => {
let store;
beforeEach(() => {
store = mockStore();
});

afterEach(() => {
// nock.cleanAll();
epicMiddleware.replaceEpic(searchEpic);
});

it('produces the search epic', (done) => {
let action = {
...searchTextStarted("TEST"),
services: [{
type: 'wfs',
options: {
url: 'base/web/client/test-resources/wfs/Wyoming.json',
typeName: 'topp:states',
queriableAttributes: ['STATE_NAME']
}
}]
};

store.dispatch( action );

setTimeout(() => {
let actions = store.getActions();
expect(actions.length).toBe(4);
expect(actions[1].type).toBe(TEXT_SEARCH_LOADING);
expect(actions[2].type).toBe(TEXT_SEARCH_RESULTS_LOADED);
expect(actions[3].type).toBe(TEXT_SEARCH_LOADING);
done();
}, 1000);
});
});
41 changes: 41 additions & 0 deletions web/client/epics/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* 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 GeoCodingApi = require('../api/Nominatim');

const {TEXT_SEARCH_STARTED, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_RESET, searchTextLoading, searchResultLoaded, searchResultError} = require('../actions/search');
const Rx = require('rxjs');
const services = require('../api/searchText');

const get = require('lodash');
const searchEpic = action$ =>
action$.ofType(TEXT_SEARCH_STARTED)
.debounceTime(250)
.mergeMap( action =>
Rx.Observable.forkJoin(
(action.services || [ {type: "nominatim"} ])
.map( service => services[service.type](action.searchText, service.options)
.then( (response= []) => response.map(result => ({...result, __SERVICE__: service})).slice(0, service.max || 10 / services.length) )
)
).concatAll()
// ----[a]------------------
// --------[c]--------------
// -------------[b]---------
.scan( (oldRes, newRes) => [...oldRes, ...newRes].sort( (a, b) => get(a, "_SERVICE__.priority") > get(b, "_SERVICE__.priority") ))
// ----[a]-[a,c]-[a,b,c]-----
.map((results) => searchResultLoaded(results, false))
.takeUntil(action$.ofType([ TEXT_SEARCH_STARTED, TEXT_SEARCH_RESULTS_PURGE, TEXT_SEARCH_RESET]))
.startWith(searchTextLoading(true))
.concat([searchTextLoading(false)])
.catch(e => Rx.Observable.from([searchResultError(e), searchTextLoading(false)]))

);

module.exports = {
searchEpic
};
Loading

0 comments on commit 7d03732

Please sign in to comment.