Skip to content

Commit

Permalink
fix(Configure): use props a unique source of truth (#1967)
Browse files Browse the repository at this point in the history
* fix(Configure): use props a unique source of truth

* docs(geo-search): add example

this illustrate how configure parameters can be updated

* Update Search_parameters.md
  • Loading branch information
mthuret authored and vvo committed Feb 14, 2017
1 parent e9e78d7 commit 9d53d86
Show file tree
Hide file tree
Showing 12 changed files with 240 additions and 31 deletions.
3 changes: 3 additions & 0 deletions docgen/src/examples/Recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ examples: [{
}, {
id: 'react-router-v4',
title: 'Usage with react-router v4.'
}, {
id: 'geo-search',
title: 'Geo search using dynamic search parameters'
}]
examplesEndpoint: https://github.com/algolia/instantsearch.js/tree/v2/packages/react-instantsearch/examples
---
Expand Down
9 changes: 9 additions & 0 deletions docgen/src/guide/Search_parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ Here's an example configuring the [distinct parameter](https://www.algolia.com/d
* You could also pass `hitsPerPage: 20` to configure the number of hits being shown when not using
the [`<HitsPerPage>` widget](widgets/HitsPerPage.html).

## Dynamic search parameters updates

Every applied search parameters can be retrieved by listening to the `onSearchStateChange`
hook from the [`<InstantSearch>`](guide/<InstantSearch>.html) root component.

But to update the search parameters, you will need to pass updated props to the `<Configure/>` widget, directly modifying the search `state` prop and injecting it will have no effect.

[Read the example](https://github.com/algolia/instantsearch.js/tree/v2/packages/react-instantsearch/examples/geo-search) performing geo-search with `react-instantsearch` to see how you can update search parameters.

<div class="guide-nav">
<div class="guide-nav-left">
Previous: <a href="guide/Routing.html">← Routing</a>
Expand Down
Binary file added docgen/src/images/examples/geo-search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/react-instantsearch/examples/geo-search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
This example shows how to perform a geo search using `react-instantsearch`.

To start the example:

```sh
yarn install --no-lockfile
yarn start
```

Read more about `react-instantsearch` [in our documentation](https://community.algolia.com/instantsearch.js/react/).
19 changes: 19 additions & 0 deletions packages/react-instantsearch/examples/geo-search/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"private": true,
"devDependencies": {
"react-scripts": "^0.8.5"
},
"dependencies": {
"google-map-react": "^0.22.3",
"qs": "^6.3.0",
"react": "^15.4.2",
"react-dom": "^15.4.2",
"react-instantsearch": "latest",
"react-instantsearch-theme-algolia": "latest"
},
"scripts": {
"start": "react-scripts start"
},
"main": "index.js",
"license": "MIT"
}
12 changes: 12 additions & 0 deletions packages/react-instantsearch/examples/geo-search/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>geo-search with react-instantsearch</title>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
24 changes: 24 additions & 0 deletions packages/react-instantsearch/examples/geo-search/public/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
body {
font-family: Roboto;
font-size: 16px;
color: #565A5C;
}

.map{
padding-right: 0;
padding-left: 0;
height: 700px;
}

.marker {
transform: translate(-50%, -100%) scale(0.5, 0.5);
}

.ais-InstantSearch__root {
display: flex;
flex-direction: column;
}

.ais-SearchBox__root {
margin: 10px 0;
}
143 changes: 143 additions & 0 deletions packages/react-instantsearch/examples/geo-search/src/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
InstantSearch,
SearchBox,
Configure,
} from 'react-instantsearch/dom';
import {
connectHits,
} from 'react-instantsearch/connectors';

import React, {PropTypes} from 'react';
import GoogleMap from 'google-map-react';
import {fitBounds} from 'google-map-react/utils';
import 'react-instantsearch-theme-algolia/style.css';
import qs from 'qs';

const updateAfter = 700;
const searchStateToUrl =
searchState =>
searchState ? `${window.location.pathname}?${qs.stringify(searchState)}` : '';

class App extends React.Component {
constructor() {
super();

// retrieve searchState and aroundLatLng properties from the URL at first rendering
const initialSearchState = qs.parse(window.location.search.slice(1));
const aroundLatLng = initialSearchState.configure ? initialSearchState.configure.aroundLatLng : null;
this.state = {searchState: initialSearchState, aroundLatLng};

this.onSearchStateChange = this.onSearchStateChange.bind(this);
this.onLatLngChange = this.onLatLngChange.bind(this);
window.addEventListener(
'popstate',
({state: searchState}) => this.setState({searchState})
);
}

// when a click on the map is performed, the query is reset and the aroundLatLng property updated.
onLatLngChange = ({lat, lng}) => {
this.setState({searchState: {...this.state.searchState, query: ''}, aroundLatLng: `${lat},${lng}`});
};

onSearchStateChange = searchState => {
// update the URL when there is a new search state.
clearTimeout(this.debouncedSetState);
this.debouncedSetState = setTimeout(() => {
window.history.pushState(
searchState,
null,
searchStateToUrl(searchState)
);
}, updateAfter);

// when a new query is performed, removed the aroundLatLng property.
const aroundLatLng = this.state.searchState.query !== searchState.query ? null : this.state.aroundLatLng;
this.setState({searchState, aroundLatLng});
};

render() {
const configuration = this.state.aroundLatLng
? <Configure aroundLatLng={this.state.aroundLatLng} />
: <Configure aroundLatLngViaIP={true} aroundRadius="all" />;
return (
<InstantSearch
appId="latency"
apiKey="6be0576ff61c053d5f9a3225e2a90f76"
indexName="airbnb"
searchState={this.state.searchState}
onSearchStateChange={this.onSearchStateChange}
>
{configuration}
Either type a destination or click somewhere on the map to see the closest appartement.
<SearchBox />
<div className="map">
<ConnectedHitsMap onLatLngChange={this.onLatLngChange}/>
</div>
</InstantSearch>
);
}
}

function CustomMarker() {
/* eslint-disable max-len */
return (
<svg width="60" height="102" viewBox="0 0 102 60" className="marker">
<g fill="none" fillRule="evenodd">
<g transform="translate(-60, 0)" stroke="#8962B2" id="pin" viewBox="0 0 100 100">
<path
d="M157.39 34.315c0 18.546-33.825 83.958-33.825 83.958S89.74 52.86 89.74 34.315C89.74 15.768 104.885.73 123.565.73c18.68 0 33.825 15.038 33.825 33.585z"
strokeWidth="5.53" fill="#E6D2FC"></path>
<path
d="M123.565 49.13c-8.008 0-14.496-6.498-14.496-14.52 0-8.017 6.487-14.52 14.495-14.52s14.496 6.503 14.496 14.52c0 8.022-6.487 14.52-14.495 14.52z"
strokeWidth="2.765" fill="#FFF"></path>
</g>
</g>
</svg>);
/* eslint-enable max-len */
}

function HitsMap({hits, onLatLngChange}) {
const availableSpace = {
width: document.body.getBoundingClientRect().width * 5 / 12,
height: 400,
};
const boundingPoints = hits.reduce((bounds, hit) => {
const pos = hit;
if (pos.lat > bounds.nw.lat) bounds.nw.lat = pos.lat;
if (pos.lat < bounds.se.lat) bounds.se.lat = pos.lat;

if (pos.lng < bounds.nw.lng) bounds.nw.lng = pos.lng;
if (pos.lng > bounds.se.lng) bounds.se.lng = pos.lng;
return bounds;
}, {
nw: {lat: -85, lng: 180},
se: {lat: 85, lng: -180},
});
const boundsConfig = hits.length > 0 ? fitBounds(boundingPoints, availableSpace) : {};
const markers = hits.map(hit => <CustomMarker lat={hit.lat} lng={hit.lng} key={hit.objectID}></CustomMarker>);
const options = {
minZoomOverride: true,
minZoom: 2,
};
return (
<GoogleMap
options={() => options}
bootstrapURLKeys={{
key: 'AIzaSyAl60n7p07HYQK6lVilAe_ggwbBJFktNw8',
}}
center={boundsConfig.center}
zoom={boundsConfig.zoom}
onClick={onLatLngChange}
>{markers}</GoogleMap>
);
}

HitsMap.propTypes = {
hits: PropTypes.array,
onLatLngChange: PropTypes.func,
};

const ConnectedHitsMap = connectHits(HitsMap);

export default App;
5 changes: 5 additions & 0 deletions packages/react-instantsearch/examples/geo-search/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App/>, document.getElementById('root'));
19 changes: 6 additions & 13 deletions packages/react-instantsearch/src/connectors/connectConfigure.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import createConnector from '../core/createConnector.js';
import {omit, isEmpty} from 'lodash';
import {omit, isEmpty, difference, keys} from 'lodash';

const namespace = 'configure';

function getCurrentRefinement(props, searchState) {
return Object.keys(props).reduce((acc, item) => {
acc[item] = searchState[namespace] && searchState[namespace][item]
? searchState[namespace][item] : props[item];
return acc;
}, {});
}

export default createConnector({
displayName: 'AlgoliaConfigure',
getProvidedProps() {
return {};
},
getSearchParameters(searchParameters, props, searchState) {
getSearchParameters(searchParameters, props) {
const items = omit(props, 'children');
const configuration = getCurrentRefinement(items, searchState);
return searchParameters.setQueryParameters(configuration);
return searchParameters.setQueryParameters(items);
},
transitionState(props, prevSearchState, nextSearchState) {
const items = omit(props, 'children');
const nonPresentKeys = this._props ? difference(keys(this._props), keys(props)) : [];
this._props = props;
return {
...nextSearchState,
[namespace]: {...items, ...nextSearchState[namespace]},
[namespace]: {...omit(nextSearchState[namespace], nonPresentKeys), ...items},
};
},
cleanUp(props, searchState) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,22 @@ describe('connectConfigure', () => {
expect(searchParameters.getQueryParameter.bind(searchParameters, 'children')).toThrow();
});

it('configure parameters that are in the search state should override the default one', () => {
const searchParameters = getSearchParameters(
new SearchParameters(),
{distinct: 1, whatever: 'please', children: 'whatever'},
{configure: {whatever: 'priority'}}
);
expect(searchParameters.getQueryParameter('distinct')).toEqual(1);
expect(searchParameters.getQueryParameter('whatever')).toEqual('priority');
expect(searchParameters.getQueryParameter.bind(searchParameters, 'children')).toThrow();
});

it('calling transitionState should add configure parameters to the search state', () => {
let searchState = transitionState(
const providedThis = {};
let searchState = transitionState.call(providedThis,
{distinct: 1, whatever: 'please', children: 'whatever'},
{},
{}
);
expect(searchState).toEqual({configure: {distinct: 1, whatever: 'please'}});

searchState = transitionState(
{distinct: 1, whatever: 'please', children: 'whatever'},
searchState = transitionState.call(providedThis,
{whatever: 'other', children: 'whatever'},
{configure: {distinct: 1, whatever: 'please'}},
{configure: {distinct: 1, whatever: 'please'}},
{configure: {distinct: 1, whatever: 'whatever'}},
);
expect(searchState).toEqual({configure: {distinct: 1, whatever: 'whatever'}});

expect(searchState).toEqual({configure: {whatever: 'other'}});
});

it('calling cleanUp should remove configure parameters from the search state', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/react-instantsearch/src/core/createConnector.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function createConnector(connectorDesc) {
) :
null;
const transitionState = hasTransitionState ?
(prevWidgetsState, nextWidgetsState) => connectorDesc.transitionState(
(prevWidgetsState, nextWidgetsState) => connectorDesc.transitionState.call(this,
this.props,
prevWidgetsState,
nextWidgetsState
Expand All @@ -107,7 +107,7 @@ export default function createConnector(connectorDesc) {
// and getMetadata with the new props.
this.context.ais.widgetsManager.update();
if (connectorDesc.transitionState) {
this.context.ais.onSearchStateChange(connectorDesc.transitionState(
this.context.ais.onSearchStateChange(connectorDesc.transitionState.call(this,
nextProps,
this.context.ais.store.getState().widgets,
this.context.ais.store.getState().widgets,
Expand Down

0 comments on commit 9d53d86

Please sign in to comment.