1
0
Fork 0
docsearch/packages/docsearch-react/src/utils/createDocSearchSources.ts
Paul Jankowski 4f6b5b1b88
feat(v5): UI and DX improvements (#2949)
* feat: Rename assistantId to agentId

* feat: Allow reading default facet values from index searchParameters

* feat: Remove indexName prop from Sidepanel, cleanup documentation pages

* feat: Move appId and apiKey up into @docsearch/core

* feat: Add back nested grouping of search results

* add changeset

* revert changes to example demo

* fix: e2e tests
2026-08-04 09:43:08 -04:00

298 lines
8.5 KiB
TypeScript

import type {
AutocompleteSource,
AutocompleteState,
} from '@algolia/autocomplete-core';
import type {
FacetFilters,
SearchParamsObject,
SearchResponse,
} from 'algoliasearch/lite';
import type React from 'react';
import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
import type {
DocSearchHit,
DocSearchState,
InternalDocSearchHit,
} from '../types';
import type { useSearchClient } from '../useSearchClient';
import { SOURCE_IDS } from './collections';
import { groupBy } from './groupBy';
import { identity } from './identity';
import { isModifierEvent } from './isModifierEvent';
import { removeHighlightTags } from './removeHighlightTags';
export type BuildQuerySourcesState = Pick<
AutocompleteState<InternalDocSearchHit>,
'context'
>;
export type StoredSearchesLike<TItem> = {
getAll: () => TItem[];
};
export type FacetSelections = Record<string, string>;
export function createFacetFilters(
searchParametersFacetFilters: SearchParamsObject['facetFilters'],
facetSelections: FacetSelections
): SearchParamsObject['facetFilters'] {
const selections = Object.entries(facetSelections);
const selectedFacets = new Set(selections.map(([facet]) => facet));
const dynamicFacetFilters: string[] = [];
for (const [facet, selection] of selections) {
if (selection !== '') {
dynamicFacetFilters.push(`${facet}:${selection}`);
}
}
if (selectedFacets.size === 0) {
return searchParametersFacetFilters;
}
let configuredFacetFilters: FacetFilters = [];
if (
searchParametersFacetFilters &&
Array.isArray(searchParametersFacetFilters)
) {
configuredFacetFilters = searchParametersFacetFilters;
} else if (searchParametersFacetFilters) {
configuredFacetFilters = [searchParametersFacetFilters];
}
const remainingFacetFilters = configuredFacetFilters.filter((facetFilter) => {
if (typeof facetFilter !== 'string') {
return true;
}
const separatorIndex = facetFilter.indexOf(':');
const facet = facetFilter.slice(0, separatorIndex);
return separatorIndex <= 0 || !selectedFacets.has(facet);
});
return [...remainingFacetFilters, ...dynamicFacetFilters];
}
export function buildNoQuerySources({
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
}: {
recentSearches: StoredSearchesLike<unknown>;
favoriteSearches: StoredSearchesLike<unknown>;
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
disableUserPersonalization: boolean;
}): Array<AutocompleteSource<InternalDocSearchHit>> {
if (disableUserPersonalization) {
return [];
}
return [
{
sourceId: SOURCE_IDS.favoriteSearches,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return favoriteSearches.getAll() as InternalDocSearchHit[];
},
},
{
sourceId: SOURCE_IDS.recentSearches,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return recentSearches.getAll() as InternalDocSearchHit[];
},
},
];
}
export async function buildQuerySources({
query,
state: sourcesState,
setContext,
setStatus,
searchClient,
indexes,
snippetLength,
insights,
appId,
apiKey,
maxResultsPerGroup,
transformItems = identity,
saveRecentSearch,
onClose,
facetSelections,
}: {
query: string;
state: BuildQuerySourcesState;
setContext: (
context: Partial<DocSearchState<InternalDocSearchHit>['context']>
) => void;
setStatus: (status: DocSearchState<InternalDocSearchHit>['status']) => void;
searchClient: ReturnType<typeof useSearchClient>;
indexes: DocSearchIndex[];
snippetLength: React.MutableRefObject<number>;
insights: boolean;
appId?: string;
apiKey?: string;
maxResultsPerGroup?: number;
transformItems?: DocSearchProps['transformItems'];
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
facetSelections: React.MutableRefObject<FacetSelections>;
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> {
const insightsActive = insights;
try {
const { results } = await searchClient.search<DocSearchHit>({
requests: indexes.map((index) => {
const indexName = index.name;
const searchParams = index.searchParameters;
const facetFilters = createFacetFilters(
searchParams?.facetFilters,
facetSelections.current
);
return {
query,
indexName,
attributesToRetrieve: searchParams?.attributesToRetrieve ?? [
'hierarchy.lvl0',
'hierarchy.lvl1',
'hierarchy.lvl2',
'hierarchy.lvl3',
'hierarchy.lvl4',
'hierarchy.lvl5',
'hierarchy.lvl6',
'content',
'type',
'url',
],
attributesToSnippet: searchParams?.attributesToSnippet ?? [
`hierarchy.lvl1:${snippetLength.current}`,
`hierarchy.lvl2:${snippetLength.current}`,
`hierarchy.lvl3:${snippetLength.current}`,
`hierarchy.lvl4:${snippetLength.current}`,
`hierarchy.lvl5:${snippetLength.current}`,
`hierarchy.lvl6:${snippetLength.current}`,
`content:${snippetLength.current}`,
],
snippetEllipsisText: searchParams?.snippetEllipsisText ?? '…',
highlightPreTag: searchParams?.highlightPreTag ?? '<mark>',
highlightPostTag: searchParams?.highlightPostTag ?? '</mark>',
hitsPerPage: searchParams?.hitsPerPage ?? 20,
clickAnalytics: searchParams?.clickAnalytics ?? insightsActive,
...searchParams,
...(facetFilters ? { facetFilters } : {}),
};
}),
});
return results.flatMap((res) => {
const result = res as SearchResponse<DocSearchHit>;
const { hits, nbHits } = result;
const transformedHits = transformItems(hits);
const sources = groupBy<DocSearchHit>(
transformedHits,
(hit) => removeHighlightTags(hit),
maxResultsPerGroup
);
if (
(sourcesState.context.searchSuggestions as unknown[]).length <
Object.keys(sources).length
) {
setContext({
searchSuggestions: {
...(sourcesState.context.searchSuggestions ?? []),
...Object.keys(sources),
},
});
}
if (nbHits) {
const currentNbHits = sourcesState.context.nbHits as number | undefined;
setContext({
nbHits: (currentNbHits ?? 0) + nbHits,
});
}
let insightsParams = {};
if (insightsActive) {
insightsParams = {
__autocomplete_indexName: result.index,
__autocomplete_queryID: result.queryID,
__autocomplete_algoliaCredentials: { appId, apiKey },
};
}
return Object.values<DocSearchHit[]>(sources).map((items, index) => ({
sourceId: `hits_${result.index}_${index}`,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return Object.values(
groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup)
)
.map((groupedHits) =>
groupedHits.map((item) => {
let parent: InternalDocSearchHit | null = null;
const potentialParent = groupedHits.find(
(siblingItem) =>
siblingItem.type === 'lvl1' &&
siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1
) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
})
)
.flat();
},
}));
});
} catch (error) {
if ((error as Error).name === 'RetryError') {
setStatus('error');
}
throw error;
}
}