Skip to content

Commit

Permalink
feat(connetConfigure): add a connector to create a connector widget
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Maxime Janton authored and bobylito committed Apr 9, 2018
1 parent 7e639d6 commit 8fdf752
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 93 deletions.
2 changes: 2 additions & 0 deletions dev/app/builtin/init-stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import initSortBySelectorStories from './stories/sort-by-selector.stories';
import initStarRatingStories from './stories/star-rating.stories';
import initStatsStories from './stories/stats.stories';
import initToggleStories from './stories/toggle.stories';
import initConfigureStories from './stories/configure.stories';

export default () => {
initAnalyticsStories();
Expand All @@ -48,4 +49,5 @@ export default () => {
initStatsStories();
initStarRatingStories();
initToggleStories();
initConfigureStories();
};
29 changes: 29 additions & 0 deletions dev/app/builtin/stories/configure.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint-disable import/default */

import { storiesOf } from 'dev-novel';

import instantsearch from '../../../../index';
import { wrapWithHits } from '../../utils/wrap-with-hits.js';

const stories = storiesOf('Configure');

export default () => {
stories.add(
'Force 1 hit per page',
wrapWithHits(container => {
const description = document.createElement('div');
description.innerHTML = `
<p>Search parameters provied to the Configure widget:</p>
<pre>{ hitsPerPage: 1 }</pre>
`;

container.appendChild(description);

window.search.addWidget(
instantsearch.widgets.configure({
hitsPerPage: 1,
})
);
})
);
};
91 changes: 91 additions & 0 deletions src/connectors/configure/__tests__/connectConfigure-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import algoliasearchHelper, { SearchParameters } from 'algoliasearch-helper';

import connectConfigure from '../connectConfigure.js';

const fakeClient = { addAlgoliaAgent: () => {}, search: jest.fn() };

describe('connectConfigure', () => {
let helper;

beforeEach(() => {
helper = algoliasearchHelper(fakeClient, '', {});
});

describe('throws on bad usage', () => {
it('without searchParameters', () => {
const makeWidget = connectConfigure();
expect(() => makeWidget()).toThrow();
});

it('with a renderFn but no unmountFn', () => {
expect(() => connectConfigure(jest.fn(), undefined)).toThrow();
});

it('with a unmountFn but no renderFn', () => {
expect(() => connectConfigure(undefined, jest.fn())).toThrow();
});
});

it('should apply searchParameters', () => {
const makeWidget = connectConfigure();
const widget = makeWidget({ searchParameters: { analytics: true } });

const config = widget.getConfiguration(SearchParameters.make({}));
expect(config).toEqual({ analytics: true });
});

it('should apply searchParameters with a higher priority', () => {
const makeWidget = connectConfigure();
const widget = makeWidget({ searchParameters: { analytics: true } });

{
const config = widget.getConfiguration(
SearchParameters.make({ analytics: false })
);
expect(config).toEqual({ analytics: true });
}

{
const config = widget.getConfiguration(
SearchParameters.make({ analytics: false, extra: true })
);
expect(config).toEqual({ analytics: true });
}
});

it('should apply new searchParameters on refine()', () => {
const renderFn = jest.fn();
const makeWidget = connectConfigure(renderFn, jest.fn());
const widget = makeWidget({ searchParameters: { analytics: true } });

helper.setState(widget.getConfiguration());
widget.init({ helper });

expect(widget.getConfiguration()).toEqual({ analytics: true });
expect(helper.getState().analytics).toEqual(true);

const { refine } = renderFn.mock.calls[0][0];
expect(refine).toBe(widget._refine);

refine({ hitsPerPage: 3 });

expect(widget.getConfiguration()).toEqual({ hitsPerPage: 3 });
expect(helper.getState().analytics).toBe(undefined);
expect(helper.getState().hitsPerPage).toBe(3);
});

it('should dispose all the state set by configure', () => {
const makeWidget = connectConfigure();
const widget = makeWidget({ searchParameters: { analytics: true } });

helper.setState(widget.getConfiguration());
widget.init({ helper });

expect(widget.getConfiguration()).toEqual({ analytics: true });
expect(helper.getState().analytics).toBe(true);

const nextState = widget.dispose({ state: helper.getState() });

expect(nextState.analytics).toBe(undefined);
});
});
114 changes: 114 additions & 0 deletions src/connectors/configure/connectConfigure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import isFunction from 'lodash/isFunction';
import isPlainObject from 'lodash/isPlainObject';

import { enhanceConfiguration } from '../../lib/InstantSearch.js';

const usage = `Usage:
var customConfigureWidget = connectConfigure(
function renderFn(params, isFirstRendering) {
// params = {
// refine,
// widgetParams
// }
},
function disposeFn() {}
)
`;

/**
* @typedef {Object} CustomConfigureWidgetOptions
* @property {Object} searchParameters The Configure widget options are search parameters
*/

/**
* @typedef {Object} ConfigureRenderingOptions
* @property {function(searchParameters: Object)} refine Sets new `searchParameters` and trigger a search.
* @property {Object} widgetParams All original `CustomConfigureWidgetOptions` forwarded to the `renderFn`.
*/

/**
* The **Configure** connector provides the logic to build a custom widget
* that will give you ability to override or force some search parameters sent to Algolia API.
*
* @type {Connector}
* @param {function(ConfigureRenderingOptions)} renderFn Rendering function for the custom **Configure** Widget.
* @param {function} unmountFn Unmount function called when the widget is disposed.
* @return {function(CustomConfigureWidgetOptions)} Re-usable widget factory for a custom **Configure** widget.
*/
export default function connectConfigure(renderFn, unmountFn) {
if (
(isFunction(renderFn) && !isFunction(unmountFn)) ||
(!isFunction(renderFn) && isFunction(unmountFn))
) {
throw new Error(usage);
}

return (widgetParams = {}) => {
if (!isPlainObject(widgetParams.searchParameters)) {
throw new Error(usage);
}

return {
getConfiguration() {
return widgetParams.searchParameters;
},

init({ helper }) {
this._refine = this.refine(helper);

if (isFunction(renderFn)) {
renderFn(
{
refine: this._refine,
widgetParams,
},
true
);
}
},

refine(helper) {
return searchParameters => {
// merge new `searchParameters` with the ones set from other widgets
const actualState = this.removeSearchParameters(helper.getState());
const nextSearchParameters = enhanceConfiguration({})(actualState, {
getConfiguration: () => searchParameters,
});

// trigger a search with the new merged searchParameters
helper.setState(nextSearchParameters).search();

// update original `widgetParams.searchParameters` to the new refined one
widgetParams.searchParameters = searchParameters;
};
},

render() {
if (renderFn) {
renderFn(
{
refine: this._refine,
widgetParams,
},
false
);
}
},

dispose({ state }) {
if (unmountFn) unmountFn();
return this.removeSearchParameters(state);
},

removeSearchParameters(state) {
// widgetParams are assumed 'controlled',
// so they override whatever other widgets give the state
return state.mutateMe(mutableState => {
Object.keys(widgetParams.searchParameters).forEach(key => {
delete mutableState[key];
});
});
},
};
};
}
1 change: 1 addition & 0 deletions src/connectors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ export {
default as connectBreadcrumb,
} from './breadcrumb/connectBreadcrumb.js';
export { default as connectGeoSearch } from './geo-search/connectGeoSearch.js';
export { default as connectConfigure } from './configure/connectConfigure.js';
2 changes: 1 addition & 1 deletion src/lib/InstantSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ Usage: instantsearch({
}
}

function enhanceConfiguration(searchParametersFromUrl) {
export function enhanceConfiguration(searchParametersFromUrl) {
return (configuration, widgetDefinition) => {
if (!widgetDefinition.getConfiguration) return configuration;

Expand Down
89 changes: 89 additions & 0 deletions src/lib/__tests__/enhanceConfiguration-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { enhanceConfiguration } from '../InstantSearch';

const createWidget = (configuration = {}) => ({
getConfiguration: () => configuration,
});

describe('enhanceConfiguration', () => {
it('should return the same object if widget does not provide a configuration', () => {
const configuration = { analytics: true, page: 2 };
const widget = {};

const output = enhanceConfiguration({})(configuration, widget);
expect(output).toBe(configuration);
});

it('should return a new object if widget does provide a configuration', () => {
const configuration = { analytics: true, page: 2 };
const widget = createWidget(configuration);

const output = enhanceConfiguration({})(configuration, widget);
expect(output).not.toBe(configuration);
});

it('should add widget configuration to an empty state', () => {
const configuration = { analytics: true, page: 2 };
const widget = createWidget(configuration);

const output = enhanceConfiguration({})(configuration, widget);
expect(output).toEqual(configuration);
});

it('should call `getConfiguration` from widget correctly', () => {
const widget = { getConfiguration: jest.fn() };

const configuration = {};
const searchParametersFromUrl = {};
enhanceConfiguration(searchParametersFromUrl)(configuration, widget);

expect(widget.getConfiguration).toHaveBeenCalled();
expect(widget.getConfiguration).toHaveBeenCalledWith(
configuration,
searchParametersFromUrl
);
});

it('should replace boolean values', () => {
const actualConfiguration = { analytics: false };
const widget = createWidget({ analytics: true });

const output = enhanceConfiguration({})(actualConfiguration, widget);
expect(output.analytics).toBe(true);
});

it('should union array', () => {
{
const actualConfiguration = { refinements: ['foo'] };
const widget = createWidget({ refinements: ['foo', 'bar'] });

const output = enhanceConfiguration({})(actualConfiguration, widget);
expect(output.refinements).toEqual(['foo', 'bar']);
}

{
const actualConfiguration = { refinements: ['foo'] };
const widget = createWidget({ refinements: ['bar'] });

const output = enhanceConfiguration({})(actualConfiguration, widget);
expect(output.refinements).toEqual(['foo', 'bar']);
}

{
const actualConfiguration = { refinements: ['foo', 'bar'] };
const widget = createWidget({ refinements: [] });

const output = enhanceConfiguration({})(actualConfiguration, widget);
expect(output.refinements).toEqual(['foo', 'bar']);
}
});

it('should replace nested values', () => {
const actualConfiguration = { refinements: { lvl1: ['foo'], lvl2: false } };
const widget = createWidget({ refinements: { lvl1: ['bar'], lvl2: true } });

const output = enhanceConfiguration({})(actualConfiguration, widget);
expect(output).toEqual({
refinements: { lvl1: ['foo', 'bar'], lvl2: true },
});
});
});
Loading

0 comments on commit 8fdf752

Please sign in to comment.