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

feat(plugins): allow multi-index categoryAttribute in query suggestions #981

Open
wants to merge 6 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,47 @@ const hits: Hit<any> = [
},
},
];
const multiIndexHits: Hit<any> = [
{
index_1: {
exact_nb_hits: 100,
facets: {
exact_matches: {
data_origin: [
{
value: 'Index 1',
count: 100,
},
],
},
},
},
index_2: {
exact_nb_hits: 200,
facets: {
exact_matches: {
data_origin: [
{
value: 'Index 2',
count: 200,
},
],
},
},
},
nb_words: 1,
popularity: 1230,
query: 'cooktop',
objectID: 'cooktop',
_highlightResult: {
query: {
value: 'cooktop',
matchLevel: 'none',
matchedWords: [],
},
},
},
];
/* eslint-enable @typescript-eslint/camelcase */

const searchClient = createSearchClient({
Expand Down Expand Up @@ -394,6 +435,67 @@ describe('createQuerySuggestionsPlugin', () => {
});
});

test('accumulates suggestion categories from multiple indexes', async () => {
castToJestMock(searchClient.search).mockReturnValueOnce(
Promise.resolve(
createMultiSearchResponse({
hits: multiIndexHits,
})
)
);

const querySuggestionsPlugin = createQuerySuggestionsPlugin({
searchClient,
indexName: 'indexName',
categoryAttribute: [
[
'index_1',
'facets',
'exact_matches',
'data_origin',
],
[
'index_2',
'facets',
'exact_matches',
'data_origin',
]
],
categoriesPerItem: 2,
});

const container = document.createElement('div');
const panelContainer = document.createElement('div');

document.body.appendChild(panelContainer);

autocomplete({
container,
panelContainer,
plugins: [querySuggestionsPlugin],
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');

fireEvent.input(input, { target: { value: 'a' } });

await waitFor(() => {
expect(
within(
panelContainer.querySelector(
'[data-autocomplete-source-id="querySuggestionsPlugin"]'
)
)
.getAllByRole('option')
.map((option) => option.textContent)
).toEqual([
'cooktop', // Query Suggestions item
'in Index 2', // Category item
'in Index 1', // Category item
]);
});
});

test('fills the input with the query item key followed by a space on tap ahead', async () => {
const querySuggestionsPlugin = createQuerySuggestionsPlugin({
searchClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { SearchOptions } from '@algolia/client-search';
import { SearchClient } from 'algoliasearch/lite';

import { getTemplates } from './getTemplates';
import { AutocompleteQuerySuggestionsHit, QuerySuggestionsHit } from './types';
import {
AutocompleteQuerySuggestionsHit,
QuerySuggestionsHit,
QuerySuggestionsFacetValue,
} from './types';

export type CreateQuerySuggestionsPluginParams<
TItem extends QuerySuggestionsHit
Expand Down Expand Up @@ -45,11 +49,23 @@ export type CreateQuerySuggestionsPluginParams<
/**
* The attribute or attribute path to display categories for.
*
* If suggestion index is connected to multiple indexes, array of paths can be used.
* The assumption in this case is that a single category gets split across multiple indexes,
* having a uniform value per index, so the total matches for category values will be accumulated
* by picking the first match per each path (and it should only have one).
*
* Multiple attribute names can be used if required, but they should all designate a single "entity",
* even if having different names.
*
* @example ["instant_search", "facets", "exact_matches", "categories"]
* @example ["instant_search", "facets", "exact_matches", "hierarchicalCategories.lvl0"]
* @example [
* ["index_1", "facets", "exact_matches", "data_origin"],
* ["index_2", "facets", "exact_matches", "data_origin"],
* ]
* @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-plugin-query-suggestions/createQuerySuggestionsPlugin/#param-categoryattribute
*/
categoryAttribute?: string | string[];
categoryAttribute?: string | string[] | string[][];
/**
* How many items to display categories for.
*
Expand Down Expand Up @@ -125,12 +141,34 @@ export function createQuerySuggestionsPlugin<
> = [current];

if (i <= itemsWithCategories - 1) {
const categories = getAttributeValueByPath(
current,
Array.isArray(categoryAttribute)
? categoryAttribute
: [categoryAttribute]
)
let paths = (Array.isArray(categoryAttribute[0])
? categoryAttribute
: [categoryAttribute]) as string[][];

if (typeof categoryAttribute === 'string') {
paths = [[categoryAttribute]];
}

const categoriesValues = paths.reduce<
QuerySuggestionsFacetValue[]
>((totalCategories, path) => {
const attrVal = getAttributeValueByPath(current, path);

if (!attrVal) {
return totalCategories;
}

// use only the first facet value if multiple indexes needs to be targeted by multiple paths
return totalCategories.concat(
paths.length > 1 ? attrVal[0] : attrVal
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain why only one should be kept?

Copy link
Author

@andreyvolokitin andreyvolokitin Jun 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When indexes gets split by some uniform "property", like data origin, each index will contain same attribute (i.e. data_origin), and the value will be different for each index (data_origin="Store" or data_origin="Blog"), and same for all records across the index. By this logic, attrVal should only contain 1 record, and if it's not the case — I thought it would "wrong" and took "precautions"

But thinking about it now — the categoryAttribute logic/intention is to provide a facet used for accumulating available "categories" (facet values) which produce hits per each "category". This might mean there's no reason which will forbid to pass multiple non-related facets/attributes, for any indexes — plugin will just accumulate all the things, sorted by count. It is all available "categories" after all, so it shouldn't cause anything unusual

If the user wants to use the logic described in a 1st paragraph — they can use a uniform facet value across the index and get their single element inside attrVal . If something else is required — all the categories will be accumulated by provided paths

If this seems like a proper solution I'll keep all the elements (I'm not very familiar with all algolia peculiarities so can't judge, also this gets back to multi-attribute discussion — now it seems like it should be actually useful quite often).

One thing is that if there're multiple indexes separated in described manner, and additionally some facet is used which can have different values across all indexes — how the actual searching will be done? This is multiple indexes needing to be filtered by facet and all hits accumulated in a single widget — but this is, as far as I understood, actually impossible to do I think? So could such attribute only be useful if applied for a single index? And could this be useful in a such "multi-index" scenario? It seems that for a single index it definitely could be useful

In short: I don't have enough algolia experience to decide on this

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think it will be best to keep all the items — it will be just a generic way of accumulating categories, and the user will decide what should be done with it

}, []);

if (paths.length > 1) {
categoriesValues.sort((a, b) => b.count - a.count);
}

const categories = categoriesValues
.map((x) => x.value)
.slice(0, categoriesPerItem);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hit } from '@algolia/client-search';

type QuerySuggestionsFacetValue = { value: string; count: number };
export type QuerySuggestionsFacetValue = { value: string; count: number };

type QuerySuggestionsIndexMatch<TKey extends string> = Record<
TKey,
Expand Down