diff --git a/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts b/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts index 8ced9f1a..572e3b6d 100644 --- a/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts +++ b/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts @@ -4,6 +4,7 @@ import { MOVIES, meilisearchClient, } from '../../../__tests__/test.utils' +import { HighlightMetadata } from '../highlight' type Movie = (typeof MOVIES)[number] @@ -184,4 +185,136 @@ describe('fetchMeilisearchResults', () => { matchedWords: [], }) }) + + describe('nested object and array highlighting', () => { + interface Person { + id: number + name: string + nicknames: string[] + familyMembers: Array<{ + relationship: string + name: string + }> + } + + interface PersonHighlightResult { + id: HighlightMetadata + name: HighlightMetadata + nicknames: HighlightMetadata[] + familyMembers: Array<{ + relationship: HighlightMetadata + name: HighlightMetadata + }> + } + + const PERSON: Person = { + id: 1, + name: 'Joseph', + nicknames: ['Joe', 'Joey'], + familyMembers: [ + { + relationship: 'mother', + name: 'Susan', + }, + { + relationship: 'father', + name: 'John', + }, + ], + } + const PEOPLE_INDEX = 'people_highlight_test' + + beforeAll(async () => { + await meilisearchClient.deleteIndex(PEOPLE_INDEX) + const task = await meilisearchClient + .index(PEOPLE_INDEX) + .addDocuments([PERSON]) + await meilisearchClient.waitForTask(task.taskUid) + }) + + afterAll(async () => { + await meilisearchClient.deleteIndex(PEOPLE_INDEX) + }) + + test('highlights in array values', async () => { + const pre = '' + const post = '' + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: PEOPLE_INDEX, + query: 'Joe', + params: { + highlightPreTag: pre, + highlightPostTag: post, + }, + }, + ], + }) + + const highlightResult = results[0].hits[0] + ._highlightResult as PersonHighlightResult + expect(highlightResult.nicknames[0]).toEqual({ + value: `${pre}Joe${post}`, + fullyHighlighted: true, + matchLevel: 'full', + matchedWords: ['Joe'], + }) + }) + + test('highlights in nested objects within arrays', async () => { + const pre = '' + const post = '' + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: PEOPLE_INDEX, + query: 'Susan', + params: { + highlightPreTag: pre, + highlightPostTag: post, + }, + }, + ], + }) + + const highlightResult = results[0].hits[0] + ._highlightResult as PersonHighlightResult + expect(highlightResult.familyMembers[0].name).toEqual({ + value: `${pre}Susan${post}`, + fullyHighlighted: true, + matchLevel: 'full', + matchedWords: ['Susan'], + }) + }) + + test('highlights multiple nested fields', async () => { + const pre = '' + const post = '' + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: PEOPLE_INDEX, + query: 'mother', + params: { + highlightPreTag: pre, + highlightPostTag: post, + }, + }, + ], + }) + + const highlightResult = results[0].hits[0] + ._highlightResult as PersonHighlightResult + expect(highlightResult.familyMembers[0].relationship).toEqual({ + value: `${pre}mother${post}`, + fullyHighlighted: true, + matchLevel: 'full', + matchedWords: ['mother'], + }) + }) + }) }) diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index ebb7751e..05d3411e 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -8,7 +8,9 @@ import { HITS_PER_PAGE, } from '../constants' import { SearchClient as MeilisearchSearchClient } from '../types/SearchClient' -import { HighlightResult } from 'instantsearch.js/es/types/algoliasearch' +import { FieldHighlight } from 'instantsearch.js/es/types/algoliasearch' +import { calculateHighlightMetadata } from './highlight' +import { mapOneOrMany } from '../utils' interface SearchParams { /** @@ -28,32 +30,12 @@ interface SearchParams { > } -interface HighlightMetadata { - value: string - fullyHighlighted: boolean - matchLevel: 'none' | 'partial' | 'full' - matchedWords: string[] -} - export function fetchMeilisearchResults>({ searchClient, queries, }: SearchParams): Promise>> { return searchClient - .search( - queries.map((searchParameters) => { - const { params, ...headers } = searchParameters - return { - ...headers, - params: { - hitsPerPage: HITS_PER_PAGE, - highlightPreTag: HIGHLIGHT_PRE_TAG, - highlightPostTag: HIGHLIGHT_POST_TAG, - ...params, - }, - } - }) - ) + .search(buildSearchRequest(queries)) .then( (response: Awaited>>) => { return response.results.map( @@ -64,28 +46,7 @@ export function fetchMeilisearchResults>({ const query = queries[resultsArrayIndex] return { ...result, - hits: result.hits.map((hit) => ({ - ...hit, - _highlightResult: ( - Object.entries(hit?._highlightResult || {}) as Array< - | [keyof TRecord, { value: string }] - | [keyof TRecord, Array<{ value: string }>] // if the field is an array - > - ).reduce((acc, [field, highlightResult]) => { - return { - ...acc, - // if the field is an array, highlightResult is an array of objects - [field]: mapOneOrMany(highlightResult, (highlightResult) => - calculateHighlightMetadata( - query.query || '', - query.params?.highlightPreTag || HIGHLIGHT_PRE_TAG, - query.params?.highlightPostTag || HIGHLIGHT_POST_TAG, - highlightResult.value - ) - ), - } - }, {} as HighlightResult), - })), + hits: buildHits(result, query), } } ) @@ -93,57 +54,45 @@ export function fetchMeilisearchResults>({ ) } -/** - * Calculate the highlight metadata for a given highlight value. - * @param query - The query string. - * @param preTag - The pre tag. - * @param postTag - The post tag. - * @param highlightValue - The highlight value response from Meilisearch. - * @returns The highlight metadata. - */ -function calculateHighlightMetadata( - query: string, - preTag: string, - postTag: string, - highlightValue: string -): HighlightMetadata { - // Extract all highlighted segments - const highlightRegex = new RegExp(`${preTag}(.*?)${postTag}`, 'g') - const matches: string[] = [] - let match - while ((match = highlightRegex.exec(highlightValue)) !== null) { - matches.push(match[1]) - } - - // Remove highlight tags to get the highlighted text without the tags - const cleanValue = highlightValue.replace( - new RegExp(`${preTag}|${postTag}`, 'g'), - '' - ) - - // Determine if the entire attribute is highlighted - // fullyHighlighted = true if cleanValue and the concatenation of all matched segments are identical - const highlightedText = matches.join('') - const fullyHighlighted = cleanValue === highlightedText - - // Determine match level: - // - 'none' if no matches - // - 'partial' if some matches but not fully highlighted - // - 'full' if the highlighted text is the entire field value content - let matchLevel: 'none' | 'partial' | 'full' = 'none' - if (matches.length > 0) { - matchLevel = cleanValue.includes(query) ? 'full' : 'partial' - } - - return { - value: highlightValue, - fullyHighlighted, - matchLevel, - matchedWords: matches, - } +function buildSearchRequest(queries: AlgoliaMultipleQueriesQuery[]) { + return queries.map((searchParameters) => { + const { params, ...headers } = searchParameters + return { + ...headers, + params: { + hitsPerPage: HITS_PER_PAGE, + highlightPreTag: HIGHLIGHT_PRE_TAG, + highlightPostTag: HIGHLIGHT_POST_TAG, + ...params, + }, + } + }) } -// Helper to apply a function to a single value or an array of values -function mapOneOrMany(value: T | T[], mapFn: (value: T) => U): U | U[] { - return Array.isArray(value) ? value.map(mapFn) : mapFn(value) +function buildHits( + result: AlgoliaSearchResponse, + query: AlgoliaMultipleQueriesQuery +) { + return result.hits.map((hit) => ({ + ...hit, + _highlightResult: ( + Object.entries(hit?._highlightResult || {}) as Array< + | [keyof TRecord, { value: string }] + | [keyof TRecord, Array<{ value: string }>] // if the field is an array + > + ).reduce((acc, [field, highlightResult]) => { + return { + ...acc, + // if the field is an array, highlightResult is an array of objects + [field]: mapOneOrMany(highlightResult, (highlightResult) => + calculateHighlightMetadata( + query.query || '', + query.params?.highlightPreTag || HIGHLIGHT_PRE_TAG, + query.params?.highlightPostTag || HIGHLIGHT_POST_TAG, + highlightResult.value + ) + ), + } + }, {} as FieldHighlight), + })) } diff --git a/packages/autocomplete-client/src/search/highlight.ts b/packages/autocomplete-client/src/search/highlight.ts new file mode 100644 index 00000000..9523c2fb --- /dev/null +++ b/packages/autocomplete-client/src/search/highlight.ts @@ -0,0 +1,56 @@ +export interface HighlightMetadata { + value: string + fullyHighlighted: boolean + matchLevel: 'none' | 'partial' | 'full' + matchedWords: string[] +} + +/** + * Calculate the highlight metadata for a given highlight value. + * @param query - The query string. + * @param preTag - The pre tag. + * @param postTag - The post tag. + * @param highlightValue - The highlight value response from Meilisearch. + * @returns The highlight metadata. + */ +export function calculateHighlightMetadata( + query: string, + preTag: string, + postTag: string, + highlightValue: string +): HighlightMetadata { + // Extract all highlighted segments + const highlightRegex = new RegExp(`${preTag}(.*?)${postTag}`, 'g') + const matches: string[] = [] + let match + while ((match = highlightRegex.exec(highlightValue)) !== null) { + matches.push(match[1]) + } + + // Remove highlight tags to get the highlighted text without the tags + const cleanValue = highlightValue.replace( + new RegExp(`${preTag}|${postTag}`, 'g'), + '' + ) + + // Determine if the entire attribute is highlighted + // fullyHighlighted = true if cleanValue and the concatenation of all matched segments are identical + const highlightedText = matches.join('') + const fullyHighlighted = cleanValue === highlightedText + + // Determine match level: + // - 'none' if no matches + // - 'partial' if some matches but not fully highlighted + // - 'full' if the highlighted text is the entire field value content + let matchLevel: 'none' | 'partial' | 'full' = 'none' + if (matches.length > 0) { + matchLevel = cleanValue.includes(query) ? 'full' : 'partial' + } + + return { + value: highlightValue, + fullyHighlighted, + matchLevel, + matchedWords: matches, + } +} diff --git a/packages/autocomplete-client/src/utils.ts b/packages/autocomplete-client/src/utils.ts new file mode 100644 index 00000000..866dc1cb --- /dev/null +++ b/packages/autocomplete-client/src/utils.ts @@ -0,0 +1,12 @@ +/** + * Apply a function to a single value or an array of values + * @param value - The value or array of values to apply the function to + * @param mapFn - The function to apply to the value or array of values + * @returns The result of the function applied to the value or array of values + */ +export function mapOneOrMany( + value: T | T[], + mapFn: (value: T) => U +): U | U[] { + return Array.isArray(value) ? value.map(mapFn) : mapFn(value) +}