diff --git a/dev/app/builtin/stories/search-box.stories.js b/dev/app/builtin/stories/search-box.stories.js
index 6620a421a7..038f8549fa 100644
--- a/dev/app/builtin/stories/search-box.stories.js
+++ b/dev/app/builtin/stories/search-box.stories.js
@@ -20,6 +20,34 @@ export default () => {
);
})
)
+ .add(
+ 'display loading indicator',
+ wrapWithHits(container => {
+ window.search.addWidget(
+ instantsearch.widgets.searchBox({
+ container,
+ placeholder: 'Search for products',
+ poweredBy: true,
+ loadingIndicator: true,
+ })
+ );
+ })
+ )
+ .add(
+ 'display loading indicator with a template',
+ wrapWithHits(container => {
+ window.search.addWidget(
+ instantsearch.widgets.searchBox({
+ container,
+ placeholder: 'Search for products',
+ poweredBy: true,
+ loadingIndicator: {
+ template: '⚡️',
+ },
+ })
+ );
+ })
+ )
.add(
'with custom templates',
wrapWithHits(container => {
@@ -66,5 +94,32 @@ export default () => {
})
);
})
+ )
+ .add(
+ 'with a provided input',
+ wrapWithHits(container => {
+ container.innerHTML = '';
+ const input = container.firstChild;
+ container.appendChild(input);
+ window.search.addWidget(
+ instantsearch.widgets.searchBox({
+ container: input,
+ })
+ );
+ })
+ )
+ .add(
+ 'with a provided input and the loading indicator',
+ wrapWithHits(container => {
+ container.innerHTML = '';
+ const input = container.firstChild;
+ container.appendChild(input);
+ window.search.addWidget(
+ instantsearch.widgets.searchBox({
+ container: input,
+ loadingIndicator: true,
+ })
+ );
+ })
);
};
diff --git a/scripts/validate-commit-msgs.sh b/scripts/validate-commit-msgs.sh
index 34ce439804..23466a06ab 100755
--- a/scripts/validate-commit-msgs.sh
+++ b/scripts/validate-commit-msgs.sh
@@ -39,13 +39,13 @@ for sha in `git log --format=oneline "$RANGE" | cut '-d ' -f1`; do
FIRST_LINE=`git log --format=%B -n 1 $sha | head -1`
MSG_LENGTH=`echo "$FIRST_LINE" | wc -c`
- if [ $MSG_LENGTH -gt 100 ]; then
+ if echo $FIRST_LINE | grep -qE '^Merge (pull request|branch)'; then
+ echo "OK (merge)"
+ elif [ $MSG_LENGTH -gt 100 ]; then
echo "KO (too long): $FIRST_LINE"
EXIT=2
elif echo $FIRST_LINE | grep -qE '^v\d+\.\d+\.\d+(-beta\.\d+)?'; then
echo "OK (version)"
- elif echo $FIRST_LINE | grep -qE '^Merge (pull request|branch)'; then
- echo "OK (merge)"
elif echo $FIRST_LINE | grep -qE '^(feat|fix|docs?|style|refactor|perf|tests?|chore|revert)(\(.+\))?: .*'; then
echo "OK"
else
diff --git a/src/connectors/search-box/__tests__/connectSearchBox-test.js b/src/connectors/search-box/__tests__/connectSearchBox-test.js
index 3b13b6902b..9846bd538f 100644
--- a/src/connectors/search-box/__tests__/connectSearchBox-test.js
+++ b/src/connectors/search-box/__tests__/connectSearchBox-test.js
@@ -1,5 +1,3 @@
-import sinon from 'sinon';
-
import jsHelper from 'algoliasearch-helper';
const SearchResults = jsHelper.SearchResults;
@@ -11,7 +9,7 @@ describe('connectSearchBox', () => {
it('Renders during init and render', () => {
// test that the dummyRendering is called with the isFirstRendering
// flag set accordingly
- const rendering = sinon.stub();
+ const rendering = jest.fn();
const makeWidget = connectSearchBox(rendering);
const widget = makeWidget({
@@ -21,7 +19,7 @@ describe('connectSearchBox', () => {
expect(widget.getConfiguration).toBe(undefined);
const helper = jsHelper(fakeClient);
- helper.search = sinon.stub();
+ helper.search = () => {};
widget.init({
helper,
@@ -30,46 +28,45 @@ describe('connectSearchBox', () => {
onHistoryChange: () => {},
});
- {
- // should call the rendering once with isFirstRendering to true
- expect(rendering.callCount).toBe(1);
- const isFirstRendering = rendering.lastCall.args[1];
- expect(isFirstRendering).toBe(true);
-
- // should provide good values for the first rendering
- const { query, widgetParams } = rendering.lastCall.args[0];
- expect(query).toBe(helper.state.query);
- expect(widgetParams).toEqual({ foo: 'bar' });
- }
+ // should call the rendering once with isFirstRendering to true
+ expect(rendering).toHaveBeenCalledTimes(1);
+ // should provide good values for the first rendering
+ expect(rendering).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ query: helper.state.query,
+ widgetParams: { foo: 'bar' },
+ }),
+ true
+ );
widget.render({
results: new SearchResults(helper.state, [{}]),
state: helper.state,
helper,
createURL: () => '#',
+ searchMetadata: { isSearchStalled: false },
});
- {
- // Should call the rendering a second time, with isFirstRendering to false
- expect(rendering.callCount).toBe(2);
- const isFirstRendering = rendering.lastCall.args[1];
- expect(isFirstRendering).toBe(false);
-
- // should provide good values after the first search
- const { query, widgetParams } = rendering.lastCall.args[0];
- expect(query).toBe(helper.state.query);
- expect(widgetParams).toEqual({ foo: 'bar' });
- }
+ // Should call the rendering a second time, with isFirstRendering to false
+ expect(rendering).toHaveBeenCalledTimes(2);
+ // should provide good values after the first search
+ expect(rendering).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ query: helper.state.query,
+ widgetParams: { foo: 'bar' },
+ }),
+ false
+ );
});
it('Provides a function to update the refinements at each step', () => {
- const rendering = sinon.stub();
+ const rendering = jest.fn();
const makeWidget = connectSearchBox(rendering);
const widget = makeWidget();
const helper = jsHelper(fakeClient);
- helper.search = sinon.stub();
+ helper.search = jest.fn();
widget.init({
helper,
@@ -81,11 +78,10 @@ describe('connectSearchBox', () => {
{
// first rendering
expect(helper.state.query).toBe('');
- const renderOptions = rendering.lastCall.args[0];
- const { refine } = renderOptions;
+ const { refine } = rendering.mock.calls[0][0];
refine('bip');
expect(helper.state.query).toBe('bip');
- expect(helper.search.callCount).toBe(1);
+ expect(helper.search).toHaveBeenCalledTimes(1);
}
widget.render({
@@ -93,28 +89,30 @@ describe('connectSearchBox', () => {
state: helper.state,
helper,
createURL: () => '#',
+ searchMetadata: { isSearchStalled: false },
});
{
// Second rendering
expect(helper.state.query).toBe('bip');
- const renderOptions = rendering.lastCall.args[0];
- const { refine, query } = renderOptions;
+ const { refine, query } = rendering.mock.calls[1][0];
expect(query).toBe('bip');
refine('bop');
expect(helper.state.query).toBe('bop');
- expect(helper.search.callCount).toBe(2);
+ expect(helper.search).toHaveBeenCalledTimes(2);
}
});
it('provides a function to clear the query and perform new search', () => {
- const rendering = sinon.stub();
+ const rendering = jest.fn();
const makeWidget = connectSearchBox(rendering);
const widget = makeWidget();
- const helper = jsHelper(fakeClient);
- helper.search = sinon.stub();
+ const helper = jsHelper(fakeClient, '', {
+ query: 'bup',
+ });
+ helper.search = jest.fn();
widget.init({
helper,
@@ -125,12 +123,12 @@ describe('connectSearchBox', () => {
{
// first rendering
+ expect(helper.state.query).toBe('bup');
+ const { refine, clear } = rendering.mock.calls[0][0];
+ clear(); // triggers a search
expect(helper.state.query).toBe('');
- const renderOptions = rendering.lastCall.args[0];
- const { refine } = renderOptions;
- refine('bip');
- expect(helper.state.query).toBe('bip');
- expect(helper.search.callCount).toBe(1);
+ expect(helper.search).toHaveBeenCalledTimes(1);
+ refine('bip'); // triggers a search
}
widget.render({
@@ -138,27 +136,27 @@ describe('connectSearchBox', () => {
state: helper.state,
helper,
createURL: () => '#',
+ searchMetadata: { isSearchStalled: false },
});
{
// Second rendering
expect(helper.state.query).toBe('bip');
- const renderOptions = rendering.lastCall.args[0];
- const { clear, query } = renderOptions;
- expect(query).toBe('bip');
- clear();
+ const { clear } = rendering.mock.calls[1][0];
+ clear(); // triggers a search
expect(helper.state.query).toBe('');
- expect(helper.search.callCount).toBe(2);
+ // refine and clear functions trigger searches. clear + refine + clear
+ expect(helper.search).toHaveBeenCalledTimes(3);
}
});
it('queryHook parameter let the dev control the behavior of the search', () => {
- const rendering = sinon.stub();
+ const rendering = jest.fn();
const makeWidget = connectSearchBox(rendering);
// letSearchThrough will control if the provided function should be called
let letSearchThrough = false;
- const queryHook = sinon.spy((q, search) => {
+ const queryHook = jest.fn((q, search) => {
if (letSearchThrough) search(q);
});
@@ -167,7 +165,7 @@ describe('connectSearchBox', () => {
});
const helper = jsHelper(fakeClient);
- helper.search = sinon.stub();
+ helper.search = jest.fn();
widget.init({
helper,
@@ -178,19 +176,18 @@ describe('connectSearchBox', () => {
{
// first rendering
- const renderOptions = rendering.lastCall.args[0];
- const { refine } = renderOptions;
+ const { refine } = rendering.mock.calls[0][0];
refine('bip');
- expect(queryHook.callCount).toBe(1);
+ expect(queryHook).toHaveBeenCalledTimes(1);
expect(helper.state.query).toBe('');
- expect(helper.search.callCount).toBe(0);
+ expect(helper.search).not.toHaveBeenCalled();
letSearchThrough = true;
refine('bip');
- expect(queryHook.callCount).toBe(2);
+ expect(queryHook).toHaveBeenCalledTimes(2);
expect(helper.state.query).toBe('bip');
- expect(helper.search.callCount).toBe(1);
+ expect(helper.search).toHaveBeenCalledTimes(1);
}
// reset the hook behavior
@@ -201,34 +198,34 @@ describe('connectSearchBox', () => {
state: helper.state,
helper,
createURL: () => '#',
+ searchMetadata: { isSearchStalled: false },
});
{
// Second rendering
- const renderOptions = rendering.lastCall.args[0];
- const { refine } = renderOptions;
+ const { refine } = rendering.mock.calls[1][0];
refine('bop');
- expect(queryHook.callCount).toBe(3);
+ expect(queryHook).toHaveBeenCalledTimes(3);
expect(helper.state.query).toBe('bip');
- expect(helper.search.callCount).toBe(1);
+ expect(helper.search).toHaveBeenCalledTimes(1);
letSearchThrough = true;
refine('bop');
- expect(queryHook.callCount).toBe(4);
+ expect(queryHook).toHaveBeenCalledTimes(4);
expect(helper.state.query).toBe('bop');
- expect(helper.search.callCount).toBe(2);
+ expect(helper.search).toHaveBeenCalledTimes(2);
}
});
it('should always provide the same refine() and clear() function reference', () => {
- const rendering = sinon.stub();
+ const rendering = jest.fn();
const makeWidget = connectSearchBox(rendering);
const widget = makeWidget();
const helper = jsHelper(fakeClient);
- helper.search = sinon.stub();
+ helper.search = () => {};
widget.init({
helper,
@@ -242,31 +239,33 @@ describe('connectSearchBox', () => {
state: helper.state,
helper,
createURL: () => '#',
+ searchMetadata: { isSearchStalled: false },
});
- const firstRenderOptions = rendering.lastCall.args[0];
+ const firstRenderOptions = rendering.mock.calls[0][0];
widget.render({
results: new SearchResults(helper.state, [{}]),
state: helper.state,
helper,
createURL: () => '#',
+ searchMetadata: { isSearchStalled: false },
});
- const secondRenderOptions = rendering.lastCall.args[0];
+ const secondRenderOptions = rendering.mock.calls[1][0];
expect(firstRenderOptions.clear).toBe(secondRenderOptions.clear);
expect(firstRenderOptions.refine).toBe(secondRenderOptions.refine);
});
it('should clear on init as well', () => {
- const rendering = sinon.stub();
+ const rendering = jest.fn();
const makeWidget = connectSearchBox(rendering);
const widget = makeWidget();
const helper = jsHelper(fakeClient);
- helper.search = sinon.stub();
+ helper.search = jest.fn();
helper.setQuery('foobar');
expect(helper.state.query).toBe('foobar');
@@ -278,10 +277,10 @@ describe('connectSearchBox', () => {
onHistoryChange: () => {},
});
- const renderingOptions = rendering.lastCall.args[0];
- renderingOptions.clear();
+ const { clear } = rendering.mock.calls[0][0];
+ clear();
expect(helper.state.query).toBe('');
- expect(helper.search.called).toBe(true);
+ expect(helper.search).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/connectors/search-box/connectSearchBox.js b/src/connectors/search-box/connectSearchBox.js
index 0e47fbe4ad..487e2eb0d2 100644
--- a/src/connectors/search-box/connectSearchBox.js
+++ b/src/connectors/search-box/connectSearchBox.js
@@ -35,6 +35,9 @@ Full documentation available at https://community.algolia.com/instantsearch.js/c
* @property {function(string)} refine Sets a new query and searches.
* @property {function()} clear Remove the query and perform search.
* @property {Object} widgetParams All original `CustomSearchBoxWidgetOptions` forwarded to the `renderFn`.
+ * @property {boolean} isSearchStalled `true` if the search results takes more than a certain time to come back
+ * from Algolia servers. This can be configured on the InstantSearch constructor with the attribute
+ * `stalledSearchDelay` which is 200ms, by default.
*/
/**
@@ -127,7 +130,7 @@ export default function connectSearchBox(renderFn, unmountFn) {
);
},
- render({ helper, instantSearchInstance }) {
+ render({ helper, instantSearchInstance, searchMetadata }) {
this._clear = clear(helper);
renderFn(
@@ -138,6 +141,7 @@ export default function connectSearchBox(renderFn, unmountFn) {
clear: this._cachedClear,
widgetParams,
instantSearchInstance,
+ isSearchStalled: searchMetadata.isSearchStalled,
},
false
);
diff --git a/src/css/default/_search-box.scss b/src/css/default/_search-box.scss
index 514724f278..841d356b89 100644
--- a/src/css/default/_search-box.scss
+++ b/src/css/default/_search-box.scss
@@ -29,6 +29,21 @@
}
}
+ @include element(loading-indicator-wrapper) {
+ display: none;
+ background: transparent;
+ position: absolute;
+ user-select: none;
+ top: 4px;
+ left: 7px;
+
+ svg {
+ vertical-align: middle;
+ height: 14px;
+ width: 14px;
+ }
+ }
+
@include element(reset) {
background: none;
cursor: pointer;
@@ -69,3 +84,14 @@
vertical-align: middle;
}
}
+
+.ais-search-box.ais-stalled-search {
+ .ais-search-box--magnifier-wrapper {
+ display: none;
+ }
+
+ .ais-search-box--loading-indicator-wrapper {
+ display: block;
+ }
+}
+
diff --git a/src/css/theme/_search-box.scss b/src/css/theme/_search-box.scss
index a1c2bacd2c..7bce36b556 100644
--- a/src/css/theme/_search-box.scss
+++ b/src/css/theme/_search-box.scss
@@ -5,6 +5,7 @@
white-space: nowrap;
font-size: 14px;
+
&--input {
appearance: none;
font: inherit;
@@ -42,4 +43,15 @@
width: 18px;
}
}
+
+ &--loading-indicator-wrapper {
+ fill: #BFC7D8;
+ left: 12px;
+ top: calc(50% - 18px / 2);
+
+ svg {
+ height: 18px;
+ width: 18px;
+ }
+ }
}
diff --git a/src/lib/InstantSearch.js b/src/lib/InstantSearch.js
index 8ba89e77a2..2daa8ae99a 100644
--- a/src/lib/InstantSearch.js
+++ b/src/lib/InstantSearch.js
@@ -42,6 +42,7 @@ class InstantSearch extends EventEmitter {
urlSync = null,
searchFunction,
createAlgoliaClient = defaultCreateAlgoliaClient,
+ stalledSearchDelay = 200,
}) {
super();
if (appId === null || apiKey === null || indexName === null) {
@@ -66,6 +67,7 @@ Usage: instantsearch({
helpers: createHelpers({ numberLocale }),
compileOptions: {},
};
+ this._stalledSearchDelay = stalledSearchDelay;
if (searchFunction) {
this._searchFunction = searchFunction;
@@ -260,8 +262,25 @@ Usage: instantsearch({
this.helper = helper;
this._init(helper.state, this.helper);
this.helper.on('result', this._render.bind(this, this.helper));
+
+ this._searchStalledTimer = null;
+ this._isSearchStalled = false;
+
this.helper.search();
+ this.helper.on('search', () => {
+ if (!this._searchStalledTimer) {
+ this._searchStalledTimer = setTimeout(() => {
+ this._isSearchStalled = true;
+ this._render(
+ this.helper,
+ this.helper.lastResults,
+ this.helper.lastResults._state
+ );
+ }, this._stalledSearchDelay);
+ }
+ });
+
// track we started the search if we add more widgets,
// to init them directly after add
this.started = true;
@@ -285,6 +304,12 @@ Usage: instantsearch({
}
_render(helper, results, state) {
+ if (!this.helper.hasPendingRequests()) {
+ clearTimeout(this._searchStalledTimer);
+ this._searchStalledTimer = null;
+ this._isSearchStalled = false;
+ }
+
forEach(this.widgets, widget => {
if (!widget.render) {
return;
@@ -296,6 +321,9 @@ Usage: instantsearch({
helper,
createURL: this._createAbsoluteURL,
instantSearchInstance: this,
+ searchMetadata: {
+ isSearchStalled: this._isSearchStalled,
+ },
});
});
diff --git a/src/lib/__tests__/InstantSearch-test-2.js b/src/lib/__tests__/InstantSearch-test-2.js
index 9ded64195e..eaab98e9a1 100644
--- a/src/lib/__tests__/InstantSearch-test-2.js
+++ b/src/lib/__tests__/InstantSearch-test-2.js
@@ -1,20 +1,20 @@
-import sinon from 'sinon';
-
// import algoliaSearchHelper from 'algoliasearch-helper';
import InstantSearch from '../InstantSearch';
+jest.useFakeTimers();
+
const appId = 'appId';
const apiKey = 'apiKey';
const indexName = 'lifecycle';
describe('InstantSearch lifecycle', () => {
it('calls the provided searchFunction when used', () => {
- const searchFunctionSpy = sinon.spy(h => {
+ const searchFunctionSpy = jest.fn(h => {
h.setQuery('test').search();
});
const fakeClient = {
- search: sinon.spy(),
+ search: jest.fn(),
addAlgoliaAgent: () => {},
};
@@ -26,13 +26,121 @@ describe('InstantSearch lifecycle', () => {
createAlgoliaClient: () => fakeClient,
});
- expect(searchFunctionSpy.callCount).toBe(0);
- expect(fakeClient.search.callCount).toBe(0);
+ expect(searchFunctionSpy).not.toHaveBeenCalled();
+ expect(fakeClient.search).not.toHaveBeenCalled();
search.start();
- expect(searchFunctionSpy.callCount).toBe(1);
+ expect(searchFunctionSpy).toHaveBeenCalledTimes(1);
expect(search.helper.state.query).toBe('test');
- expect(fakeClient.search.callCount).toBe(1);
+ expect(fakeClient.search).toHaveBeenCalledTimes(1);
+ });
+
+ const fakeResults = () => ({
+ results: [
+ {
+ hits: [{}, {}],
+ nbHits: 2,
+ page: 0,
+ nbPages: 1,
+ hitsPerPage: 4,
+ processingTimeMS: 1,
+ exhaustiveNbHits: true,
+ query: '',
+ params: '',
+ index: 'quick_links',
+ },
+ ],
+ });
+
+ it('triggers the stalled search rendering once if the search does not resolve in time', () => {
+ const searchResultsResolvers = [];
+ const searchResultsPromises = [];
+ const fakeClient = {
+ search: jest.fn((qs, cb) => {
+ const p = new Promise(resolve =>
+ searchResultsResolvers.push(resolve)
+ ).then(() => {
+ cb(null, fakeResults());
+ });
+ searchResultsPromises.push(p);
+ }),
+ addAlgoliaAgent: () => {},
+ };
+
+ const search = new InstantSearch({
+ appId,
+ apiKey,
+ indexName,
+ createAlgoliaClient: () => fakeClient,
+ });
+
+ const widget = {
+ getConfiguration: jest.fn(),
+ init: jest.fn(),
+ render: jest.fn(),
+ };
+
+ search.addWidget(widget);
+
+ // when a widget is added the methods of the widget are not called
+ expect(widget.getConfiguration).not.toHaveBeenCalled();
+ expect(widget.init).not.toHaveBeenCalled();
+ expect(widget.render).not.toHaveBeenCalled();
+
+ search.start();
+
+ // During start, IS.js calls the getConfiguration, init and then send a search
+ expect(widget.getConfiguration).toHaveBeenCalledTimes(1);
+ expect(widget.init).toHaveBeenCalledTimes(1);
+ expect(widget.render).not.toHaveBeenCalled();
+
+ // first results come back
+ searchResultsResolvers[0]();
+
+ return searchResultsPromises[0].then(() => {
+ // render has now been called
+ expect(widget.render).toHaveBeenCalledTimes(1);
+
+ expect(widget.render).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ searchMetadata: {
+ isSearchStalled: false,
+ },
+ })
+ );
+
+ // New search
+ search.helper.search();
+ // results are not back yet
+ expect(widget.render).toHaveBeenCalledTimes(1);
+ // delay is reached
+ jest.runAllTimers();
+
+ expect(widget.render).toHaveBeenCalledTimes(2);
+ expect(widget.render).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ searchMetadata: {
+ isSearchStalled: true,
+ },
+ })
+ );
+
+ searchResultsResolvers[1]();
+ return searchResultsPromises[1].then(() => {
+ expect(widget.render).toHaveBeenCalledTimes(3);
+ expect(widget.render).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ searchMetadata: {
+ isSearchStalled: false,
+ },
+ })
+ );
+
+ // getConfiguration and init are not called a second time
+ expect(widget.getConfiguration).toHaveBeenCalledTimes(1);
+ expect(widget.init).toHaveBeenCalledTimes(1);
+ });
+ });
});
});
diff --git a/src/lib/__tests__/InstantSearch-test.js b/src/lib/__tests__/InstantSearch-test.js
index a27e411cc2..0eb894c38f 100644
--- a/src/lib/__tests__/InstantSearch-test.js
+++ b/src/lib/__tests__/InstantSearch-test.js
@@ -254,16 +254,7 @@ describe('InstantSearch lifecycle', () => {
true,
'widget.render called once'
);
- expect(widget.render.args[0]).toEqual([
- {
- createURL: search._createAbsoluteURL,
- results,
- state: helper.state,
- helper,
- templatesConfig: search.templatesConfig,
- instantSearchInstance: search,
- },
- ]);
+ expect(widget.render.args[0]).toMatchSnapshot();
});
});
});
diff --git a/src/lib/__tests__/__snapshots__/InstantSearch-test.js.snap b/src/lib/__tests__/__snapshots__/InstantSearch-test.js.snap
new file mode 100644
index 0000000000..9e7ebf2010
--- /dev/null
+++ b/src/lib/__tests__/__snapshots__/InstantSearch-test.js.snap
@@ -0,0 +1,270 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`InstantSearch lifecycle when adding a widget when we call search.start when we have results calls widget.render({results, state, helper, templatesConfig, instantSearchInstance}) 1`] = `
+Array [
+ Object {
+ "createURL": [Function],
+ "helper": AlgoliaSearchHelper {
+ "_currentNbQueries": 0,
+ "_events": Object {
+ "result": [Function],
+ "search": [Function],
+ },
+ "_eventsCount": 2,
+ "_lastQueryIdReceived": -1,
+ "_queryId": 0,
+ "client": Object {
+ "addAlgoliaAgent": [Function],
+ "algolia": "client",
+ },
+ "derivedHelpers": Array [],
+ "lastResults": null,
+ "search": [Function],
+ "state": SearchParameters {
+ "advancedSyntax": undefined,
+ "allowTyposOnNumericTokens": undefined,
+ "analytics": undefined,
+ "analyticsTags": undefined,
+ "aroundLatLng": undefined,
+ "aroundLatLngViaIP": undefined,
+ "aroundPrecision": undefined,
+ "aroundRadius": undefined,
+ "attributesToHighlight": undefined,
+ "attributesToRetrieve": undefined,
+ "attributesToSnippet": undefined,
+ "disableExactOnAttributes": undefined,
+ "disjunctiveFacets": Array [],
+ "disjunctiveFacetsRefinements": Object {},
+ "distinct": undefined,
+ "enableExactOnSingleWordQuery": undefined,
+ "facets": Array [],
+ "facetsExcludes": Object {},
+ "facetsRefinements": Object {},
+ "getRankingInfo": undefined,
+ "hierarchicalFacets": Array [],
+ "hierarchicalFacetsRefinements": Object {},
+ "highlightPostTag": undefined,
+ "highlightPreTag": undefined,
+ "hitsPerPage": undefined,
+ "ignorePlurals": undefined,
+ "index": "",
+ "insideBoundingBox": undefined,
+ "insidePolygon": undefined,
+ "length": undefined,
+ "maxValuesPerFacet": undefined,
+ "minProximity": undefined,
+ "minWordSizefor1Typo": undefined,
+ "minWordSizefor2Typos": undefined,
+ "minimumAroundRadius": undefined,
+ "numericFilters": undefined,
+ "numericRefinements": Object {},
+ "offset": undefined,
+ "optionalFacetFilters": undefined,
+ "optionalTagFilters": undefined,
+ "optionalWords": undefined,
+ "page": 0,
+ "query": "",
+ "queryType": undefined,
+ "removeWordsIfNoResults": undefined,
+ "replaceSynonymsInHighlight": undefined,
+ "restrictSearchableAttributes": undefined,
+ "sendMeToUrlSync": true,
+ "snippetEllipsisText": undefined,
+ "synonyms": undefined,
+ "tagFilters": undefined,
+ "tagRefinements": Array [],
+ "typoTolerance": undefined,
+ },
+ },
+ "instantSearchInstance": InstantSearch {
+ "_createAbsoluteURL": [Function],
+ "_createURL": [Function],
+ "_events": Object {},
+ "_eventsCount": 0,
+ "_isSearchStalled": false,
+ "_maxListeners": undefined,
+ "_onHistoryChange": [Function],
+ "_searchStalledTimer": null,
+ "_stalledSearchDelay": 200,
+ "client": Object {
+ "addAlgoliaAgent": [Function],
+ "algolia": "client",
+ },
+ "domain": null,
+ "helper": AlgoliaSearchHelper {
+ "_currentNbQueries": 0,
+ "_events": Object {
+ "result": [Function],
+ "search": [Function],
+ },
+ "_eventsCount": 2,
+ "_lastQueryIdReceived": -1,
+ "_queryId": 0,
+ "client": Object {
+ "addAlgoliaAgent": [Function],
+ "algolia": "client",
+ },
+ "derivedHelpers": Array [],
+ "lastResults": null,
+ "search": [Function],
+ "state": SearchParameters {
+ "advancedSyntax": undefined,
+ "allowTyposOnNumericTokens": undefined,
+ "analytics": undefined,
+ "analyticsTags": undefined,
+ "aroundLatLng": undefined,
+ "aroundLatLngViaIP": undefined,
+ "aroundPrecision": undefined,
+ "aroundRadius": undefined,
+ "attributesToHighlight": undefined,
+ "attributesToRetrieve": undefined,
+ "attributesToSnippet": undefined,
+ "disableExactOnAttributes": undefined,
+ "disjunctiveFacets": Array [],
+ "disjunctiveFacetsRefinements": Object {},
+ "distinct": undefined,
+ "enableExactOnSingleWordQuery": undefined,
+ "facets": Array [],
+ "facetsExcludes": Object {},
+ "facetsRefinements": Object {},
+ "getRankingInfo": undefined,
+ "hierarchicalFacets": Array [],
+ "hierarchicalFacetsRefinements": Object {},
+ "highlightPostTag": undefined,
+ "highlightPreTag": undefined,
+ "hitsPerPage": undefined,
+ "ignorePlurals": undefined,
+ "index": "",
+ "insideBoundingBox": undefined,
+ "insidePolygon": undefined,
+ "length": undefined,
+ "maxValuesPerFacet": undefined,
+ "minProximity": undefined,
+ "minWordSizefor1Typo": undefined,
+ "minWordSizefor2Typos": undefined,
+ "minimumAroundRadius": undefined,
+ "numericFilters": undefined,
+ "numericRefinements": Object {},
+ "offset": undefined,
+ "optionalFacetFilters": undefined,
+ "optionalTagFilters": undefined,
+ "optionalWords": undefined,
+ "page": 0,
+ "query": "",
+ "queryType": undefined,
+ "removeWordsIfNoResults": undefined,
+ "replaceSynonymsInHighlight": undefined,
+ "restrictSearchableAttributes": undefined,
+ "sendMeToUrlSync": true,
+ "snippetEllipsisText": undefined,
+ "synonyms": undefined,
+ "tagFilters": undefined,
+ "tagRefinements": Array [],
+ "typoTolerance": undefined,
+ },
+ },
+ "indexName": "lifecycle",
+ "searchParameters": Object {
+ "another": Object {
+ "config": "parameter",
+ "different": "parameter",
+ },
+ "index": "lifecycle",
+ "some": "modified",
+ "values": Array [
+ -2,
+ -1,
+ ],
+ },
+ "started": true,
+ "templatesConfig": Object {
+ "compileOptions": Object {},
+ "helpers": Object {
+ "formatNumber": [Function],
+ },
+ },
+ "urlSync": Object {},
+ "widgets": Array [
+ Object {
+ "getConfiguration": [Function],
+ "init": [Function],
+ "render": [Function],
+ },
+ Object {
+ "createURL": [Function],
+ "getConfiguration": [Function],
+ "onHistoryChange": [Function],
+ "render": [Function],
+ },
+ ],
+ },
+ "results": Object {
+ "some": "data",
+ },
+ "searchMetadata": Object {
+ "isSearchStalled": false,
+ },
+ "state": SearchParameters {
+ "advancedSyntax": undefined,
+ "allowTyposOnNumericTokens": undefined,
+ "analytics": undefined,
+ "analyticsTags": undefined,
+ "aroundLatLng": undefined,
+ "aroundLatLngViaIP": undefined,
+ "aroundPrecision": undefined,
+ "aroundRadius": undefined,
+ "attributesToHighlight": undefined,
+ "attributesToRetrieve": undefined,
+ "attributesToSnippet": undefined,
+ "disableExactOnAttributes": undefined,
+ "disjunctiveFacets": Array [],
+ "disjunctiveFacetsRefinements": Object {},
+ "distinct": undefined,
+ "enableExactOnSingleWordQuery": undefined,
+ "facets": Array [],
+ "facetsExcludes": Object {},
+ "facetsRefinements": Object {},
+ "getRankingInfo": undefined,
+ "hierarchicalFacets": Array [],
+ "hierarchicalFacetsRefinements": Object {},
+ "highlightPostTag": undefined,
+ "highlightPreTag": undefined,
+ "hitsPerPage": undefined,
+ "ignorePlurals": undefined,
+ "index": "",
+ "insideBoundingBox": undefined,
+ "insidePolygon": undefined,
+ "length": undefined,
+ "maxValuesPerFacet": undefined,
+ "minProximity": undefined,
+ "minWordSizefor1Typo": undefined,
+ "minWordSizefor2Typos": undefined,
+ "minimumAroundRadius": undefined,
+ "numericFilters": undefined,
+ "numericRefinements": Object {},
+ "offset": undefined,
+ "optionalFacetFilters": undefined,
+ "optionalTagFilters": undefined,
+ "optionalWords": undefined,
+ "page": 0,
+ "query": "",
+ "queryType": undefined,
+ "removeWordsIfNoResults": undefined,
+ "replaceSynonymsInHighlight": undefined,
+ "restrictSearchableAttributes": undefined,
+ "sendMeToUrlSync": true,
+ "snippetEllipsisText": undefined,
+ "synonyms": undefined,
+ "tagFilters": undefined,
+ "tagRefinements": Array [],
+ "typoTolerance": undefined,
+ },
+ "templatesConfig": Object {
+ "compileOptions": Object {},
+ "helpers": Object {
+ "formatNumber": [Function],
+ },
+ },
+ },
+]
+`;
diff --git a/src/lib/main.js b/src/lib/main.js
index eaa592ce9e..5b08523322 100644
--- a/src/lib/main.js
+++ b/src/lib/main.js
@@ -67,6 +67,7 @@ import * as widgets from '../widgets/index.js';
* [Full documentation](https://community.algolia.com/algoliasearch-helper-js/reference.html#searchparameters)
* @property {boolean|UrlSyncOptions} [urlSync] Url synchronization configuration.
* Setting to `true` will synchronize the needed search parameters with the browser url.
+ * @property {number} [stalledSearchDelay=200] Time before a search is considered stalled.
*/
/**
diff --git a/src/widgets/search-box/__tests__/search-box-test.js b/src/widgets/search-box/__tests__/search-box-test.js
index da82721c32..fa1abc59ae 100644
--- a/src/widgets/search-box/__tests__/search-box-test.js
+++ b/src/widgets/search-box/__tests__/search-box-test.js
@@ -1,5 +1,3 @@
-import sinon from 'sinon';
-import expect from 'expect';
import searchBox from '../search-box';
import EventEmitter from 'events';
import expectJSX from 'expect-jsx';
@@ -24,8 +22,8 @@ describe('searchBox()', () => {
query: '',
};
helper = {
- setQuery: sinon.spy(),
- search: sinon.spy(),
+ setQuery: jest.fn(),
+ search: jest.fn(),
state: {
query: '',
},
@@ -248,8 +246,8 @@ describe('searchBox()', () => {
$('.ais-search-box--reset-wrapper')[0].click();
// Then
- expect(helper.setQuery.called).toBe(true);
- expect(helper.search.called).toBe(true);
+ expect(helper.setQuery).toHaveBeenCalled();
+ expect(helper.search).toHaveBeenCalled();
});
it('should let the user define its own string template', () => {
@@ -522,18 +520,18 @@ describe('searchBox()', () => {
it('performs a search on any change', () => {
simulateInputEvent('test', 'tes', widget, helper, state, container);
- expect(helper.search.called).toBe(true);
+ expect(helper.search).toHaveBeenCalled();
});
it('sets the query on any change', () => {
simulateInputEvent('test', 'tes', widget, helper, state, container);
- expect(helper.setQuery.calledOnce).toBe(true);
+ expect(helper.setQuery).toHaveBeenCalledTimes(1);
});
it('does nothing when query is the same as state', () => {
simulateInputEvent('test', 'test', widget, helper, state, container);
- expect(helper.setQuery.calledOnce).toBe(false);
- expect(helper.search.called).toBe(false);
+ expect(helper.setQuery).not.toHaveBeenCalled();
+ expect(helper.search).not.toHaveBeenCalled();
});
});
@@ -544,17 +542,17 @@ describe('searchBox()', () => {
});
it('updates the query', () => {
- expect(helper.setQuery.callCount).toBe(1);
+ expect(helper.setQuery).toHaveBeenCalledTimes(1);
});
it('does not search', () => {
- expect(helper.search.callCount).toBe(0);
+ expect(helper.search).toHaveBeenCalledTimes(0);
});
});
describe('using a queryHook', () => {
it('calls the queryHook', () => {
- const queryHook = sinon.spy();
+ const queryHook = jest.fn();
widget = searchBox({ container, queryHook });
simulateInputEvent(
'queryhook input',
@@ -564,35 +562,35 @@ describe('searchBox()', () => {
state,
container
);
- expect(queryHook.calledOnce).toBe(true);
- expect(queryHook.firstCall.args[0]).toBe('queryhook input');
- expect(queryHook.firstCall.args[1]).toBeA(Function);
+ expect(queryHook).toHaveBeenCalledTimes(1);
+ expect(queryHook).toHaveBeenLastCalledWith(
+ 'queryhook input',
+ expect.any(Function)
+ );
});
it('does not perform a search by default', () => {
- const queryHook = sinon.spy();
+ const queryHook = jest.fn();
widget = searchBox({ container, queryHook });
simulateInputEvent('test', 'tes', widget, helper, state, container);
- expect(helper.setQuery.calledOnce).toBe(false);
- expect(helper.search.called).toBe(false);
+ expect(helper.setQuery).toHaveBeenCalledTimes(0);
+ expect(helper.search).not.toHaveBeenCalled();
});
it('when calling the provided search function', () => {
- const queryHook = sinon.spy((query, search) => search(query));
+ const queryHook = jest.fn((query, search) => search(query));
widget = searchBox({ container, queryHook });
simulateInputEvent('oh rly?', 'tes', widget, helper, state, container);
- expect(helper.setQuery.calledOnce).toBe(true);
- expect(helper.setQuery.firstCall.args[0]).toBe('oh rly?');
- expect(helper.search.called).toBe(true);
+ expect(helper.setQuery).toHaveBeenCalledTimes(1);
+ expect(helper.setQuery).toHaveBeenLastCalledWith('oh rly?');
+ expect(helper.search).toHaveBeenCalled();
});
it('can override the query', () => {
- const queryHook = sinon.spy((originalQuery, search) =>
- search('hi mom!')
- );
+ const queryHook = jest.fn((originalQuery, search) => search('hi mom!'));
widget = searchBox({ container, queryHook });
simulateInputEvent('come.on.', 'tes', widget, helper, state, container);
- expect(helper.setQuery.firstCall.args[0]).toBe('hi mom!');
+ expect(helper.setQuery).toHaveBeenLastCalledWith('hi mom!');
});
});
});
@@ -609,7 +607,7 @@ describe('searchBox()', () => {
it('do not perform the search on keyup event (should be done by input event)', () => {
simulateKeyUpEvent({}, widget, helper, state, container);
- expect(helper.search.called).toBe(false);
+ expect(helper.search).not.toHaveBeenCalled();
});
});
@@ -627,8 +625,8 @@ describe('searchBox()', () => {
const e1 = new window.Event('input');
container.dispatchEvent(e1);
- expect(helper.setQuery.callCount).toBe(1);
- expect(helper.search.callCount).toBe(0);
+ expect(helper.setQuery).toHaveBeenCalledTimes(1);
+ expect(helper.search).toHaveBeenCalledTimes(0);
// setQuery is mocked and does not apply the modification of the helper
// we have to set it ourselves
@@ -638,8 +636,8 @@ describe('searchBox()', () => {
Object.defineProperty(e2, 'keyCode', { get: () => 13 });
container.dispatchEvent(e2);
- expect(helper.setQuery.callCount).toBe(1);
- expect(helper.search.callCount).toBe(1);
+ expect(helper.setQuery).toHaveBeenCalledTimes(1);
+ expect(helper.search).toHaveBeenCalledTimes(1);
});
it("doesn't perform the search on keyup if not ", () => {
@@ -648,8 +646,8 @@ describe('searchBox()', () => {
Object.defineProperty(event, 'keyCode', { get: () => 42 });
container.dispatchEvent(event);
- expect(helper.setQuery.callCount).toBe(0);
- expect(helper.search.callCount).toBe(0);
+ expect(helper.setQuery).toHaveBeenCalledTimes(0);
+ expect(helper.search).toHaveBeenCalledTimes(0);
});
});
});
@@ -677,7 +675,10 @@ describe('searchBox()', () => {
widget = searchBox({ container });
widget.init({ state, helper, onHistoryChange });
container.blur();
- widget.render({ helper: { state: { query: 'new value' } } });
+ widget.render({
+ helper: { state: { query: 'new value' } },
+ searchMetadata: { isSearchStalled: false },
+ });
expect(container.value).toBe('new value');
});
@@ -688,15 +689,18 @@ describe('searchBox()', () => {
widget = searchBox({ container });
widget.init({ state, helper, onHistoryChange });
input.focus();
- widget.render({ helper: { state: { query: 'new value' } } });
+ widget.render({
+ helper: { state: { query: 'new value' } },
+ searchMetadata: { isSearchStalled: false },
+ });
expect(container.value).toBe('initial');
});
describe('autofocus', () => {
beforeEach(() => {
container = document.body.appendChild(document.createElement('input'));
- container.focus = sinon.spy();
- container.setSelectionRange = sinon.spy();
+ container.focus = jest.fn();
+ container.setSelectionRange = jest.fn();
});
describe('when auto', () => {
@@ -710,7 +714,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.focus.called).toEqual(true);
+ expect(container.focus).toHaveBeenCalled();
});
it('is not called if search is not empty', () => {
@@ -719,7 +723,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.focus.called).toEqual(false);
+ expect(container.focus).not.toHaveBeenCalled();
});
});
@@ -734,7 +738,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.focus.called).toEqual(true);
+ expect(container.focus).toHaveBeenCalled();
});
it('is called if search is not empty', () => {
@@ -743,7 +747,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.focus.called).toEqual(true);
+ expect(container.focus).toHaveBeenCalled();
});
it('forces cursor to be at the end of the query', () => {
@@ -752,7 +756,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.setSelectionRange.calledWith(3, 3)).toEqual(true);
+ expect(container.setSelectionRange).toHaveBeenLastCalledWith(3, 3);
});
});
@@ -767,7 +771,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.focus.called).toEqual(false);
+ expect(container.focus).not.toHaveBeenCalled();
});
it('is not called if search is not empty', () => {
@@ -776,7 +780,7 @@ describe('searchBox()', () => {
// When
widget.init({ state, helper, onHistoryChange });
// Then
- expect(container.focus.called).toEqual(false);
+ expect(container.focus).not.toHaveBeenCalled();
});
});
});
diff --git a/src/widgets/search-box/defaultTemplates.js b/src/widgets/search-box/defaultTemplates.js
index 28442c9563..a9da5ba542 100644
--- a/src/widgets/search-box/defaultTemplates.js
+++ b/src/widgets/search-box/defaultTemplates.js
@@ -33,6 +33,27 @@ export default {
fill-rule="evenodd">
+
+ `,
+ loadingIndicator: `
+
`,
};
diff --git a/src/widgets/search-box/search-box.js b/src/widgets/search-box/search-box.js
index 01662a9459..d0dc417fc6 100644
--- a/src/widgets/search-box/search-box.js
+++ b/src/widgets/search-box/search-box.js
@@ -24,7 +24,11 @@ const renderer = ({
wrapInput,
reset,
magnifier,
-}) => ({ refine, clear, query, onHistoryChange }, isFirstRendering) => {
+ loadingIndicator,
+}) => (
+ { refine, clear, query, onHistoryChange, isSearchStalled },
+ isFirstRendering
+) => {
if (isFirstRendering) {
const INPUT_EVENT = window.addEventListener ? 'input' : 'propertychange';
const input = createInput(containerNode);
@@ -53,6 +57,8 @@ const renderer = ({
if (magnifier) addMagnifier(input, magnifier, templates);
if (reset) addReset(input, reset, templates, clear);
+ if (loadingIndicator)
+ addLoadingIndicator(input, loadingIndicator, templates);
addDefaultAttributesToInput(placeholder, input, queryFromInput, cssClasses);
@@ -106,11 +112,12 @@ const renderer = ({
}
}
} else {
- const input = getInput(containerNode);
- const isFocused = document.activeElement === input;
- if (!isFocused && query !== input.value) {
- input.value = query;
- }
+ renderAfterInit({
+ containerNode,
+ query,
+ loadingIndicator,
+ isSearchStalled,
+ });
}
if (reset) {
@@ -124,6 +131,31 @@ const renderer = ({
}
};
+function renderAfterInit({
+ containerNode,
+ query,
+ loadingIndicator,
+ isSearchStalled,
+}) {
+ const input = getInput(containerNode);
+ const isFocused = document.activeElement === input;
+ if (!isFocused && query !== input.value) {
+ input.value = query;
+ }
+
+ if (loadingIndicator) {
+ const rootElement =
+ containerNode.tagName === 'INPUT'
+ ? containerNode.parentNode
+ : containerNode.firstChild;
+ if (isSearchStalled) {
+ rootElement.classList.add('ais-stalled-search');
+ } else {
+ rootElement.classList.remove('ais-stalled-search');
+ }
+ }
+}
+
const disposer = containerNode => () => {
const range = document.createRange(); // IE10+
range.selectNodeContents(containerNode);
@@ -161,6 +193,12 @@ searchBox({
* @property {{root: string}} [cssClasses] CSS classes added to the reset buton.
*/
+/**
+ * @typedef {Object} SearchBoxLoadingIndicatorOption
+ * @property {function|string} template Template used for displaying the button. Can accept a function or a Hogan string.
+ * @property {{root: string}} [cssClasses] CSS classes added to the loading-indicator element.
+ */
+
/**
* @typedef {Object} SearchBoxCSSClasses
* @property {string|string[]} [root] CSS class to add to the
@@ -181,6 +219,7 @@ searchBox({
* @property {boolean|SearchBoxPoweredByOption} [poweredBy=false] Define if a "powered by Algolia" link should be added near the input.
* @property {boolean|SearchBoxResetOption} [reset=true] Define if a reset button should be added in the input when there is a query.
* @property {boolean|SearchBoxMagnifierOption} [magnifier=true] Define if a magnifier should be added at beginning of the input to indicate a search input.
+ * @property {boolean|SearchBoxLoadingIndicatorOption} [loadingIndicator=false] Define if a loading indicator should be added at beginning of the input to indicate that search is currently stalled.
* @property {boolean} [wrapInput=true] Wrap the input in a `div.ais-search-box`.
* @property {boolean|string} [autofocus="auto"] autofocus on the input.
* @property {boolean} [searchOnEnterKeyPressOnly=false] If set, trigger the search
@@ -208,7 +247,8 @@ searchBox({
* placeholder: 'Search for products',
* autofocus: false,
* poweredBy: true,
- * reset: false,
+ * reset: true,
+ * loadingIndicator: false
* })
* );
*/
@@ -223,6 +263,7 @@ export default function searchBox(
searchOnEnterKeyPressOnly = false,
reset = true,
magnifier = true,
+ loadingIndicator = false,
queryHook,
} = {}
) {
@@ -253,6 +294,7 @@ export default function searchBox(
wrapInput,
reset,
magnifier,
+ loadingIndicator,
});
try {
@@ -402,6 +444,31 @@ function addMagnifier(input, magnifier, { magnifier: magnifierTemplate }) {
input.parentNode.appendChild(htmlNode);
}
+function addLoadingIndicator(
+ input,
+ loadingIndicator,
+ { loadingIndicator: loadingIndicatorTemplate }
+) {
+ loadingIndicator = {
+ cssClasses: {},
+ template: loadingIndicatorTemplate,
+ ...loadingIndicator,
+ };
+
+ const loadingIndicatorCSSClasses = {
+ root: cx(bem('loading-indicator'), loadingIndicator.cssClasses.root),
+ };
+ const stringNode = processTemplate(loadingIndicator.template, {
+ cssClasses: loadingIndicatorCSSClasses,
+ });
+
+ const htmlNode = createNodeFromString(
+ stringNode,
+ cx(bem('loading-indicator-wrapper'))
+ );
+ input.parentNode.appendChild(htmlNode);
+}
+
/**
* Adds a powered by in the searchbox widget
* @private