Skip to content

Commit 8fdf752

Browse files
Maxime Jantonbobylito
authored andcommitted
feat(connetConfigure): add a connector to create a connector widget
* refactor(lib): extract `enhanceConfiguration` into utils * feat(connectors): add `connectConfigure` * feat(widgets): use `connectConfigure` * docs(dev-novel): add configure widget example * fix(configure): stick to the actual API * fix(connectConfigure): remove old searchParameters on refine * test(configure): move tests to connector * fix(connectConfigure): typos * test(connectConfigure): split bad usage * fix(connectConfigure): check usage for renderFn before * refactor(connectConfigure): review comments * refactor(configure): provide implicit undefined * test(connectConfigure): expect to throw on bad usage * test(connectConfigure): use `refine` from renderFn params * fix(connnectConfigure): typo on searchParameters * refactor(enhanceConfigure): export it from InstantSearch.js * test(enhanceConfiguration): unit testing
1 parent 7e639d6 commit 8fdf752

File tree

9 files changed

+335
-93
lines changed

9 files changed

+335
-93
lines changed

dev/app/builtin/init-stories.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import initSortBySelectorStories from './stories/sort-by-selector.stories';
2222
import initStarRatingStories from './stories/star-rating.stories';
2323
import initStatsStories from './stories/stats.stories';
2424
import initToggleStories from './stories/toggle.stories';
25+
import initConfigureStories from './stories/configure.stories';
2526

2627
export default () => {
2728
initAnalyticsStories();
@@ -48,4 +49,5 @@ export default () => {
4849
initStatsStories();
4950
initStarRatingStories();
5051
initToggleStories();
52+
initConfigureStories();
5153
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* eslint-disable import/default */
2+
3+
import { storiesOf } from 'dev-novel';
4+
5+
import instantsearch from '../../../../index';
6+
import { wrapWithHits } from '../../utils/wrap-with-hits.js';
7+
8+
const stories = storiesOf('Configure');
9+
10+
export default () => {
11+
stories.add(
12+
'Force 1 hit per page',
13+
wrapWithHits(container => {
14+
const description = document.createElement('div');
15+
description.innerHTML = `
16+
<p>Search parameters provied to the Configure widget:</p>
17+
<pre>{ hitsPerPage: 1 }</pre>
18+
`;
19+
20+
container.appendChild(description);
21+
22+
window.search.addWidget(
23+
instantsearch.widgets.configure({
24+
hitsPerPage: 1,
25+
})
26+
);
27+
})
28+
);
29+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import algoliasearchHelper, { SearchParameters } from 'algoliasearch-helper';
2+
3+
import connectConfigure from '../connectConfigure.js';
4+
5+
const fakeClient = { addAlgoliaAgent: () => {}, search: jest.fn() };
6+
7+
describe('connectConfigure', () => {
8+
let helper;
9+
10+
beforeEach(() => {
11+
helper = algoliasearchHelper(fakeClient, '', {});
12+
});
13+
14+
describe('throws on bad usage', () => {
15+
it('without searchParameters', () => {
16+
const makeWidget = connectConfigure();
17+
expect(() => makeWidget()).toThrow();
18+
});
19+
20+
it('with a renderFn but no unmountFn', () => {
21+
expect(() => connectConfigure(jest.fn(), undefined)).toThrow();
22+
});
23+
24+
it('with a unmountFn but no renderFn', () => {
25+
expect(() => connectConfigure(undefined, jest.fn())).toThrow();
26+
});
27+
});
28+
29+
it('should apply searchParameters', () => {
30+
const makeWidget = connectConfigure();
31+
const widget = makeWidget({ searchParameters: { analytics: true } });
32+
33+
const config = widget.getConfiguration(SearchParameters.make({}));
34+
expect(config).toEqual({ analytics: true });
35+
});
36+
37+
it('should apply searchParameters with a higher priority', () => {
38+
const makeWidget = connectConfigure();
39+
const widget = makeWidget({ searchParameters: { analytics: true } });
40+
41+
{
42+
const config = widget.getConfiguration(
43+
SearchParameters.make({ analytics: false })
44+
);
45+
expect(config).toEqual({ analytics: true });
46+
}
47+
48+
{
49+
const config = widget.getConfiguration(
50+
SearchParameters.make({ analytics: false, extra: true })
51+
);
52+
expect(config).toEqual({ analytics: true });
53+
}
54+
});
55+
56+
it('should apply new searchParameters on refine()', () => {
57+
const renderFn = jest.fn();
58+
const makeWidget = connectConfigure(renderFn, jest.fn());
59+
const widget = makeWidget({ searchParameters: { analytics: true } });
60+
61+
helper.setState(widget.getConfiguration());
62+
widget.init({ helper });
63+
64+
expect(widget.getConfiguration()).toEqual({ analytics: true });
65+
expect(helper.getState().analytics).toEqual(true);
66+
67+
const { refine } = renderFn.mock.calls[0][0];
68+
expect(refine).toBe(widget._refine);
69+
70+
refine({ hitsPerPage: 3 });
71+
72+
expect(widget.getConfiguration()).toEqual({ hitsPerPage: 3 });
73+
expect(helper.getState().analytics).toBe(undefined);
74+
expect(helper.getState().hitsPerPage).toBe(3);
75+
});
76+
77+
it('should dispose all the state set by configure', () => {
78+
const makeWidget = connectConfigure();
79+
const widget = makeWidget({ searchParameters: { analytics: true } });
80+
81+
helper.setState(widget.getConfiguration());
82+
widget.init({ helper });
83+
84+
expect(widget.getConfiguration()).toEqual({ analytics: true });
85+
expect(helper.getState().analytics).toBe(true);
86+
87+
const nextState = widget.dispose({ state: helper.getState() });
88+
89+
expect(nextState.analytics).toBe(undefined);
90+
});
91+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import isFunction from 'lodash/isFunction';
2+
import isPlainObject from 'lodash/isPlainObject';
3+
4+
import { enhanceConfiguration } from '../../lib/InstantSearch.js';
5+
6+
const usage = `Usage:
7+
var customConfigureWidget = connectConfigure(
8+
function renderFn(params, isFirstRendering) {
9+
// params = {
10+
// refine,
11+
// widgetParams
12+
// }
13+
},
14+
function disposeFn() {}
15+
)
16+
`;
17+
18+
/**
19+
* @typedef {Object} CustomConfigureWidgetOptions
20+
* @property {Object} searchParameters The Configure widget options are search parameters
21+
*/
22+
23+
/**
24+
* @typedef {Object} ConfigureRenderingOptions
25+
* @property {function(searchParameters: Object)} refine Sets new `searchParameters` and trigger a search.
26+
* @property {Object} widgetParams All original `CustomConfigureWidgetOptions` forwarded to the `renderFn`.
27+
*/
28+
29+
/**
30+
* The **Configure** connector provides the logic to build a custom widget
31+
* that will give you ability to override or force some search parameters sent to Algolia API.
32+
*
33+
* @type {Connector}
34+
* @param {function(ConfigureRenderingOptions)} renderFn Rendering function for the custom **Configure** Widget.
35+
* @param {function} unmountFn Unmount function called when the widget is disposed.
36+
* @return {function(CustomConfigureWidgetOptions)} Re-usable widget factory for a custom **Configure** widget.
37+
*/
38+
export default function connectConfigure(renderFn, unmountFn) {
39+
if (
40+
(isFunction(renderFn) && !isFunction(unmountFn)) ||
41+
(!isFunction(renderFn) && isFunction(unmountFn))
42+
) {
43+
throw new Error(usage);
44+
}
45+
46+
return (widgetParams = {}) => {
47+
if (!isPlainObject(widgetParams.searchParameters)) {
48+
throw new Error(usage);
49+
}
50+
51+
return {
52+
getConfiguration() {
53+
return widgetParams.searchParameters;
54+
},
55+
56+
init({ helper }) {
57+
this._refine = this.refine(helper);
58+
59+
if (isFunction(renderFn)) {
60+
renderFn(
61+
{
62+
refine: this._refine,
63+
widgetParams,
64+
},
65+
true
66+
);
67+
}
68+
},
69+
70+
refine(helper) {
71+
return searchParameters => {
72+
// merge new `searchParameters` with the ones set from other widgets
73+
const actualState = this.removeSearchParameters(helper.getState());
74+
const nextSearchParameters = enhanceConfiguration({})(actualState, {
75+
getConfiguration: () => searchParameters,
76+
});
77+
78+
// trigger a search with the new merged searchParameters
79+
helper.setState(nextSearchParameters).search();
80+
81+
// update original `widgetParams.searchParameters` to the new refined one
82+
widgetParams.searchParameters = searchParameters;
83+
};
84+
},
85+
86+
render() {
87+
if (renderFn) {
88+
renderFn(
89+
{
90+
refine: this._refine,
91+
widgetParams,
92+
},
93+
false
94+
);
95+
}
96+
},
97+
98+
dispose({ state }) {
99+
if (unmountFn) unmountFn();
100+
return this.removeSearchParameters(state);
101+
},
102+
103+
removeSearchParameters(state) {
104+
// widgetParams are assumed 'controlled',
105+
// so they override whatever other widgets give the state
106+
return state.mutateMe(mutableState => {
107+
Object.keys(widgetParams.searchParameters).forEach(key => {
108+
delete mutableState[key];
109+
});
110+
});
111+
},
112+
};
113+
};
114+
}

src/connectors/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ export {
4545
default as connectBreadcrumb,
4646
} from './breadcrumb/connectBreadcrumb.js';
4747
export { default as connectGeoSearch } from './geo-search/connectGeoSearch.js';
48+
export { default as connectConfigure } from './configure/connectConfigure.js';

src/lib/InstantSearch.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ Usage: instantsearch({
371371
}
372372
}
373373

374-
function enhanceConfiguration(searchParametersFromUrl) {
374+
export function enhanceConfiguration(searchParametersFromUrl) {
375375
return (configuration, widgetDefinition) => {
376376
if (!widgetDefinition.getConfiguration) return configuration;
377377

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { enhanceConfiguration } from '../InstantSearch';
2+
3+
const createWidget = (configuration = {}) => ({
4+
getConfiguration: () => configuration,
5+
});
6+
7+
describe('enhanceConfiguration', () => {
8+
it('should return the same object if widget does not provide a configuration', () => {
9+
const configuration = { analytics: true, page: 2 };
10+
const widget = {};
11+
12+
const output = enhanceConfiguration({})(configuration, widget);
13+
expect(output).toBe(configuration);
14+
});
15+
16+
it('should return a new object if widget does provide a configuration', () => {
17+
const configuration = { analytics: true, page: 2 };
18+
const widget = createWidget(configuration);
19+
20+
const output = enhanceConfiguration({})(configuration, widget);
21+
expect(output).not.toBe(configuration);
22+
});
23+
24+
it('should add widget configuration to an empty state', () => {
25+
const configuration = { analytics: true, page: 2 };
26+
const widget = createWidget(configuration);
27+
28+
const output = enhanceConfiguration({})(configuration, widget);
29+
expect(output).toEqual(configuration);
30+
});
31+
32+
it('should call `getConfiguration` from widget correctly', () => {
33+
const widget = { getConfiguration: jest.fn() };
34+
35+
const configuration = {};
36+
const searchParametersFromUrl = {};
37+
enhanceConfiguration(searchParametersFromUrl)(configuration, widget);
38+
39+
expect(widget.getConfiguration).toHaveBeenCalled();
40+
expect(widget.getConfiguration).toHaveBeenCalledWith(
41+
configuration,
42+
searchParametersFromUrl
43+
);
44+
});
45+
46+
it('should replace boolean values', () => {
47+
const actualConfiguration = { analytics: false };
48+
const widget = createWidget({ analytics: true });
49+
50+
const output = enhanceConfiguration({})(actualConfiguration, widget);
51+
expect(output.analytics).toBe(true);
52+
});
53+
54+
it('should union array', () => {
55+
{
56+
const actualConfiguration = { refinements: ['foo'] };
57+
const widget = createWidget({ refinements: ['foo', 'bar'] });
58+
59+
const output = enhanceConfiguration({})(actualConfiguration, widget);
60+
expect(output.refinements).toEqual(['foo', 'bar']);
61+
}
62+
63+
{
64+
const actualConfiguration = { refinements: ['foo'] };
65+
const widget = createWidget({ refinements: ['bar'] });
66+
67+
const output = enhanceConfiguration({})(actualConfiguration, widget);
68+
expect(output.refinements).toEqual(['foo', 'bar']);
69+
}
70+
71+
{
72+
const actualConfiguration = { refinements: ['foo', 'bar'] };
73+
const widget = createWidget({ refinements: [] });
74+
75+
const output = enhanceConfiguration({})(actualConfiguration, widget);
76+
expect(output.refinements).toEqual(['foo', 'bar']);
77+
}
78+
});
79+
80+
it('should replace nested values', () => {
81+
const actualConfiguration = { refinements: { lvl1: ['foo'], lvl2: false } };
82+
const widget = createWidget({ refinements: { lvl1: ['bar'], lvl2: true } });
83+
84+
const output = enhanceConfiguration({})(actualConfiguration, widget);
85+
expect(output).toEqual({
86+
refinements: { lvl1: ['foo', 'bar'], lvl2: true },
87+
});
88+
});
89+
});

0 commit comments

Comments
 (0)