diff --git a/src/hooks/configs.ts b/src/hooks/configs.ts new file mode 100644 index 0000000..a7af7a1 --- /dev/null +++ b/src/hooks/configs.ts @@ -0,0 +1,130 @@ +import { + ResourceTypeEnum, + LearningResourcesSearchRetrieveDepartmentEnum as DepartmentEnum, + LearningResourcesSearchRetrieveLevelEnum as LevelEnum, + LearningResourcesSearchRetrievePlatformEnum as PlatformEnum, + LearningResourcesSearchRetrieveOfferedByEnum as OfferedByEnum, + LearningResourcesSearchRetrieveSortbyEnum as SortByEnum, + // ContentFile Search Enums + ContentFileSearchRetrieveSortbyEnum as ContentFileSortEnum +} from "../open_api_generated" + +type Endpoint = "resources" | "content_files" + +type SearchParams = { + endpoint: Endpoint + activeFacets: { + resource_type?: ResourceTypeEnum[] + department?: DepartmentEnum[] + course_feature?: string[] + content_feature_type?: string[] + level?: LevelEnum[] + platform?: PlatformEnum[] + offered_by?: OfferedByEnum[] + topic?: string[] + certification?: boolean + professional?: boolean + } + sort?: SortByEnum | ContentFileSortEnum + queryText: string +} + +type FacetName = keyof SearchParams["activeFacets"] + +const withinEnum = + (allowed: T[]) => + (value: string) => + (allowed as string[]).includes(value) + +type EndpointParamConfig = { + sort: { + alias: string + isValid: (value: string) => boolean + } + facets: { + [K in keyof SearchParams["activeFacets"]]?: { + alias: string + isValid: (value: string) => boolean + isBoolean?: boolean + } + } +} + +const searchParamConfig: Record = { + resources: { + sort: { + alias: "s", + isValid: withinEnum(Object.values(SortByEnum)) + }, + facets: { + resource_type: { + alias: "r", + isValid: withinEnum(Object.values(ResourceTypeEnum)) + }, + department: { + alias: "d", + isValid: withinEnum(Object.values(DepartmentEnum)) + }, + level: { + alias: "l", + isValid: withinEnum(Object.values(LevelEnum)) + }, + platform: { + alias: "p", + isValid: withinEnum(Object.values(PlatformEnum)) + }, + offered_by: { + alias: "o", + isValid: withinEnum(Object.values(OfferedByEnum)) + }, + topic: { + alias: "t", + isValid: (value: string) => value.length > 0 + }, + certification: { + alias: "c", + isValid: (value: string) => value === "true", + isBoolean: true + }, + professional: { + alias: "pr", + isValid: (value: string) => value === "true", + isBoolean: true + }, + course_feature: { + alias: "cf", + isValid: (value: string) => value.length > 0 + } + } + }, + content_files: { + sort: { + alias: "s", + isValid: withinEnum(Object.values(ContentFileSortEnum)) + }, + facets: { + offered_by: { + alias: "o", + isValid: withinEnum(Object.values(OfferedByEnum)) + }, + platform: { + alias: "p", + isValid: withinEnum(Object.values(PlatformEnum)) + }, + content_feature_type: { + alias: "cf", + isValid: (value: string) => value.length > 0 + }, + topic: { + alias: "t", + isValid: (value: string) => value.length > 0 + } + } + } +} + +const QUERY_TEXT_ALIAS = "q" +const ENDPOINT_ALIAS = "e" + +export type { SearchParams, Endpoint, FacetName } +export { searchParamConfig, QUERY_TEXT_ALIAS, ENDPOINT_ALIAS } diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..28bda1a --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,13 @@ +export { default as useSearchQueryParams } from "./useSearchQueryParams" +export type { + UseSearchQueryParamsProps, + UseSearchQueryParamsResult +} from "./useSearchQueryParams" + +export type { SearchParams, Endpoint, FacetName } from "./configs" + +export { default as useInfiniteSearch } from "./useInfiniteSearch" +export type { + UseInfiniteSearchProps, + UseInfiniteSearchResult +} from "./useInfiniteSearch" diff --git a/src/hooks/useInfiniteSearch.test.ts b/src/hooks/useInfiniteSearch.test.ts new file mode 100644 index 0000000..d5dd055 --- /dev/null +++ b/src/hooks/useInfiniteSearch.test.ts @@ -0,0 +1,391 @@ +import { renderHook, act } from "@testing-library/react-hooks/dom" +import useInfiniteSearch from "./useInfiniteSearch" + +/** + * Return a fetcher that can be resolved or rejected externally. + */ +const getDefferedFetcher = ({ count }: { count: number }) => { + const result = { + fetch: jest.fn( + (_url: string) => + new Promise((res, rej) => { + result.resolve = (id?: string) => res({ data: { count, id } }) + result.reject = (error: Error) => rej(error) + }) + ), + lastResult: () => + result.fetch.mock.results[result.fetch.mock.results.length - 1], + resolve: (_id?: string): void => { + throw new Error("Not yet assigned.") + }, + reject: (_error: Error): void => { + throw new Error("Not yet assigned.") + }, + waitForFetch: async (id?: string) => { + return act(async () => { + result.resolve(id) + await result.lastResult() + }) + }, + lastUrl: () => { + const lastCall = + result.fetch.mock.calls[result.fetch.mock.calls.length - 1] + return new URL(lastCall[0]) + }, + lastUrlQueryString: () => { + return result.lastUrl().searchParams.toString().replace(/%2C/g, ",") + } + } + return result +} + +describe("useInfiniteSearchApi", () => { + test("Makes paginated requests with given parameters", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { result } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "test", + activeFacets: { department: ["6", "8"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + } + }) + + /** + * First page --- fetched automatically + */ + expect(fetcher.fetch).toHaveBeenCalledTimes(1) + expect(fetcher.lastUrlQueryString()).toEqual( + "department=6,8&limit=10&q=test" + ) + expect(result.current).toEqual( + expect.objectContaining({ + status: "pending", + isFetchingNextPage: true, + hasNextPage: true, + pages: [] + }) + ) + await fetcher.waitForFetch() + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: false, + hasNextPage: true, + pages: [expect.anything()] + }) + ) + + /** + * Second page --- fetched via fetchNextPage + */ + expect(fetcher.fetch).toHaveBeenCalledTimes(1) + act(() => { + result.current.fetchNextPage() + }) + expect(fetcher.fetch).toHaveBeenCalledTimes(2) + expect(fetcher.lastUrlQueryString()).toEqual( + "department=6,8&limit=10&offset=10&q=test" + ) + await fetcher.waitForFetch() + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: false, + hasNextPage: true, + pages: [expect.anything(), expect.anything()] + }) + ) + + /** + * Third page --- fetched via fetchNextPage + */ + expect(fetcher.fetch).toHaveBeenCalledTimes(2) + act(() => { + result.current.fetchNextPage() + }) + expect(fetcher.fetch).toHaveBeenCalledTimes(3) + expect(fetcher.lastUrlQueryString()).toEqual( + "department=6,8&limit=10&offset=20&q=test" + ) + await fetcher.waitForFetch() + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: false, + hasNextPage: false, // No more pages + pages: [expect.anything(), expect.anything(), expect.anything()] + }) + ) + + // No more pages + result.current.fetchNextPage() + result.current.fetchNextPage() + result.current.fetchNextPage() + expect(fetcher.fetch).toHaveBeenCalledTimes(3) + }) + + test("fetchNextPage is a no-op if fetch is already underway", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { result } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "test", + activeFacets: { department: ["6", "8"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + } + }) + await fetcher.waitForFetch() + + expect(fetcher.fetch).toHaveBeenCalledTimes(1) + act(() => { + result.current.fetchNextPage() + result.current.fetchNextPage() + result.current.fetchNextPage() + }) + expect(fetcher.fetch).toHaveBeenCalledTimes(2) + }) + + test("when parameters change, result is reset and refetch occurs", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { result, rerender } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "test", + activeFacets: { department: ["6", "8"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + } + }) + await fetcher.waitForFetch() + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: false, + hasNextPage: true, + pages: [expect.anything()] + }) + ) + + rerender({ + params: { + queryText: "test", + activeFacets: { department: ["6"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + }) + expect(result.current).toEqual( + expect.objectContaining({ + status: "pending", + isFetchingNextPage: true, + hasNextPage: true, + pages: [] + }) + ) + expect(fetcher.lastUrlQueryString()).toEqual("department=6&limit=10&q=test") + }) + + test("when parameters change and keepPreviousData=true, previous data is kept during refetch", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { result, rerender } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "test", + activeFacets: { department: ["6", "8"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch, + keepPreviousData: true + } + }) + await fetcher.waitForFetch("1") + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: false, + hasNextPage: true, + pages: [{ count: 23, id: "1" }] + }) + ) + + rerender({ + params: { + queryText: "test", + activeFacets: { department: ["6"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch, + keepPreviousData: true + }) + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: true, + hasNextPage: true, + pages: [{ count: 23, id: "1" }] + }) + ) + expect(fetcher.lastUrlQueryString()).toEqual("department=6&limit=10&q=test") + await fetcher.waitForFetch("2") + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + isFetchingNextPage: false, + hasNextPage: true, + pages: [{ count: 23, id: "2" }] + }) + ) + }) + + test("fetch errors are reflected in status", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { result } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "test", + activeFacets: { department: ["6", "8"] }, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + } + }) + expect(result.current.status).toBe("pending") + await fetcher.waitForFetch() + expect(result.current.status).toBe("success") + await act(async () => { + result.current.fetchNextPage() + fetcher.reject(new Error("Shucks.")) + await fetcher.lastResult() + }) + expect(result.current.status).toBe("error") + expect(result.current.error).toEqual(new Error("Shucks.")) + }) + + test("Outdated responses are ignored", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { result, rerender } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "one", + activeFacets: {}, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + } + }) + const resolveFirst = fetcher.resolve + rerender({ + params: { + queryText: "two", + activeFacets: {}, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + }) + const resolveSecond = fetcher.resolve + expect(resolveFirst).not.toBe(resolveSecond) // sanity + + /** + * Resolve the second promise first + */ + await act(async () => { + resolveSecond("two") + await fetcher.lastResult() + }) + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + pages: [expect.objectContaining({ id: "two" })] + }) + ) + + /** + * Resolve the first promise + */ + await act(async () => { + resolveFirst("one") + await fetcher.lastResult() + }) + // First promise result should be ignored + expect(result.current).toEqual( + expect.objectContaining({ + status: "success", + pages: [expect.objectContaining({ id: "two" })] + }) + ) + }) + + test("Changing endpoint changes the URL", async () => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { rerender } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "one", + activeFacets: {}, + endpoint: "resources" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + } + }) + expect(fetcher.lastUrl().pathname).toBe( + "/api/v1/learning_resources_search/" + ) + rerender({ + params: { + queryText: "one", + activeFacets: {}, + endpoint: "content_files" + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch + }) + expect(fetcher.lastUrl().pathname).toBe("/api/v1/content_file_search/") + }) + + test.each([ + { endpoint: "resources", expected: ["department", "course_feature"] }, + { + endpoint: "content_files", + expected: ["offered_by", "content_feature_type"] + } + ] as const)( + "Makes requests with aggregations based on endpoint", + async ({ expected, endpoint }) => { + const fetcher = getDefferedFetcher({ count: 23 }) + const { rerender } = renderHook(useInfiniteSearch, { + initialProps: { + params: { + queryText: "one", + activeFacets: {}, + endpoint + }, + baseUrl: "https://example.com", + makeRequest: fetcher.fetch, + aggregations: { + resources: ["department", "course_feature"], + content_files: ["offered_by", "content_feature_type"] + } + } + }) + expect(fetcher.lastUrl().searchParams.get("aggregations")).toEqual( + expected.join(",") + ) + } + ) +}) diff --git a/src/hooks/useInfiniteSearch.ts b/src/hooks/useInfiniteSearch.ts new file mode 100644 index 0000000..7371c64 --- /dev/null +++ b/src/hooks/useInfiniteSearch.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import type { SearchParams } from "./configs" +import { getSearchUrl } from "./util" +import type { + SearchResponse, + ContentFileSearchRetrieveAggregationsEnum as ContentFileAggregationsEnum, + LearningResourcesSearchRetrieveAggregationsEnum as ResourceAggregationsEnum +} from "../open_api_generated" + +type Status = "pending" | "error" | "success" + +type UseInfiniteSearchResult = { + pages: SearchResponse[] + error?: unknown + status: Status + isFetchingNextPage: boolean + hasNextPage: boolean + fetchNextPage: () => Promise +} + +type AggregationsConfig = { + resources: ResourceAggregationsEnum[] + content_files: ContentFileAggregationsEnum[] +} + +type UseInfiniteSearchProps = { + /** + * Search parameters to use for API request. + */ + params: SearchParams + /** + * The base URL for the API. + */ + baseUrl: string + /** + * The number of items to fetch per page. + */ + limit?: number + /** + * Object including the aggregations to be used for each endpoint. + */ + aggregations?: AggregationsConfig + /** + * A function which makes a request to API and returns a promise that resolves + * to `{ data: }`. Optional. Defaults to an implementation that + * uses fetch. + */ + makeRequest?: (url: string) => Promise<{ data: any }> + /** + * If true, keep previous data when fetching new pages. + */ + keepPreviousData?: boolean +} + +const DEFAULT_LIMIT = 10 + +const defaultMakeRequest = async (url: string) => { + const response = await fetch(url) + if (!response.ok) { + throw new Error("Failed to fetch data") + } + const data = await response.json() + return { data } +} + +interface TracksCalled { + called?: boolean + (): Promise +} + +/** + * Given a set of search parameters, this hook fetches search results from the + * API and paginates through them. + * + * The return value is modeled after react-query's useInfiniteQuery hook. + */ +const useInfiniteSearch = ({ + params, + limit = DEFAULT_LIMIT, + makeRequest = defaultMakeRequest, + baseUrl, + aggregations, + keepPreviousData +}: UseInfiniteSearchProps): UseInfiniteSearchResult => { + const [nextPage, setNextPage] = useState(0) + const [error, setError] = useState() + const [pages, setPages] = useState([]) + const [status, setStatus] = useState("pending") + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false) + const [isPreviousData, setIsPreviousData] = useState(false) + const urlRef = useRef() + + const hasNextPage = + pages[0] === undefined || pages[0]?.count > nextPage * limit + + const getPageUrl = useCallback( + (page: number) => { + const offset = page * limit + return getSearchUrl(baseUrl, { + limit, + offset, + ...params, + aggregations: aggregations?.[params.endpoint] ?? [] + }) + }, + [aggregations, baseUrl, limit, params] + ) + + const fetchNextPage: TracksCalled = useCallback(async () => { + if (!hasNextPage || fetchNextPage.called) return + fetchNextPage.called = true + const url = getPageUrl(nextPage) + urlRef.current = url + try { + setIsFetchingNextPage(true) + const { data } = await makeRequest(url) + if (url !== urlRef.current) return + urlRef.current = null + setIsFetchingNextPage(false) + setStatus("success") + setIsPreviousData(false) + setPages(pages => { + if (nextPage === 0) return [data] + return [...pages, data] + }) + setNextPage(nextPage + 1) + } catch (err) { + if (url !== urlRef.current) return + setStatus("error") + setError(err) + } + }, [getPageUrl, hasNextPage, makeRequest, nextPage]) + + const firstPageUrl = getPageUrl(0) + useEffect(() => { + // Reset state when first page changes + setNextPage(0) + if (keepPreviousData) { + setIsPreviousData(true) + } else { + setPages([]) + setError(undefined) + setIsFetchingNextPage(false) + setStatus("pending") + } + urlRef.current = null + }, [firstPageUrl, keepPreviousData]) + + useEffect(() => { + if (status === "pending" || isPreviousData) { + fetchNextPage() + } + }, [status, fetchNextPage, isPreviousData]) + + return { + pages, + error, + status, + isFetchingNextPage, + hasNextPage, + fetchNextPage + } +} + +export default useInfiniteSearch +export type { UseInfiniteSearchResult, UseInfiniteSearchProps } diff --git a/src/hooks/useSearchQueryParams.test.tsx b/src/hooks/useSearchQueryParams.test.tsx new file mode 100644 index 0000000..0a93813 --- /dev/null +++ b/src/hooks/useSearchQueryParams.test.tsx @@ -0,0 +1,438 @@ +/* eslint-disable prefer-template */ +import React from "react" +import { renderHook, act } from "@testing-library/react-hooks/dom" +import useSearchQueryParams from "./useSearchQueryParams" +import type { + UseSearchQueryParamsProps, + UseSearchQueryParamsResult +} from "./useSearchQueryParams" +import type { Endpoint, SearchParams } from "./configs" + +const setup = ({ + initial = "", + props +}: { + props?: Omit + initial: string | URLSearchParams +}) => { + const searchParamsRef: React.MutableRefObject = { + current: new URLSearchParams(initial) + } + const useTestHook = () => { + /** + * Usually these would be passed to `useSearchQueryParams` via a routing + * library, e.g., React Router's `useSearchParams` hook. + */ + const [searchParams, setSearchParams] = React.useState( + searchParamsRef.current + ) + searchParamsRef.current = searchParams + return useSearchQueryParams({ searchParams, setSearchParams, ...props }) + } + const result = renderHook(useTestHook) + return { + ...result, + searchParams: searchParamsRef + } +} + +const assertParamsExtracted = ( + initial: string | URLSearchParams, + expected: UseSearchQueryParamsResult["params"], + props?: Omit +) => { + const { result, searchParams } = setup({ initial, props }) + expect(result.current.params).toEqual(expected) + // Search params not modified + expect(searchParams.current).toEqual(new URLSearchParams(initial)) +} + +test("Extracts expected facets from the given URLSearchParams for endpoint=resources", () => { + // resource type + assertParamsExtracted("?r=course&r=podcast", { + queryText: "", + activeFacets: { resource_type: ["course", "podcast"] }, + endpoint: "resources" + }) + // department + assertParamsExtracted("?r=course&d=6&d=8", { + queryText: "", + activeFacets: { + resource_type: ["course"], + department: ["6", "8"] + }, + endpoint: "resources" + }) + // level + assertParamsExtracted("?l=noncredit&r=program&l=high_school", { + queryText: "", + activeFacets: { + resource_type: ["program"], + level: ["noncredit", "high_school"] + }, + endpoint: "resources" + }) + + // platform + assertParamsExtracted("?l=noncredit&p=ocw&p=mitxonline", { + queryText: "", + activeFacets: { + platform: ["ocw", "mitxonline"], + level: ["noncredit"] + }, + endpoint: "resources" + }) + // offered by + assertParamsExtracted("?l=graduate&o=bootcamps&o=xpro", { + queryText: "", + activeFacets: { + offered_by: ["bootcamps", "xpro"], + level: ["graduate"] + }, + endpoint: "resources" + }) + // topics + assertParamsExtracted("?l=graduate&t=python&t=javascript", { + queryText: "", + activeFacets: { + topic: ["python", "javascript"], + level: ["graduate"] + }, + endpoint: "resources" + }) +}) + +test("Extracts boolean facet values correctly", () => { + assertParamsExtracted("?l=graduate&c=true", { + queryText: "", + activeFacets: { + level: ["graduate"], + certification: true + }, + endpoint: "resources" + }) + + assertParamsExtracted("?l=graduate&pr=true", { + queryText: "", + activeFacets: { + level: ["graduate"], + professional: true + }, + endpoint: "resources" + }) +}) + +test("Ignores invalid facet values", () => { + const initial = new URLSearchParams([ + ...Object.entries({ + r: "course", + d: "6", + l: "noncredit", + p: "ocw", + o: "ocw", + t: "python", + c: "true", + pr: "true" + }), + ...Object.entries({ + r: "bogus", + d: "bogus", + l: "bogus", + p: "bogus", + o: "bogus", + t: "all-topics-allowed", + c: "bogus", + pr: "bogus", + cats: "bogus-key-and-value", + dogs: "bogus-key-and-value" + }) + ]) + assertParamsExtracted(initial, { + queryText: "", + activeFacets: { + resource_type: ["course"], + department: ["6"], + level: ["noncredit"], + platform: ["ocw"], + offered_by: ["ocw"], + topic: ["python", "all-topics-allowed"], + certification: true, + professional: true + }, + endpoint: "resources" + }) +}) + +test.each([ + { + initial: "?d=6&cf=whatever&e=resources", + expectedFacets: { course_feature: ["whatever"], department: ["6"] }, + expectedEndpoint: "resources" + }, + { + initial: "?r=course&d=6&cf=whatever&e=content_files", + expectedFacets: { content_feature_type: ["whatever"] }, + expectedEndpoint: "content_files" + } +])( + "Ignores / keeps facets based on endpoint", + ({ initial, expectedFacets, expectedEndpoint }) => { + assertParamsExtracted(initial, { + queryText: "", + activeFacets: expectedFacets as SearchParams["activeFacets"], + endpoint: expectedEndpoint as Endpoint + }) + } +) + +test("Ignores / keeps sort values based on endpoint", () => { + assertParamsExtracted("?d=6&s=mitcoursenumber", { + endpoint: "resources", + activeFacets: { department: ["6"] }, + sort: "mitcoursenumber", + queryText: "" + }) + + assertParamsExtracted("?r=course&e=resources&s=-resource_readable_id", { + endpoint: "resources", + activeFacets: { resource_type: ["course"] }, + // sort: "-resource_readable_id", // not valid for resources + queryText: "" + }) + + assertParamsExtracted( + "?s=-resource_readable_id&d=6&cf=whatever&e=content_files", + { + endpoint: "content_files", + activeFacets: { content_feature_type: ["whatever"] }, + sort: "-resource_readable_id", + queryText: "" + } + ) + + assertParamsExtracted("?p=ocw&s=mitcoursenumber&e=content_files", { + activeFacets: { platform: ["ocw"] }, + endpoint: "content_files", + // sort: "mitcoursenumber", // invalid for content_files + queryText: "" + }) +}) + +test("Query text is extracted correctly", () => { + assertParamsExtracted("?q=python", { + queryText: "python", + activeFacets: {}, + endpoint: "resources" + }) + + assertParamsExtracted("?q=python&q=javascript", { + queryText: "python", + activeFacets: {}, + endpoint: "resources" + }) + + assertParamsExtracted("?q=python&q=javascript&e=content_files", { + queryText: "python", + activeFacets: {}, + endpoint: "content_files" + }) +}) + +test("Setting current text does not affect query parameters at all", () => { + const initial = "?r=course&d=6&q=python" + const { result, searchParams } = setup({ initial }) + expect(result.current.params).toEqual({ + queryText: "python", + activeFacets: { + resource_type: ["course"], + department: ["6"] + }, + endpoint: "resources" + }) + + const params0 = result.current.params + + act(() => { + result.current.setCurrentText("javascript") + }) + + // current text has been updated + expect(result.current.currentText).toBe("javascript") + // params still equal + expect(result.current.params).toEqual({ + queryText: "python", + activeFacets: { + resource_type: ["course"], + department: ["6"] + }, + endpoint: "resources" + }) + // and equal by reerence + expect(result.current.params).toBe(params0) + // URLSearchParams not modified + expect(searchParams.current).toEqual(new URLSearchParams(initial)) +}) + +test("Setting queryText updates current text and search params", async () => { + const initial = "?r=course&d=6&q=python" + const { result, searchParams } = setup({ initial }) + + act(() => { + result.current.setCurrentTextAndQuery("javascript") + }) + + expect(result.current.currentText).toBe("javascript") + expect(searchParams.current).toEqual( + new URLSearchParams("?d=6&q=javascript&r=course") + ) +}) + +test("Setting sort updates search params", () => { + const initial = "?r=course&d=6&q=python" + const { result, searchParams } = setup({ initial }) + + act(() => { + result.current.setSort("mitcoursenumber") + }) + expect(searchParams.current).toEqual( + new URLSearchParams("?d=6&q=python&r=course&s=mitcoursenumber") + ) +}) + +test.each([ + { + initial: "?r=course&d=6", + expected: new URLSearchParams("?d=6&r=course&r=program") + }, + { + initial: "?d=6", + expected: new URLSearchParams("?d=6&r=program") + }, + { + initial: "?d=6&r=program", + expected: new URLSearchParams("?d=6&r=program") + } +])("Turning a facet on with setFacetActive", ({ initial, expected }) => { + const { result, searchParams } = setup({ initial }) + act(() => { + result.current.setFacetActive("resource_type", "program", true) + }) + expect(searchParams.current).toEqual(expected) +}) + +test.each([ + { + initial: "?r=course&r=program&d=6", + expected: new URLSearchParams("?d=6&r=course") + }, + { + initial: "?d=6", + expected: new URLSearchParams("?d=6") + }, + { + initial: "?d=6&r=program", + expected: new URLSearchParams("?d=6") + } +])("Turning a facet off with setFacetActive", () => { + const { result, searchParams } = setup({ + initial: "?r=course&r=program&d=6" + }) + act(() => { + result.current.setFacetActive("resource_type", "program", false) + }) + expect(searchParams.current).toEqual(new URLSearchParams("?d=6")) +}) + +test.each([ + { + initial: "?r=course", + expected: new URLSearchParams("?c=true&r=course") + }, + { + initial: "?r=course&c=true", + expected: new URLSearchParams("?c=true&r=course") + } +])( + "Turning a boolean facet on with setFacetActive", + ({ initial, expected }) => { + const { result, searchParams } = setup({ initial }) + act(() => { + result.current.setFacetActive("certification", "irrelevant", true) + }) + expect(searchParams.current).toEqual(expected) + } +) + +test.each([ + { + initial: "?r=course", + expected: new URLSearchParams("?r=course") + }, + { + initial: "?r=course&c=true", + expected: new URLSearchParams("?r=course") + } +])( + "Turning a boolean facet off with setFacetActive", + ({ initial, expected }) => { + const { result, searchParams } = setup({ initial }) + act(() => { + result.current.setFacetActive("certification", "irrelevant", false) + }) + expect(searchParams.current).toEqual(expected) + } +) + +test.each([ + { + initial: "?d=6", + expected: "?d=6&e=content_files", + endpoint: "content_files", + props: undefined + }, + { + initial: "?d=6", + expected: "?d=6", + endpoint: "invalid", + props: undefined + }, + { + initial: "?d=6&e=content_files", + expected: "?d=6", + endpoint: "resources", + props: undefined + }, + { + initial: "?d=6&e=resources", + expected: "?d=6", + endpoint: "content_files", + props: { defaultEndpoint: "content_files" } + }, + { + initial: "?d=6&e=content_Files", + expected: "?d=6", + endpoint: "resources", + props: { defaultEndpoint: "resources" } + } +] as const)( + "Changing endpoint updates search params", + ({ initial, expected, endpoint, props }) => { + const { result, searchParams } = setup({ initial, props }) + act(() => { + result.current.setEndpoint(endpoint) + }) + expect(searchParams.current).toEqual(new URLSearchParams(expected)) + } +) + +test("clearFacets clears all facets", () => { + const { result, searchParams } = setup({ + initial: "?r=course&d=6&q=python&x=irrelevant" + }) + act(() => { + result.current.clearFacets() + }) + expect(searchParams.current).toEqual( + new URLSearchParams("?q=python&x=irrelevant") + ) +}) diff --git a/src/hooks/useSearchQueryParams.ts b/src/hooks/useSearchQueryParams.ts new file mode 100644 index 0000000..9330b16 --- /dev/null +++ b/src/hooks/useSearchQueryParams.ts @@ -0,0 +1,260 @@ +import { useCallback, useMemo, useRef, useState } from "react" +import { isEqual } from "lodash" +import { ENDPOINT_ALIAS, QUERY_TEXT_ALIAS, searchParamConfig } from "./configs" +import type { Endpoint, SearchParams, FacetName } from "./configs" + +interface UseSearchQueryParamsResult { + /** + * Object containing parameters to be used in a search request. Calculated + * based solely on UrlSearchParams. + * + * Note: The `params.endpoint` value determines what facets are available. + */ + params: SearchParams + /** + * The current text to display in search input. This may be different from + * `params.queryText` if the user has typed in the search input but not yet + * submitted the search. + */ + currentText: string + /** + * Modifies the UrlSearchParams, updating the portion of UrlSearchParams that + * corresponds to specified facet. + */ + setFacetActive: ( + /** + * Facet name. E.g., "department", "level", etc. + */ + name: string, + /** + * Facet value. E.g., "6", "8", etc. + * + * For boolean facets, this value is ignored and the facet state (true or + * false) is determined solely by the `checked` parameter. + */ + value: string, + /** + * Whether the facet should be active or inactive. + */ + checked: boolean + ) => void + /** + * Modifies the current UrlSearchParams to clear all facets according to the + * current endpoint. + */ + clearFacets: () => void + /** + * Sets the current text to display in the search input. Does NOT affect the + * params object. + */ + setCurrentText: (value: string) => void + /** + * Modifies the current UrlSearchParams; sets the current text to display in + * the search input AND updates portion of UrlSearchParams corresponding to + * `params.queryText`. + */ + setCurrentTextAndQuery: (value: string) => void + /** + * Modifies the current UrlSearchParams, updating the portion of UrlSearchParams + * that corresponds to sort value. Valid values are determined by the current + * endpoint. + */ + setSort: (value: string | null) => void + /** + * Modifies the current UrlSearchParams, updating the portion of UrlSearchParams + * that corresponds to the endpoint. + */ + setEndpoint: (value: string) => void +} + +interface UseSearchQueryParamsProps { + /** + * Source of truth for search parameters. + * + * Note: React should be aware of the state of this object. For example, + * do NOT pass `new UrlSearchParams(window.location.search)` directly. + * Instead, use `useSearchParams` hook from `react-router-dom` or equivalent. + */ + searchParams: URLSearchParams + /** + * A setter for `searchParams`. + */ + setSearchParams: ( + value: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams) + ) => void + /** + * Default search endpoint. + */ + defaultEndpoint?: Endpoint +} + +const getEndpoint = ( + searchParams: URLSearchParams, + defaultEndpoint: Endpoint +): Endpoint => { + const endpoint = searchParams.get(ENDPOINT_ALIAS) ?? defaultEndpoint + if (Object.keys(searchParamConfig).includes(endpoint)) { + return endpoint as Endpoint + } + return defaultEndpoint +} + +/** + * Returns search API parameters derived from UrlSearchParameters, along with + * functions to modify the UrlSearchParams. + */ +const useSearchQueryParams = ({ + searchParams, + setSearchParams, + defaultEndpoint = "resources" +}: UseSearchQueryParamsProps): UseSearchQueryParamsResult => { + const endpoint = getEndpoint(searchParams, defaultEndpoint) + const queryText = searchParams.get(QUERY_TEXT_ALIAS) ?? "" + const [currentText, setCurrentText] = useState(queryText) + + const activeFacetsRef = useRef< + UseSearchQueryParamsResult["params"]["activeFacets"] + >({}) + const activeFacets = useMemo(() => { + const active = Object.entries(searchParamConfig[endpoint].facets).reduce( + (acc, [facet, { isValid, alias, isBoolean }]) => { + const values = searchParams.getAll(alias) + const valid = values.filter(v => isValid(v)) + if (valid.length === 0) return acc + if (isBoolean) { + acc[facet] = true + } else { + acc[facet] = valid + } + return acc + }, + {} as Record + ) as UseSearchQueryParamsResult["params"]["activeFacets"] + if (isEqual(activeFacetsRef.current, active)) { + return activeFacetsRef.current + } + return active + }, [endpoint, searchParams]) + + const sort = searchParams.get(searchParamConfig[endpoint].sort.alias) ?? "" + const params = useMemo(() => { + const value: UseSearchQueryParamsResult["params"] = { + endpoint, + activeFacets, + queryText + } + if (sort && searchParamConfig[endpoint].sort.isValid(sort)) { + value.sort = sort as UseSearchQueryParamsResult["params"]["sort"] + } + return value + }, [activeFacets, endpoint, queryText, sort]) + + const setSort = useCallback( + (value: string | null) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + const { alias, isValid } = searchParamConfig[endpoint].sort + if (value === null) { + next.delete(alias) + } else if (isValid(value)) { + next.set(alias, value) + } else { + return prev + } + next.sort() + return next + }) + }, + [endpoint, setSearchParams] + ) + const setCurrentTextAndQuery = useCallback( + (value: string) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + if (value) { + next.set(QUERY_TEXT_ALIAS, value) + } else { + next.delete(QUERY_TEXT_ALIAS) + } + next.sort() + return next + }) + setCurrentText(value) + }, + [setSearchParams] + ) + const setFacetActive = useCallback( + (facet: string, value: string, checked: boolean) => { + const config = searchParamConfig[endpoint]["facets"][facet as FacetName] + if (!config) return + const { isValid, alias, isBoolean } = config + setSearchParams(prev => { + const next = new URLSearchParams(prev) + const facetValues = next.getAll(alias) + if (isBoolean) { + if (checked) { + next.set(alias, "true") + } else { + next.delete(alias) + } + next.sort() + return next + } + if (!isValid(value)) return next + const exists = facetValues.includes(value) + if ((exists && checked) || (!exists && !checked)) return next + if (checked) { + next.append(alias, value) + } else { + next.delete(alias, value) + } + next.sort() + return next + }) + }, + [endpoint, setSearchParams] + ) + + const setEndpoint = useCallback( + (value: string) => { + if (!Object.keys(searchParamConfig).includes(value)) return + setSearchParams(prev => { + const next = new URLSearchParams(prev) + if (value === defaultEndpoint) { + next.delete(ENDPOINT_ALIAS) + } else { + next.set(ENDPOINT_ALIAS, value) + } + next.sort() + return next + }) + }, + [defaultEndpoint, setSearchParams] + ) + + const clearFacets = useCallback(() => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + Object.values(searchParamConfig[endpoint].facets).forEach(({ alias }) => { + next.delete(alias) + }) + next.sort() + return next + }) + }, [endpoint, setSearchParams]) + + const result: UseSearchQueryParamsResult = { + params, + currentText, + setFacetActive, + setCurrentText, + setCurrentTextAndQuery, + setSort, + setEndpoint, + clearFacets + } + return result +} + +export default useSearchQueryParams +export type { UseSearchQueryParamsResult, UseSearchQueryParamsProps } diff --git a/src/hooks/util.ts b/src/hooks/util.ts new file mode 100644 index 0000000..4529853 --- /dev/null +++ b/src/hooks/util.ts @@ -0,0 +1,56 @@ +import type { SearchParams, Endpoint } from "./configs" + +const endpointUrls: Record = { + resources: "api/v1/learning_resources_search/", + content_files: "api/v1/content_file_search/" +} + +export const getSearchUrl = ( + baseUrl: string, + { + endpoint, + queryText, + sort, + activeFacets, + aggregations, + limit, + offset + }: SearchParams & { + aggregations: string[] + limit?: number + offset?: number + } +): string => { + const url = new URL(endpointUrls[endpoint], baseUrl) + + if (queryText) { + url.searchParams.append("q", queryText) + } + if (offset) { + url.searchParams.append("offset", offset.toString()) + } + + if (limit) { + url.searchParams.append("limit", limit.toString()) + } + + if (sort) { + url.searchParams.append("sortby", sort) + } + + if (aggregations && aggregations.length > 0) { + url.searchParams.append("aggregations", aggregations.join(",")) + } + + if (activeFacets) { + for (const [key, value] of Object.entries(activeFacets)) { + const asArray = Array.isArray(value) ? value : [value] + if (asArray.length > 0) { + url.searchParams.append(key, asArray.join(",")) + } + } + } + + url.searchParams.sort() + return url.toString() +} diff --git a/src/index.ts b/src/index.ts index b3a2262..6c21c85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,17 @@ export * from "./constants" export * from "./url_utils" export * from "./open_api_generated/api" +export type { + UseInfiniteSearchProps, + UseInfiniteSearchResult +} from "./hooks/useInfiniteSearch" +export type { + UseSearchQueryParamsProps, + UseSearchQueryParamsResult +} from "./hooks/useSearchQueryParams" +export { default as useInfiniteSearch } from "./hooks/useInfiniteSearch" +export { default as useSearchQueryParams } from "./hooks/useSearchQueryParams" + export { buildSearchUrl, SearchQueryParams } from "./search" export interface Bucket {