Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(index): warn for inconsistent UI state in development mode #4140

Merged
merged 6 commits into from Oct 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/lib/__tests__/InstantSearch-test.js
Expand Up @@ -1203,3 +1203,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?:
samouss marked this conversation as resolved.
Show resolved Hide resolved
| '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,
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
} 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
francoischalifour marked this conversation as resolved.
Show resolved Hide resolved
function getWidgetNames(connectorName: string): string[] {
switch (connectorName) {
case 'range':
francoischalifour marked this conversation as resolved.
Show resolved Hide resolved
return ['rangeInput', 'rangeSlider'];

case 'menu':
return ['menu', 'menuSelect'];

default:
return [connectorName];
}
}

type StateToWidgets = {
[TParameter in keyof IndexUiState]: Array<Widget['$$type']>;
francoischalifour marked this conversation as resolved.
Show resolved Hide resolved
};

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()
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
.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