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