diff --git a/src/lib/__tests__/InstantSearch-test.js b/src/lib/__tests__/InstantSearch-test.js index 9baff9cd97..f38d930d3c 100644 --- a/src/lib/__tests__/InstantSearch-test.js +++ b/src/lib/__tests__/InstantSearch-test.js @@ -1241,3 +1241,73 @@ describe('refresh', () => { expect(searchClient.search).toHaveBeenCalledTimes(2); }); }); + +describe('UI state', () => { + it('warns if UI state contains unmounted widgets in development mode', () => { + const searchClient = createSearchClient(); + const search = new InstantSearch({ + indexName: 'indexName', + searchClient, + initialUiState: { + indexName: { + query: 'First query', + page: 3, + refinementList: { + brand: ['Apple'], + }, + hierarchicalMenu: { + categories: 'Mobile', + }, + range: { + price: '100:200', + }, + menu: { + category: 'Hardware', + }, + }, + }, + }); + + const searchBox = connectSearchBox(() => null)({}); + const customWidget = { render() {} }; + + search.addWidgets([searchBox, customWidget]); + + expect(() => { + search.start(); + }) + .toWarnDev(`[InstantSearch.js]: The UI state for the index "indexName" is not consistent with the widgets mounted. + +This can happen when the UI state is specified via \`initialUiState\` or \`routing\` but that the widgets responsible for this state were not added. This results in those query parameters not being sent to the API. + +To fully reflect the state, some widgets need to be added to the index "indexName": + +- \`page\` needs one of these widgets: "pagination", "infiniteHits" +- \`refinementList\` needs one of these widgets: "refinementList" +- \`hierarchicalMenu\` needs one of these widgets: "hierarchicalMenu" +- \`range\` needs one of these widgets: "rangeInput", "rangeSlider" +- \`menu\` needs one of these widgets: "menu", "menuSelect" + +If you do not wish to display widgets but still want to support their search parameters, you can mount "virtual widgets" that don't render anything: + +\`\`\` +const virtualPagination = connectPagination(() => null); +const virtualRefinementList = connectRefinementList(() => null); +const virtualHierarchicalMenu = connectHierarchicalMenu(() => null); +const virtualRange = connectRange(() => null); +const virtualMenu = connectMenu(() => null); + +search.addWidgets([ + virtualPagination({ /* ... */ }), + virtualRefinementList({ /* ... */ }), + virtualHierarchicalMenu({ /* ... */ }), + virtualRange({ /* ... */ }), + virtualMenu({ /* ... */ }) +]); +\`\`\` + +If you're using custom widgets that do set these query parameters, we recommend using connectors instead. + +See https://www.algolia.com/doc/guides/building-search-ui/widgets/customize-an-existing-widget/js/#customize-the-complete-ui-of-the-widgets`); + }); +}); diff --git a/src/types/widget.ts b/src/types/widget.ts index 76bf782b0b..0b398a2313 100644 --- a/src/types/widget.ts +++ b/src/types/widget.ts @@ -115,7 +115,31 @@ export type UiState = { * have at least a `render` or a `init` function. */ export interface Widget { - $$type?: string; + $$type?: + | 'ais.autocomplete' + | 'ais.breadcrumb' + | 'ais.clearRefinements' + | 'ais.configure' + | 'ais.currentRefinements' + | 'ais.geoSearch' + | 'ais.hierarchicalMenu' + | 'ais.hits' + | 'ais.hitsPerPage' + | 'ais.index' + | 'ais.infiniteHits' + | 'ais.menu' + | 'ais.numericMenu' + | 'ais.pagination' + | 'ais.poweredBy' + | 'ais.queryRules' + | 'ais.range' + | 'ais.ratingMenu' + | 'ais.refinementList' + | 'ais.searchBox' + | 'ais.sortBy' + | 'ais.stats' + | 'ais.toggleRefinement' + | 'ais.voiceSearch'; /** * Called once before the first search */ diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index 47af816b1e..edabb80a8a 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -21,6 +21,8 @@ import { createDocumentationMessageGenerator, resolveSearchParameters, mergeSearchParameters, + warning, + capitalize, } from '../../lib/utils'; const withUsage = createDocumentationMessageGenerator({ @@ -342,6 +344,117 @@ const index = (props: IndexProps): Index => { // it at the index level because it's either: all of them or none of them // that are stalled. The queries are performed into a single network request. instantSearchInstance.scheduleStalledRender(); + + if (__DEV__) { + // Some connectors are responsible for multiple widgets so we need + // to map them. + // eslint-disable-next-line no-inner-declarations + function getWidgetNames(connectorName: string): string[] { + switch (connectorName) { + case 'range': + return ['rangeInput', 'rangeSlider']; + + case 'menu': + return ['menu', 'menuSelect']; + + default: + return [connectorName]; + } + } + + type StateToWidgets = { + [TParameter in keyof IndexUiState]: Array; + }; + + const stateToWidgetsMap: StateToWidgets = { + query: ['ais.searchBox', 'ais.autocomplete', 'ais.voiceSearch'], + refinementList: ['ais.refinementList'], + menu: ['ais.menu'], + hierarchicalMenu: ['ais.hierarchicalMenu'], + numericMenu: ['ais.numericMenu'], + ratingMenu: ['ais.ratingMenu'], + range: ['ais.range'], + toggle: ['ais.toggleRefinement'], + geoSearch: ['ais.geoSearch'], + sortBy: ['ais.sortBy'], + page: ['ais.pagination', 'ais.infiniteHits'], + hitsPerPage: ['ais.hitsPerPage'], + configure: ['ais.configure'], + }; + + const mountedWidgets = this.getWidgets() + .map(widget => widget.$$type) + .filter(Boolean); + + type MissingWidgets = Array<[string, Array]>; + + const missingWidgets = Object.keys(localUiState).reduce< + MissingWidgets + >((acc, parameter) => { + const requiredWidgets: Array = + stateToWidgetsMap[parameter]; + + if ( + !requiredWidgets.some(requiredWidget => + mountedWidgets.includes(requiredWidget) + ) + ) { + acc.push([ + parameter, + stateToWidgetsMap[parameter].map( + (widgetIdentifier: string) => + widgetIdentifier.split('ais.')[1] + ), + ]); + } + + return acc; + }, []); + + warning( + missingWidgets.length === 0, + `The UI state for the index "${this.getIndexId()}" is not consistent with the widgets mounted. + +This can happen when the UI state is specified via \`initialUiState\` or \`routing\` but that the widgets responsible for this state were not added. This results in those query parameters not being sent to the API. + +To fully reflect the state, some widgets need to be added to the index "${this.getIndexId()}": + +${missingWidgets + .map(([stateParameter, widgets]) => { + return `- \`${stateParameter}\` needs one of these widgets: ${([] as string[]) + .concat(...widgets.map(name => getWidgetNames(name!))) + .map((name: string) => `"${name}"`) + .join(', ')}`; + }) + .join('\n')} + +If you do not wish to display widgets but still want to support their search parameters, you can mount "virtual widgets" that don't render anything: + +\`\`\` +${missingWidgets + .map(([_stateParameter, widgets]) => { + const capitalizedWidget = capitalize(widgets[0]!); + + return `const virtual${capitalizedWidget} = connect${capitalizedWidget}(() => null);`; + }) + .join('\n')} + +search.addWidgets([ + ${missingWidgets + .map(([_stateParameter, widgets]) => { + const capitalizedWidget = capitalize(widgets[0]!); + + return `virtual${capitalizedWidget}({ /* ... */ })`; + }) + .join(',\n ')} +]); +\`\`\` + +If you're using custom widgets that do set these query parameters, we recommend using connectors instead. + +See https://www.algolia.com/doc/guides/building-search-ui/widgets/customize-an-existing-widget/js/#customize-the-complete-ui-of-the-widgets` + ); + } }); derivedHelper.on('result', () => {