From 042733decdef4afb21c94d35948d3c5a274e8acb Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Wed, 31 Jul 2019 16:19:39 +0200 Subject: [PATCH] chore(core): migrate core file to TS (#3984) * chore(core): migrate core file to TS this depends on: - https://github.com/DefinitelyTyped/DefinitelyTyped/pull/37105 - https://github.com/algolia/algoliasearch-helper-js/pull/739 * chore: undo needless change * chore: add type info * chore(enhanceConfiguration): prevent helper to be superfluously created (#3959) * fix(enhanceConfiguration): create SearchParameters once less * test: add test for hierarchicalFacets this test failed with the old mergeDeep-based enhanceConfiguration * create helpers outside of enhanceConfiguration * chore: more consistent type * getConfiguration now gets called with SearchParameters only * update existing TS widgets to make tsc pass * chore: change conditional style to be more readable * chore: clarify TODO * avoid extra constructor call * chore: go back to old behaviour, test later * chore: remove import * chore: reuse parameters instance * chore: update @types/algoliasearch * chore: move files around * move more (also routing now) * chore: make index' options a subset * chore: undo readability change * chore: remove unusted * chore(configure): make TS pass * Update src/types/widget.ts --- package.json | 3 +- src/connectors/configure/connectConfigure.ts | 9 +- .../__tests__/connectInfiniteHits-test.ts | 3 +- .../{InstantSearch.js => InstantSearch.ts} | 200 +++++++++++++----- src/lib/__tests__/InstantSearch-test.js | 5 +- src/lib/__tests__/RoutingManager-test.ts | 24 +-- src/lib/createHelpers.ts | 2 +- src/lib/main.js | 134 ------------ src/lib/main.ts | 41 ++++ src/types/instantsearch.ts | 84 +++++--- src/types/widget.ts | 14 ++ src/widgets/index/__tests__/index-test.ts | 22 +- src/widgets/index/index.ts | 22 +- .../__tests__/infinite-hits-test.ts | 2 +- test/mock/createInstantSearch.ts | 49 ++++- test/mock/createSearchClient.ts | 32 +-- yarn.lock | 20 +- 17 files changed, 375 insertions(+), 291 deletions(-) rename src/lib/{InstantSearch.js => InstantSearch.ts} (66%) delete mode 100644 src/lib/main.js create mode 100644 src/lib/main.ts diff --git a/package.json b/package.json index 197b3a80b1..1590c2d5a6 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,7 @@ "@storybook/addon-actions": "5.1.9", "@storybook/html": "5.1.9", "@storybook/theming": "5.1.9", - "@types/algoliasearch": "3.30.14", - "@types/algoliasearch-helper": "2.26.1", + "@types/algoliasearch": "3.30.16", "@types/classnames": "^2.2.7", "@types/enzyme": "^3.1.15", "@types/enzyme-adapter-react-16": "^1.0.3", diff --git a/src/connectors/configure/connectConfigure.ts b/src/connectors/configure/connectConfigure.ts index e12e8bbb1c..87c80642c5 100644 --- a/src/connectors/configure/connectConfigure.ts +++ b/src/connectors/configure/connectConfigure.ts @@ -4,7 +4,6 @@ import algoliasearchHelper, { AlgoliaSearchHelper, } from 'algoliasearch-helper'; import { - InstantSearchOptions, Renderer, RendererOptions, Unmounter, @@ -80,7 +79,6 @@ const connectConfigure: ConfigureConnector = ( } type ConnectorState = { - instantSearchInstance?: InstantSearchOptions; refine?: Refine; }; @@ -114,24 +112,23 @@ const connectConfigure: ConfigureConnector = ( }, init({ instantSearchInstance, helper }) { - connectorState.instantSearchInstance = instantSearchInstance; connectorState.refine = refine(helper); renderFn( { refine: connectorState.refine!, - instantSearchInstance: connectorState.instantSearchInstance, + instantSearchInstance, widgetParams, }, true ); }, - render() { + render({ instantSearchInstance }) { renderFn( { refine: connectorState.refine!, - instantSearchInstance: connectorState.instantSearchInstance, + instantSearchInstance, widgetParams, }, false diff --git a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts index 68afb94cf1..5be592a368 100644 --- a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts +++ b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts @@ -2,6 +2,7 @@ import algoliasearchHelper, { SearchResults, SearchParameters, } from 'algoliasearch-helper'; +import { Client } from 'algoliasearch'; import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; import { createInitOptions, @@ -11,8 +12,6 @@ import { createSingleSearchResponse } from '../../../../test/mock/createAPIRespo import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight'; import connectInfiniteHits from '../connectInfiniteHits'; -import { Client } from '../../../types'; - jest.mock('../../../lib/utils/hits-absolute-position', () => ({ // The real implementation creates a new array instance, which can cause bugs, // especially with the __escaped mark, we thus make sure the mock also has the diff --git a/src/lib/InstantSearch.js b/src/lib/InstantSearch.ts similarity index 66% rename from src/lib/InstantSearch.js rename to src/lib/InstantSearch.ts index 1247eb6a2f..f070afae3c 100644 --- a/src/lib/InstantSearch.js +++ b/src/lib/InstantSearch.ts @@ -1,6 +1,11 @@ -import algoliasearchHelper from 'algoliasearch-helper'; +import algoliasearchHelper, { + AlgoliaSearchHelper, + SearchParameters, + PlainSearchParameters, +} from 'algoliasearch-helper'; +import { Client as AlgoliaSearchClient } from 'algoliasearch'; import EventEmitter from 'events'; -import index from '../widgets/index/index'; +import index, { Index } from '../widgets/index/index'; import RoutingManager from './RoutingManager'; import simpleMapping from './stateMappings/simple'; import historyRouter from './routers/history'; @@ -12,6 +17,14 @@ import { defer, noop, } from './utils'; +import { + InsightsClient as AlgoliaInsightsClient, + SearchClient, + Widget, + StateMapping, + Router, + UiState, +} from '../types'; const withUsage = createDocumentationMessageGenerator({ name: 'instantsearch', @@ -26,23 +39,111 @@ function defaultCreateURL() { return '#'; } +export type Routing = { + router: Router; + stateMapping: StateMapping; +}; + /** - * Widgets are the building blocks of InstantSearch.js. Any - * valid widget must have at least a `render` or a `init` function. - * @typedef {Object} Widget - * @property {function} [render] Called after each search response has been received - * @property {function} [getConfiguration] Let the widget update the configuration - * of the search with new parameters - * @property {function} [init] Called once before the first search + * Global options for an InstantSearch instance. */ +export type InstantSearchOptions = { + /** + * The name of the main index + */ + indexName: string; + + /** + * The search client to plug to InstantSearch.js + * + * Usage: + * ```javascript + * // Using the default Algolia search client + * instantsearch({ + * indexName: 'indexName', + * searchClient: algoliasearch('appId', 'apiKey') + * }); + * + * // Using a custom search client + * instantsearch({ + * indexName: 'indexName', + * searchClient: { + * search(requests) { + * // fetch response based on requests + * return response; + * }, + * searchForFacetValues(requests) { + * // fetch response based on requests + * return response; + * } + * } + * }); + * ``` + */ + searchClient: SearchClient | AlgoliaSearchClient; + + /** + * The locale used to display numbers. This will be passed + * to `Number.prototype.toLocaleString()` + */ + numberLocale?: string; + + /** + * A hook that will be called each time a search needs to be done, with the + * helper as a parameter. It's your responsibility to call `helper.search()`. + * This option allows you to avoid doing searches at page load for example. + */ + searchFunction?: (helper: AlgoliaSearchHelper) => void; + + /** + * Additional parameters to unconditionally pass to the Algolia API. See also + * the `configure` widget for dynamically passing search parameters. + */ + searchParameters?: PlainSearchParameters; + + /** + * Time before a search is considered stalled. The default is 200ms + */ + stalledSearchDelay?: number; + + /** + * Router configuration used to save the UI State into the URL or any other + * client side persistence. Passing `true` will use the default URL options. + */ + routing?: Partial> | boolean; + + /** + * the instance of search-insights to use for sending insights events inside + * widgets like `hits`. + */ + insightsClient?: AlgoliaInsightsClient; +}; /** * The actual implementation of the InstantSearch. This is * created using the `instantsearch` factory function. - * @fires Instantsearch#render This event is triggered each time a render is done + * It emits the 'render' event every time a search is done */ class InstantSearch extends EventEmitter { - constructor(options) { + public client: InstantSearchOptions['searchClient']; + public indexName: string; + public insightsClient: AlgoliaInsightsClient | null; + public helper: AlgoliaSearchHelper | null; + public mainHelper: AlgoliaSearchHelper | null; + public mainIndex: Index; + public started: boolean; + public templatesConfig: object; + public _stalledSearchDelay: number; + public _searchStalledTimer: any; + public _isSearchStalled: boolean; + public _searchParameters: PlainSearchParameters; + public _searchFunction?: InstantSearchOptions['searchFunction']; + public _createURL?: (params: SearchParameters) => string; + public _createAbsoluteURL?: (params: SearchParameters) => string; + public _mainHelperSearch?: AlgoliaSearchHelper['search']; + public routing?: Routing; + + public constructor(options: InstantSearchOptions) { super(); const { @@ -64,7 +165,7 @@ class InstantSearch extends EventEmitter { throw new Error(withUsage('The `searchClient` option is required.')); } - if (typeof options.urlSync !== 'undefined') { + if (typeof (options as any).urlSync !== 'undefined') { throw new Error( withUsage( 'The `urlSync` option was removed in InstantSearch.js 3. You may want to use the `routing` option.' @@ -72,7 +173,7 @@ class InstantSearch extends EventEmitter { ); } - if (typeof searchClient.search !== 'function') { + if (typeof (searchClient as any).search !== 'function') { throw new Error( `The \`searchClient\` must implement a \`search\` method. @@ -80,8 +181,13 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend ); } - if (typeof searchClient.addAlgoliaAgent === 'function') { - searchClient.addAlgoliaAgent(`instantsearch.js (${version})`); + if ( + typeof (searchClient as AlgoliaSearchClient).addAlgoliaAgent === + 'function' + ) { + (searchClient as AlgoliaSearchClient).addAlgoliaAgent( + `instantsearch.js (${version})` + ); } if (insightsClient && typeof insightsClient !== 'function') { @@ -133,12 +239,11 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend * widget after InstantSearch started is considered **EXPERIMENTAL** and therefore * it is possibly buggy, if you find anything please * [open an issue](https://github.com/algolia/instantsearch.js/issues/new?title=Problem%20with%20hot%20addWidget). - * @param {Widget} widget The widget to add to InstantSearch. Widgets are simple objects + * @param widget The widget to add to InstantSearch. Widgets are simple objects * that have methods that map the search life cycle in a UI perspective. Usually widgets are * created by [widget factories](widgets.html) like the one provided with InstantSearch.js. - * @return {undefined} This method does not return anything */ - addWidget(widget) { + public addWidget(widget: Widget) { this.addWidgets([widget]); } @@ -146,10 +251,9 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend * Adds multiple widgets. This can be done before and after the InstantSearch has been started. This feature * is considered **EXPERIMENTAL** and therefore it is possibly buggy, if you find anything please * [open an issue](https://github.com/algolia/instantsearch.js/issues/new?title=Problem%20with%20addWidgets). - * @param {Widget[]} widgets The array of widgets to add to InstantSearch. - * @return {undefined} This method does not return anything + * @param {Widget[]} widgets The array of widgets to add to InstantSearch. */ - addWidgets(widgets) { + public addWidgets(widgets: Widget[]) { if (!Array.isArray(widgets)) { throw new Error( withUsage( @@ -180,9 +284,8 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend * is considered **EXPERIMENTAL** and therefore it is possibly buggy, if you find anything please * [open an issue](https://github.com/algolia/instantsearch.js/issues/new?title=Problem%20with%20removeWidget). * @param {Widget} widget The widget instance to remove from InstantSearch. This widget must implement a `dispose()` method in order to be gracefully removed. - * @return {undefined} This method does not return anything */ - removeWidget(widget) { + public removeWidget(widget: Widget) { this.removeWidgets([widget]); } @@ -190,10 +293,9 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend * Removes multiple widgets. This can be done only after the InstantSearch has been started. This feature * is considered **EXPERIMENTAL** and therefore it is possibly buggy, if you find anything please * [open an issue](https://github.com/algolia/instantsearch.js/issues/new?title=Problem%20with%20addWidgets). - * @param {Widget[]} widgets Array of widgets instances to remove from InstantSearch. - * @return {undefined} This method does not return anything + * @param {Widget[]} widgets Array of widgets instances to remove from InstantSearch. */ - removeWidgets(widgets) { + public removeWidgets(widgets: Widget[]) { if (!Array.isArray(widgets)) { throw new Error( withUsage( @@ -216,10 +318,8 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend * first search. This method should be called after all widgets have been added * to the instance of InstantSearch.js. InstantSearch.js also supports adding and removing * widgets after the start as an **EXPERIMENTAL** feature. - * - * @return {undefined} Does not return anything */ - start() { + public start() { if (this.started) { throw new Error( withUsage('The `start` method has already been called once.') @@ -245,7 +345,7 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend // This Helper is used for the queries, we don't care about its state. The // states are managed at the `index` level. We use this Helper to create // DerivedHelper scoped into the `index` widgets. - const mainHelper = algoliasearchHelper(this.client); + const mainHelper = algoliasearchHelper(this.client, this.indexName); mainHelper.search = () => { // This solution allows us to keep the exact same API for the users but @@ -256,21 +356,25 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend }; if (this._searchFunction) { + // this client isn't used to actually search, but required for the helper + // to not throw errors + const fakeClient = ({ + search: () => new Promise(noop), + } as any) as AlgoliaSearchClient; + this._mainHelperSearch = mainHelper.search.bind(mainHelper); mainHelper.search = () => { const mainIndexHelper = this.mainIndex.getHelper(); const searchFunctionHelper = algoliasearchHelper( - { - search: () => new Promise(noop), - }, - mainIndexHelper.state.index, - mainIndexHelper.state + fakeClient, + mainIndexHelper!.state.index, + mainIndexHelper!.state ); searchFunctionHelper.once('search', ({ state }) => { - mainIndexHelper.overrideStateWithoutTriggeringChangeEvent(state); - this._mainHelperSearch(); + mainIndexHelper!.overrideStateWithoutTriggeringChangeEvent(state); + this._mainHelperSearch!(); }); - this._searchFunction(searchFunctionHelper); + this._searchFunction!(searchFunctionHelper); return mainHelper; }; } @@ -307,7 +411,7 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend * [open an issue](https://github.com/algolia/instantsearch.js/issues/new?title=Problem%20with%20dispose). * @return {undefined} This method does not return anything */ - dispose() { + public dispose(): void { this.scheduleSearch.cancel(); this.scheduleRender.cancel(); clearTimeout(this._searchStalledTimer); @@ -323,17 +427,17 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend // The helper needs to be reset to perform the next search from a fresh state. // If not reset, it would use the state stored before calling `dispose()`. this.removeAllListeners(); - this.mainHelper.removeAllListeners(); + this.mainHelper!.removeAllListeners(); this.mainHelper = null; this.helper = null; } - scheduleSearch = defer(() => { - this.mainHelper.search(); + public scheduleSearch = defer(() => { + this.mainHelper!.search(); }); - scheduleRender = defer(() => { - if (!this.mainHelper.hasPendingRequests()) { + public scheduleRender = defer(() => { + if (!this.mainHelper!.hasPendingRequests()) { clearTimeout(this._searchStalledTimer); this._searchStalledTimer = null; this._isSearchStalled = false; @@ -346,7 +450,7 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend this.emit('render'); }); - scheduleStalledRender() { + public scheduleStalledRender() { if (!this._searchStalledTimer) { this._searchStalledTimer = setTimeout(() => { this._isSearchStalled = true; @@ -355,7 +459,7 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend } } - createURL(params) { + public createURL(params: PlainSearchParameters): string { if (!this._createURL) { throw new Error( withUsage('The `start` method needs to be called before `createURL`.') @@ -363,11 +467,11 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend } return this._createURL( - this.mainIndex.getHelper().state.setQueryParameters(params) + this.mainIndex.getHelper()!.state.setQueryParameters(params) ); } - refresh() { + public refresh() { if (!this.mainHelper) { throw new Error( withUsage('The `start` method needs to be called before `refresh`.') diff --git a/src/lib/__tests__/InstantSearch-test.js b/src/lib/__tests__/InstantSearch-test.js index fd3bb79de4..fdda86aa4c 100644 --- a/src/lib/__tests__/InstantSearch-test.js +++ b/src/lib/__tests__/InstantSearch-test.js @@ -339,8 +339,9 @@ describe('removeWidget(s)', () => { describe('start', () => { it('creates two Helper one for the instance + one for the index', () => { const searchClient = createSearchClient(); + const indexName = 'my_index_name'; const search = new InstantSearch({ - indexName: 'index_name', + indexName, searchClient, }); @@ -349,7 +350,7 @@ describe('start', () => { search.start(); expect(algoliasearchHelper).toHaveBeenCalledTimes(2); - expect(algoliasearchHelper).toHaveBeenCalledWith(searchClient); + expect(algoliasearchHelper).toHaveBeenCalledWith(searchClient, indexName); }); it('replaces the regular `search` with `searchOnlyWithDerivedHelpers`', () => { diff --git a/src/lib/__tests__/RoutingManager-test.ts b/src/lib/__tests__/RoutingManager-test.ts index 62bcd84b2d..bdc4c97f4b 100644 --- a/src/lib/__tests__/RoutingManager-test.ts +++ b/src/lib/__tests__/RoutingManager-test.ts @@ -153,7 +153,7 @@ describe('RoutingManager', () => { // in the test after the TypeScript migration. // In a next refactor, we can consider changing this test implementation. const uiStates = router.getAllUiStates({ - searchParameters: search.mainIndex.getHelper().state, + searchParameters: search.mainIndex.getHelper()!.state, }); expect(uiStates).toEqual(widgetState); @@ -163,7 +163,7 @@ describe('RoutingManager', () => { {}, { helper: search.mainIndex.getHelper(), - searchParameters: search.mainIndex.getHelper().state, + searchParameters: search.mainIndex.getHelper()!.state, } ); }); @@ -198,7 +198,7 @@ describe('RoutingManager', () => { // in the test after the TypeScript migration. // In a next refactor, we can consider changing this test implementation. const uiStates = router.getAllUiStates({ - searchParameters: search.mainIndex.getHelper().state, + searchParameters: search.mainIndex.getHelper()!.state, }); expect(uiStates).toEqual({}); @@ -239,17 +239,17 @@ describe('RoutingManager', () => { // in the test after the TypeScript migration. // In a next refactor, we can consider changing this test implementation. const searchParameters = router.getAllSearchParameters({ - currentSearchParameters: search.mainIndex.getHelper().state, + currentSearchParameters: search.mainIndex.getHelper()!.state, uiState: {}, }); expect(searchParameters).toEqual( - search.mainIndex.getHelper().state.setQuery('test') + search.mainIndex.getHelper()!.state.setQuery('test') ); expect(widget.getWidgetSearchParameters).toHaveBeenCalledTimes(1); expect(widget.getWidgetSearchParameters).toHaveBeenCalledWith( - search.mainIndex.getHelper().state, + search.mainIndex.getHelper()!.state, { uiState: {}, } @@ -283,11 +283,11 @@ describe('RoutingManager', () => { // in the test after the TypeScript migration. // In a next refactor, we can consider changing this test implementation. const searchParameters = router.getAllSearchParameters({ - currentSearchParameters: search.mainIndex.getHelper().state, + currentSearchParameters: search.mainIndex.getHelper()!.state, uiState: {}, }); - expect(searchParameters).toEqual(search.mainIndex.getHelper().state); + expect(searchParameters).toEqual(search.mainIndex.getHelper()!.state); }); }); @@ -326,7 +326,7 @@ describe('RoutingManager', () => { expect(router.write).toHaveBeenCalledTimes(0); - search.mainIndex.getHelper().setQuery('q'); // routing write updates on change + search.mainIndex.getHelper()!.setQuery('q'); // routing write updates on change expect(router.write).toHaveBeenCalledTimes(1); expect(router.write).toHaveBeenCalledWith({ @@ -368,7 +368,7 @@ describe('RoutingManager', () => { search.once('render', () => { // initialization is done at this point - expect(search.mainIndex.getHelper().state.query).toBeUndefined(); + expect(search.mainIndex.getHelper()!.state.query).toBeUndefined(); // this simulates a router update with a uiState of {q: 'a'} onRouterUpdateCallback({ @@ -378,7 +378,7 @@ describe('RoutingManager', () => { search.once('render', () => { // the router update triggers a new search // and given that the widget reads q as a query parameter - expect(search.mainIndex.getHelper().state.query).toEqual('a'); + expect(search.mainIndex.getHelper()!.state.query).toEqual('a'); done(); }); }); @@ -428,7 +428,7 @@ describe('RoutingManager', () => { search.once('render', () => { // initialization is done at this point - expect(search.mainIndex.getHelper().state.query).toEqual('test'); + expect(search.mainIndex.getHelper()!.state.query).toEqual('test'); expect(router.write).toHaveBeenLastCalledWith({ query: 'TEST', diff --git a/src/lib/createHelpers.ts b/src/lib/createHelpers.ts index 5173ff08bf..3567b4be65 100644 --- a/src/lib/createHelpers.ts +++ b/src/lib/createHelpers.ts @@ -16,7 +16,7 @@ interface HoganHelpers { export default function hoganHelpers({ numberLocale, }: { - numberLocale: string; + numberLocale?: string; }): HoganHelpers { return { formatNumber(value, render) { diff --git a/src/lib/main.js b/src/lib/main.js deleted file mode 100644 index b8d5a730e6..0000000000 --- a/src/lib/main.js +++ /dev/null @@ -1,134 +0,0 @@ -/** @module module:instantsearch */ - -import InstantSearch from './InstantSearch'; -import version from './version'; - -import * as connectors from '../connectors/index'; -import * as widgets from '../widgets/index'; -import * as helpers from '../helpers/index'; - -import * as routers from './routers/index'; -import * as stateMappings from './stateMappings/index'; - -/** - * @external SearchParameters - * @see https://www.algolia.com/doc/api-reference/search-api-parameters/ - */ - -/** - * @external InstantSearch - * @see /instantsearch.html - */ - -/** - * @typedef {Object|boolean} RoutingOptions - * @property {Router} [router=HistoryRouter()] The router is the part that will save the UI State. - * By default, it uses an instance of the `HistoryRouter` with the default parameters. - * @property {StateMapping} [stateMapping=SimpleStateMapping()] This object transforms the UI state into - * the object that willl be saved by the router. - */ - -/** - * The state mapping is a way to customize the structure before sending it to the router. It can transform - * and filter out the properties. To work correctly, for any state ui S, the following should be valid: - * `S = routeToState(stateToRoute(S))`. - * @typedef {Object} StateMapping - * @property {function} stateToRoute Transforms a UI state representation into a route object. - * It receives an object that contains the UI state of all the widgets in the page. It should - * return an object of any form as long as this form can be read by the `routeToState`. - * @property {function} routeToState Transforms route object into a UI state representation. - * It receives an object that contains the UI state stored by the router. The format is the output - * of `stateToRoute`. - */ - -/** - * The router is the part that saves and reads the object from the storage (most of the time the URL). - * @typedef {Object} Router - * @property {function} onUpdate Sets an event listener that is triggered when the storage is updated. - * The function should accept a callback to trigger when the update happens. In the case of the history - * / URL in a browser, the callback will be called by `onPopState`. - * @property {function} read Reads the storage and gets a route object. It does not take parameters, - * and should return an object. - * @property {function} write Pushes a route object into a storage. Takes the UI state mapped by the state - * mapping configured in the mapping. - * @property {function} createURL Transforms a route object into a URL. It receives an object and should - * return a string. It may return an empty string. - * @property {function} dispose Cleans up any event listeners. - */ - -/** - * @typedef {Object} SearchClient - * @property {function} search Performs the requests in the hits. - * @property {function} searchForFacetValues Performs the requests in the facet values. - */ - -/** - * @typedef {Object} InstantSearchOptions - * @property {string} indexName The name of the main index - * @property {SearchClient} searchClient The search client to plug to InstantSearch.js - * - * Usage: - * ```javascript - * // Using the default Algolia search client - * instantsearch({ - * indexName: 'indexName', - * searchClient: algoliasearch('appId', 'apiKey') - * }); - * - * // Using a custom search client - * instantsearch({ - * indexName: 'indexName', - * searchClient: { - * search(requests) { - * // fetch response based on requests - * return response; - * }, - * searchForFacetValues(requests) { - * // fetch response based on requests - * return response; - * } - * } - * }); - * ``` - * @property {string} [numberLocale] The locale used to display numbers. This will be passed - * to [`Number.prototype.toLocaleString()`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString) - * @property {function} [searchFunction] A hook that will be called each time a search needs to be done, with the - * helper as a parameter. It's your responsibility to call `helper.search()`. This option allows you to avoid doing - * searches at page load for example. - * @property {object} [searchParameters] Additional parameters to pass to - * the Algolia API ([see full documentation](https://community.algolia.com/algoliasearch-helper-js/reference.html#searchparameters)). - * @property {number} [stalledSearchDelay=200] Time before a search is considered stalled. - * @property {RoutingOptions} [routing] Router configuration used to save the UI State into the URL or - * any client side persistence. - */ - -/** - * InstantSearch is the main component of InstantSearch.js. This object - * manages the widget and lets you add new ones. - * - * Two parameters are required to get you started with InstantSearch.js: - * - `indexName`: the main index that you will use for your new search UI - * - `searchClient`: the search client to plug to InstantSearch.js - * - * The [search client provided by Algolia](https://github.com/algolia/algoliasearch-client-javascript) - * needs an `appId` and an `apiKey`. Those parameters can be found in your - * [Algolia dashboard](https://www.algolia.com/api-keys). - * - * If you want to get up and running quickly with InstantSearch.js, have a - * look at the [getting started](getting-started.html). - * @function instantsearch - * @param {InstantSearchOptions} options The options - * @return {InstantSearch} the instantsearch instance - */ -const instantsearch = options => new InstantSearch(options); - -instantsearch.routers = routers; -instantsearch.stateMappings = stateMappings; -instantsearch.connectors = connectors; -instantsearch.widgets = widgets; -instantsearch.version = version; -instantsearch.highlight = helpers.highlight; -instantsearch.snippet = helpers.snippet; -instantsearch.insights = helpers.insights; - -export default instantsearch; diff --git a/src/lib/main.ts b/src/lib/main.ts new file mode 100644 index 0000000000..ebd7c890fc --- /dev/null +++ b/src/lib/main.ts @@ -0,0 +1,41 @@ +import InstantSearch from './InstantSearch'; +import version from './version'; + +import * as connectors from '../connectors/index'; +import * as widgets from '../widgets/index'; +import * as helpers from '../helpers/index'; + +import * as routers from './routers/index'; +import * as stateMappings from './stateMappings/index'; +import { InstantSearchOptions } from '../types'; + +/** + * InstantSearch is the main component of InstantSearch.js. This object + * manages the widget and lets you add new ones. + * + * Two parameters are required to get you started with InstantSearch.js: + * - `indexName`: the main index that you will use for your new search UI + * - `searchClient`: the search client to plug to InstantSearch.js + * + * The [search client provided by Algolia](https://github.com/algolia/algoliasearch-client-javascript) + * needs an `appId` and an `apiKey`. Those parameters can be found in your + * [Algolia dashboard](https://www.algolia.com/api-keys). + * + * If you want to get up and running quickly with InstantSearch.js, have a + * look at the [getting started](getting-started.html). + * @function instantsearch + * @param {InstantSearchOptions} options The options + */ +const instantsearch = (options: InstantSearchOptions) => + new InstantSearch(options); + +instantsearch.routers = routers; +instantsearch.stateMappings = stateMappings; +instantsearch.connectors = connectors; +instantsearch.widgets = widgets; +instantsearch.version = version; +instantsearch.highlight = helpers.highlight; +instantsearch.snippet = helpers.snippet; +instantsearch.insights = helpers.insights; + +export default instantsearch; diff --git a/src/types/instantsearch.ts b/src/types/instantsearch.ts index ce2621b379..a0a8bb379f 100644 --- a/src/types/instantsearch.ts +++ b/src/types/instantsearch.ts @@ -1,14 +1,10 @@ +import { SearchParameters } from 'algoliasearch-helper'; import { Client as AlgoliaSearchClient } from 'algoliasearch'; -import { - AlgoliaSearchHelper, - SearchParameters, - PlainSearchParameters, -} from 'algoliasearch-helper'; -import { Index } from '../widgets/index/index'; -import { InsightsClient as AlgoliaInsightsClient } from './insights'; import { Widget, UiState } from './widget'; - -export type InstantSearchOptions = any; +export { + default as InstantSearch, + InstantSearchOptions, +} from '../lib/InstantSearch'; // @TODO: can this be written some other way? export type HelperChangeEvent = { @@ -90,34 +86,66 @@ export type NumericRefinement = { export type Refinement = FacetRefinement | NumericRefinement; +/** + * The router is the part that saves and reads the object from the storage. + * Usually this is the URL. + */ export interface Router extends Widget { + /** + * onUpdate Sets an event listener that is triggered when the storage is updated. + * The function should accept a callback to trigger when the update happens. + * In the case of the history / URL in a browser, the callback will be called + * by `onPopState`. + */ onUpdate(callback: (route: TRouteState) => void): void; - read(): UiState; + + /** + * Reads the storage and gets a route object. It does not take parameters, + * and should return an object + */ + read(): TRouteState; + + /** + * Pushes a route object into a storage. Takes the UI state mapped by the state + * mapping configured in the mapping + */ write(route: TRouteState): void; + + /** + * Transforms a route object into a URL. It receives an object and should + * return a string. It may return an empty string. + */ createURL(state: TRouteState): string; } +/** + * The state mapping is a way to customize the structure before sending it to the router. + * It can transform and filter out the properties. To work correctly, the following + * should be valid for any UiState: + * `UiState = routeToState(stateToRoute(UiState))`. + */ export type StateMapping = { - stateToRoute(state: UiState): TRouteState; - routeToState(route: TRouteState): UiState; -}; - -export type Client = AlgoliaSearchClient; - -export type InstantSearch = { - helper: AlgoliaSearchHelper | null; - mainHelper: AlgoliaSearchHelper | null; - mainIndex: Index; - insightsClient: AlgoliaInsightsClient | null; - templatesConfig: object; - _isSearchStalled: boolean; - _searchParameters: PlainSearchParameters; - _createAbsoluteURL(state: PlainSearchParameters): string; - scheduleSearch(): void; - scheduleRender(): void; - scheduleStalledRender(): void; + /** + * Transforms a UI state representation into a route object. + * It receives an object that contains the UI state of all the widgets in the page. + * It should return an object of any form as long as this form can be read by + * the `routeToState` function. + */ + stateToRoute(uiState: UiState): TRouteState; + /** + * Transforms route object into a UI state representation. + * It receives an object that contains the UI state stored by the router. + * The format is the output of `stateToRoute`. + */ + routeToState(routeState: TRouteState): UiState; }; +// @TODO: use the generic form of this in routers export type RouteState = { [stateKey: string]: any; }; + +export type SearchClient = Pick< + AlgoliaSearchClient, + 'search' | 'searchForFacetValues' +>; diff --git a/src/types/widget.ts b/src/types/widget.ts index 5586b86167..d6638864cd 100644 --- a/src/types/widget.ts +++ b/src/types/widget.ts @@ -102,10 +102,24 @@ export type UiState = { hitsPerPage?: number; }; +/** + * Widgets are the building blocks of InstantSearch.js. Any valid widget must + * have at least a `render` or a `init` function. + */ export interface Widget { $$type?: string; + /** + * Called once before the first search + */ init?(options: InitOptions): void; + /** + * Called after each search response has been received + */ render?(options: RenderOptions): void; + /** + * Called when this widget is unmounted. Used to remove refinements set by + * during this widget's initialization and life time. + */ dispose?(options: DisposeOptions): SearchParameters | void; getConfiguration?(previousConfiguration: SearchParameters): SearchParameters; getWidgetState?( diff --git a/src/widgets/index/__tests__/index-test.ts b/src/widgets/index/__tests__/index-test.ts index a21cc692c2..096ecba531 100644 --- a/src/widgets/index/__tests__/index-test.ts +++ b/src/widgets/index/__tests__/index-test.ts @@ -215,7 +215,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('schedules a search to take the added widgets into account', () => { const instance = index({ indexName: 'index_name' }); const instantSearchInstance = createInstantSearch({ - scheduleSearch: jest.fn(), + scheduleSearch: jest.fn() as any, }); instance.init( @@ -234,7 +234,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('does not trigger a search without widgets to add', () => { const instance = index({ indexName: 'index_name' }); const instantSearchInstance = createInstantSearch({ - scheduleSearch: jest.fn(), + scheduleSearch: jest.fn() as any, }); instance.init( @@ -407,7 +407,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('schedules a search to take the removed widgets into account', () => { const instance = index({ indexName: 'index_name' }); const instantSearchInstance = createInstantSearch({ - scheduleSearch: jest.fn(), + scheduleSearch: jest.fn() as any, }); const searchBox = createSearchBox(); @@ -430,7 +430,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('does not schedule a search without widgets to remove', () => { const instance = index({ indexName: 'index_name' }); const instantSearchInstance = createInstantSearch({ - scheduleSearch: jest.fn(), + scheduleSearch: jest.fn() as any, }); const searchBox = createSearchBox(); @@ -453,7 +453,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('does not schedule a search without widgets in the index', () => { const instance = index({ indexName: 'index_name' }); const instantSearchInstance = createInstantSearch({ - scheduleSearch: jest.fn(), + scheduleSearch: jest.fn() as any, }); const searchBox = createSearchBox(); @@ -760,7 +760,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('schedules a render on DerivedHelper results', async () => { const instance = index({ indexName: 'index_name' }); - const instantSearchInstance = createInstantSearch(); + const instantSearchInstance = createInstantSearch({ + scheduleRender: jest.fn() as any, + }); instance.init( createInitOptions({ @@ -780,7 +782,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('schedules a stalled render on DerivedHelper search', () => { const instance = index({ indexName: 'index_name' }); - const instantSearchInstance = createInstantSearch(); + const instantSearchInstance = createInstantSearch({ + scheduleStalledRender: jest.fn() as any, + }); instance.init( createInitOptions({ @@ -1755,7 +1759,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" it('removes the listeners on DerivedHelper', async () => { const instance = index({ indexName: 'index_name' }); - const instantSearchInstance = createInstantSearch(); + const instantSearchInstance = createInstantSearch({ + scheduleRender: jest.fn() as any, + }); instance.init( createInitOptions({ diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index 749f776c4d..6fd81d48c2 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -4,15 +4,14 @@ import algoliasearchHelper, { PlainSearchParameters, SearchResults, } from 'algoliasearch-helper'; +import { Client } from 'algoliasearch'; import { InstantSearch, UiState, Widget, InitOptions, RenderOptions, - DisposeOptions, WidgetStateOptions, - Client, ScopedResult, } from '../../types'; import { @@ -30,6 +29,9 @@ type IndexProps = { indexName: string; }; +type IndexInitOptions = Pick; +type IndexRenderOptions = Pick; + export type Index = Widget & { getIndexId(): string; getHelper(): Helper | null; @@ -38,9 +40,9 @@ export type Index = Widget & { getWidgets(): Widget[]; addWidgets(widgets: Widget[]): Index; removeWidgets(widgets: Widget[]): Index; - init(options: InitOptions): void; - render(options: RenderOptions): void; - dispose(options: DisposeOptions): void; + init(options: IndexInitOptions): void; + render(options: IndexRenderOptions): void; + dispose(): void; getWidgetState(uiState: UiState): UiState; }; @@ -188,7 +190,7 @@ const index = (props: IndexProps): Index => { instantSearchInstance: localInstantSearchInstance, state: helper!.state, templatesConfig: localInstantSearchInstance.templatesConfig, - createURL: localInstantSearchInstance._createAbsoluteURL, + createURL: localInstantSearchInstance._createAbsoluteURL!, }); } }); @@ -234,7 +236,7 @@ const index = (props: IndexProps): Index => { return this; }, - init({ instantSearchInstance, parent }: InitOptions) { + init({ instantSearchInstance, parent }: IndexInitOptions) { localInstantSearchInstance = instantSearchInstance; localParent = parent; @@ -318,13 +320,13 @@ const index = (props: IndexProps): Index => { instantSearchInstance, state: helper!.state, templatesConfig: instantSearchInstance.templatesConfig, - createURL: instantSearchInstance._createAbsoluteURL, + createURL: instantSearchInstance._createAbsoluteURL!, }); } }); }, - render({ instantSearchInstance }: RenderOptions) { + render({ instantSearchInstance }: IndexRenderOptions) { localWidgets.forEach(widget => { // At this point, all the variables used below are set. Both `helper` // and `derivedHelper` have been created at the `init` step. The attribute @@ -341,7 +343,7 @@ const index = (props: IndexProps): Index => { scopedResults: resolveScopedResultsFromIndex(this), state: derivedHelper!.lastResults._state, templatesConfig: instantSearchInstance.templatesConfig, - createURL: instantSearchInstance._createAbsoluteURL, + createURL: instantSearchInstance._createAbsoluteURL!, searchMetadata: { isSearchStalled: instantSearchInstance._isSearchStalled, }, diff --git a/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts b/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts index b0f10929b0..1455f0fc99 100644 --- a/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts +++ b/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts @@ -1,7 +1,7 @@ import { render } from 'preact-compat'; import algoliasearchHelper from 'algoliasearch-helper'; +import { Client } from 'algoliasearch'; import infiniteHits from '../infinite-hits'; -import { Client } from '../../../types'; jest.mock('preact-compat', () => { const module = require.requireActual('preact-compat'); diff --git a/test/mock/createInstantSearch.ts b/test/mock/createInstantSearch.ts index 1fbe76eeb2..4718ee5bfd 100644 --- a/test/mock/createInstantSearch.ts +++ b/test/mock/createInstantSearch.ts @@ -2,26 +2,65 @@ import algoliasearchHelper from 'algoliasearch-helper'; import index from '../../src/widgets/index/index'; import { InstantSearch } from '../../src/types'; import { createSearchClient } from './createSearchClient'; +import defer from '../../src/lib/utils/defer'; export const createInstantSearch = ( args: Partial = {} ): InstantSearch => { const searchClient = createSearchClient(); - const mainHelper = algoliasearchHelper(searchClient, 'index_name', {}); - const mainIndex = index({ indexName: 'index_name' }); + const indexName = 'index_name'; + const mainHelper = algoliasearchHelper(searchClient, indexName, {}); + const mainIndex = index({ indexName }); + + let started = false; return { + indexName, mainIndex, mainHelper, + client: searchClient, + started, + start: () => { + started = true; + }, + dispose: () => { + started = false; + }, + refresh: jest.fn(), helper: mainHelper, // @TODO: use the Helper from the index once the RoutingManger uses the index templatesConfig: {}, insightsClient: null, - scheduleStalledRender: jest.fn(), - scheduleSearch: jest.fn(), - scheduleRender: jest.fn(), + scheduleStalledRender: defer(jest.fn()), + scheduleSearch: defer(jest.fn()), + scheduleRender: defer(jest.fn()), _isSearchStalled: true, + _stalledSearchDelay: 200, + _searchStalledTimer: null, _searchParameters: {}, + _createURL: jest.fn(() => '#'), _createAbsoluteURL: jest.fn(() => '#'), + createURL: jest.fn(() => '#'), + routing: undefined, + addWidget: jest.fn(), + addWidgets: jest.fn(), + removeWidget: jest.fn(), + removeWidgets: jest.fn(), + // methods from EventEmitter + addListener: jest.fn(), + removeListener: jest.fn(), + on: jest.fn(), + once: jest.fn(), + off: jest.fn(), + prependListener: jest.fn(), + prependOnceListener: jest.fn(), + removeAllListeners: jest.fn(), + setMaxListeners: jest.fn(), + getMaxListeners: jest.fn(), + listeners: jest.fn(), + rawListeners: jest.fn(), + emit: jest.fn(), + eventNames: jest.fn(), + listenerCount: jest.fn(), ...args, }; }; diff --git a/test/mock/createSearchClient.ts b/test/mock/createSearchClient.ts index a39fefcf04..b14638e5c4 100644 --- a/test/mock/createSearchClient.ts +++ b/test/mock/createSearchClient.ts @@ -1,28 +1,28 @@ import { MultiResponse } from 'algoliasearch'; -import { Client } from '../../src/types'; +import { SearchClient } from '../../src/types'; + import { createSingleSearchResponse, createMultiSearchResponse, createSFFVResponse, } from './createAPIResponse'; -export const createSearchClient = (args: Partial = {}): Client => - ({ - search: jest.fn(requests => - Promise.resolve( - createMultiSearchResponse( - ...requests.map(() => createSingleSearchResponse()) - ) +export const createSearchClient = ( + args: Partial = {} +): SearchClient => ({ + search: jest.fn(requests => + Promise.resolve( + createMultiSearchResponse( + ...requests.map(() => createSingleSearchResponse()) ) - ), - searchForFacetValues: jest.fn(() => - Promise.resolve([createSFFVResponse()]) - ), - ...args, - } as Client); + ) + ), + searchForFacetValues: jest.fn(() => Promise.resolve([createSFFVResponse()])), + ...args, +}); type ControlledClient = { - searchClient: Client; + searchClient: SearchClient; searches: Array<{ promise: Promise; resolver: () => void; @@ -30,7 +30,7 @@ type ControlledClient = { }; export const createControlledSearchClient = ( - args: Partial = {} + args: Partial = {} ): ControlledClient => { const searches: ControlledClient['searches'] = []; const searchClient = createSearchClient({ diff --git a/yarn.lock b/yarn.lock index e12ff25650..e68af6bbb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1645,22 +1645,10 @@ traverse "^0.6.6" unified "^6.1.6" -"@types/algoliasearch-helper@2.26.1": - version "2.26.1" - resolved "https://registry.yarnpkg.com/@types/algoliasearch-helper/-/algoliasearch-helper-2.26.1.tgz#60cf377e7cb4bd9a55f7eba35182792763230a24" - integrity sha512-JN1wq/yLxxBcc6MeSe57F9Aqv8wL964L0nBOUTSQ5OECzWxaECuGYV06VnGKn/c+9AGB97RAgqx2PUbYflZNqA== - dependencies: - "@types/algoliasearch" "*" - -"@types/algoliasearch@*": - version "3.30.8" - resolved "https://registry.yarnpkg.com/@types/algoliasearch/-/algoliasearch-3.30.8.tgz#f7004bd905e61f7f56b0e6da2744ebd4dbafc87c" - integrity sha512-Lzf9kcLtFJe9gAu6lqc0/RR/yiQy7LfWXZYpZVaXUGsJ1t+ifyAiH/iPpXhslDqS11A6JiPKBeQTuG0/VtPLdA== - -"@types/algoliasearch@3.30.14": - version "3.30.14" - resolved "https://registry.yarnpkg.com/@types/algoliasearch/-/algoliasearch-3.30.14.tgz#2166c0fedfa029a4be0fd80e5b35b4eb2d99731e" - integrity sha512-GfUz6Cb7BXSyR/mQchUVy4Y6TNbho/0NrdqDtfSENJaBI9+miD00xowISIsidDJTonjQIq0tN7f/+OQBVBM1Cw== +"@types/algoliasearch@3.30.16": + version "3.30.16" + resolved "https://registry.yarnpkg.com/@types/algoliasearch/-/algoliasearch-3.30.16.tgz#df71aa3eee3648441075ee6dcc428e54dd861196" + integrity sha512-47FcMwJuW5NJnzjgkX6O9LKyUeNuVFaeU5iEjCAPH21LQNqev1l6PL/LhGSkme89YIcT0DAl27dqA/woq4BBrw== "@types/babel__core@^7.1.0": version "7.1.1"