From 6701bb39d2fd3adcb3dbbcfdce722c15046a1b2a Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 1 Mar 2024 15:22:35 -0500 Subject: [PATCH 1/7] remove useSearchParams We've updated react router, so we can use theirs. --- frontends/mit-open/package.json | 4 +- frontends/ol-components/package.json | 4 +- .../components/RoutedDrawer/RoutedDrawer.tsx | 2 +- frontends/ol-utilities/package.json | 3 +- frontends/ol-utilities/src/hooks/index.ts | 1 - .../src/hooks/useSearchParams.test.tsx | 49 ------------------- .../ol-utilities/src/hooks/useSearchParams.ts | 29 ----------- yarn.lock | 13 +++-- 8 files changed, 12 insertions(+), 93 deletions(-) delete mode 100644 frontends/ol-utilities/src/hooks/useSearchParams.test.tsx delete mode 100644 frontends/ol-utilities/src/hooks/useSearchParams.ts diff --git a/frontends/mit-open/package.json b/frontends/mit-open/package.json index 768f7a73d6..f79dec08c2 100644 --- a/frontends/mit-open/package.json +++ b/frontends/mit-open/package.json @@ -55,8 +55,8 @@ "react-dotdotdot": "^1.3.1", "react-helmet-async": "^2.0.3", "react-infinite-scroller": "^1.2.6", - "react-router": "^6.19.0", - "react-router-dom": "^6.19.0", + "react-router": "^6.22.2", + "react-router-dom": "^6.22.2", "tiny-invariant": "^1.3.1", "yup": "^1.2.0" }, diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 232e0d63b5..d6629412d9 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -19,8 +19,8 @@ "ol-test-utilities": "workspace:*", "ol-utilities": "workspace:*", "rc-tooltip": "^6.1.2", - "react-router": "^6.19.0", - "react-router-dom": "^6.19.0", + "react-router": "^6.22.2", + "react-router-dom": "^6.22.2", "react-select": "^5.7.7", "react-share": "^5.0.3", "tiny-invariant": "^1.3.1" diff --git a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx index a00325d378..87f166e436 100644 --- a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx +++ b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" import Drawer from "@mui/material/Drawer" import type { DrawerProps } from "@mui/material/Drawer" -import { useSearchParams } from "ol-utilities" import IconButton from "@mui/material/IconButton" import CloseIcon from "@mui/icons-material/Close" +import { useSearchParams } from "react-router-dom" const closeSx: React.CSSProperties = { position: "absolute", diff --git a/frontends/ol-utilities/package.json b/frontends/ol-utilities/package.json index f42663a32b..583daa3d4f 100644 --- a/frontends/ol-utilities/package.json +++ b/frontends/ol-utilities/package.json @@ -6,8 +6,7 @@ "./test-utils/factories": "./src/test-utils/factories.ts" }, "peerDependencies": { - "react": "^18.2.0", - "react-router": "^6.19.0" + "react": "^18.2.0" }, "dependencies": { "@dnd-kit/core": "^6.0.8", diff --git a/frontends/ol-utilities/src/hooks/index.ts b/frontends/ol-utilities/src/hooks/index.ts index b69206cef0..4a3c532835 100644 --- a/frontends/ol-utilities/src/hooks/index.ts +++ b/frontends/ol-utilities/src/hooks/index.ts @@ -1,2 +1 @@ -export { default as useSearchParams } from "./useSearchParams" export { default as useToggle } from "./useToggle" diff --git a/frontends/ol-utilities/src/hooks/useSearchParams.test.tsx b/frontends/ol-utilities/src/hooks/useSearchParams.test.tsx deleted file mode 100644 index 62ac9e20f5..0000000000 --- a/frontends/ol-utilities/src/hooks/useSearchParams.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from "@testing-library/react" -import user from "@testing-library/user-event" -import React, { useCallback } from "react" -import { MemoryRouter } from "react-router" -import useSearchParams from "./useSearchParams" - -const TestComponent = () => { - const [searchParams, setSearchParams] = useSearchParams() - const onClick = useCallback(() => { - const clicks = Number(searchParams.get("count")) || 0 - const newParams = new URLSearchParams() - newParams.set("count", String(clicks + 1)) - setSearchParams(newParams) - }, [searchParams, setSearchParams]) - return ( -
- -
{`params: ${searchParams}`}
-
- ) -} - -describe("useSearchParams", () => { - it.each([ - { search: "?count=3", text: "params: count=3" }, - { search: "", text: "params:" }, - ])("Makes searchParams available", ({ search, text }) => { - const initialEntries = [search] - render( - - - , - ) - screen.getByText(text) - }) - - it("re-renders when setSearchParams is called", async () => { - const initialEntries = ["?count=3"] - render( - - - , - ) - screen.getByText("params: count=3") - const button = screen.getByRole("button") - user.click(button) - await screen.findByText("params: count=4") - }) -}) diff --git a/frontends/ol-utilities/src/hooks/useSearchParams.ts b/frontends/ol-utilities/src/hooks/useSearchParams.ts deleted file mode 100644 index 7bea74515b..0000000000 --- a/frontends/ol-utilities/src/hooks/useSearchParams.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useCallback, useMemo } from "react" -import { useLocation, useNavigate } from "react-router" - -/** - * A hook for getting/setting search parameters of the CURRENT location. The API is a - * subset of React Router's v6 useSearchParams hook. - */ -const useSearchParams = (): [ - URLSearchParams, - (newSearchParams: URLSearchParams) => void, -] => { - const navigate = useNavigate() - /** - * Do not get location directly from `useHistory`... The return value of - * `useHistory` is mutable: if we just get location off of it, changes to - * location will not trigger a re-render. - */ - const { search } = useLocation() - const searchParams = useMemo(() => new URLSearchParams(search), [search]) - const setSearchParams = useCallback( - (newParams: URLSearchParams) => { - navigate({ search: newParams.toString() }, { replace: true }) - }, - [navigate], - ) - return [searchParams, setSearchParams] -} - -export default useSearchParams diff --git a/yarn.lock b/yarn.lock index 26ae5e9ed9..a6b12df627 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17548,8 +17548,8 @@ __metadata: react-dotdotdot: ^1.3.1 react-helmet-async: ^2.0.3 react-infinite-scroller: ^1.2.6 - react-router: ^6.19.0 - react-router-dom: ^6.19.0 + react-router: ^6.22.2 + react-router-dom: ^6.22.2 storybook: ^7.6.6 swc-loader: ^0.2.3 tiny-invariant: ^1.3.1 @@ -18189,8 +18189,8 @@ __metadata: rc-tooltip: ^6.1.2 react: ^18.2.0 react-dom: ^18.2.0 - react-router: ^6.19.0 - react-router-dom: ^6.19.0 + react-router: ^6.22.2 + react-router-dom: ^6.22.2 react-select: ^5.7.7 react-share: ^5.0.3 storybook-addon-react-router-v6: ^2.0.10 @@ -18246,7 +18246,6 @@ __metadata: validator: ^13.7.0 peerDependencies: react: ^18.2.0 - react-router: ^6.19.0 languageName: unknown linkType: soft @@ -20595,7 +20594,7 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:^6.19.0": +"react-router-dom@npm:^6.22.2": version: 6.22.3 resolution: "react-router-dom@npm:6.22.3" dependencies: @@ -20608,7 +20607,7 @@ __metadata: languageName: node linkType: hard -"react-router@npm:6.22.3, react-router@npm:^6.19.0": +"react-router@npm:6.22.3, react-router@npm:^6.22.2": version: 6.22.3 resolution: "react-router@npm:6.22.3" dependencies: From 366f65bf987174e71fc94b8b4972d048bfff8517 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 15 Mar 2024 14:03:12 -0400 Subject: [PATCH 2/7] add a search page --- frontends/api/src/clients.ts | 8 + .../api/src/hooks/learningResources/index.ts | 12 + .../src/hooks/learningResources/keyFactory.ts | 11 + frontends/api/src/test-utils/urls.ts | 13 +- frontends/mit-open/package.json | 1 + frontends/mit-open/src/common/urls.ts | 2 + .../PlainVerticalList/PlainVerticalList.tsx | 20 + .../LearningResourceCard.tsx | 3 +- .../LearningPathListingPage.tsx | 1 - .../src/pages/SearchPage/ResourceTypeTabs.tsx | 99 +++++ .../src/pages/SearchPage/SearchPage.test.tsx | 291 +++++++++++++ .../src/pages/SearchPage/SearchPage.tsx | 392 ++++++++++++++++++ frontends/mit-open/src/routes.tsx | 5 + .../components/SearchInput/SearchInput.tsx | 5 +- frontends/ol-components/src/index.ts | 2 + yarn.lock | 82 +++- 16 files changed, 941 insertions(+), 6 deletions(-) create mode 100644 frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx create mode 100644 frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx create mode 100644 frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx create mode 100644 frontends/mit-open/src/pages/SearchPage/SearchPage.tsx diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index bd60b54a5b..953145bb85 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -4,6 +4,7 @@ import { TopicsApi, ArticlesApi, ProgramLettersApi, + LearningResourcesSearchApi, } from "./generated/api" import axiosInstance from "./axios" @@ -14,6 +15,12 @@ const learningResourcesApi = new LearningResourcesApi( axiosInstance, ) +const learningResourcesSearchApi = new LearningResourcesSearchApi( + undefined, + BASE_PATH, + axiosInstance, +) + const learningpathsApi = new LearningpathsApi( undefined, BASE_PATH, @@ -35,4 +42,5 @@ export { topicsApi, articlesApi, programLettersApi, + learningResourcesSearchApi, } diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index a42fb6bb08..4d4556b5fb 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -17,6 +17,7 @@ import type { LearningPathRelationshipRequest, MicroLearningPathRelationship, LearningResource, + LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, } from "../../generated" import learningResources, { invalidateResourceQueries } from "./keyFactory" @@ -197,6 +198,16 @@ const useLearningpathRelationshipDestroy = () => { }) } +const useLearningResourcesSearch = ( + params: LRSearchRequest, + opts?: Pick, +) => { + return useQuery({ + ...learningResources.search(params), + ...opts, + }) +} + export { useLearningResourcesList, useLearningResourcesDetail, @@ -210,4 +221,5 @@ export { useLearningpathRelationshipMove, useLearningpathRelationshipCreate, useLearningpathRelationshipDestroy, + useLearningResourcesSearch, } diff --git a/frontends/api/src/hooks/learningResources/keyFactory.ts b/frontends/api/src/hooks/learningResources/keyFactory.ts index 4534589e49..ea0de3c986 100644 --- a/frontends/api/src/hooks/learningResources/keyFactory.ts +++ b/frontends/api/src/hooks/learningResources/keyFactory.ts @@ -2,6 +2,7 @@ import type { QueryClient, Query } from "@tanstack/react-query" import { learningResourcesApi, learningpathsApi, + learningResourcesSearchApi, topicsApi, } from "../../clients" import axiosInstance from "../../axios" @@ -13,6 +14,7 @@ import type { PaginatedLearningResourceList, LearningResource, PaginatedLearningPathRelationshipList, + LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, } from "../../generated" import { createQueryKeys } from "@lukemorales/query-key-factory" @@ -68,6 +70,15 @@ const learningResources = createQueryKeys("learningResources", { }), }, }, + search: (params: LRSearchRequest) => { + return { + queryKey: [params], + queryFn: () => + learningResourcesSearchApi + .learningResourcesSearchRetrieve(params) + .then((res) => res.data), + } + }, }) const listHasResource = diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index 8402c32699..13be3c785e 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -67,4 +67,15 @@ const programLetters = { details: (id: string) => `/api/v1/program_letters/${id}/`, } -export { learningResources, topics, learningPaths, articles, programLetters } +const search = { + resources: () => "/api/v1/learning_resources_search/", +} + +export { + learningResources, + topics, + learningPaths, + articles, + search, + programLetters, +} diff --git a/frontends/mit-open/package.json b/frontends/mit-open/package.json index f79dec08c2..f8fa5e2445 100644 --- a/frontends/mit-open/package.json +++ b/frontends/mit-open/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@ebay/nice-modal-react": "^1.2.13", + "@mitodl/course-search-utils": "^3.0.3", "@mui/icons-material": "^5.14.19", "@sentry/react": "^7.57.0", "@tanstack/react-query": "^4.36.1", diff --git a/frontends/mit-open/src/common/urls.ts b/frontends/mit-open/src/common/urls.ts index 39c8199afd..7561769202 100644 --- a/frontends/mit-open/src/common/urls.ts +++ b/frontends/mit-open/src/common/urls.ts @@ -45,3 +45,5 @@ export const login = ({ } export const DASHBOARD = "/dashboard/" + +export const SEARCH = "/search/" diff --git a/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx b/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx new file mode 100644 index 0000000000..d5aea812d9 --- /dev/null +++ b/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx @@ -0,0 +1,20 @@ +import { styled } from "ol-components" + +/** + * A plain list: No markers, margin, or padding around the list. + * Customizable margin between items. + */ +const PlainVerticalList = styled.ul<{ itemSpacing: string }>` + list-style: none; + margin: 0px; + padding: 0px; + + > li { + margin-bottom: ${({ itemSpacing }) => itemSpacing}; + } + > li:last-child { + margin-bottom: none; + } +` + +export default PlainVerticalList diff --git a/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx b/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx index 73cfb23b2e..def1fcb260 100644 --- a/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx @@ -1,5 +1,4 @@ import React, { useCallback } from "react" -import classNames from "classnames" import * as NiceModal from "@ebay/nice-modal-react" import LearningResourceCardTemplate from "@/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate" @@ -44,7 +43,7 @@ const LearningResourceCard: React.FC = ({ variant={variant} sortable={sortable} suppressImage={suppressImage} - className={classNames("ic-resource-card", className)} + className={className} resource={resource} imgConfig={imgConfigs[variant]} onActivate={console.log} diff --git a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx index d38b16a459..f0d03844d6 100644 --- a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx +++ b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx @@ -76,7 +76,6 @@ const ListCard: React.FC = ({ list, onActivate, canEdit }) => { return ( : null} diff --git a/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx b/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx new file mode 100644 index 0000000000..b20ac992ea --- /dev/null +++ b/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx @@ -0,0 +1,99 @@ +import React from "react" +import { Tab, TabContext, TabList, TabPanel } from "ol-components" +import type { ResourceTypeEnum, LearningResourceSearchResponse } from "api" +import type { UseSearchQueryParamsResult } from "@mitodl/course-search-utils" + +type TabConfig = { + resource_type: ResourceTypeEnum + label: string +} + +type Aggregations = LearningResourceSearchResponse["metadata"]["aggregations"] +const resourceTypeCounts = (aggregations?: Aggregations) => { + if (!aggregations) return null + const buckets = aggregations?.resource_type ?? [] + const counts = buckets.reduce( + (acc, bucket) => { + acc[bucket.key] = bucket.doc_count + return acc + }, + {} as Record, + ) + return counts +} +const appendCount = (label: string, count?: number) => { + if (Number.isFinite(count)) { + return `${label} (${count})` + } + return label +} + +const ResourceTypesTabContext: React.FC<{ + resourceType?: ResourceTypeEnum + children: React.ReactNode +}> = ({ resourceType, children }) => { + const tab = resourceType ?? "all" + return {children} +} + +type ResourceTypeTabsProps = { + aggregations?: Aggregations + tabs: TabConfig[] + setFacetActive: UseSearchQueryParamsResult["setFacetActive"] + clearFacet: UseSearchQueryParamsResult["clearFacet"] + onTabChange?: (tab: ResourceTypeEnum | "all") => void +} +const ResourceTypeTabList: React.FC = ({ + tabs, + aggregations, + setFacetActive, + clearFacet, + onTabChange, +}) => { + const counts = resourceTypeCounts(aggregations) + const allCount = counts + ? tabs.reduce((acc, tab) => acc + (counts[tab.resource_type] ?? 0), 0) + : undefined + return ( + { + clearFacet("resource_type") + if (value !== "all") { + setFacetActive("resource_type", value, true) + } + onTabChange?.(value) + }} + > + + {tabs.map((t) => { + const count = counts ? counts[t.resource_type] ?? 0 : undefined + return ( + + ) + })} + + ) +} + +const ResourceTypeTabPanels: React.FC<{ + tabs: TabConfig[] + children?: React.ReactNode +}> = ({ tabs, children }) => { + return ( + <> + {children} + {tabs.map((t) => ( + + {children} + + ))} + + ) +} + +export { ResourceTypesTabContext, ResourceTypeTabList, ResourceTypeTabPanels } +export type { TabConfig } diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx new file mode 100644 index 0000000000..c865fe3ff2 --- /dev/null +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx @@ -0,0 +1,291 @@ +import React from "react" +import { + renderWithProviders, + screen, + within, + user, + waitFor, +} from "@/test-utils" +import SearchPage from "./SearchPage" +import { setMockResponse, urls, factories, makeRequest } from "api/test-utils" +import type { LearningResourceSearchResponse } from "api" +import invariant from "tiny-invariant" + +const setMockSearchResponse = ( + responseBody: Partial, +) => { + setMockResponse.get(expect.stringContaining(urls.search.resources()), { + count: 0, + next: null, + previous: null, + results: [], + metadata: { + aggregations: {}, + suggestions: [], + }, + ...responseBody, + }) +} + +const getLastApiSearchParams = () => { + const call = makeRequest.mock.calls.find(([method, url]) => { + if (method !== "get") return false + return url.startsWith(urls.search.resources()) + }) + invariant(call) + const [_method, url] = call + const fullUrl = new URL(url, "http://mit.edu") + return fullUrl.searchParams +} + +describe("SearchPage", () => { + test("Renders search results", async () => { + const resources = factories.learningResources.resources({ + count: 10, + }).results + setMockSearchResponse({ + count: 1000, + metadata: { + aggregations: { + resource_type: [ + { key: "course", doc_count: 100 }, + { key: "podcast", doc_count: 200 }, + { key: "program", doc_count: 300 }, + { key: "irrelevant", doc_count: 400 }, + ], + }, + suggestions: [], + }, + results: resources, + }) + renderWithProviders() + const tabpanel = await screen.findByRole("tabpanel") + const headings = await within(tabpanel).findAllByRole("heading") + expect(headings.length).toBe(10) + expect(headings.map((h) => h.textContent)).toEqual( + resources.map((r) => r.title), + ) + }) + + test.each([ + { url: "?r=course", expectedActive: /Courses/ }, + { url: "?r=podcast", expectedActive: /Podcasts/ }, + { url: "", expectedActive: /All/ }, + ])("Active tab determined by URL $url", async ({ url, expectedActive }) => { + setMockSearchResponse({ + count: 1000, + metadata: { + aggregations: { + resource_type: [ + { key: "course", doc_count: 100 }, + { key: "podcast", doc_count: 200 }, + { key: "program", doc_count: 300 }, + { key: "irrelevant", doc_count: 400 }, + ], + }, + suggestions: [], + }, + }) + renderWithProviders(, { url }) + const tab = screen.getByRole("tab", { selected: true }) + expect(tab).toHaveAccessibleName(expectedActive) + }) + + test("Clicking tabs updates URL", async () => { + setMockSearchResponse({ + count: 1000, + metadata: { + aggregations: { + resource_type: [ + { key: "course", doc_count: 100 }, + { key: "podcast", doc_count: 200 }, + { key: "program", doc_count: 300 }, + { key: "irrelevant", doc_count: 400 }, + ], + }, + suggestions: [], + }, + }) + const { location } = renderWithProviders() + const tabAll = screen.getByRole("tab", { name: /All/ }) + const tabCourses = screen.getByRole("tab", { name: /Courses/ }) + expect(tabAll).toHaveAttribute("aria-selected") + await user.click(tabCourses) + expect(tabCourses).toHaveAttribute("aria-selected") + expect(new URLSearchParams(location.current.search).get("r")).toBe("course") + await user.click(tabAll) + expect(tabAll).toHaveAttribute("aria-selected") + expect(new URLSearchParams(location.current.search).get("r")).toBe(null) + }) + + test("Tab titles show corret result counts", async () => { + setMockSearchResponse({ + count: 700, + metadata: { + aggregations: { + resource_type: [ + { key: "course", doc_count: 100 }, + { key: "podcast", doc_count: 200 }, + { key: "irrelevant", doc_count: 400 }, + ], + }, + suggestions: [], + }, + }) + renderWithProviders() + const tabs = screen.getAllByRole("tab") + // initially (before API response) not result counts + expect(tabs.map((tab) => tab.textContent)).toEqual([ + "All", + "Courses", + "Podcasts", + "Programs", + ]) + // eventually (after API response) result counts show + await waitFor(() => { + expect(tabs.map((tab) => tab.textContent)).toEqual([ + "All (300)", + "Courses (100)", + "Podcasts (200)", + "Programs (0)", + ]) + }) + }) + + test.each([ + { url: "?d=6", expected: { department: "6" } }, + { + url: "?d=6&r=course", + expected: { department: "6", resource_type: "course" }, + }, + { url: "?q=woof", expected: { q: "woof" } }, + ])( + "Makes API call with correct facets and aggregations", + async ({ url, expected }) => { + setMockSearchResponse({ + count: 700, + metadata: { + aggregations: { + department: [ + { key: "8", doc_count: 100 }, + { key: "6", doc_count: 200 }, + ], + }, + suggestions: [], + }, + }) + renderWithProviders(, { url }) + await waitFor(() => { + expect(makeRequest.mock.calls.length > 0).toBe(true) + }) + const apiSearchParams = getLastApiSearchParams() + expect(apiSearchParams.getAll("aggregations").sort()).toEqual([ + "course_feature", + "department", + "level", + "resource_type", + "topic", + ]) + expect(Object.fromEntries(apiSearchParams.entries())).toEqual( + expect.objectContaining(expected), + ) + }, + ) + + test("Toggling facets", async () => { + setMockSearchResponse({ + count: 700, + metadata: { + aggregations: { + department: [ + { key: "8", doc_count: 100 }, // Physics + { key: "5", doc_count: 200 }, // Chemistry + ], + }, + suggestions: [], + }, + }) + const { location } = renderWithProviders(, { + url: "?d=8&d=5", + }) + const clearAll = await screen.findByRole("button", { name: /clear all/i }) + const physics = await screen.findByRole("checkbox", { name: "Physics" }) + const chemistry = await screen.findByRole("checkbox", { name: "Chemistry" }) + // initial + expect(physics).toBeChecked() + expect(chemistry).toBeChecked() + // clear all + await user.click(clearAll) + expect(location.current.search).toBe("") + expect(physics).not.toBeChecked() + expect(chemistry).not.toBeChecked() + // toggle physics + await user.click(physics) + expect(physics).toBeChecked() + expect(location.current.search).toBe("?d=8") + }) + + test("Submitting text updates URL", async () => { + setMockSearchResponse({}) + const { location } = renderWithProviders(, { url: "?q=meow" }) + const queryInput = await screen.findByRole("textbox", { + name: "Search for", + }) + expect(queryInput.value).toBe("meow") + await user.clear(queryInput) + await user.paste("woof") + expect(location.current.search).toBe("?q=meow") + await user.click(screen.getByRole("button", { name: "Search" })) + expect(location.current.search).toBe("?q=woof") + }) +}) + +/** + * Simple tests to check that data / handlers with pagination controls are + * working as expected. + */ +describe("Search Page pagination controls", () => { + const getPagination = () => + screen.getByRole("navigation", { name: "pagination navigation" }) + + test("?page URLSearchParam controls activate page", async () => { + setMockSearchResponse({ count: 137 }) + renderWithProviders(, { url: "?d=6&page=3" }) + const pagination = getPagination() + // p3 is current page + await within(pagination).findByRole("button", { + name: "page 3", + current: true, + }) + // as opposed to p4 + await within(pagination).findByRole("button", { name: "Go to page 4" }) + }) + + test("Clicking on a page updates URL", async () => { + setMockSearchResponse({ count: 137 }) + const { location } = renderWithProviders(, { + url: "?d=6&page=3", + }) + const pagination = getPagination() + const p4 = await within(pagination).findByRole("button", { + name: "Go to page 4", + }) + await user.click(p4) + await waitFor(() => { + const params = new URLSearchParams(location.current.search) + expect(params.get("page")).toBe("4") + }) + }) + + test("Max page is determined by count", async () => { + setMockSearchResponse({ count: 137 }) + renderWithProviders(, { url: "?d=6&page=3" }) + const pagination = getPagination() + // p14 exists + await within(pagination).findByRole("button", { name: "Go to page 14" }) + // items + const items = await within(pagination).findAllByRole("listitem") + expect(items.at(-2)?.textContent).toBe("14") // "Last page" + expect(items.at(-1)?.textContent).toBe("") // "Next" button + }) +}) diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx new file mode 100644 index 0000000000..85dd2f0e79 --- /dev/null +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx @@ -0,0 +1,392 @@ +import React from "react" +import { + styled, + Container, + SearchInput, + Pagination, + Card, + CardContent, + Grid, + Skeleton, +} from "ol-components" +import { MetaTags } from "ol-utilities" + +import { ResourceTypeEnum } from "api" +import type { LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest } from "api" +import { useLearningResourcesSearch } from "api/hooks/learningResources" + +import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" +import { + useSearchQueryParams, + FacetDisplay, + getDepartmentName, + getLevelName, +} from "@mitodl/course-search-utils" +import type { FacetManifest } from "@mitodl/course-search-utils" +import { useSearchParams } from "@mitodl/course-search-utils/react-router" +import LearningResourceCard from "@/page-components/LearningResourceCard/LearningResourceCard" +import PlainVerticalList from "@/components/PlainVerticalList/PlainVerticalList" + +import { + ResourceTypeTabList, + ResourceTypeTabPanels, + ResourceTypesTabContext, +} from "./ResourceTypeTabs" +import type { TabConfig } from "./ResourceTypeTabs" + +const RESOURCE_FACETS: FacetManifest = [ + { + name: "department", + title: "Departments", + useFilterableFacet: true, + expandedOnLoad: true, + labelFunction: getDepartmentName, + }, + { + name: "level", + title: "Level", + useFilterableFacet: false, + expandedOnLoad: false, + labelFunction: getLevelName, + }, + { + name: "topic", + title: "Topics", + useFilterableFacet: true, + expandedOnLoad: false, + }, + { + name: "course_feature", + title: "Features", + useFilterableFacet: true, + expandedOnLoad: false, + }, +] + +const AGGREGATIONS: LRSearchRequest["aggregations"] = [ + "resource_type", + "level", + "department", + "topic", + "course_feature", +] + +const ColoredHeader = styled.div` + background-color: ${({ theme }) => theme.palette.secondary.light}; + height: 150px; + display: flex; + align-items: center; +` +const SearchField = styled(SearchInput)` + background-color: ${({ theme }) => theme.custom.colorBackgroundLight}; + width: 100%; +` + +const FacetStyles = styled.div` + * { + color: ${({ theme }) => theme.palette.secondary.main}; + } + + input[type="checkbox"] { + accent-color: ${({ theme }) => theme.palette.primary.main}; + } + + .filter-section-main-title { + font-size: ${({ theme }) => theme.custom.fontLg}; + font-weight: bold; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + } + + .filter-section-button { + font-size: ${({ theme }) => theme.custom.fontLg}; + font-weight: 600; + padding-left: 0px; + background-color: transparent; + display: flex; + justify-content: space-between; + width: 100%; + border: none; + cursor: pointer; + } + + .facets { + box-sizing: border-box; + background-color: ${({ theme }) => theme.custom.colorBackgroundLight}; + border-radius: 4px; + padding: 1rem; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + + .facet-visible { + display: flex; + flex-direction: row; + align-items: center; + height: 25px; + font-size: 0.875em; + + input, + label { + cursor: pointer; + } + + input[type="checkbox"] { + margin-left: 4px; + margin-right: 10px; + } + + .facet-count { + color: ${({ theme }) => theme.palette.text.secondary}; + } + } + + .facet-more-less { + cursor: pointer; + color: ${({ theme }) => theme.palette.secondary.main}; + font-size: ${({ theme }) => theme.custom.fontSm}; + text-align: right; + } + } + + .filterable-facet { + .facet-list { + max-height: 400px; + overflow: auto; + padding-right: 0.5rem; + } + + .input-wrapper { + position: relative; + .input-postfix-icon { + display: none; + } + .input-postfix-button { + cursor: pointer; + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + padding: 0; + } + } + } + + .facet-label { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + } + + input.facet-filter { + background-color: initial; + border-radius: 0; + border: 1px solid ${({ theme }) => theme.custom.inputBorderGrey}; + padding: 10px; + margin-top: 10px; + margin-bottom: 10px; + width: 100%; + } + + .active-search-filter { + margin-right: 6px; + margin-bottom: 9px; + padding-left: 8px; + + background-color: ${({ theme }) => theme.custom.colorBackgroundLight}; + font-size: ${({ theme }) => theme.custom.fontSm}; + display: inline-flex; + align-items: center; + flex-wrap: nowrap; + border: 1px solid ${({ theme }) => theme.custom.inputBorderGrey}; + border-radius: 14px; + + .remove-filter-button { + padding: 4px; + margin-right: 4px; + + display: flex; + align-items: center; + + cursor: pointer; + border: none; + background: none; + .material-icons { + font-size: 1.25em; + } + } + } + + .clear-all-filters-button { + font-size: ${({ theme }) => theme.custom.fontNormal}; + font-weight: normal; + text-decoration: underline; + background: none; + border: none; + cursor: pointer; + } +` +const PaginationContainer = styled.div` + display: flex; + justify-content: end; +` + +const PAGE_SIZE = 10 +const MAX_PAGE = 50 +const getLastPage = (count: number): number => { + const pages = Math.ceil(count / PAGE_SIZE) + return pages > MAX_PAGE ? MAX_PAGE : pages +} + +const TABS: TabConfig[] = [ + { + label: "Courses", + resource_type: ResourceTypeEnum.Course, + }, + { + label: "Podcasts", + resource_type: ResourceTypeEnum.Podcast, + }, + { + label: "Programs", + resource_type: ResourceTypeEnum.Program, + }, +] +const ALL_RESOURCE_TABS = TABS.map((t) => t.resource_type) + +const SearchPage: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams() + + const { + params, + setFacetActive, + clearFacet, + clearFacets, + currentText, + setCurrentText, + setCurrentTextAndQuery, + } = useSearchQueryParams({ + searchParams, + setSearchParams, + }) + const page = +(searchParams.get("page") ?? "1") + + const setPage = (newPage: number) => { + setSearchParams((current) => { + const copy = new URLSearchParams(current) + copy.set("page", newPage.toString()) + return copy + }) + } + + const resourceType = params.activeFacets.resource_type + const { data, isFetching } = useLearningResourcesSearch( + { + aggregations: AGGREGATIONS, + q: params.queryText, + resource_type: resourceType ? resourceType : ALL_RESOURCE_TABS, + department: params.activeFacets.department, + level: params.activeFacets.level, + topic: params.activeFacets.topic, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }, + { keepPreviousData: true }, + ) + + const resultsTitle = params.queryText + ? `${data?.count} results for "${params.queryText}"` + : `${data?.count} results` + + return ( + <> + + Search + + + + + + + setCurrentText(e.target.value)} + onSubmit={(e) => setCurrentTextAndQuery(e.target.value)} + onClear={() => setCurrentText("")} + placeholder="Search for resources" + /> + + + + + + + + + +

{isFetching ? : resultsTitle}

+
+ + + setPage(1)} + /> + + + + + data?.metadata.aggregations[group] ?? null + } + /> + + + + + {data && data.count > 0 ? ( + + {data.results.map((resource) => ( +
  • + +
  • + ))} +
    + ) : ( + + No results found for your query. + + )} + + setPage(newPage)} + /> + +
    +
    +
    +
    +
    + + ) +} + +export default SearchPage diff --git a/frontends/mit-open/src/routes.tsx b/frontends/mit-open/src/routes.tsx index 5ae7a0800e..788bc6a33b 100644 --- a/frontends/mit-open/src/routes.tsx +++ b/frontends/mit-open/src/routes.tsx @@ -12,6 +12,7 @@ import ErrorPage from "@/pages/ErrorPage/ErrorPage" import * as urls from "@/common/urls" import Header from "@/page-components/Header/Header" import { Permissions } from "@/common/permissions" +import SearchPage from "./pages/SearchPage/SearchPage" const routes: RouteObject[] = [ { @@ -48,6 +49,10 @@ const routes: RouteObject[] = [ path: urls.PROGRAMLETTER_VIEW, element: , }, + { + path: urls.SEARCH, + element: , + }, { element: , children: [ diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx index a1ce5896f1..851aaf9ef4 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useMemo } from "react" import SearchIcon from "@mui/icons-material/Search" import ClearIcon from "@mui/icons-material/Clear" import OutlinedInput from "@mui/material/OutlinedInput" +import type { OutlinedInputProps } from "@mui/material/OutlinedInput" import InputAdornment from "@mui/material/InputAdornment" import IconButton from "@mui/material/IconButton" @@ -19,6 +20,7 @@ export interface SearchSubmissionEvent { type SearchSubmitHandler = (event: SearchSubmissionEvent) => void interface SearchInputProps { + color?: OutlinedInputProps["color"] className?: string classNameClear?: string classNameSearch?: string @@ -40,7 +42,7 @@ const searchIconAdjustments = { } const SearchInput: React.FC = (props) => { - const { onSubmit, value } = props + const { onSubmit, value, color } = props const handleSubmit = useCallback(() => { const event = { target: { value }, @@ -63,6 +65,7 @@ const SearchInput: React.FC = (props) => { autoFocus={props.autoFocus} className={props.className} placeholder={props.placeholder} + color={color} value={props.value} onChange={props.onChange} onKeyDown={onInputKeyDown} diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index d222d3fe4e..6d584c96a6 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -101,6 +101,8 @@ export { default as FormHelperText } from "@mui/material/FormHelperText" export type { FormHelperTextProps } from "@mui/material/FormHelperText" export { default as FormLabel } from "@mui/material/FormLabel" export type { FormLabelProps } from "@mui/material/FormLabel" +export { default as Pagination } from "@mui/material/Pagination" +export type { PaginationProps } from "@mui/material/Pagination" export * from "./components/BasicDialog/BasicDialog" export * from "./components/BannerPage/BannerPage" diff --git a/yarn.lock b/yarn.lock index a6b12df627..2e0275bbe5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3597,6 +3597,31 @@ __metadata: languageName: node linkType: hard +"@mitodl/course-search-utils@npm:^3.0.3": + version: 3.0.3 + resolution: "@mitodl/course-search-utils@npm:3.0.3" + dependencies: + axios: ^1.6.7 + fuse.js: ^7.0.0 + query-string: ^6.13.1 + ramda: ^0.27.1 + react-dotdotdot: ^1.3.1 + peerDependencies: + "@types/history": ^4.9 + history: ^4.9 || ^5.0.0 + react: ^16.13.1 + react-router: ^6.22.2 + peerDependenciesMeta: + "@types/history": + optional: true + history: + optional: true + react-router: + optional: true + checksum: 1c6799e422729974be8c747c54470b0d9fc19dea544d8a02ab95981178362d3c5ac3be50b989d594afcafec802ad6d429b3c502e1773014c46c58090ca1e70be + languageName: node + linkType: hard + "@mui/base@npm:5.0.0-beta.39": version: 5.0.0-beta.39 resolution: "@mui/base@npm:5.0.0-beta.39" @@ -8039,7 +8064,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.6.3": +"axios@npm:^1.6.3, axios@npm:^1.6.7": version: 1.6.8 resolution: "axios@npm:1.6.8" dependencies: @@ -10098,6 +10123,13 @@ __metadata: languageName: node linkType: hard +"decode-uri-component@npm:^0.2.0": + version: 0.2.2 + resolution: "decode-uri-component@npm:0.2.2" + checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139 + languageName: node + linkType: hard + "dedent@npm:^0.7.0": version: 0.7.0 resolution: "dedent@npm:0.7.0" @@ -12214,6 +12246,13 @@ __metadata: languageName: node linkType: hard +"filter-obj@npm:^1.1.0": + version: 1.1.0 + resolution: "filter-obj@npm:1.1.0" + checksum: cf2104a7c45ff48e7f505b78a3991c8f7f30f28bd8106ef582721f321f1c6277f7751aacd5d83026cb079d9d5091082f588d14a72e7c5d720ece79118fa61e10 + languageName: node + linkType: hard + "finalhandler@npm:1.2.0": version: 1.2.0 resolution: "finalhandler@npm:1.2.0" @@ -12698,6 +12737,13 @@ __metadata: languageName: node linkType: hard +"fuse.js@npm:^7.0.0": + version: 7.0.0 + resolution: "fuse.js@npm:7.0.0" + checksum: d15750efec1808370c0cae92ec9473aa7261c59bca1f15f1cf60039ba6f804b8f95340b5cabd83a4ef55839c1034764856e0128e443921f072aa0d8a20e4cacf + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -17516,6 +17562,7 @@ __metadata: "@emotion/react": ^11.11.1 "@emotion/styled": ^11.11.0 "@faker-js/faker": ^7.3.0 + "@mitodl/course-search-utils": ^3.0.3 "@mui/icons-material": ^5.14.19 "@sentry/react": ^7.57.0 "@storybook/addon-essentials": ^7.6.6 @@ -20176,6 +20223,18 @@ __metadata: languageName: node linkType: hard +"query-string@npm:^6.13.1": + version: 6.14.1 + resolution: "query-string@npm:6.14.1" + dependencies: + decode-uri-component: ^0.2.0 + filter-obj: ^1.1.0 + split-on-first: ^1.0.0 + strict-uri-encode: ^2.0.0 + checksum: f2c7347578fa0f3fd4eaace506470cb4e9dc52d409a7ddbd613f614b9a594d750877e193b5d5e843c7477b3b295b857ec328903c943957adc41a3efb6c929449 + languageName: node + linkType: hard + "querystringify@npm:^2.1.1": version: 2.2.0 resolution: "querystringify@npm:2.2.0" @@ -20220,6 +20279,13 @@ __metadata: languageName: node linkType: hard +"ramda@npm:^0.27.1": + version: 0.27.2 + resolution: "ramda@npm:0.27.2" + checksum: 28d6735dd1eea1a796c56cf6111f3673c6105bbd736e521cdd7826c46a18eeff337c2dba4668f6eed990d539b9961fd6db19aa46ccc1530ba67a396c0a9f580d + languageName: node + linkType: hard + "randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -22177,6 +22243,13 @@ __metadata: languageName: node linkType: hard +"split-on-first@npm:^1.0.0": + version: 1.1.0 + resolution: "split-on-first@npm:1.1.0" + checksum: 16ff85b54ddcf17f9147210a4022529b343edbcbea4ce977c8f30e38408b8d6e0f25f92cd35b86a524d4797f455e29ab89eb8db787f3c10708e0b47ebf528d30 + languageName: node + linkType: hard + "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3" @@ -22318,6 +22391,13 @@ __metadata: languageName: node linkType: hard +"strict-uri-encode@npm:^2.0.0": + version: 2.0.0 + resolution: "strict-uri-encode@npm:2.0.0" + checksum: eaac4cf978b6fbd480f1092cab8b233c9b949bcabfc9b598dd79a758f7243c28765ef7639c876fa72940dac687181b35486ea01ff7df3e65ce3848c64822c581 + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" From 8f4cfc52da06d78993bdb20e0c7bbdc10fa9f16f Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 15 Mar 2024 14:28:15 -0400 Subject: [PATCH 3/7] hook up homepage searchbox --- .../src/pages/HomePage/HomePage.test.tsx | 17 ++++++++++++++++- .../mit-open/src/pages/HomePage/HomePage.tsx | 15 +++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/frontends/mit-open/src/pages/HomePage/HomePage.test.tsx b/frontends/mit-open/src/pages/HomePage/HomePage.test.tsx index 78f5d62168..3a0789dea7 100644 --- a/frontends/mit-open/src/pages/HomePage/HomePage.test.tsx +++ b/frontends/mit-open/src/pages/HomePage/HomePage.test.tsx @@ -4,7 +4,7 @@ import HomePage from "./HomePage" import { urls, setMockResponse } from "api/test-utils" import { learningResources as factory } from "api/test-utils/factories" -import { renderWithProviders, screen, within } from "../../test-utils" +import { renderWithProviders, screen, within, user } from "../../test-utils" import invariant from "tiny-invariant" import type { LearningResource } from "api" import LearningResourceCard from "@/page-components/LearningResourceCard/LearningResourceCard" @@ -20,6 +20,21 @@ const checkLRC = async (container: HTMLElement, resource: LearningResource) => { } describe("HomePage", () => { + test("Submitting search goes to search page", async () => { + const resources = factory.resources({ count: 0 }) + setMockResponse.get(urls.learningResources.list(), resources) + const { location } = renderWithProviders() + const searchbox = screen.getByRole("textbox", { name: /search for/i }) + await user.click(searchbox) + await user.paste("physics") + await user.type(searchbox, "[Enter]") + expect(location.current).toEqual( + expect.objectContaining({ + pathname: "/search", + search: "?q=physics", + }), + ) + }) it("Shows Upcoming Courses", async () => { const resources = factory.resources({ count: 4 }) setMockResponse.get(urls.learningResources.list(), resources) diff --git a/frontends/mit-open/src/pages/HomePage/HomePage.tsx b/frontends/mit-open/src/pages/HomePage/HomePage.tsx index 2ecbc5f092..5609330764 100644 --- a/frontends/mit-open/src/pages/HomePage/HomePage.tsx +++ b/frontends/mit-open/src/pages/HomePage/HomePage.tsx @@ -11,6 +11,7 @@ import type { SearchInputProps } from "ol-components" import { GridContainer } from "@/components/GridLayout/GridLayout" import { useLearningResourcesList } from "api/hooks/learningResources" import HomePageCarousel from "./HomePageCarousel" +import { useNavigate } from "react-router" const EXPLORE_BUTTONS = [ { @@ -134,13 +135,19 @@ const FrontPageImage = styled.img` const HomePage: React.FC = () => { const [searchText, setSearchText] = useState("") const onSearchClear = useCallback(() => setSearchText(""), []) + const navigate = useNavigate() const onSearchChange: SearchInputProps["onChange"] = useCallback((e) => { setSearchText(e.target.value) }, []) - const onSearchSubmit: SearchInputProps["onSubmit"] = useCallback((e) => { - console.log("Submitting search") - console.log(e) - }, []) + const onSearchSubmit: SearchInputProps["onSubmit"] = useCallback( + (e) => { + navigate({ + pathname: "/search", + search: `q=${e.target.value}`, + }) + }, + [navigate], + ) const resourcesQuery = useLearningResourcesList() return ( From cf2cdd2c90af799867cee244ac4aaf9effadf84b Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 15 Mar 2024 14:35:03 -0400 Subject: [PATCH 4/7] fix stylelinting --- .../components/PlainVerticalList/PlainVerticalList.tsx | 5 +++-- frontends/mit-open/src/pages/SearchPage/SearchPage.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx b/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx index d5aea812d9..efd888e622 100644 --- a/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx +++ b/frontends/mit-open/src/components/PlainVerticalList/PlainVerticalList.tsx @@ -6,12 +6,13 @@ import { styled } from "ol-components" */ const PlainVerticalList = styled.ul<{ itemSpacing: string }>` list-style: none; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; > li { margin-bottom: ${({ itemSpacing }) => itemSpacing}; } + > li:last-child { margin-bottom: none; } diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx index 85dd2f0e79..271a8f5731 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx @@ -102,7 +102,7 @@ const FacetStyles = styled.div` .filter-section-button { font-size: ${({ theme }) => theme.custom.fontLg}; font-weight: 600; - padding-left: 0px; + padding-left: 0; background-color: transparent; display: flex; justify-content: space-between; @@ -158,9 +158,11 @@ const FacetStyles = styled.div` .input-wrapper { position: relative; + .input-postfix-icon { display: none; } + .input-postfix-button { cursor: pointer; position: absolute; @@ -195,7 +197,6 @@ const FacetStyles = styled.div` margin-right: 6px; margin-bottom: 9px; padding-left: 8px; - background-color: ${({ theme }) => theme.custom.colorBackgroundLight}; font-size: ${({ theme }) => theme.custom.fontSm}; display: inline-flex; @@ -207,13 +208,12 @@ const FacetStyles = styled.div` .remove-filter-button { padding: 4px; margin-right: 4px; - display: flex; align-items: center; - cursor: pointer; border: none; background: none; + .material-icons { font-size: 1.25em; } From 602ab602eebfd60ac76fd2bb1b3caa960a4c6644 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 15 Mar 2024 15:17:45 -0400 Subject: [PATCH 5/7] add some comments --- .../src/pages/SearchPage/ResourceTypeTabs.tsx | 27 ++++++++++++++++++- .../src/pages/SearchPage/SearchPage.tsx | 16 +++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx b/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx index b20ac992ea..872ec4d53f 100644 --- a/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx +++ b/frontends/mit-open/src/pages/SearchPage/ResourceTypeTabs.tsx @@ -28,6 +28,9 @@ const appendCount = (label: string, count?: number) => { return label } +/** + * + */ const ResourceTypesTabContext: React.FC<{ resourceType?: ResourceTypeEnum children: React.ReactNode @@ -95,5 +98,27 @@ const ResourceTypeTabPanels: React.FC<{ ) } -export { ResourceTypesTabContext, ResourceTypeTabList, ResourceTypeTabPanels } +/** + * Components for a tabbed search UI with tabs controlling resource_type facet. + * + * Intended usage is: + * ```jsx + * + * + * + * Panel Content + * + * + * ``` + * + * These are exported as three separate components (Context, TabList, TabPanels) + * to facilitate placement within a grid layout. + */ +const ResourceTypeTabs = { + Context: ResourceTypesTabContext, + TabList: ResourceTypeTabList, + TabPanels: ResourceTypeTabPanels, +} + +export { ResourceTypeTabs } export type { TabConfig } diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx index 271a8f5731..84a0145c6b 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx @@ -27,11 +27,7 @@ import { useSearchParams } from "@mitodl/course-search-utils/react-router" import LearningResourceCard from "@/page-components/LearningResourceCard/LearningResourceCard" import PlainVerticalList from "@/components/PlainVerticalList/PlainVerticalList" -import { - ResourceTypeTabList, - ResourceTypeTabPanels, - ResourceTypesTabContext, -} from "./ResourceTypeTabs" +import { ResourceTypeTabs } from "./ResourceTypeTabs" import type { TabConfig } from "./ResourceTypeTabs" const RESOURCE_FACETS: FacetManifest = [ @@ -325,7 +321,7 @@ const SearchPage: React.FC = () => { - @@ -334,7 +330,7 @@ const SearchPage: React.FC = () => { - { - + {data && data.count > 0 ? ( {data.results.map((resource) => ( @@ -380,9 +376,9 @@ const SearchPage: React.FC = () => { onChange={(_, newPage) => setPage(newPage)} /> - + - + From 27f2e5a0358a5809f04fa430e0489c68884720ab Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 18 Mar 2024 11:36:04 -0400 Subject: [PATCH 6/7] only support topics facet for now --- .../src/pages/SearchPage/SearchPage.test.tsx | 31 +++++++-------- .../src/pages/SearchPage/SearchPage.tsx | 39 ++----------------- 2 files changed, 17 insertions(+), 53 deletions(-) diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx index c865fe3ff2..44e0779e79 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx @@ -153,10 +153,10 @@ describe("SearchPage", () => { }) test.each([ - { url: "?d=6", expected: { department: "6" } }, + { url: "?t=physics", expected: { topic: "physics" } }, { - url: "?d=6&r=course", - expected: { department: "6", resource_type: "course" }, + url: "?r=course", + expected: { resource_type: "course" }, }, { url: "?q=woof", expected: { q: "woof" } }, ])( @@ -166,9 +166,9 @@ describe("SearchPage", () => { count: 700, metadata: { aggregations: { - department: [ - { key: "8", doc_count: 100 }, - { key: "6", doc_count: 200 }, + topic: [ + { key: "physics", doc_count: 100 }, + { key: "chemistry", doc_count: 200 }, ], }, suggestions: [], @@ -180,9 +180,6 @@ describe("SearchPage", () => { }) const apiSearchParams = getLastApiSearchParams() expect(apiSearchParams.getAll("aggregations").sort()).toEqual([ - "course_feature", - "department", - "level", "resource_type", "topic", ]) @@ -197,16 +194,16 @@ describe("SearchPage", () => { count: 700, metadata: { aggregations: { - department: [ - { key: "8", doc_count: 100 }, // Physics - { key: "5", doc_count: 200 }, // Chemistry + topic: [ + { key: "Physics", doc_count: 100 }, // Physics + { key: "Chemistry", doc_count: 200 }, // Chemistry ], }, suggestions: [], }, }) const { location } = renderWithProviders(, { - url: "?d=8&d=5", + url: "?t=Physics&t=Chemistry", }) const clearAll = await screen.findByRole("button", { name: /clear all/i }) const physics = await screen.findByRole("checkbox", { name: "Physics" }) @@ -222,7 +219,7 @@ describe("SearchPage", () => { // toggle physics await user.click(physics) expect(physics).toBeChecked() - expect(location.current.search).toBe("?d=8") + expect(location.current.search).toBe("?t=Physics") }) test("Submitting text updates URL", async () => { @@ -250,7 +247,7 @@ describe("Search Page pagination controls", () => { test("?page URLSearchParam controls activate page", async () => { setMockSearchResponse({ count: 137 }) - renderWithProviders(, { url: "?d=6&page=3" }) + renderWithProviders(, { url: "?page=3" }) const pagination = getPagination() // p3 is current page await within(pagination).findByRole("button", { @@ -264,7 +261,7 @@ describe("Search Page pagination controls", () => { test("Clicking on a page updates URL", async () => { setMockSearchResponse({ count: 137 }) const { location } = renderWithProviders(, { - url: "?d=6&page=3", + url: "?page=3", }) const pagination = getPagination() const p4 = await within(pagination).findByRole("button", { @@ -279,7 +276,7 @@ describe("Search Page pagination controls", () => { test("Max page is determined by count", async () => { setMockSearchResponse({ count: 137 }) - renderWithProviders(, { url: "?d=6&page=3" }) + renderWithProviders(, { url: "?page=3" }) const pagination = getPagination() // p14 exists await within(pagination).findByRole("button", { name: "Go to page 14" }) diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx index 84a0145c6b..602716a650 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx @@ -16,12 +16,7 @@ import type { LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest a import { useLearningResourcesSearch } from "api/hooks/learningResources" import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" -import { - useSearchQueryParams, - FacetDisplay, - getDepartmentName, - getLevelName, -} from "@mitodl/course-search-utils" +import { useSearchQueryParams, FacetDisplay } from "@mitodl/course-search-utils" import type { FacetManifest } from "@mitodl/course-search-utils" import { useSearchParams } from "@mitodl/course-search-utils/react-router" import LearningResourceCard from "@/page-components/LearningResourceCard/LearningResourceCard" @@ -31,41 +26,15 @@ import { ResourceTypeTabs } from "./ResourceTypeTabs" import type { TabConfig } from "./ResourceTypeTabs" const RESOURCE_FACETS: FacetManifest = [ - { - name: "department", - title: "Departments", - useFilterableFacet: true, - expandedOnLoad: true, - labelFunction: getDepartmentName, - }, - { - name: "level", - title: "Level", - useFilterableFacet: false, - expandedOnLoad: false, - labelFunction: getLevelName, - }, { name: "topic", title: "Topics", useFilterableFacet: true, - expandedOnLoad: false, - }, - { - name: "course_feature", - title: "Features", - useFilterableFacet: true, - expandedOnLoad: false, + expandedOnLoad: true, }, ] -const AGGREGATIONS: LRSearchRequest["aggregations"] = [ - "resource_type", - "level", - "department", - "topic", - "course_feature", -] +const AGGREGATIONS: LRSearchRequest["aggregations"] = ["resource_type", "topic"] const ColoredHeader = styled.div` background-color: ${({ theme }) => theme.palette.secondary.light}; @@ -284,8 +253,6 @@ const SearchPage: React.FC = () => { aggregations: AGGREGATIONS, q: params.queryText, resource_type: resourceType ? resourceType : ALL_RESOURCE_TABS, - department: params.activeFacets.department, - level: params.activeFacets.level, topic: params.activeFacets.topic, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, From 87c557bfc8644c13b432e4ba790ac1297dd02a49 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 18 Mar 2024 16:03:32 -0400 Subject: [PATCH 7/7] submit search on clear text button --- .../mit-open/src/pages/SearchPage/SearchPage.test.tsx | 7 +++++++ frontends/mit-open/src/pages/SearchPage/SearchPage.tsx | 2 +- .../src/components/SearchInput/SearchInput.test.tsx | 2 +- .../src/components/SearchInput/SearchInput.tsx | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx index 44e0779e79..82afcc3d58 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.test.tsx @@ -237,6 +237,13 @@ describe("SearchPage", () => { }) }) +test("Clearing text updates URL", async () => { + setMockSearchResponse({}) + const { location } = renderWithProviders(, { url: "?q=meow" }) + await user.click(screen.getByRole("button", { name: "Clear search text" })) + expect(location.current.search).toBe("") +}) + /** * Simple tests to check that data / handlers with pagination controls are * working as expected. diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx index 602716a650..2c3e0dd9d6 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx @@ -279,7 +279,7 @@ const SearchPage: React.FC = () => { value={currentText} onChange={(e) => setCurrentText(e.target.value)} onSubmit={(e) => setCurrentTextAndQuery(e.target.value)} - onClear={() => setCurrentText("")} + onClear={() => setCurrentTextAndQuery("")} placeholder="Search for resources" /> diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx index c0403918f4..e242a06eee 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx @@ -21,7 +21,7 @@ const getSearchButton = (): HTMLButtonElement => { * This actually returns an icon (inside a button) */ const getClearButton = (): HTMLButtonElement => { - const button = screen.getByLabelText("Clear") + const button = screen.getByLabelText("Clear search text") invariant(button instanceof HTMLButtonElement) return button } diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx index 851aaf9ef4..0ecd24fd5b 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx @@ -74,7 +74,7 @@ const SearchInput: React.FC = (props) => { {props.value && (