Files
frontend-app-authoring/src/search-manager/data/apiHooks.ts
2024-09-20 10:15:25 -07:00

269 lines
9.5 KiB
TypeScript

import React from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { type Filter, MeiliSearch } from 'meilisearch';
import {
SearchSortOption,
TAG_SEP,
fetchAvailableTagOptions,
fetchSearchResults,
fetchTagsThatMatchKeyword,
getContentSearchConfig,
fetchDocumentById,
OverrideQueries,
} from './api';
/**
* Load the Meilisearch connection details from the CMS: the URL to use, the index name, and an API key specific
* to the current user that allows it to search all content he have permission to view.
*
*/
export const useContentSearchConnection = (): {
client?: MeiliSearch,
indexName?: string,
hasConnectionError: boolean;
} => {
const { data: connectionDetails, isError: hasConnectionError } = useQuery({
queryKey: ['content_search'],
queryFn: getContentSearchConfig,
cacheTime: 60 * 60_000, // Even if we're not actively using the search modal, keep it in memory up to an hour
staleTime: 60 * 60_000, // If cache is up to one hour old, no need to re-fetch
refetchInterval: 60 * 60_000,
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
refetchOnMount: false,
});
const indexName = connectionDetails?.indexName;
const client = React.useMemo(() => {
if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
return undefined;
}
return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
}, [connectionDetails?.apiKey, connectionDetails?.url]);
return { client, indexName, hasConnectionError };
};
/**
* Get the results of a search
*/
export const useContentSearchResults = ({
client,
indexName,
extraFilter,
searchKeywords,
blockTypesFilter = [],
problemTypesFilter = [],
tagsFilter = [],
sort = [],
overrideQueries,
}: {
/** The Meilisearch API client */
client?: MeiliSearch;
/** Which search index contains the content data */
indexName?: string;
/** Other filters to apply to the search, e.g. course ID */
extraFilter?: Filter;
/** The keywords that the user is searching for, if any */
searchKeywords: string;
/** Only search for these block types (e.g. `["html", "problem"]`) */
blockTypesFilter?: string[];
/** Only search for these problem types (e.g. `["choiceresponse", "multiplechoiceresponse"]`) */
problemTypesFilter?: string[];
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
tagsFilter?: string[];
/** Sort search results using these options */
sort?: SearchSortOption[];
/** Set true to fetch collections along with components */
overrideQueries?: OverrideQueries,
}) => {
const query = useInfiniteQuery({
enabled: client !== undefined && indexName !== undefined,
queryKey: [
'content_search',
'results',
client?.config.apiKey,
client?.config.host,
indexName,
extraFilter,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
overrideQueries,
],
queryFn: ({ pageParam = 0 }) => {
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
return fetchSearchResults({
client,
extraFilter,
indexName,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
// For infinite pagination of results, we can retrieve additional pages if requested.
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
offset: pageParam,
overrideQueries,
});
},
getNextPageParam: (lastPage) => lastPage.nextOffset,
// Avoid flickering results when user is typing... keep old results until new is available.
keepPreviousData: true,
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
});
const pages = query.data?.pages;
const hits = React.useMemo(
() => pages?.reduce((allHits, page) => [...allHits, ...page.hits], []) ?? [],
[pages],
);
const collectionHits = React.useMemo(
() => pages?.reduce((allHits, page) => [...allHits, ...page.collectionHits], []) ?? [],
[pages],
);
return {
hits,
collectionHits,
// The distribution of block type filter options
blockTypes: pages?.[0]?.blockTypes ?? {},
problemTypes: pages?.[0]?.problemTypes ?? {},
status: query.status,
isFetching: query.isFetching,
isError: query.isError,
isFetchingNextPage: query.isFetchingNextPage,
// Call this to load more pages. We include some "safety" features recommended by the docs: this should never be
// called while already fetching a page, and parameters (like 'event') should not be passed into fetchNextPage().
// See https://tanstack.com/query/v4/docs/framework/react/guides/infinite-queries
fetchNextPage: () => { if (!query.isFetching && !query.isFetchingNextPage) { query.fetchNextPage(); } },
hasNextPage: query.hasNextPage,
// The last page has the most accurate count of total hits
totalHits: pages?.[pages.length - 1]?.totalHits ?? 0,
totalCollectionHits: pages?.[pages.length - 1]?.totalCollectionHits ?? 0,
};
};
/**
* Get the available tags that can be used to refine a search, based on the search filters applied so far.
* Also the user can use a keyword search to find specific tags.
*/
export const useTagFilterOptions = (args: {
/** The Meilisearch client instance */
client?: MeiliSearch;
/** Which index to search */
indexName?: string;
/** Overall query string for the search; may be empty */
searchKeywords: string;
/** Filter to only include these block types e.g. `["problem", "html"]` */
blockTypesFilter?: string[];
/** Any other filters to apply to the overall search. */
extraFilter?: Filter;
/** Only show taxonomies/tags that match these keywords */
tagSearchKeywords?: string;
/** Only fetch tags below this parent tag/taxonomy e.g. `"Places > North America"` */
parentTagPath?: string;
}) => {
const mainQuery = useQuery({
enabled: args.client !== undefined && args.indexName !== undefined,
queryKey: [
'content_search',
'tag_filter_options',
args.client?.config.apiKey,
args.client?.config.host,
args.indexName,
args.extraFilter,
args.searchKeywords,
args.blockTypesFilter,
args.parentTagPath,
],
queryFn: () => {
const { client, indexName } = args;
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
return fetchAvailableTagOptions({ ...args, client, indexName });
},
// Avoid flickering results when user is typing... keep old results until new is available.
keepPreviousData: true,
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
});
const tagKeywordSearchData = useQuery({
enabled: args.client !== undefined && args.indexName !== undefined,
queryKey: [
'content_search',
'tags_keyword_search_data',
args.client?.config.apiKey,
args.client?.config.host,
args.indexName,
args.extraFilter,
args.blockTypesFilter,
args.tagSearchKeywords,
],
queryFn: () => {
const { client, indexName } = args;
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
return fetchTagsThatMatchKeyword({ ...args, client, indexName });
},
// Avoid flickering results when user is typing... keep old results until new is available.
keepPreviousData: true,
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
});
const data = React.useMemo(() => {
if (!args.tagSearchKeywords || !tagKeywordSearchData.data) {
// If there's no keyword search being used to filter the list of available tags, just use the results of the
// main query.
return { tags: mainQuery.data?.tags, mayBeMissingResults: mainQuery.data?.mayBeMissingResults ?? false };
}
if (mainQuery.data === undefined) {
return { tags: undefined, mayBeMissingResults: false };
}
// Combine these two queries to filter the list of tags based on the keyword search.
const tags = mainQuery.data.tags.filter(
({ tagPath }) => tagKeywordSearchData.data.matches.some(
(matchingTag) => matchingTag.tagPath === tagPath || matchingTag.tagPath.startsWith(tagPath + TAG_SEP),
),
);
return {
tags,
mayBeMissingResults: mainQuery.data.mayBeMissingResults || tagKeywordSearchData.data.mayBeMissingResults,
};
}, [mainQuery.data, tagKeywordSearchData.data]);
return { ...mainQuery, data };
};
/* istanbul ignore next */
export const useGetSingleDocument = ({ client, indexName, id }: {
client?: MeiliSearch;
indexName?: string;
id: string | number;
}) => (
useQuery({
enabled: client !== undefined && indexName !== undefined,
queryKey: [
'content_search',
client?.config.apiKey,
client?.config.host,
indexName,
id,
],
queryFn: () => {
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
return fetchDocumentById({ client, indexName, id });
},
})
);