Skip to content

Commit

Permalink
fix(index): warn for inconsistent UI state in development mode (#4140)
Browse files Browse the repository at this point in the history
  • Loading branch information
francoischalifour authored and Haroenv committed Oct 23, 2019
1 parent ceddd42 commit 7e277dc
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 1 deletion.
70 changes: 70 additions & 0 deletions src/lib/__tests__/InstantSearch-test.js
Expand Up @@ -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`);
});
});
26 changes: 25 additions & 1 deletion src/types/widget.ts
Expand Up @@ -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
*/
Expand Down
113 changes: 113 additions & 0 deletions src/widgets/index/index.ts
Expand Up @@ -21,6 +21,8 @@ import {
createDocumentationMessageGenerator,
resolveSearchParameters,
mergeSearchParameters,
warning,
capitalize,
} from '../../lib/utils';

const withUsage = createDocumentationMessageGenerator({
Expand Down Expand Up @@ -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<Widget['$$type']>;
};

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<Widget['$$type']>]>;

const missingWidgets = Object.keys(localUiState).reduce<
MissingWidgets
>((acc, parameter) => {
const requiredWidgets: Array<Widget['$$type']> =
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', () => {
Expand Down

0 comments on commit 7e277dc

Please sign in to comment.