diff --git a/frontends/api/src/ssr/prefetch.ts b/frontends/api/src/ssr/prefetch.ts index 67e377e28c..39d4eda1df 100644 --- a/frontends/api/src/ssr/prefetch.ts +++ b/frontends/api/src/ssr/prefetch.ts @@ -12,7 +12,9 @@ export const prefetch = async ( queryClient = queryClient || new QueryClient() await Promise.all( - queries.map((query) => queryClient.prefetchQuery(query as Query)), + queries + .filter(Boolean) + .map((query) => queryClient.prefetchQuery(query as Query)), ) return { dehydratedState: dehydrate(queryClient), queryClient } diff --git a/frontends/main/package.json b/frontends/main/package.json index 6511220ed6..31f1353973 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -12,7 +12,7 @@ "dependencies": { "@ebay/nice-modal-react": "^1.2.13", "@emotion/cache": "^11.13.1", - "@mitodl/course-search-utils": "^3.3.1", + "@mitodl/course-search-utils": "3.3.2", "@next/bundle-analyzer": "^14.2.15", "@remixicon/react": "^4.2.0", "@sentry/nextjs": "^8.36.0", diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelPage.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelPage.tsx index cffcee85e1..a0ffba91ee 100644 --- a/frontends/main/src/app-pages/ChannelPage/ChannelPage.tsx +++ b/frontends/main/src/app-pages/ChannelPage/ChannelPage.tsx @@ -6,13 +6,9 @@ import { useParams } from "next/navigation" import { ChannelPageTemplate } from "./ChannelPageTemplate" import { useChannelDetail } from "api/hooks/channels" import ChannelSearch from "./ChannelSearch" -import type { - Facets, - FacetKey, - BooleanFacets, -} from "@mitodl/course-search-utils" import { ChannelTypeEnum } from "api/v0" import { Typography } from "ol-components" +import { getConstantSearchParams } from "./searchRequests" type RouteParams = { channelType: ChannelTypeEnum @@ -22,20 +18,11 @@ type RouteParams = { const ChannelPage: React.FC = () => { const { channelType, name } = useParams() const channelQuery = useChannelDetail(String(channelType), String(name)) - const searchParams: Facets & BooleanFacets = {} const publicDescription = channelQuery.data?.public_description - if (channelQuery.data?.search_filter) { - const urlParams = new URLSearchParams(channelQuery.data.search_filter) - for (const [key, value] of urlParams.entries()) { - const paramEntry = searchParams[key as FacetKey] - if (paramEntry !== undefined) { - paramEntry.push(value) - } else { - searchParams[key as FacetKey] = [value] - } - } - } + const channelSearchFilter = channelQuery.data?.search_filter + + const searchParams = getConstantSearchParams(channelSearchFilter) return ( name && @@ -46,9 +33,9 @@ const ChannelPage: React.FC = () => { {publicDescription && ( {publicDescription} )} - {channelQuery.data?.search_filter && ( + {channelSearchFilter && ( diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx index e756bb4e8e..9f1f9cbb7c 100644 --- a/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx +++ b/frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx @@ -1,23 +1,13 @@ import React, { useCallback, useMemo } from "react" -import { LearningResourceOfferor } from "api" import { ChannelTypeEnum } from "api/v0" import { useOfferorsList } from "api/hooks/learningResources" - -import { - useResourceSearchParams, - UseResourceSearchParamsProps, -} from "@mitodl/course-search-utils" -import type { - Facets, - BooleanFacets, - FacetManifest, -} from "@mitodl/course-search-utils" +import { useResourceSearchParams } from "@mitodl/course-search-utils" +import type { Facets, BooleanFacets } from "@mitodl/course-search-utils" import { useSearchParams } from "@mitodl/course-search-utils/next" import SearchDisplay from "@/page-components/SearchDisplay/SearchDisplay" import { Container, styled, VisuallyHidden } from "ol-components" import { SearchField } from "@/page-components/SearchField/SearchField" - -import { getFacetManifest } from "@/app-pages/SearchPage/SearchPage" +import { getFacets } from "./searchRequests" import _ from "lodash" @@ -35,34 +25,6 @@ const StyledSearchField = styled(SearchField)({ width: "624px", }) -const FACETS_BY_CHANNEL_TYPE: Record = { - [ChannelTypeEnum.Topic]: [ - "free", - "resource_type", - "certification_type", - "delivery", - "offered_by", - "department", - ], - [ChannelTypeEnum.Department]: [ - "free", - "resource_type", - "certification_type", - "topic", - "delivery", - "offered_by", - ], - [ChannelTypeEnum.Unit]: [ - "free", - "resource_type", - "topic", - "certification_type", - "delivery", - "department", - ], - [ChannelTypeEnum.Pathway]: [], -} - const SHOW_PROFESSIONAL_TOGGLE_BY_CHANNEL_TYPE: Record< ChannelTypeEnum, boolean @@ -73,24 +35,6 @@ const SHOW_PROFESSIONAL_TOGGLE_BY_CHANNEL_TYPE: Record< [ChannelTypeEnum.Pathway]: false, } -const getFacetManifestForChannelType = ( - channelType: ChannelTypeEnum, - offerors: Record, - constantSearchParams: Facets, - resourceCategory: string | null, -): FacetManifest => { - const facets = FACETS_BY_CHANNEL_TYPE[channelType] || [] - return getFacetManifest(offerors, resourceCategory) - .filter( - (facetSetting) => - !Object.keys(constantSearchParams).includes(facetSetting.name) && - facets.includes(facetSetting.name), - ) - .sort( - (a, b) => facets.indexOf(a.name) - facets.indexOf(b.name), - ) as FacetManifest -} - interface ChannelSearchProps { constantSearchParams: Facets & BooleanFacets channelType: ChannelTypeEnum @@ -110,14 +54,9 @@ const ChannelSearch: React.FC = ({ const [searchParams, setSearchParams] = useSearchParams() const resourceCategory = searchParams.get("resource_category") - const facetManifest = useMemo( + const { facetNames, facetManifest } = useMemo( () => - getFacetManifestForChannelType( - channelType, - offerors, - constantSearchParams, - resourceCategory, - ), + getFacets(channelType, offerors, constantSearchParams, resourceCategory), [offerors, channelType, constantSearchParams, resourceCategory], ) @@ -140,18 +79,6 @@ const ChannelSearch: React.FC = ({ setPage(1) }, [setPage]) - const facetNames = Array.from( - new Set( - facetManifest.flatMap((facet) => { - if (facet.type === "group") { - return facet.facets.map((subfacet) => subfacet.name) - } else { - return [facet.name] - } - }), - ), - ) as UseResourceSearchParamsProps["facets"] - const { hasFacets, params, diff --git a/frontends/main/src/app-pages/ChannelPage/searchRequests.ts b/frontends/main/src/app-pages/ChannelPage/searchRequests.ts new file mode 100644 index 0000000000..7c934234c4 --- /dev/null +++ b/frontends/main/src/app-pages/ChannelPage/searchRequests.ts @@ -0,0 +1,102 @@ +import type { + Facets, + FacetKey, + BooleanFacets, + FacetManifest, + UseResourceSearchParamsProps, +} from "@mitodl/course-search-utils" +import { LearningResourceOfferor } from "api" +import { ChannelTypeEnum } from "api/v0" +import { getFacetManifest } from "@/page-components/SearchDisplay/getFacetManifest" + +export const getConstantSearchParams = (searchFilter?: string) => { + const searchParams: Facets & BooleanFacets = {} + + if (searchFilter) { + const urlParams = new URLSearchParams(searchFilter) + for (const [key, value] of urlParams.entries()) { + const paramEntry = searchParams[key as FacetKey] + if (paramEntry !== undefined) { + paramEntry.push(value) + } else { + searchParams[key as FacetKey] = [value] + } + } + } + + return searchParams +} + +const FACETS_BY_CHANNEL_TYPE: Record = { + [ChannelTypeEnum.Topic]: [ + "free", + "resource_type", + "certification_type", + "delivery", + "offered_by", + "department", + ], + [ChannelTypeEnum.Department]: [ + "free", + "resource_type", + "certification_type", + "topic", + "delivery", + "offered_by", + ], + [ChannelTypeEnum.Unit]: [ + "free", + "resource_type", + "topic", + "certification_type", + "delivery", + "department", + ], + [ChannelTypeEnum.Pathway]: [], +} + +const getFacetManifestForChannelType = ( + channelType: ChannelTypeEnum, + offerors: Record, + constantSearchParams: Facets, + resourceCategory: string | null, +): FacetManifest => { + const facets = FACETS_BY_CHANNEL_TYPE[channelType] || [] + return getFacetManifest(offerors, resourceCategory) + .filter( + (facetSetting) => + !Object.keys(constantSearchParams).includes(facetSetting.name) && + facets.includes(facetSetting.name), + ) + .sort( + (a, b) => facets.indexOf(a.name) - facets.indexOf(b.name), + ) as FacetManifest +} + +export const getFacets = ( + channelType: ChannelTypeEnum, + offerors: Record, + constantSearchParams: Facets, + resourceCategory: string | null, +) => { + const facetManifest = getFacetManifestForChannelType( + channelType, + offerors, + constantSearchParams, + resourceCategory, + ) + + const facetNames = Array.from( + new Set( + facetManifest.flatMap((facet) => { + if (facet.type === "group") { + return facet.facets.map((subFacet) => subFacet.name) + } else { + return [facet.name] + } + }), + ), + ) as UseResourceSearchParamsProps["facets"] + + return { facetNames, facetManifest } +} diff --git a/frontends/main/src/app-pages/SearchPage/SearchPage.tsx b/frontends/main/src/app-pages/SearchPage/SearchPage.tsx index 1bdedf0c70..10d7ccb1b4 100644 --- a/frontends/main/src/app-pages/SearchPage/SearchPage.tsx +++ b/frontends/main/src/app-pages/SearchPage/SearchPage.tsx @@ -4,19 +4,14 @@ import _ from "lodash" import React, { useCallback, useMemo } from "react" import type { FacetManifest } from "@mitodl/course-search-utils" import { useSearchParams } from "@mitodl/course-search-utils/next" -import { - useResourceSearchParams, - UseResourceSearchParamsProps, - getCertificationTypeName, - getDepartmentName, -} from "@mitodl/course-search-utils" +import { useResourceSearchParams } from "@mitodl/course-search-utils" import SearchDisplay from "@/page-components/SearchDisplay/SearchDisplay" import { styled, Container, theme, VisuallyHidden } from "ol-components" import { SearchField } from "@/page-components/SearchField/SearchField" -import type { LearningResourceOfferor } from "api" import { useOfferorsList } from "api/hooks/learningResources" -import { capitalize } from "ol-utilities" import LearningResourceDrawer from "@/page-components/LearningResourceDrawer/LearningResourceDrawer" +import { facetNames } from "./searchRequests" +import getFacetManifest from "@/page-components/SearchDisplay/getFacetManifest" const cssGradient = ` linear-gradient( @@ -61,100 +56,6 @@ const StyledSearchField = styled(SearchField)(({ theme }) => ({ }, })) -const LEARNING_MATERIAL = "learning_material" - -export const getFacetManifest = ( - offerors: Record, - resourceCategory: string | null, -) => { - const mainfest = [ - { - type: "group", - name: "free", - facets: [ - { - value: true, - name: "free", - label: "Free", - }, - ], - }, - { - name: "resource_type", - title: "Resource Type", - type: "static", - expandedOnLoad: true, - preserveItems: true, - labelFunction: (key: string) => - key - .split("_") - .map((word) => capitalize(word)) - .join(" "), - }, - { - name: "certification_type", - title: "Certificate", - type: "static", - expandedOnLoad: true, - preserveItems: true, - labelFunction: (key: string) => getCertificationTypeName(key) || key, - }, - { - name: "topic", - title: "Topic", - type: "filterable", - expandedOnLoad: true, - preserveItems: true, - }, - { - name: "delivery", - title: "Format", - type: "static", - expandedOnLoad: true, - preserveItems: true, - labelFunction: (key: string) => - key - .split("_") - .map((word) => capitalize(word)) - .join("-"), - }, - { - name: "offered_by", - title: "Offered By", - type: "static", - expandedOnLoad: false, - preserveItems: true, - labelFunction: (key: string) => offerors[key]?.name ?? key, - }, - { - name: "department", - title: "Department", - type: "filterable", - expandedOnLoad: false, - preserveItems: true, - labelFunction: (key: string) => getDepartmentName(key) || key, - }, - ] - - //Only display the resource_type facet if the resource_category is learning_material - if (resourceCategory !== LEARNING_MATERIAL) { - mainfest.splice(1, 1) - } - - return mainfest -} - -const facetNames = [ - "resource_type", - "certification_type", - "delivery", - "department", - "topic", - "offered_by", - "free", - "professional", -] as UseResourceSearchParamsProps["facets"] - const constantSearchParams = {} const useFacetManifest = (resourceCategory: string | null) => { diff --git a/frontends/main/src/app-pages/SearchPage/searchRequests.ts b/frontends/main/src/app-pages/SearchPage/searchRequests.ts new file mode 100644 index 0000000000..819b87f50f --- /dev/null +++ b/frontends/main/src/app-pages/SearchPage/searchRequests.ts @@ -0,0 +1,12 @@ +import { UseResourceSearchParamsProps } from "@mitodl/course-search-utils" + +export const facetNames = [ + "resource_type", + "certification_type", + "delivery", + "department", + "topic", + "offered_by", + "free", + "professional", +] as UseResourceSearchParamsProps["facets"] diff --git a/frontends/main/src/app/c/[channelType]/[name]/page.tsx b/frontends/main/src/app/c/[channelType]/[name]/page.tsx index d696be97ca..04a309948f 100644 --- a/frontends/main/src/app/c/[channelType]/[name]/page.tsx +++ b/frontends/main/src/app/c/[channelType]/[name]/page.tsx @@ -1,21 +1,39 @@ import React from "react" import ChannelPage from "@/app-pages/ChannelPage/ChannelPage" import { channelsApi } from "api/clients" -import { ChannelTypeEnum } from "api/v0" +import { ChannelTypeEnum, UnitChannel } from "api/v0" +import { + FeaturedListOfferedByEnum, + LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, + PaginatedLearningResourceOfferorDetailList, + LearningResourceOfferorDetail, +} from "api" import { getMetadataAsync } from "@/common/metadata" +import { Hydrate } from "@tanstack/react-query" +import { prefetch } from "api/ssr/prefetch" +import { learningResources } from "api/hooks/learningResources" +import { channels } from "api/hooks/channels" +import { testimonials } from "api/hooks/testimonials" import handleNotFound from "@/common/handleNotFound" import type { PageParams } from "@/app/types" +import getSearchParams from "@/page-components/SearchDisplay/getSearchParams" +import validateRequestParams from "@/page-components/SearchDisplay/validateRequestParams" +import type { ResourceSearchRequest } from "@/page-components/SearchDisplay/validateRequestParams" +import { + getConstantSearchParams, + getFacets, +} from "@/app-pages/ChannelPage/searchRequests" type RouteParams = { channelType: ChannelTypeEnum - name: string + name: FeaturedListOfferedByEnum } export async function generateMetadata({ searchParams, params, -}: PageParams) { - const { channelType, name } = await params +}: PageParams<{ [key: string]: string }, RouteParams>) { + const { channelType, name } = await params! const { data } = await handleNotFound( channelsApi.channelsTypeRetrieve({ channel_type: channelType, name: name }), @@ -28,8 +46,65 @@ export async function generateMetadata({ }) } -const Page: React.FC = () => { - return +const Page: React.FC = async ({ + params, + searchParams, +}: PageParams) => { + const { channelType, name } = await params! + const search = await searchParams + + const { queryClient } = await prefetch([ + learningResources.offerors({}), + channelType === ChannelTypeEnum.Unit && + learningResources.featured({ + limit: 12, + offered_by: [name], + }), + channelType === ChannelTypeEnum.Unit && + testimonials.list({ offerors: [name] }), + channels.detailByType(channelType, name), + ]) + + const channel = queryClient.getQueryData( + channels.detailByType(channelType, name).queryKey, + ) + const offerors = queryClient + .getQueryData( + learningResources.offerors({}).queryKey, + )! + .results.reduce( + (memo, offeror) => ({ + ...memo, + [offeror.code]: offeror, + }), + [], + ) + + const constantSearchParams = getConstantSearchParams(channel?.search_filter) + + const { facetNames } = getFacets( + channelType, + offerors as unknown as Record, + constantSearchParams, + null, + ) + + const searchRequest = getSearchParams({ + requestParams: validateRequestParams(search!), + constantSearchParams, + facetNames, + page: Number(search!.page ?? 1), + }) + + const { dehydratedState } = await prefetch( + [learningResources.search(searchRequest as LRSearchRequest)], + queryClient, + ) + return ( + + + + ) } export default Page diff --git a/frontends/main/src/app/search/page.tsx b/frontends/main/src/app/search/page.tsx index 239623d7fb..6befe4bc01 100644 --- a/frontends/main/src/app/search/page.tsx +++ b/frontends/main/src/app/search/page.tsx @@ -1,7 +1,15 @@ import React from "react" +import { Hydrate } from "@tanstack/react-query" +import { prefetch } from "api/ssr/prefetch" +import { learningResources } from "api/hooks/learningResources" +import type { PageParams } from "@/app/types" import { getMetadataAsync } from "@/common/metadata" import SearchPage from "@/app-pages/SearchPage/SearchPage" -import type { PageParams } from "@/app/types" +import { facetNames } from "@/app-pages/SearchPage/searchRequests" +import getSearchParams from "@/page-components/SearchDisplay/getSearchParams" +import validateRequestParams from "@/page-components/SearchDisplay/validateRequestParams" +import type { ResourceSearchRequest } from "@/page-components/SearchDisplay/validateRequestParams" +import { LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest } from "api" export async function generateMetadata({ searchParams }: PageParams) { return await getMetadataAsync({ @@ -22,8 +30,28 @@ export async function generateMetadata({ searchParams }: PageParams) { */ export const dynamic = "force-dynamic" -const Page: React.FC = () => { - return +const Page: React.FC = async ({ + searchParams, +}: PageParams) => { + const search = await searchParams + + const params = getSearchParams({ + requestParams: validateRequestParams(search!), + constantSearchParams: {}, + facetNames, + page: Number(search!.page ?? 1), + }) + + const { dehydratedState } = await prefetch([ + learningResources.offerors({}), + learningResources.search(params as LRSearchRequest), + ]) + + return ( + + + + ) } export default Page diff --git a/frontends/main/src/app/types.d.ts b/frontends/main/src/app/types.d.ts index 77335d0f0a..23245c13cc 100644 --- a/frontends/main/src/app/types.d.ts +++ b/frontends/main/src/app/types.d.ts @@ -1,17 +1,16 @@ -export type SearchParams = { - [key: string]: string | string[] | undefined -} - -type PageParamsWithRouteParams = { - params: Promise +type PageParamsWithRouteParams = { searchParams?: Promise + params?: Promise } -type PageParamsWithoutRouteParams = { +type PageParamsWithoutRouteParams = { searchParams?: Promise } -export type PageParams> = +export type PageParams< + SearchParams = Record, + RouteParams = Record, +> = RouteParams extends Record - ? PageParamsWithoutRoute - : PageParamsWithRoute + ? PageParamsWithoutRouteParams + : PageParamsWithRouteParams diff --git a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx index 5cdff8cb24..582f048bf2 100644 --- a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -53,6 +53,7 @@ import type { TabConfig } from "./ResourceCategoryTabs" import { ResourceCard } from "../ResourceCard/ResourceCard" import { useUserMe } from "api/hooks/user" import { usePostHog } from "posthog-js/react" +import getSearchParams from "./getSearchParams" const StyledResourceTabs = styled(ResourceCategoryTabs.TabList)` margin-top: 0 px; @@ -587,27 +588,17 @@ const SearchDisplay: React.FC = ({ ) ?? TABS.find((t) => t.defaultTab) ?? TABS[0] + const allParams = useMemo(() => { - return { - ...constantSearchParams, - resource_category: activeTab.resource_category - ? [activeTab.resource_category] - : undefined, - yearly_decay_percent: searchParams.get("yearly_decay_percent"), - search_mode: searchParams.get("search_mode"), - slop: searchParams.get("slop"), - min_score: searchParams.get("min_score"), - max_incompleteness_penalty: searchParams.get( - "max_incompleteness_penalty", - ), - content_file_score_weight: searchParams.get("content_file_score_weight"), - ...requestParams, - aggregations: (facetNames || []).concat([ - "resource_category", - ]) as LRSearchRequest["aggregations"], - offset: (page - 1) * PAGE_SIZE, - limit: PAGE_SIZE, - } + return getSearchParams({ + searchParams, + requestParams, + constantSearchParams, + resourceCategory: activeTab?.resource_category || undefined, + facetNames, + page, + pageSize: PAGE_SIZE, + }) }, [ searchParams, requestParams, @@ -746,7 +737,7 @@ const SearchDisplay: React.FC = ({ step={0.2} /> - Relavance score penalty percent per year for resources without + Relevance score penalty percent per year for resources without upcoming runs. Only affects results if there is a search term.
diff --git a/frontends/main/src/page-components/SearchDisplay/getFacetManifest.ts b/frontends/main/src/page-components/SearchDisplay/getFacetManifest.ts new file mode 100644 index 0000000000..9da93ffda8 --- /dev/null +++ b/frontends/main/src/page-components/SearchDisplay/getFacetManifest.ts @@ -0,0 +1,91 @@ +import { + getCertificationTypeName, + getDepartmentName, +} from "@mitodl/course-search-utils" +import type { LearningResourceOfferor } from "api" +import { capitalize } from "ol-utilities" + +const LEARNING_MATERIAL = "learning_material" + +export const getFacetManifest = ( + offerors: Record, + resourceCategory: string | null, +) => { + const manifest = [ + { + type: "group", + name: "free", + facets: [ + { + value: true, + name: "free", + label: "Free", + }, + ], + }, + { + name: "resource_type", + title: "Resource Type", + type: "static", + expandedOnLoad: true, + preserveItems: true, + labelFunction: (key: string) => + key + .split("_") + .map((word) => capitalize(word)) + .join(" "), + }, + { + name: "certification_type", + title: "Certificate", + type: "static", + expandedOnLoad: true, + preserveItems: true, + labelFunction: (key: string) => getCertificationTypeName(key) || key, + }, + { + name: "topic", + title: "Topic", + type: "filterable", + expandedOnLoad: true, + preserveItems: true, + }, + { + name: "delivery", + title: "Format", + type: "static", + expandedOnLoad: true, + preserveItems: true, + labelFunction: (key: string) => + key + .split("_") + .map((word) => capitalize(word)) + .join("-"), + }, + { + name: "offered_by", + title: "Offered By", + type: "static", + expandedOnLoad: false, + preserveItems: true, + labelFunction: (key: string) => offerors[key]?.name ?? key, + }, + { + name: "department", + title: "Department", + type: "filterable", + expandedOnLoad: false, + preserveItems: true, + labelFunction: (key: string) => getDepartmentName(key) || key, + }, + ] + + //Only display the resource_type facet if the resource_category is learning_material + if (resourceCategory !== LEARNING_MATERIAL) { + manifest.splice(1, 1) + } + + return manifest +} + +export default getFacetManifest diff --git a/frontends/main/src/page-components/SearchDisplay/getSearchParams.ts b/frontends/main/src/page-components/SearchDisplay/getSearchParams.ts new file mode 100644 index 0000000000..b3ab1ce758 --- /dev/null +++ b/frontends/main/src/page-components/SearchDisplay/getSearchParams.ts @@ -0,0 +1,51 @@ +import { + LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as ResourceSearchRequest, + ResourceCategoryEnum, +} from "api" +import { UseResourceSearchParamsProps } from "@mitodl/course-search-utils" +import type { + UseResourceSearchParamsResult, + Facets, + BooleanFacets, +} from "@mitodl/course-search-utils" + +export const PAGE_SIZE = 20 + +type SearchParams = { + searchParams?: URLSearchParams + requestParams: UseResourceSearchParamsResult["params"] + constantSearchParams?: Facets & BooleanFacets + resourceCategory?: ResourceCategoryEnum + facetNames: UseResourceSearchParamsProps["facets"] + page: number + pageSize?: number +} + +const getSearchParams = ({ + searchParams = new URLSearchParams({}), + requestParams, + constantSearchParams = {}, + resourceCategory, + facetNames, + page, + pageSize = PAGE_SIZE, +}: SearchParams) => { + return { + ...constantSearchParams, + yearly_decay_percent: searchParams.get("yearly_decay_percent"), + search_mode: searchParams.get("search_mode"), + slop: searchParams.get("slop"), + min_score: searchParams.get("min_score"), + max_incompleteness_penalty: searchParams.get("max_incompleteness_penalty"), + content_file_score_weight: searchParams.get("content_file_score_weight"), + resource_category: resourceCategory ? [resourceCategory] : null, + aggregations: [...(facetNames || []), "resource_category"], + ...requestParams, + offset: (Number(page) - 1) * pageSize, + limit: pageSize, + } +} + +export default getSearchParams + +export type { ResourceSearchRequest } diff --git a/frontends/main/src/page-components/SearchDisplay/validateRequestParams.test.ts b/frontends/main/src/page-components/SearchDisplay/validateRequestParams.test.ts new file mode 100644 index 0000000000..5963842206 --- /dev/null +++ b/frontends/main/src/page-components/SearchDisplay/validateRequestParams.test.ts @@ -0,0 +1,48 @@ +import validateRequestParams from "./validateRequestParams" + +describe("SearchDisplay validateRequestParams", () => { + test("Single values are converted to arrays", async () => { + expect( + validateRequestParams({ + certification_type: "completion", + platform: "mitxonline", + }), + ).toStrictEqual({ + certification_type: ["completion"], + platform: ["mitxonline"], + }) + }) + + test("Single string values are preserved", async () => { + expect( + validateRequestParams({ + q: "search string", + }), + ).toStrictEqual({ + q: "search string", + }) + }) + + test("Booleans strings are converted or removed", async () => { + expect( + validateRequestParams({ + free: "true", + professional: "false", + certification: undefined, + }), + ).toStrictEqual({ + free: true, + professional: false, + }) + }) + + test("Values are filtered to enums", async () => { + expect( + validateRequestParams({ + platform: ["ocw", "xpro", "not a platform"], + }), + ).toStrictEqual({ + platform: ["ocw", "xpro"], + }) + }) +}) diff --git a/frontends/main/src/page-components/SearchDisplay/validateRequestParams.ts b/frontends/main/src/page-components/SearchDisplay/validateRequestParams.ts new file mode 100644 index 0000000000..559b7e1ec8 --- /dev/null +++ b/frontends/main/src/page-components/SearchDisplay/validateRequestParams.ts @@ -0,0 +1,58 @@ +import { LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LearningResourcesSearchRetrieveRequest } from "api" +import { resourceSearchValidators } from "@mitodl/course-search-utils" + +type StringOrArray = { + [K in keyof T]: string | string[] +} + +type ResourceSearchRequest = + StringOrArray + +/* Validates and transforms URLSearchParams on the page URL to request params + * for useLearningResourcesSearch, converting string boolean + * values to boolean, single parameter array values to [string] + * and removing undefined values that would cause a query key + * mismatch and invalidate the SSR prefetch. + * Borrowing from @mitodl/course-search ./hooks/validation + * but for use on the server. + */ +const validateRequestParams = ( + searchParams: ResourceSearchRequest, +): LearningResourcesSearchRetrieveRequest => { + return Object.entries(resourceSearchValidators).reduce( + (acc, [key, validator]) => { + const paramKey = key as keyof LearningResourcesSearchRetrieveRequest + + if (searchParams[paramKey]) { + const value = searchParams[paramKey] as string[] + const validated = validator(toArray(value)) + + return { ...acc, [paramKey]: validated } + } + + return acc + }, + {}, + ) +} + +/* Maps strings to [string] to handle Next.js' treatment + * of repeated search parameters, ie. + * ?key=value -> { key: "value" } + * ?key=value1&key=value2 -> { key: ["value1", "value2"] } + * + * Search values in @mitodl/course-search are + * expected to be arrays of strings + */ +const toArray = (value?: string | string[]): T[] => { + if (Array.isArray(value)) { + return value as T[] + } + if (typeof value === "string") { + return [value as T] + } + return value ?? [] +} + +export default validateRequestParams +export type { ResourceSearchRequest } diff --git a/yarn.lock b/yarn.lock index 8cdec73308..68bf90a081 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3537,9 +3537,9 @@ __metadata: languageName: node linkType: hard -"@mitodl/course-search-utils@npm:^3.3.1": - version: 3.3.1 - resolution: "@mitodl/course-search-utils@npm:3.3.1" +"@mitodl/course-search-utils@npm:3.3.2": + version: 3.3.2 + resolution: "@mitodl/course-search-utils@npm:3.3.2" dependencies: "@mitodl/open-api-axios": "npm:2024.9.16" "@remixicon/react": "npm:^4.2.0" @@ -3559,7 +3559,7 @@ __metadata: optional: true react-router: optional: true - checksum: 10/d0f69157755f1ff90a4cb9cee017320f08e2073066ac4b948120eb8b2c3c6cedb844f9c358596b47e8a04c9eba896c4da49f593436a9a9bf0f9d9894d2fc3119 + checksum: 10/664a99188bfe29271d986b7a3175457949a9cfe9eec7abf407a00653e56951cb3a86b67e3175c0246dd7652f59ad85ec817a929454164d369f44c98afbcd826b languageName: node linkType: hard @@ -10855,11 +10855,11 @@ __metadata: linkType: hard "eslint-plugin-react-hooks@npm:^5.0.0": - version: 5.0.0 - resolution: "eslint-plugin-react-hooks@npm:5.0.0" + version: 5.1.0 + resolution: "eslint-plugin-react-hooks@npm:5.1.0" peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - checksum: 10/b762789832806b6981e2d910994e72aa7a85136fe0880572334b26cf1274ba37bd3b1365e77d2c2f92465337c4a65c84ef647bc499d33b86fc1110f2df7ef1bb + checksum: 10/b6778fd9e1940b06868921309e8b269426e17eda555816d4b71def4dcf0572de1199fdb627ac09ce42160b9569a93cd9b0fd81b740ab4df98205461c53997a43 languageName: node linkType: hard @@ -10904,14 +10904,14 @@ __metadata: linkType: hard "eslint-plugin-testing-library@npm:^7.0.0": - version: 7.0.0 - resolution: "eslint-plugin-testing-library@npm:7.0.0" + version: 7.1.1 + resolution: "eslint-plugin-testing-library@npm:7.1.1" dependencies: "@typescript-eslint/scope-manager": "npm:^8.15.0" "@typescript-eslint/utils": "npm:^8.15.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - checksum: 10/f69bfde9e14d306561ef7f913f768aa9af06131e79a0b44a96598fecc44a37b60f8f1a5dfd68de6746838a8ae7d8d22a2607d05a91bf216d3e187bebed3c4b43 + checksum: 10/48a7a7f93afd16f9cf9cccaf7a1e7ba2e2ea9072d598558ce758d396c7a4d6a71e49b4ec654feef67350141f4f2737d7460c07dbfaed4eb60a09d1c7ceb11558 languageName: node linkType: hard @@ -14310,7 +14310,7 @@ __metadata: "@ebay/nice-modal-react": "npm:^1.2.13" "@emotion/cache": "npm:^11.13.1" "@faker-js/faker": "npm:^9.0.0" - "@mitodl/course-search-utils": "npm:^3.3.1" + "@mitodl/course-search-utils": "npm:3.3.2" "@next/bundle-analyzer": "npm:^14.2.15" "@remixicon/react": "npm:^4.2.0" "@sentry/nextjs": "npm:^8.36.0"