diff --git a/RELEASE.rst b/RELEASE.rst index 0cec8dcb60..9f4e76782f 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,24 @@ Release Notes ============= +Version 0.24.2 +-------------- + +- Card bug fixes (#1793) +- upgrade stories & events grid to grid2 (#1792) +- Fixes to address scim-form-keycloak weirdness (#1789) +- Maintain API instructor order (#1783) +- Fix DST issue (#1788) +- Update Node.js to v22 (#1786) +- Update dependency ruff to v0.7.2 (#1785) +- Image and video optimizations (#1769) +- Upgrade to Next.js v15 (#1776) +- Fix issue with hero image causing horizontal scroll (#1777) +- add new learning resource drawer layout (#1711) +- Change frontend to use resource_prices field instead of prices field (#1737) +- move facets first in tab order (#1751) +- Card Accessibility Improvements (#1778) + Version 0.24.1 (Released October 30, 2024) -------------- diff --git a/docker-compose.apps.yml b/docker-compose.apps.yml index b6360acebd..4601007c97 100644 --- a/docker-compose.apps.yml +++ b/docker-compose.apps.yml @@ -28,7 +28,7 @@ services: profiles: - frontend working_dir: /src - image: node:20.18 + image: node:22.11 entrypoint: ["/bin/sh", "-c"] command: - | diff --git a/frontends/main/package.json b/frontends/main/package.json index dec56e6013..9cf7375c7a 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -15,12 +15,12 @@ "@mitodl/course-search-utils": "^3.3.1", "@next/bundle-analyzer": "^14.2.15", "@remixicon/react": "^4.2.0", - "@sentry/nextjs": "^8", + "@sentry/nextjs": "^8.36.0", "@tanstack/react-query": "^4.36.1", "api": "workspace:*", "formik": "^2.4.6", "lodash": "^4.17.21", - "next": "^14.2.15", + "next": "^15.0.2", "ol-ckeditor": "0.0.0", "ol-components": "0.0.0", "ol-utilities": "0.0.0", @@ -40,7 +40,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.7", - "@types/node": "^20", + "@types/node": "^22.0.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@types/react-slick": "^0.23.13", diff --git a/frontends/main/src/app-pages/AboutPage/AboutPage.tsx b/frontends/main/src/app-pages/AboutPage/AboutPage.tsx index 0c1aecdefb..0b011cc018 100644 --- a/frontends/main/src/app-pages/AboutPage/AboutPage.tsx +++ b/frontends/main/src/app-pages/AboutPage/AboutPage.tsx @@ -10,6 +10,7 @@ import { import * as urls from "@/common/urls" import React from "react" import domeImage from "@/public/mit-dome-2.jpg" +import Image from "next/image" const WHAT_IS_MIT_OPEN_FRAGMENT_IDENTIFIER = "what-is-mit-learn" const NON_DEGREE_LEARNING_FRAGMENT_IDENTIFIER = "non-degree-learning" @@ -81,13 +82,15 @@ const SubHeaderTextContainer = styled.div({ alignSelf: "flex-start", }) -const SubHeaderImage = styled.img({ +const SubHeaderImageContainer = styled.div({ flexGrow: 1, alignSelf: "stretch", + position: "relative", +}) + +const SubHeaderImage = styled(Image)({ borderRadius: "8px", - backgroundSize: "cover", - backgroundPosition: "center", - backgroundImage: `url(${domeImage.src})`, + objectFit: "cover", [theme.breakpoints.down("md")]: { height: "300px", }, @@ -165,7 +168,13 @@ const AboutPage: React.FC = () => {
  • Continue your education at your own pace
  • - + + + diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx index 1835398066..2c85049974 100644 --- a/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx +++ b/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx @@ -316,8 +316,8 @@ describe.each(NON_UNIT_CHANNEL_TYPES)( assertHeadings([ { level: 1, name: channel.title }, { level: 2, name: `Search within ${channel.title}` }, - { level: 3, name: "Filter" }, { level: 3, name: "Search Results" }, + { level: 3, name: "Filter" }, ]) }) }, 10000) @@ -379,7 +379,7 @@ describe("Channel Pages, Topic only", () => { }) describe("Channel Pages, Unit only", () => { - it("Displays the channel title, banner, and avatar", async () => { + it("Displays the channel title, banner", async () => { const { channel } = setupApis({ search_filter: "offered_by=ocw", channel_type: "unit", @@ -388,9 +388,24 @@ describe("Channel Pages, Unit only", () => { url: `/c/${channel.channel_type}/${channel.name}`, }) - const title = await screen.findByRole("heading", { name: channel.title }) - getByImageSrc(title, `${window.origin}${channel.configuration.logo}`) + await screen.findByRole("heading", { name: channel.title }) }) + + it("Displays the channel logo", async () => { + const { channel } = setupApis({ + name: "ocw", + channel_type: "unit", + }) + renderWithProviders(, { + url: `/c/${channel.channel_type}/${channel.name}`, + }) + + const images = await screen.findAllByRole("img", { + name: "MIT OpenCourseWare", + }) + expect(images[0]).toHaveAttribute("src", "/images/unit_logos/ocw.svg") + }) + it("Displays a featured carousel if the channel type is 'unit'", async () => { const { channel } = setupApis({ search_filter: "offered_by=ocw", @@ -436,8 +451,8 @@ describe("Channel Pages, Unit only", () => { { level: 2, name: "Featured Courses" }, { level: 2, name: "What Learners Say" }, { level: 2, name: `Search within ${channel.title}` }, - { level: 3, name: "Filter" }, { level: 3, name: "Search Results" }, + { level: 3, name: "Filter" }, ]) }) }) diff --git a/frontends/main/src/app-pages/ChannelPage/DefaultChannelTemplate.tsx b/frontends/main/src/app-pages/ChannelPage/DefaultChannelTemplate.tsx index cc9cdbb121..4114a0b4f4 100644 --- a/frontends/main/src/app-pages/ChannelPage/DefaultChannelTemplate.tsx +++ b/frontends/main/src/app-pages/ChannelPage/DefaultChannelTemplate.tsx @@ -1,5 +1,6 @@ import React from "react" import { styled, Breadcrumbs, Banner } from "ol-components" +import { backgroundSrcSetCSS } from "ol-utilities" import { SearchSubscriptionToggle } from "@/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle" import { useChannelDetail } from "api/hooks/channels" import ChannelMenu from "@/components/ChannelMenu/ChannelMenu" @@ -10,6 +11,7 @@ import { CHANNEL_TYPE_BREADCRUMB_TARGETS, ChannelControls, } from "./ChannelPageTemplate" +import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" const ChildrenContainer = styled.div(({ theme }) => ({ paddingTop: "40px", @@ -57,6 +59,7 @@ const DefaultChannelTemplate: React.FC = ({ const channel = useChannelDetail(String(channelType), String(name)) const urlParams = new URLSearchParams(channel.data?.search_filter) const displayConfiguration = channel.data?.configuration + return ( <> = ({ title={channel.data?.title} header={displayConfiguration?.heading} subHeader={displayConfiguration?.sub_heading} - backgroundUrl={displayConfiguration?.banner_background} + backgroundUrl={ + displayConfiguration?.banner_background ?? + backgroundSrcSetCSS(backgroundSteps) + } extraActions={ diff --git a/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx b/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx index 412a25c23a..f4b2a5de69 100644 --- a/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx +++ b/frontends/main/src/app-pages/ChannelPage/TopicChannelTemplate.tsx @@ -23,8 +23,9 @@ import { useLearningResourceTopic, useLearningResourceTopics, } from "api/hooks/learningResources" -import { propsNotNil } from "ol-utilities" +import { propsNotNil, backgroundSrcSetCSS } from "ol-utilities" import invariant from "tiny-invariant" +import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" const ChildrenContainer = styled.div(({ theme }) => ({ paddingTop: "40px", @@ -222,6 +223,7 @@ const TopicChannelTemplateInternal: React.FC< ) : ( ) + return ( <> } backgroundUrl={ displayConfiguration?.banner_background ?? - "/images/backgrounds/background_steps.jpg" + backgroundSrcSetCSS(backgroundSteps) } extraActions={ diff --git a/frontends/main/src/app-pages/ChannelPage/UnitChannelTemplate.tsx b/frontends/main/src/app-pages/ChannelPage/UnitChannelTemplate.tsx index e939d42b29..d6e8b6c9e0 100644 --- a/frontends/main/src/app-pages/ChannelPage/UnitChannelTemplate.tsx +++ b/frontends/main/src/app-pages/ChannelPage/UnitChannelTemplate.tsx @@ -8,7 +8,9 @@ import { BannerBackground, Typography, VisuallyHidden, + UnitLogo, } from "ol-components" +import { OfferedByEnum, SourceTypeEnum } from "api" import { SearchSubscriptionToggle } from "@/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle" import { ChannelDetails } from "@/page-components/ChannelDetails/ChannelDetails" import { useChannelDetail } from "api/hooks/channels" @@ -16,7 +18,6 @@ import ChannelMenu from "@/components/ChannelMenu/ChannelMenu" import ResourceCarousel, { ResourceCarouselProps, } from "@/page-components/ResourceCarousel/ResourceCarousel" -import { SourceTypeEnum } from "api" import { getSearchParamMap } from "@/common/utils" import { HOME as HOME_URL, UNITS as UNITS_URL } from "../../common/urls" import { ChannelTypeEnum } from "api/v0" @@ -38,13 +39,13 @@ const FeaturedCoursesCarousel = styled(ResourceCarousel)(({ theme }) => ({ }, })) -const UnitLogo = styled.img(({ theme }) => ({ +const UnitLogoInverted = styled(UnitLogo)(({ theme }) => ({ filter: "saturate(0%) invert(100%)", + height: 50, maxWidth: "100%", - width: "auto", - height: "50px", [theme.breakpoints.down("md")]: { - height: "40px", + height: 40, + width: "auto", }, })) @@ -126,7 +127,10 @@ const UnitChannelTemplate: React.FC = ({ {channel.data?.title} {channel.data ? ( - + ) : null} diff --git a/frontends/main/src/app-pages/DepartmentListingPage/DepartmentListingPage.tsx b/frontends/main/src/app-pages/DepartmentListingPage/DepartmentListingPage.tsx index 32e308a1ea..8399d741db 100644 --- a/frontends/main/src/app-pages/DepartmentListingPage/DepartmentListingPage.tsx +++ b/frontends/main/src/app-pages/DepartmentListingPage/DepartmentListingPage.tsx @@ -13,7 +13,7 @@ import { Banner, Breadcrumbs, } from "ol-components" -import { pluralize } from "ol-utilities" +import { pluralize, backgroundSrcSetCSS } from "ol-utilities" import type { LearningResourceSchool } from "api" import { useSchoolsList } from "api/hooks/learningResources" import { @@ -27,7 +27,7 @@ import { RiTerminalBoxLine, } from "@remixicon/react" import { HOME } from "@/common/urls" - +import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" import { aggregateProgramCounts, aggregateCourseCounts } from "@/common/utils" import { useChannelCounts } from "api/hooks/channels" @@ -201,7 +201,6 @@ const DepartmentListingPage: React.FC = () => { return ( <> { current="Departments" /> } + backgroundUrl={backgroundSrcSetCSS(backgroundSteps)} /> diff --git a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx index 6938d2be05..79d112e8ed 100644 --- a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx +++ b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx @@ -57,7 +57,7 @@ const setupAPIs = () => { limit: 6, sortby: "-news_date", }), - {}, + newsEvents.newsItems({ count: 0 }), ) setMockResponse.get( urls.newsEvents.list({ @@ -65,7 +65,7 @@ const setupAPIs = () => { limit: 5, sortby: "event_date", }), - {}, + newsEvents.eventItems({ count: 0 }), ) setMockResponse.get(urls.topics.list({ is_toplevel: true }), { diff --git a/frontends/main/src/app-pages/HomePage/HomePage.tsx b/frontends/main/src/app-pages/HomePage/HomePage.tsx index 553c797d70..475e53efd7 100644 --- a/frontends/main/src/app-pages/HomePage/HomePage.tsx +++ b/frontends/main/src/app-pages/HomePage/HomePage.tsx @@ -1,6 +1,6 @@ "use client" -import React from "react" +import React, { Suspense } from "react" import { Container, styled, theme } from "ol-components" import HeroSearch from "@/page-components/HeroSearch/HeroSearch" import BrowseTopicsSection from "./BrowseTopicsSection" @@ -38,29 +38,39 @@ const MediaCarousel = styled(ResourceCarousel)(({ theme }) => ({ }, })) +const StyledContainer = styled(Container)({ + "@media (max-width: 1365px)": { + overflow: "hidden", + }, +}) + const HomePage: React.FC = () => { return ( <> - +
    - + + +
    -
    +
    - + + + diff --git a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx index 9c6923bd8a..bcf7d7227f 100644 --- a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx @@ -4,7 +4,7 @@ import { styled, theme, Typography, - Grid, + Grid2, Card, TypographyProps, } from "ol-components" @@ -15,6 +15,7 @@ import { import type { NewsFeedItem, EventFeedItem } from "api/v0" import { formatDate } from "ol-utilities" import { RiArrowRightSLine } from "@remixicon/react" +import Link from "next/link" const Section = styled.section` background: ${theme.custom.colors.white}; @@ -111,10 +112,7 @@ const EventCard = styled(Card)` align-self: stretch; justify-content: space-between; overflow: visible; - - > a { - padding: 16px; - } + padding: 16px; ` const EventDate = styled.div` @@ -190,11 +188,11 @@ const Story: React.FC<{ item: NewsFeedItem; mobile: boolean }> = ({ mobile, }) => { return ( - + {item.image.url ? ( ) : null} - + {item.title} @@ -221,31 +219,32 @@ const NewsEventsSection: React.FC = () => { return null } - const stories = news!.results?.slice(0, 6) || [] + const stories = news.results.slice(0, 6) - const EventCards = - events!.results?.map((item) => ( - - - - - {formatDate( - (item as EventFeedItem).event_details?.event_datetime, - "D", - )} - - - {formatDate( - (item as EventFeedItem).event_details?.event_datetime, - "MMM", - )} - - + const EventCards = events.results.map((item) => ( + + + + + {formatDate( + (item as EventFeedItem).event_details?.event_datetime, + "D", + )} + + + {formatDate( + (item as EventFeedItem).event_details?.event_datetime, + "MMM", + )} + + + {item.title} - - - - )) || [] + + + + + )) return (
    @@ -287,9 +286,12 @@ const NewsEventsSection: React.FC = () => { Stories - + {stories.map((item, index) => ( - + {index >= 4 ? ( @@ -297,9 +299,9 @@ const NewsEventsSection: React.FC = () => { ) : ( )} - + ))} - + diff --git a/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx b/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx index 93bd620af7..e20e788483 100644 --- a/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx +++ b/frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx @@ -1,11 +1,14 @@ import React from "react" import { Typography, styled, Container, ButtonLink } from "ol-components" +import { backgroundSrcSetCSS } from "ol-utilities" import { useUserMe } from "api/hooks/user" import * as urls from "@/common/urls" +import personalizeImage from "@/public/images/homepage/personalize-image.png" +import personalizeBgImage from "@/public/images/homepage/personalize-bg.png" const FullWidthBackground = styled.div(({ theme }) => ({ padding: "80px 0", - background: 'url("/images/homepage/personalize-bg.png") center top no-repeat', + background: `${backgroundSrcSetCSS(personalizeBgImage)} center top no-repeat`, backgroundSize: "cover", [theme.breakpoints.down("md")]: { padding: "40px 0", @@ -108,7 +111,7 @@ const PersonalizeSection = () => { return ( - + diff --git a/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx b/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx index 94502ce32a..8c0066287d 100644 --- a/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx +++ b/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx @@ -84,11 +84,6 @@ const LearningPathListingPage: React.FC = () => { src="/images/backgrounds/course_search_banner.png" className="learningpaths-page" > - {/* TODO - - - - */} diff --git a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetter.test.tsx b/frontends/main/src/app-pages/ProgramLetterPage/ProgramLetter.test.tsx similarity index 99% rename from frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetter.test.tsx rename to frontends/main/src/app-pages/ProgramLetterPage/ProgramLetter.test.tsx index 587aa7ee1c..363a5f0b2c 100644 --- a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetter.test.tsx +++ b/frontends/main/src/app-pages/ProgramLetterPage/ProgramLetter.test.tsx @@ -23,7 +23,7 @@ describe("ProgramLetterDisplayPage", () => { const programLetter = factory.programLetter() setup({ programLetter }) await waitFor(() => { - const letterText = document.querySelector(".letter-text > .ck-content") + const letterText = document.querySelector(".letter-text > div") expect(letterText?.innerHTML).toBe( programLetter?.template_fields?.program_letter_text, ) diff --git a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage.tsx b/frontends/main/src/app-pages/ProgramLetterPage/ProgramLetterPage.tsx similarity index 90% rename from frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage.tsx rename to frontends/main/src/app-pages/ProgramLetterPage/ProgramLetterPage.tsx index f9a793e40c..c77597f94b 100644 --- a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage.tsx +++ b/frontends/main/src/app-pages/ProgramLetterPage/ProgramLetterPage.tsx @@ -4,7 +4,6 @@ import React from "react" import { styled } from "ol-components" import { useProgramLettersDetail } from "api/hooks/programLetters" import { useParams } from "next/navigation" -import { CkeditorDisplay } from "ol-ckeditor" type RouteParams = { id: string @@ -131,10 +130,10 @@ const ProgramLetterPage: React.FC = () => {
    -
    @@ -147,8 +146,10 @@ const ProgramLetterPage: React.FC = () => {
    Dear {certificateInfo?.user_full_name},
    -
    @@ -184,10 +185,10 @@ const ProgramLetterPage: React.FC = () => { )}
    -
    diff --git a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx index 3fd65a0a38..20a743990c 100644 --- a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx +++ b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx @@ -627,19 +627,27 @@ test("Set sort", async () => { test("The professional toggle updates the professional setting", async () => { setMockApiResponses({ search: { count: 137 } }) const { location } = renderWithProviders() - const professionalToggle = await screen.getAllByText("Professional")[0] + const facets = screen.getByTestId("facets-container") + const professionalToggle = await within(facets).findByRole("button", { + name: "Professional", + }) await user.click(professionalToggle) await waitFor(() => { const params = new URLSearchParams(location.current.search) expect(params.get("professional")).toBe("true") }) - const academicToggle = await screen.getAllByText("Academic")[0] + const academicToggle = await within(facets).findByRole("button", { + name: "Academic", + }) await user.click(academicToggle) await waitFor(() => { const params = new URLSearchParams(location.current.search) expect(params.get("professional")).toBe("false") }) - const viewAllToggle = await screen.getAllByText("All")[0] + const viewAllToggle = await within(facets).findByRole("button", { + name: "All", + }) + await user.click(viewAllToggle) await waitFor(() => { const params = new URLSearchParams(location.current.search) @@ -709,8 +717,8 @@ describe("Search Page pagination controls", () => { assertHeadings([ { level: 1, name: "Search" }, - { level: 2, name: "Filter" }, { level: 2, name: "Search Results" }, + { level: 2, name: "Filter" }, ]) }) }) diff --git a/frontends/main/src/app-pages/TermsPage/TermsPage.tsx b/frontends/main/src/app-pages/TermsPage/TermsPage.tsx index 73e2bc2b21..d44e161d91 100644 --- a/frontends/main/src/app-pages/TermsPage/TermsPage.tsx +++ b/frontends/main/src/app-pages/TermsPage/TermsPage.tsx @@ -1,7 +1,7 @@ "use client" import React from "react" -// Not urrently linked to. See https://github.com/mitodl/hq/issues/4639 +// Not currently linked to. See https://github.com/mitodl/hq/issues/4639 import { Breadcrumbs, Container, diff --git a/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx b/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx index 922cb42f3f..3177059a4f 100644 --- a/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx +++ b/frontends/main/src/app-pages/TopicsListingPage/TopicsListingPage.tsx @@ -14,14 +14,14 @@ import { Breadcrumbs, } from "ol-components" import Link from "next/link" -import { propsNotNil } from "ol-utilities" - +import { propsNotNil, backgroundSrcSetCSS } from "ol-utilities" import { useLearningResourceTopics } from "api/hooks/learningResources" import { LearningResourceTopic } from "api" import RootTopicIcon from "@/components/RootTopicIcon/RootTopicIcon" import { HOME } from "@/common/urls" import { aggregateProgramCounts, aggregateCourseCounts } from "@/common/utils" import { useChannelCounts } from "api/hooks/channels" +import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" type ChannelSummary = { id: number | string @@ -275,6 +275,7 @@ const TopicsListingPage: React.FC = () => { } title="Browse by Topic" header="Select a topic below to explore relevant learning resources across all Academic and Professional units." + backgroundUrl={backgroundSrcSetCSS(backgroundSteps)} /> diff --git a/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx b/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx index 224323731c..9767a828c1 100644 --- a/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx +++ b/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx @@ -1,7 +1,15 @@ import React from "react" import { LearningResourceOfferorDetail, OfferedByEnum } from "api" -import { Card, Skeleton, Typography, styled, theme } from "ol-components" +import { + Card, + Skeleton, + Typography, + styled, + theme, + UnitLogo, +} from "ol-components" import { useChannelDetail } from "api/hooks/channels" +import Link from "next/link" const CardStyled = styled(Card)({ height: "100%", @@ -34,14 +42,12 @@ const LogoContainer = styled.div({ margin: "0 auto", }, }, -}) - -const UnitLogo = styled.img({ - height: "50px", - display: "block", - [theme.breakpoints.down("md")]: { - height: "40px", - margin: "0 auto", + img: { + display: "block", + [theme.breakpoints.down("md")]: { + height: "40px", + margin: "0 auto", + }, }, }) @@ -72,23 +78,10 @@ const LoadingContent = styled.div({ padding: "24px", }) -const HeadingText = styled(Typography)(({ theme }) => ({ +const HeadingText = styled.span(({ theme }) => ({ alignSelf: "stretch", color: theme.custom.colors.darkGray2, ...theme.typography.body2, - [theme.breakpoints.down("md")]: { - display: "none", - }, -})) - -const SubHeadingText = styled(HeadingText)(({ theme }) => ({ - alignSelf: "stretch", - color: theme.custom.colors.darkGray2, - ...theme.typography.body2, - display: "none", - [theme.breakpoints.down("md")]: { - display: "block", - }, })) const CountsTextContainer = styled.div({ @@ -108,15 +101,6 @@ const CountsText = styled(Typography)(({ theme }) => ({ }, })) -const unitLogos = { - [OfferedByEnum.Mitx]: "/images/unit_logos/mitx.svg", - [OfferedByEnum.Ocw]: "/images/unit_logos/ocw.svg", - [OfferedByEnum.Bootcamps]: "/images/unit_logos/bootcamps.svg", - [OfferedByEnum.Xpro]: "/images/unit_logos/xpro.svg", - [OfferedByEnum.Mitpe]: "/images/unit_logos/mitpe.svg", - [OfferedByEnum.See]: "/images/unit_logos/see.svg", -} - interface UnitCardsProps { units: LearningResourceOfferorDetail[] | undefined courseCounts: Record @@ -125,35 +109,34 @@ interface UnitCardsProps { interface UnitCardProps { unit: LearningResourceOfferorDetail - logo: string courseCount: number programCount: number } const UnitCard: React.FC = (props) => { - const { unit, logo, courseCount, programCount } = props + const { unit, courseCount, programCount } = props const channelDetailQuery = useChannelDetail("unit", unit.code) const channelDetail = channelDetailQuery.data const unitUrl = channelDetail?.channel_url - return channelDetailQuery.isLoading ? ( - - ) : ( - + if (!unitUrl) return null + const href = unitUrl && new URL(unitUrl).pathname + + return ( + - + + + {channelDetail?.configuration?.heading} - - {channelDetail?.configuration?.sub_heading} - @@ -197,14 +180,11 @@ export const UnitCards: React.FC = (props) => { {units?.map((unit) => { const courseCount = courseCounts[unit.code] || 0 const programCount = programCounts[unit.code] || 0 - const logo = - unitLogos[unit.code as OfferedByEnum] || - `/images/unit_logos/${unit.code}.svg` + return unit.value_prop ? ( diff --git a/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.test.tsx b/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.test.tsx index b731f967a2..c4e6ba84cd 100644 --- a/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.test.tsx +++ b/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.test.tsx @@ -1,30 +1,9 @@ import React from "react" import { renderWithProviders, screen, waitFor, within } from "@/test-utils" -import type { LearningResourcesSearchResponse } from "api" import UnitsListingPage from "./UnitsListingPage" import { factories, setMockResponse, urls } from "api/test-utils" import { assertHeadings } from "ol-test-utilities" -const makeSearchResponse = ( - aggregations: Record, -): LearningResourcesSearchResponse => { - return { - metadata: { - suggestions: [], - aggregations: { - topic: Object.entries(aggregations).map(([key, docCount]) => ({ - key, - doc_count: docCount, - })), - }, - }, - count: 0, - results: [], - next: null, - previous: null, - } -} - describe("DepartmentListingPage", () => { const setupApis = () => { const make = factories.learningResources @@ -59,12 +38,6 @@ describe("DepartmentListingPage", () => { value_prop: "Professional Unit 2 value prop", professional: true, }) - const professionalUnit3 = make.offeror({ - code: "professionalUnit3", - name: "Professional Unit 3", - value_prop: "Professional Unit 3 value prop", - professional: true, - }) const units = [ academicUnit1, @@ -72,54 +45,42 @@ describe("DepartmentListingPage", () => { academicUnit3, professionalUnit1, professionalUnit2, - professionalUnit3, ] - const courseCounts = { + const courseCounts: Record = { academicUnit1: 10, academicUnit2: 20, academicUnit3: 1, professionalUnit1: 40, - professionalUnit2: 50, - professionalUnit3: 0, + professionalUnit2: 0, } - const programCounts = { + const programCounts: Record = { academicUnit1: 1, academicUnit2: 2, academicUnit3: 0, professionalUnit1: 4, professionalUnit2: 5, - professionalUnit3: 6, } - setMockResponse.get(urls.channels.counts("unit"), [ - { - name: academicUnit1, - counts: { - programs: 7, - courses: 10, - }, - }, - ]) - setMockResponse.get(urls.offerors.list(), units) - setMockResponse.get( - urls.search.resources({ - resource_type: ["course"], - aggregations: ["offered_by"], - }), - makeSearchResponse(courseCounts), - ) - setMockResponse.get( - urls.search.resources({ - resource_type: ["program"], - aggregations: ["offered_by"], + urls.channels.counts("unit"), + units.map((unit) => { + return { + name: unit.code, + counts: { + courses: courseCounts[unit.code], + programs: programCounts[unit.code], + }, + } }), - makeSearchResponse(programCounts), ) + setMockResponse.get(urls.offerors.list(), { + count: units.length, + results: units, + }) units.forEach((unit) => { setMockResponse.get(urls.channels.details("unit", unit.code), { - channel_url: `/units/${unit.code}`, + channel_url: `${window.location.origin}/units/${unit.code}`, }) }) @@ -138,49 +99,43 @@ describe("DepartmentListingPage", () => { it("Shows unit properties within the proper section", async () => { const { units, courseCounts, programCounts } = setupApis() + renderWithProviders() + + const academicSection = screen.getByTestId("UnitSection-academic") + const professionalSection = screen.getByTestId("UnitSection-professional") + await waitFor(() => { - const academicSection = screen.getByTestId("UnitSection-academic") - const professionalSection = screen.getByTestId("UnitSection-professional") - units.forEach(async (unit) => { - const section = unit.professional - ? professionalSection - : academicSection - const channelLink = await within(section).findByRole("link", { - name: unit.name, - }) - const logoImage = await within(section).findByAltText(unit.name) - const valuePropText = await within(section).findByText( - unit.value_prop ? unit.value_prop : "", - ) - expect(channelLink).toHaveAttribute("href", `/units/${unit.code}`) - expect(logoImage).toHaveAttribute( - "src", - `/images/units/${unit.code}.svg`, - ) - expect(valuePropText).toBeInTheDocument() - const courseCount = courseCounts[unit.code as keyof typeof courseCounts] - const programCount = - programCounts[unit.code as keyof typeof programCounts] - const courseCountText = await within(section).findByTestId( - `course-count-${unit.code}`, - ) - const programCountText = await within(section).findByTestId( - `program-count-${unit.code}`, - ) - if (courseCount > 0) { - expect(courseCountText).toHaveTextContent(`Courses: ${courseCount}`) - } else { - expect(courseCountText).toHaveTextContent("") - } - if (programCount > 0) { - expect(programCountText).toHaveTextContent( - `Programs: ${programCount}`, - ) - } else { - expect(programCountText).toHaveTextContent("") - } - }) + const links = within(academicSection).getAllByRole("link") + expect(links).toHaveLength(3) + return links + }) + await waitFor(() => { + const links = within(professionalSection).getAllByRole("link") + expect(links).toHaveLength(2) + return links + }) + + units.forEach((unit) => { + const section = unit.professional ? professionalSection : academicSection + const card = within(section).getByTestId(`unit-card-${unit.code}`) + const link = within(card).getByRole("link") + expect(link).toHaveAttribute("href", `/units/${unit.code}`) + + const courseCount = courseCounts[unit.code] + const programCount = programCounts[unit.code] + const courseCountEl = within(card).getByTestId( + `course-count-${unit.code}`, + ) + const programCountEl = within(card).getByTestId( + `program-count-${unit.code}`, + ) + expect(courseCountEl).toHaveTextContent( + courseCount > 0 ? `Courses: ${courseCount}` : "", + ) + expect(programCountEl).toHaveTextContent( + programCount > 0 ? `Programs: ${programCount}` : "", + ) }) }) diff --git a/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.tsx b/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.tsx index 574ccb3b42..905f2edf22 100644 --- a/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.tsx +++ b/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.tsx @@ -11,7 +11,8 @@ import { theme, Breadcrumbs, } from "ol-components" - +import { backgroundSrcSetCSS } from "ol-utilities" +import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" import { RiBookOpenLine, RiSuitcaseLine } from "@remixicon/react" import { LearningResourceOfferorDetail } from "api" import { HOME } from "@/common/urls" @@ -167,6 +168,7 @@ const UnitSection: React.FC = (props) => { programCounts, isLoading, } = props + return (
    @@ -248,6 +250,7 @@ const UnitsListingPage: React.FC = () => { } title="Academic & Professional Learning" header="Non-degree learning resources tailored to the needs of students and working professionals." + backgroundUrl={backgroundSrcSetCSS(backgroundSteps)} /> diff --git a/frontends/main/src/app/c/[channelType]/[name]/page.tsx b/frontends/main/src/app/c/[channelType]/[name]/page.tsx index 5c33fb3e1a..5cc73220e1 100644 --- a/frontends/main/src/app/c/[channelType]/[name]/page.tsx +++ b/frontends/main/src/app/c/[channelType]/[name]/page.tsx @@ -5,6 +5,10 @@ import { ChannelTypeEnum } from "api/v0" import { getMetadataAsync } from "@/common/metadata" import handleNotFound from "@/common/handleNotFound" +type SearchParams = { + [key: string]: string | string[] | undefined +} + type RouteParams = { channelType: ChannelTypeEnum name: string @@ -14,10 +18,10 @@ export async function generateMetadata({ searchParams, params, }: { - searchParams: { [key: string]: string | string[] | undefined } - params: RouteParams + searchParams: Promise + params: Promise }) { - const { channelType, name } = params + const { channelType, name } = await params const { data } = await handleNotFound( channelsApi.channelsTypeRetrieve({ channel_type: channelType, name: name }), diff --git a/frontends/main/src/app/dashboard/[tab]/page.tsx b/frontends/main/src/app/dashboard/[tab]/page.tsx index 3586bb6208..082710c443 100644 --- a/frontends/main/src/app/dashboard/[tab]/page.tsx +++ b/frontends/main/src/app/dashboard/[tab]/page.tsx @@ -10,6 +10,7 @@ export const metadata: Metadata = standardizeMetadata({ title: "Your MIT Learning Journey", social: false, }) + const Page: React.FC = () => { return ( diff --git a/frontends/main/src/app/page.tsx b/frontends/main/src/app/page.tsx index f6406e519a..34dd27b718 100644 --- a/frontends/main/src/app/page.tsx +++ b/frontends/main/src/app/page.tsx @@ -3,10 +3,14 @@ import type { Metadata } from "next" import HomePage from "@/app-pages/HomePage/HomePage" import { getMetadataAsync } from "@/common/metadata" +type SearchParams = { + [key: string]: string | string[] | undefined +} + export async function generateMetadata({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined } + searchParams: Promise }): Promise { return await getMetadataAsync({ title: "Learn with MIT", diff --git a/frontends/main/src/app/program_letter/[id]/view/page.tsx b/frontends/main/src/app/program_letter/[id]/view/page.tsx index faa058688c..849a6050e5 100644 --- a/frontends/main/src/app/program_letter/[id]/view/page.tsx +++ b/frontends/main/src/app/program_letter/[id]/view/page.tsx @@ -1,5 +1,5 @@ import React from "react" -import ProgramLetterPage from "@/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage" +import ProgramLetterPage from "@/app-pages/ProgramLetterPage/ProgramLetterPage" const Page: React.FC = () => { return diff --git a/frontends/main/src/app/search/page.tsx b/frontends/main/src/app/search/page.tsx index adc7151f65..71c7723373 100644 --- a/frontends/main/src/app/search/page.tsx +++ b/frontends/main/src/app/search/page.tsx @@ -2,10 +2,14 @@ import React from "react" import { getMetadataAsync } from "@/common/metadata" import SearchPage from "@/app-pages/SearchPage/SearchPage" +type SearchParams = { + [key: string]: string | string[] | undefined +} + export async function generateMetadata({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined } + searchParams: Promise }) { return await getMetadataAsync({ title: "Search", @@ -18,7 +22,7 @@ export async function generateMetadata({ * 1. wrap the in Suspense * 2. or force-dynamic. * - * (1) caused a hydration error for authenticated users. We haven not found + * (1) caused a hydration error for authenticated users. We have not found * the root cause of the hydration error. * * (2) seems to work well. diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts index 8d6e479d1e..0a21e9c50c 100644 --- a/frontends/main/src/common/feature_flags.ts +++ b/frontends/main/src/common/feature_flags.ts @@ -3,4 +3,5 @@ export enum FeatureFlags { EnableEcommerce = "enable-ecommerce", + DrawerV2Enabled = "lr_drawer_v2", } diff --git a/frontends/main/src/common/metadata.ts b/frontends/main/src/common/metadata.ts index c112873175..583e5a6383 100644 --- a/frontends/main/src/common/metadata.ts +++ b/frontends/main/src/common/metadata.ts @@ -10,7 +10,7 @@ type MetadataAsyncProps = { description?: string image?: string imageAlt?: string - searchParams?: { [key: string]: string | string[] | undefined } + searchParams?: Promise<{ [key: string]: string | string[] | undefined }> social?: boolean } & Metadata @@ -28,7 +28,7 @@ export const getMetadataAsync = async ({ ...otherMeta }: MetadataAsyncProps) => { // The learning resource drawer is open - const learningResourceId = searchParams?.[RESOURCE_DRAWER_QUERY_PARAM] + const learningResourceId = (await searchParams)?.[RESOURCE_DRAWER_QUERY_PARAM] if (learningResourceId) { const { data } = await handleNotFound( learningResourcesApi.learningResourcesRetrieve({ diff --git a/frontends/main/src/page-components/Header/Header.tsx b/frontends/main/src/page-components/Header/Header.tsx index e5cb4b7584..93ef5bcdb9 100644 --- a/frontends/main/src/page-components/Header/Header.tsx +++ b/frontends/main/src/page-components/Header/Header.tsx @@ -295,7 +295,7 @@ const Header: FunctionComponent = () => { desktopTrigger.current, mobileTrigger.current, ]} - navdata={navData} + navData={navData} open={drawerOpen} onClose={toggleDrawer.off} /> diff --git a/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx b/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx index f44c66a218..91b245197a 100644 --- a/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx +++ b/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx @@ -101,7 +101,6 @@ const ImageContainer = styled.div(({ theme }) => ({ alignItems: "center", justifyContent: "center", marginTop: "44px", - transform: "translateX(48px)", width: "513px", aspectRatio: "513 / 522", [theme.breakpoints.down("md")]: { @@ -109,6 +108,7 @@ const ImageContainer = styled.div(({ theme }) => ({ }, img: { width: "100%", + transform: "translateX(48px)", }, position: "relative", })) @@ -209,13 +209,7 @@ const HeroImage: React.FC = () => { return ( - + ) } diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx index eea8943d73..5eee0c4a99 100644 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx @@ -1,132 +1,14 @@ -import React, { Suspense, useCallback, useEffect, useMemo } from "react" -import { - RoutedDrawer, - LearningResourceExpanded, - imgConfigs, -} from "ol-components" -import type { - LearningResourceCardProps, - RoutedDrawerProps, -} from "ol-components" -import { useLearningResourcesDetail } from "api/hooks/learningResources" -import { useSearchParams, ReadonlyURLSearchParams } from "next/navigation" - +import React, { useCallback } from "react" import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" -import { useUserMe } from "api/hooks/user" -import NiceModal from "@ebay/nice-modal-react" -import { - AddToLearningPathDialog, - AddToUserListDialog, -} from "../Dialogs/AddToListDialog" -import { SignupPopover } from "../SignupPopover/SignupPopover" -import { usePostHog } from "posthog-js/react" - -const RESOURCE_DRAWER_PARAMS = [RESOURCE_DRAWER_QUERY_PARAM] as const - -const useCapturePageView = (resourceId: number) => { - const { data, isSuccess } = useLearningResourcesDetail(Number(resourceId)) - const posthog = usePostHog() - const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY - - useEffect(() => { - if (!apiKey || apiKey.length < 1) return - if (!isSuccess) return - posthog.capture("lrd_view", { - resourceId: data?.id, - readableId: data?.readable_id, - platformCode: data?.platform?.code, - resourceType: data?.resource_type, - }) - }, [ - isSuccess, - posthog, - data?.id, - data?.readable_id, - data?.platform?.code, - data?.resource_type, - apiKey, - ]) -} - -/** - * Convert HTML to plaintext, removing any HTML tags. - * This conversion method has some issues: - * 1. It is unsafe for untrusted HTML - * 2. It must be run in a browser, not on a server. - */ -// eslint-disable-next-line camelcase -// const unsafe_html2plaintext = (text: string) => { -// const div = document.createElement("div") -// div.innerHTML = text -// return div.textContent || div.innerText || "" -// } - -const DrawerContent: React.FC<{ - resourceId: number -}> = ({ resourceId }) => { - const resource = useLearningResourcesDetail(Number(resourceId)) - const [signupEl, setSignupEl] = React.useState(null) - const { data: user } = useUserMe() - const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] = - useMemo(() => { - if (user?.is_learning_path_editor) { - return (event, resourceId: number) => { - NiceModal.show(AddToLearningPathDialog, { resourceId }) - } - } - return null - }, [user]) - const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] = - useMemo(() => { - return (event, resourceId: number) => { - if (!user?.is_authenticated) { - setSignupEl(event.currentTarget) - return - } - NiceModal.show(AddToUserListDialog, { resourceId }) - } - }, [user]) - useCapturePageView(Number(resourceId)) - - return ( - <> - - setSignupEl(null)} /> - - ) -} - -const PAPER_PROPS: RoutedDrawerProps["PaperProps"] = { - sx: { - maxWidth: (theme) => theme.breakpoints.values.sm, - minWidth: (theme) => ({ - [theme.breakpoints.down("sm")]: { - minWidth: "100%", - }, - }), - }, -} +import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation" +import { useFeatureFlagEnabled } from "posthog-js/react" +import LearningResourceDrawerV2 from "./LearningResourceDrawerV2" +import LearningResourceDrawerV1 from "./LearningResourceDrawerV1" +import { FeatureFlags } from "@/common/feature_flags" const LearningResourceDrawer = () => { - return ( - - - {({ params }) => { - return - }} - - - ) + const drawerV2 = useFeatureFlagEnabled(FeatureFlags.DrawerV2Enabled) + return drawerV2 ? : } const getOpenDrawerSearchParams = ( diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.test.tsx similarity index 83% rename from frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx rename to frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.test.tsx index 391c2a25e9..b056bd0cc7 100644 --- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.test.tsx @@ -6,9 +6,9 @@ import { waitFor, within, } from "@/test-utils" -import LearningResourceDrawer from "./LearningResourceDrawer" +import LearningResourceDrawerV1 from "./LearningResourceDrawerV1" import { urls, factories, setMockResponse } from "api/test-utils" -import { LearningResourceExpanded } from "ol-components" +import { LearningResourceExpandedV1 } from "ol-components" import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" import { ResourceTypeEnum } from "api" import invariant from "tiny-invariant" @@ -17,7 +17,7 @@ jest.mock("ol-components", () => { const actual = jest.requireActual("ol-components") return { ...actual, - LearningResourceExpanded: jest.fn(actual.LearningResourceExpanded), + LearningResourceExpandedV1: jest.fn(actual.LearningResourceExpandedV1), } }) @@ -33,7 +33,7 @@ jest.mock("posthog-js/react", () => ({ }, })) -describe("LearningResourceDrawer", () => { +describe("LearningResourceDrawerV1", () => { it.each([ { descriptor: "is enabled", enablePostHog: true }, { descriptor: "is not enabled", enablePostHog: false }, @@ -50,12 +50,12 @@ describe("LearningResourceDrawer", () => { resource, ) - renderWithProviders(, { + renderWithProviders(, { url: `?dog=woof&${RESOURCE_DRAWER_QUERY_PARAM}=${resource.id}`, }) - expect(LearningResourceExpanded).toHaveBeenCalled() + expect(LearningResourceExpandedV1).toHaveBeenCalled() await waitFor(() => { - expectProps(LearningResourceExpanded, { resource }) + expectProps(LearningResourceExpandedV1, { resource }) }) await screen.findByRole("heading", { name: resource.title }) @@ -68,10 +68,10 @@ describe("LearningResourceDrawer", () => { ) it("Does not render drawer content when resource=id is NOT in the URL", async () => { - renderWithProviders(, { + renderWithProviders(, { url: "?dog=woof", }) - expect(LearningResourceExpanded).not.toHaveBeenCalled() + expect(LearningResourceExpandedV1).not.toHaveBeenCalled() }) test.each([ @@ -118,14 +118,14 @@ describe("LearningResourceDrawer", () => { setMockResponse.get(urls.userMe.get(), null, { code: 403 }) } - renderWithProviders(, { + renderWithProviders(, { url: `?resource=${resource.id}`, }) - expect(LearningResourceExpanded).toHaveBeenCalled() + expect(LearningResourceExpandedV1).toHaveBeenCalled() await waitFor(() => { - expectProps(LearningResourceExpanded, { resource }) + expectProps(LearningResourceExpandedV1, { resource }) }) const section = screen diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx new file mode 100644 index 0000000000..cade51dba8 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx @@ -0,0 +1,131 @@ +import React, { Suspense, useEffect, useMemo } from "react" +import { + RoutedDrawer, + LearningResourceExpandedV1, + imgConfigs, +} from "ol-components" +import type { + LearningResourceCardProps, + RoutedDrawerProps, +} from "ol-components" +import { useLearningResourcesDetail } from "api/hooks/learningResources" + +import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" +import { useUserMe } from "api/hooks/user" +import NiceModal from "@ebay/nice-modal-react" +import { + AddToLearningPathDialog, + AddToUserListDialog, +} from "../Dialogs/AddToListDialog" +import { SignupPopover } from "../SignupPopover/SignupPopover" +import { usePostHog } from "posthog-js/react" + +const RESOURCE_DRAWER_PARAMS = [RESOURCE_DRAWER_QUERY_PARAM] as const + +const useCapturePageView = (resourceId: number) => { + const { data, isSuccess } = useLearningResourcesDetail(Number(resourceId)) + const posthog = usePostHog() + const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY + + useEffect(() => { + if (!apiKey || apiKey.length < 1) return + if (!isSuccess) return + posthog.capture("lrd_view", { + resourceId: data?.id, + readableId: data?.readable_id, + platformCode: data?.platform?.code, + resourceType: data?.resource_type, + }) + }, [ + isSuccess, + posthog, + data?.id, + data?.readable_id, + data?.platform?.code, + data?.resource_type, + apiKey, + ]) +} + +/** + * Convert HTML to plaintext, removing any HTML tags. + * This conversion method has some issues: + * 1. It is unsafe for untrusted HTML + * 2. It must be run in a browser, not on a server. + */ +// eslint-disable-next-line camelcase +// const unsafe_html2plaintext = (text: string) => { +// const div = document.createElement("div") +// div.innerHTML = text +// return div.textContent || div.innerText || "" +// } + +const DrawerContent: React.FC<{ + resourceId: number +}> = ({ resourceId }) => { + const resource = useLearningResourcesDetail(Number(resourceId)) + const [signupEl, setSignupEl] = React.useState(null) + const { data: user } = useUserMe() + const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] = + useMemo(() => { + if (user?.is_learning_path_editor) { + return (event, resourceId: number) => { + NiceModal.show(AddToLearningPathDialog, { resourceId }) + } + } + return null + }, [user]) + const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] = + useMemo(() => { + return (event, resourceId: number) => { + if (!user?.is_authenticated) { + setSignupEl(event.currentTarget) + return + } + NiceModal.show(AddToUserListDialog, { resourceId }) + } + }, [user]) + useCapturePageView(Number(resourceId)) + + return ( + <> + + setSignupEl(null)} /> + + ) +} + +const PAPER_PROPS: RoutedDrawerProps["PaperProps"] = { + sx: { + maxWidth: (theme) => theme.breakpoints.values.sm, + minWidth: (theme) => ({ + [theme.breakpoints.down("sm")]: { + minWidth: "100%", + }, + }), + }, +} + +const LearningResourceDrawerV1 = () => { + return ( + + + {({ params }) => { + return + }} + + + ) +} + +export default LearningResourceDrawerV1 diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx new file mode 100644 index 0000000000..dfdb890a32 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx @@ -0,0 +1,142 @@ +import React from "react" +import { + expectProps, + renderWithProviders, + screen, + waitFor, + within, +} from "@/test-utils" +import LearningResourceDrawerV2 from "./LearningResourceDrawerV2" +import { urls, factories, setMockResponse } from "api/test-utils" +import { LearningResourceExpandedV2 } from "ol-components" +import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" +import { ResourceTypeEnum } from "api" + +jest.mock("ol-components", () => { + const actual = jest.requireActual("ol-components") + return { + ...actual, + LearningResourceExpandedV2: jest.fn(actual.LearningResourceExpandedV2), + } +}) + +const mockedPostHogCapture = jest.fn() + +jest.mock("posthog-js/react", () => ({ + PostHogProvider: (props: { children: React.ReactNode }) => ( +
    {props.children}
    + ), + + usePostHog: () => { + return { capture: mockedPostHogCapture } + }, +})) + +describe("LearningResourceDrawerV2", () => { + it.each([ + { descriptor: "is enabled", enablePostHog: true }, + { descriptor: "is not enabled", enablePostHog: false }, + ])( + "Renders drawer content when resource=id is in the URL and captures the view if PostHog $descriptor", + async ({ enablePostHog }) => { + setMockResponse.get(urls.userMe.get(), {}) + process.env.NEXT_PUBLIC_POSTHOG_API_KEY = enablePostHog + ? "12345abcdef" // pragma: allowlist secret + : "" + const resource = factories.learningResources.resource() + setMockResponse.get( + urls.learningResources.details({ id: resource.id }), + resource, + ) + + renderWithProviders(, { + url: `?dog=woof&${RESOURCE_DRAWER_QUERY_PARAM}=${resource.id}`, + }) + expect(LearningResourceExpandedV2).toHaveBeenCalled() + await waitFor(() => { + expectProps(LearningResourceExpandedV2, { resource }) + }) + await screen.findByText(resource.title) + + if (enablePostHog) { + expect(mockedPostHogCapture).toHaveBeenCalled() + } else { + expect(mockedPostHogCapture).not.toHaveBeenCalled() + } + }, + ) + + it("Does not render drawer content when resource=id is NOT in the URL", async () => { + renderWithProviders(, { + url: "?dog=woof", + }) + expect(LearningResourceExpandedV2).not.toHaveBeenCalled() + }) + + test.each([ + { + isLearningPathEditor: true, + isAuthenticated: true, + expectAddToLearningPathButton: true, + }, + { + isLearningPathEditor: false, + isAuthenticated: true, + expectAddToLearningPathButton: false, + }, + { + isLearningPathEditor: false, + isAuthenticated: false, + expectAddToLearningPathButton: false, + }, + ])( + "Renders call to action section list buttons correctly", + async ({ + isLearningPathEditor, + isAuthenticated, + expectAddToLearningPathButton, + }) => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + runs: [ + factories.learningResources.run({ + languages: ["en-us", "es-es", "fr-fr"], + }), + ], + }) + setMockResponse.get( + urls.learningResources.details({ id: resource.id }), + resource, + ) + const user = factories.user.user({ + is_learning_path_editor: isLearningPathEditor, + }) + if (isAuthenticated) { + setMockResponse.get(urls.userMe.get(), user) + } else { + setMockResponse.get(urls.userMe.get(), null, { code: 403 }) + } + + renderWithProviders(, { + url: `?resource=${resource.id}`, + }) + + expect(LearningResourceExpandedV2).toHaveBeenCalled() + + await waitFor(() => { + expectProps(LearningResourceExpandedV2, { resource }) + }) + + const section = screen.getByTestId("drawer-cta") + + const buttons = within(section).getAllByRole("button") + const expectedButtons = expectAddToLearningPathButton ? 2 : 1 + expect(buttons).toHaveLength(expectedButtons) + expect( + !!within(section).queryByRole("button", { + name: "Add to Learning Path", + }), + ).toBe(expectAddToLearningPathButton) + }, + ) +}) diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx new file mode 100644 index 0000000000..a6bf8352b5 --- /dev/null +++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx @@ -0,0 +1,146 @@ +import React, { Suspense, useEffect, useMemo } from "react" +import { + RoutedDrawer, + LearningResourceExpandedV2, + imgConfigs, +} from "ol-components" +import type { + LearningResourceCardProps, + RoutedDrawerProps, +} from "ol-components" +import { useLearningResourcesDetail } from "api/hooks/learningResources" + +import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" +import { useUserMe } from "api/hooks/user" +import NiceModal from "@ebay/nice-modal-react" +import { + AddToLearningPathDialog, + AddToUserListDialog, +} from "../Dialogs/AddToListDialog" +import { SignupPopover } from "../SignupPopover/SignupPopover" +import { usePostHog } from "posthog-js/react" + +const RESOURCE_DRAWER_PARAMS = [RESOURCE_DRAWER_QUERY_PARAM] as const + +const useCapturePageView = (resourceId: number) => { + const { data, isSuccess } = useLearningResourcesDetail(Number(resourceId)) + const posthog = usePostHog() + const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY + + useEffect(() => { + if (!apiKey || apiKey.length < 1) return + if (!isSuccess) return + posthog.capture("lrd_view", { + resourceId: data?.id, + readableId: data?.readable_id, + platformCode: data?.platform?.code, + resourceType: data?.resource_type, + }) + }, [ + isSuccess, + posthog, + data?.id, + data?.readable_id, + data?.platform?.code, + data?.resource_type, + apiKey, + ]) +} + +/** + * Convert HTML to plaintext, removing any HTML tags. + * This conversion method has some issues: + * 1. It is unsafe for untrusted HTML + * 2. It must be run in a browser, not on a server. + */ +// eslint-disable-next-line camelcase +// const unsafe_html2plaintext = (text: string) => { +// const div = document.createElement("div") +// div.innerHTML = text +// return div.textContent || div.innerText || "" +// } + +const DrawerContent: React.FC<{ + resourceId: number + closeDrawer: () => void +}> = ({ resourceId, closeDrawer }) => { + const resource = useLearningResourcesDetail(Number(resourceId)) + const [signupEl, setSignupEl] = React.useState(null) + const { data: user } = useUserMe() + const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] = + useMemo(() => { + if (user?.is_learning_path_editor) { + return (event, resourceId: number) => { + NiceModal.show(AddToLearningPathDialog, { resourceId }) + } + } + return null + }, [user]) + const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] = + useMemo(() => { + return (event, resourceId: number) => { + if (!user?.is_authenticated) { + setSignupEl(event.currentTarget) + return + } + NiceModal.show(AddToUserListDialog, { resourceId }) + } + }, [user]) + useCapturePageView(Number(resourceId)) + + return ( + <> + + setSignupEl(null)} /> + + ) +} + +const PAPER_PROPS: RoutedDrawerProps["PaperProps"] = { + sx: { + maxWidth: (theme) => ({ + [theme.breakpoints.up("md")]: { + maxWidth: theme.breakpoints.values.md, + }, + [theme.breakpoints.down("sm")]: { + maxWidth: "100%", + }, + }), + minWidth: (theme) => ({ + [theme.breakpoints.down("md")]: { + minWidth: "100%", + }, + }), + }, +} + +const LearningResourceDrawerV2 = () => { + return ( + + + {({ params, closeDrawer }) => { + return ( + + ) + }} + + + ) +} + +export default LearningResourceDrawerV2 diff --git a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx index 71a7bedde3..79ef2ade33 100644 --- a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -15,6 +15,7 @@ import { Drawer, Checkbox, VisuallyHidden, + Stack, } from "ol-components" import { @@ -60,8 +61,6 @@ const StyledResourceTabs = styled(ResourceCategoryTabs.TabList)` ` const DesktopSortContainer = styled.div` - float: right; - ${({ theme }) => theme.breakpoints.down("md")} { display: none; } @@ -439,10 +438,17 @@ const MobileFacetsTitleContainer = styled.div` } ` -const StyledGridContainer = styled(GridContainer)` +const ReversedGridContainer = styled(GridContainer)` max-width: 1272px !important; margin-left: 0 !important; width: 100% !important; + + /** + We want the facets to be visually on left, but occur second in the DOM / tab + order. This makes it easier for keyboard navigators to get directly to the + search results. + */ + flex-direction: row-reverse; ` const ExplanationContainer = styled.div` @@ -860,33 +866,8 @@ const SearchDisplay: React.FC = ({ return ( - + - - - - - Filter - - - - {hasFacets ? ( - - ) : null} - - {filterContents} - Search Results @@ -901,13 +882,15 @@ const SearchDisplay: React.FC = ({ */} {isFetching || isLoading ? "" : `${data?.count} results`} - {sortDropdown} - setPage(1)} - /> + + setPage(1)} + /> + {sortDropdown} + + ) : null} + + {filterContents} + - + ) } diff --git a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx index 562600c4c3..be64937987 100644 --- a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx +++ b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx @@ -3,7 +3,8 @@ import { screen } from "@testing-library/react" import UserListCardCondensed from "./UserListCardCondensed" import * as factories from "api/test-utils/factories" import { userListView } from "@/common/urls" -import { renderWithProviders } from "@/test-utils" +import { renderWithProviders, user } from "@/test-utils" +import invariant from "tiny-invariant" const userListFactory = factories.userLists @@ -18,4 +19,16 @@ describe("UserListCard", () => { ) screen.getByText(userList.title) }) + + test("Clicking card navigates to href", async () => { + const userList = userListFactory.userList() + renderWithProviders( + , + ) + const link = screen.getByRole("link", { name: userList.title }) + expect(link).toHaveAttribute("href", "#test") + invariant(link.parentElement) + await user.click(link.parentElement) + expect(window.location.hash).toBe("#test") + }) }) diff --git a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx index 1689b5bd71..fb6514c113 100644 --- a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx +++ b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx @@ -3,6 +3,7 @@ import { UserList } from "api" import { pluralize } from "ol-utilities" import { RiListCheck3 } from "@remixicon/react" import { ListCardCondensed, styled, theme, Typography } from "ol-components" +import Link from "next/link" const StyledCard = styled(ListCardCondensed)({ display: "flex", @@ -37,24 +38,29 @@ const IconContainer = styled.div(({ theme }) => ({ }, })) -type UserListCardCondensedProps = { - userList: U - href?: string +type UserListCardCondensedProps = { + userList: UserList + href: string className?: string } -const UserListCardCondensed = ({ +const UserListCardCondensed = ({ userList, href, className, -}: UserListCardCondensedProps) => { +}: UserListCardCondensedProps) => { return ( - + - - {userList.title} - + + + {userList.title} + + {userList.item_count} {pluralize("item", userList.item_count)} diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 3cdaeab6f4..78045e5752 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -16,11 +16,11 @@ "@dnd-kit/utilities": "^3.2.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@mui/base": "5.0.0-beta.60", - "@mui/lab": "^5.0.0-alpha.172", - "@mui/material": "^5.16.1", - "@mui/material-nextjs": "^5.16.6", - "@mui/system": "^5.16.1", + "@mui/base": "5.0.0-beta.61", + "@mui/lab": "6.0.0-beta.14", + "@mui/material": "^6.1.6", + "@mui/material-nextjs": "^6.1.6", + "@mui/system": "^6.1.6", "@remixicon/react": "^4.2.0", "@testing-library/dom": "^10.4.0", "@types/react-dom": "^18.3.0", @@ -30,7 +30,7 @@ "iso-639-1": "^3.1.2", "lodash": "^4.17.21", "material-ui-popup-state": "^5.1.0", - "next": "^14.2.7", + "next": "^15.0.2", "ol-test-utilities": "0.0.0", "ol-utilities": "0.0.0", "react": "18.3.1", diff --git a/frontends/ol-components/src/components/Banner/Banner.tsx b/frontends/ol-components/src/components/Banner/Banner.tsx index a908fb5a74..22dd0bdaa6 100644 --- a/frontends/ol-components/src/components/Banner/Banner.tsx +++ b/frontends/ol-components/src/components/Banner/Banner.tsx @@ -27,26 +27,32 @@ const BannerBackground = styled.div( backgroundUrl = DEFAULT_BACKGROUND_IMAGE_URL, backgroundSize = "cover", backgroundDim = 0, - }) => ({ - backgroundAttachment: "fixed", - backgroundImage: backgroundDim - ? `linear-gradient(rgba(0 0 0 / ${backgroundDim}%), rgba(0 0 0 / ${backgroundDim}%)), url('${backgroundUrl}')` - : `url(${backgroundUrl})`, - backgroundSize: backgroundSize, - backgroundPosition: "center top", - backgroundRepeat: "no-repeat", - color: theme.custom.colors.white, - padding: "48px 0 48px 0", - [theme.breakpoints.up("lg")]: { - backgroundSize: - backgroundUrl === DEFAULT_BACKGROUND_IMAGE_URL - ? "140%" - : backgroundSize, - }, - [theme.breakpoints.down("sm")]: { - padding: "32px 0 32px 0", - }, - }), + }) => { + const backgroundUrlFn = backgroundUrl.startsWith("image-set(") + ? backgroundUrl + : `url('${backgroundUrl}')` + + return { + backgroundAttachment: "fixed", + backgroundImage: backgroundDim + ? `linear-gradient(rgba(0 0 0 / ${backgroundDim}%), rgba(0 0 0 / ${backgroundDim}%)), ${backgroundUrlFn}` + : backgroundUrlFn, + backgroundSize: backgroundSize, + backgroundPosition: "center top", + backgroundRepeat: "no-repeat", + color: theme.custom.colors.white, + padding: "48px 0 48px 0", + [theme.breakpoints.up("lg")]: { + backgroundSize: + backgroundUrl === DEFAULT_BACKGROUND_IMAGE_URL + ? "140%" + : backgroundSize, + }, + [theme.breakpoints.down("sm")]: { + padding: "32px 0 32px 0", + }, + } + }, ) const InnerContainer = styled.div(({ theme }) => ({ diff --git a/frontends/ol-components/src/components/Card/Card.stories.tsx b/frontends/ol-components/src/components/Card/Card.stories.tsx index 2d309b5996..9e07de7686 100644 --- a/frontends/ol-components/src/components/Card/Card.stories.tsx +++ b/frontends/ol-components/src/components/Card/Card.stories.tsx @@ -1,10 +1,11 @@ import React from "react" import type { Meta, StoryObj } from "@storybook/react" import { Card } from "./Card" +import type { CardProps } from "./Card" import { ActionButton } from "../Button/Button" import { RiMenuAddLine, RiBookmarkLine } from "@remixicon/react" -const meta: Meta = { +const meta: Meta = { title: "smoot-design/Cards/Card", argTypes: { size: { @@ -19,7 +20,7 @@ const meta: Meta = { alt="Provide a meaningful description or leave this blank." /> Info - + Lorem ipsum dolor sit amet, consectetur adipiscing elit @@ -51,7 +52,7 @@ const meta: Meta = { export default meta -type Story = StoryObj +type Story = StoryObj export const Medium: Story = { args: { diff --git a/frontends/ol-components/src/components/Card/Card.test.tsx b/frontends/ol-components/src/components/Card/Card.test.tsx index a087bedb6d..8c1afc94cf 100644 --- a/frontends/ol-components/src/components/Card/Card.test.tsx +++ b/frontends/ol-components/src/components/Card/Card.test.tsx @@ -1,8 +1,10 @@ -import { render } from "@testing-library/react" +import { render, screen } from "@testing-library/react" +import user from "@testing-library/user-event" import { Card } from "./Card" import React from "react" import { getOriginalSrc } from "ol-test-utilities" import invariant from "tiny-invariant" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" describe("Card", () => { test("has class MitCard-root on root element", () => { @@ -14,6 +16,7 @@ describe("Card", () => { Footer Actions , + { wrapper: ThemeProvider }, ) const card = container.firstChild as HTMLElement const title = card.querySelector(".MitCard-title") @@ -37,4 +40,99 @@ describe("Card", () => { expect(footer).toHaveTextContent("Footer") expect(actions).toHaveTextContent("Actions") }) + + test.each([ + { forwardClicksToLink: true, finalHref: "#woof" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link if forwardClicksToLink ($forwardClicksToLink)", + async ({ forwardClicksToLink, finalHref }) => { + render( + + Title + + Info + Footer + Actions + , + { wrapper: ThemeProvider }, + ) + const card = document.querySelector(".MitCard-root") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test.each([ + { forwardClicksToLink: true, finalHref: "#meow" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link when using Content when forwardClicksToLink ($forwardClicksToLink), except buttons and links", + async ({ finalHref, forwardClicksToLink }) => { + const href = "#meow" + const onClick = jest.fn() + render( + + +
    Hello!
    +
    + +
    + + Link + +
    +
    , + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + await user.click(button) + expect(onClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + + // outermost wrapper is not actually clickable + const card = document.querySelector(".MitCard-root") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test("Clicks on interactive elements are not forwarded", async () => { + const btnOnClick = jest.fn() + const divOnClick = jest.fn() + render( + + Title + + Info + + + Link Two + {/* + eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + */} +
    + Interactive Div +
    +
    +
    , + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + screen.getByRole("link", { name: "Title" }) + const link2 = screen.getByRole("link", { name: "Link Two" }) + const div = screen.getByText("Interactive Div") + await user.click(button) + expect(btnOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + await user.click(link2) + expect(window.location.hash).toBe("#two") + await user.click(div) + expect(divOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("#two") + }) }) diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx index a04dc0302a..c2b6876f83 100644 --- a/frontends/ol-components/src/components/Card/Card.tsx +++ b/frontends/ol-components/src/components/Card/Card.tsx @@ -4,6 +4,8 @@ import React, { Children, isValidElement, CSSProperties, + useCallback, + AriaAttributes, } from "react" import styled from "@emotion/styled" import { theme } from "../ThemeProvider/ThemeProvider" @@ -13,52 +15,69 @@ import { default as NextImage, ImageProps as NextImageProps } from "next/image" export type Size = "small" | "medium" -/* - *The relative positioned wrapper allows the action buttons to live adjacent to the - * Link container in the DOM structure. They cannot be a descendent of it as - * buttons inside anchors are not valid HTML. +type LinkableProps = { + href?: string + children?: ReactNode + className?: string +} +/** + * Render a NextJS link if href is provided, otherwise a span. + * Does not scroll if the href is a query string. */ -export const Wrapper = styled.div<{ size?: Size }>` - position: relative; - ${({ size }) => { - let width - if (!size) return "" - if (size === "medium") width = 300 - if (size === "small") width = 192 - return ` - min-width: ${width}px; - max-width: ${width}px; - ` - }} -` - -export const containerStyles = ` - border-radius: 8px; - border: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.white}; - overflow: hidden; -` - -const LinkContainer = styled(Link)` - ${containerStyles} - display: block; - position: relative; - - :hover { - text-decoration: none; - border-color: ${theme.custom.colors.silverGrayLight}; - box-shadow: - 0 2px 4px 0 rgb(37 38 43 / 10%), - 0 2px 4px 0 rgb(37 38 43 / 10%); - cursor: pointer; +export const Linkable: React.FC = ({ + href, + children, + className, + ...others +}) => { + if (href) { + return ( + + {children} + + ) } -` + return ( + + {children} + + ) +} -const Container = styled.div` - ${containerStyles} - display: block; - position: relative; -` +export const BaseContainer = styled.div<{ display?: CSSProperties["display"] }>( + ({ theme, onClick, display = "block" }) => [ + { + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.white, + display, + overflow: "hidden", // to clip image so they match border radius + }, + onClick && { + "&:hover": { + borderColor: theme.custom.colors.silverGrayLight, + boxShadow: + "0 2px 4px 0 rgb(37 38 43 / 10%), 0 2px 4px 0 rgb(37 38 43 / 10%)", + cursor: "pointer", + }, + }, + ], +) +const CONTAINER_WIDTHS: Record = { + small: 192, + medium: 300, +} +const Container = styled(BaseContainer)<{ size?: Size }>(({ size }) => [ + size && { + minWidth: CONTAINER_WIDTHS[size], + maxWidth: CONTAINER_WIDTHS[size], + }, +]) const Content = () => <> @@ -83,7 +102,10 @@ const Info = styled.div<{ size?: Size }>` margin-bottom: ${({ size }) => (size === "small" ? 4 : 8)}px; ` -const Title = styled.span<{ lines?: number; size?: Size }>` +const titleOpts = { + shouldForwardProp: (prop: string) => prop !== "lines" && prop !== "size", +} +const Title = styled(Linkable, titleOpts)<{ lines?: number; size?: Size }>` text-overflow: ellipsis; height: ${({ lines, size }) => { const lineHeightPx = size === "small" ? 18 : 20 @@ -130,17 +152,70 @@ const Bottom = styled.div` const Actions = styled.div` display: flex; gap: 8px; - position: absolute; - bottom: 16px; - right: 16px; ` +/** + * Click the child anchor element if the click event target is not the anchor itself. + * + * Allows making a whole region clickable as a link, even if the link is not the + * direct target of the click event. + */ +export const useClickChildLink = ( + onClick?: React.MouseEventHandler, +): React.MouseEventHandler => { + return useCallback( + (e) => { + onClick?.(e) + if (!e.currentTarget.contains(e.target as Node)) { + // This happens if click target is a child in React tree but not DOM tree + // This can happen with portals. + // In such cases, data-card-actions won't be a parent of the target. + return + } + const anchor = e.currentTarget.querySelector( + 'a[data-card-link="true"]', + ) + const target = e.target as HTMLElement + if (!anchor || target.closest("a, button, [data-card-action]")) return + if (e.metaKey || e.ctrlKey) { + /** + * Enables ctrl+click to open card's link in new tab. + * Without this, ctrl+click only works on the anchor itself. + */ + const opts: PointerEventInit = { + bubbles: false, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + } + anchor.dispatchEvent(new PointerEvent("click", opts)) + } else { + anchor.click() + } + }, + [onClick], + ) +} + type CardProps = { children: ReactNode[] | ReactNode className?: string size?: Size - href?: string -} + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * child anchor with data-card-link="true". + * + * NOTES: + * - By default, Card.Title has `data-card-link="true"`. + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with data-card-link attribute. Its value is irrelevant. + * - Clicks will NOT be forwarded if it is (or is a child of): + * - an anchor or button element + * - OR an element with data-card-action + */ + forwardClicksToLink?: boolean + onClick?: React.MouseEventHandler + as?: React.ElementType +} & AriaAttributes export type ImageProps = NextImageProps & { size?: Size @@ -149,21 +224,50 @@ export type ImageProps = NextImageProps & { } type TitleProps = { children?: ReactNode + href?: string lines?: number style?: CSSProperties } + type SlotProps = { children?: ReactNode; style?: CSSProperties } +/** + * Card component with slots for image, info, title, footer, and actions: + * ```tsx + * + * + * Info + * Title + * Footer + * Actions + * + * ``` + * + * **Links:** Card.Title will be a link if `href` is supplied; the entire card + * will be clickable if `forwardClicksToLink` is `true`. + * + * **Custom Layout:** Use Card.Content to create a custom layout. + */ type Card = FC & { Content: FC<{ children: ReactNode }> Image: FC Info: FC + /** + * Card title with optional `href`. + */ Title: FC Footer: FC Actions: FC } -const Card: Card = ({ children, className, size, href }) => { +const Card: Card = ({ + children, + className, + size, + onClick, + forwardClicksToLink = false, + ...others +}) => { let content, image: ImageProps | null = null, info: SlotProps = {}, @@ -171,8 +275,6 @@ const Card: Card = ({ children, className, size, href }) => { footer: SlotProps = {}, actions: SlotProps = {} - const _Container = href ? LinkContainer : Container - /* * Allows rendering child elements to specific "slots": * @@ -196,54 +298,68 @@ const Card: Card = ({ children, className, size, href }) => { else if (child.type === Actions) actions = child.props }) + const handleHrefClick = useClickChildLink(onClick) + const handleClick = forwardClicksToLink ? handleHrefClick : onClick + const allClassNames = ["MitCard-root", className ?? ""].join(" ") if (content) { return ( - - <_Container className={className} href={href!}> - {content} - - + + {content} + ) } return ( - - <_Container href={href!} scroll={!href?.startsWith("?")}> - {image && ( - // alt text will be checked on Card.Image - // eslint-disable-next-line styled-components-a11y/alt-text - - )} - - {info.children && ( - - {info.children} - - )} - - {title.children} - - - -
    - {footer.children} -
    -
    - - {actions.children && ( - - {actions.children} - + + {image && ( + // alt text will be checked on Card.Image + // eslint-disable-next-line styled-components-a11y/alt-text + )} -
    + + {info.children && ( + + {info.children} + + )} + + {title.children} + + + +
    + {footer.children} +
    + {actions.children && ( + + {actions.children} + + )} +
    +
    ) } @@ -255,3 +371,4 @@ Card.Footer = Footer Card.Actions = Actions export { Card } +export type { CardProps } diff --git a/frontends/ol-components/src/components/Card/ListCard.test.tsx b/frontends/ol-components/src/components/Card/ListCard.test.tsx index 3fc65edb90..ecd730ab4d 100644 --- a/frontends/ol-components/src/components/Card/ListCard.test.tsx +++ b/frontends/ol-components/src/components/Card/ListCard.test.tsx @@ -1,17 +1,114 @@ -import { render } from "@testing-library/react" +import { render, screen } from "@testing-library/react" +import user from "@testing-library/user-event" import { ListCard } from "./ListCard" import React from "react" +import invariant from "tiny-invariant" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" describe("ListCard", () => { - test("has class MitCard-root on root element", () => { + test("has class MitListCard-root on root element", () => { const { container } = render( Hello world , + { wrapper: ThemeProvider }, ) const card = container.firstChild expect(card).toHaveClass("MitListCard-root") expect(card).toHaveClass("Foo") }) + + test.each([ + { forwardClicksToLink: true, finalHref: "#woof" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link", + async ({ forwardClicksToLink, finalHref }) => { + const href = "#woof" + render( + + Title + Info + Footer + Actions + , + { wrapper: ThemeProvider }, + ) + // outermost wrapper is not actually clickable + const card = document.querySelector(".MitListCard-root > *") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test.each([ + { forwardClicksToLink: true, finalHref: "#meow" }, + { forwardClicksToLink: false, finalHref: "" }, + ])( + "The whole card is clickable as a link when using Content, except buttons and links", + async ({ forwardClicksToLink, finalHref }) => { + const onClick = jest.fn() + const href = "#meow" + render( + + +
    Hello!
    + + + Link + +
    +
    , + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + await user.click(button) + expect(onClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + + // outermost wrapper is not actually clickable + const card = document.querySelector(".MitListCard-root > *") + invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor + + await user.click(card) + expect(window.location.hash).toBe(finalHref) + }, + ) + + test("Clicks on interactive elements are not forwarded", async () => { + const btnOnClick = jest.fn() + const divOnClick = jest.fn() + render( + + Title + Info + + + Link Two + {/* + eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + */} +
    + Interactive Div +
    +
    +
    , + { wrapper: ThemeProvider }, + ) + const button = screen.getByRole("button", { name: "Button" }) + screen.getByRole("link", { name: "Title" }) + const link2 = screen.getByRole("link", { name: "Link Two" }) + const div = screen.getByText("Interactive Div") + await user.click(button) + expect(btnOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("") + await user.click(link2) + expect(window.location.hash).toBe("#two") + await user.click(div) + expect(divOnClick).toHaveBeenCalled() + expect(window.location.hash).toBe("#two") + }) }) diff --git a/frontends/ol-components/src/components/Card/ListCard.tsx b/frontends/ol-components/src/components/Card/ListCard.tsx index 18f0ff33f1..fbb0c387ed 100644 --- a/frontends/ol-components/src/components/Card/ListCard.tsx +++ b/frontends/ol-components/src/components/Card/ListCard.tsx @@ -1,41 +1,22 @@ -import React, { FC, ReactNode, Children, isValidElement } from "react" -import Link from "next/link" +import React, { + FC, + ReactNode, + Children, + isValidElement, + AriaAttributes, +} from "react" import styled from "@emotion/styled" import { RiDraggable } from "@remixicon/react" import { theme } from "../ThemeProvider/ThemeProvider" -import { Wrapper, containerStyles, ImageProps } from "./Card" +import { BaseContainer, ImageProps, useClickChildLink, Linkable } from "./Card" import { TruncateText } from "../TruncateText/TruncateText" import { ActionButton, ActionButtonProps } from "../Button/Button" import { default as NextImage } from "next/image" -export const LinkContainer = styled(Link)` - ${containerStyles} - display: flex; - - :hover { - text-decoration: none; - border-color: ${theme.custom.colors.silverGrayLight}; - box-shadow: - 0 2px 4px 0 rgb(37 38 43 / 10%), - 0 2px 4px 0 rgb(37 38 43 / 10%); - cursor: pointer; - } -` - -export const Container = styled.div` - ${containerStyles} -` - -export const DraggableContainer = styled.div` - ${containerStyles} - display: flex; -` - const Content = () => <> export const Body = styled.div` flex-grow: 1; - overflow: hidden; margin: 24px; ${theme.breakpoints.down("md")} { margin: 12px; @@ -102,7 +83,11 @@ export const Info = styled.div` align-items: center; ` -export const Title = styled.span` +export type TitleProps = { + children?: ReactNode + href?: string +} +export const Title: React.FC = styled(Linkable)` flex-grow: 1; color: ${theme.custom.colors.darkGray2}; text-overflow: ellipsis; @@ -136,19 +121,12 @@ export const Bottom = styled.div` /** * Slot intended to contain ListCardAction buttons. */ -export const Actions = styled.div<{ hasImage?: boolean }>` +export const Actions = styled.div` display: flex; gap: 8px; - position: absolute; - bottom: 24px; - right: ${({ hasImage }) => (hasImage ? "284px" : "24px")}; ${theme.breakpoints.down("md")} { - bottom: 8px; gap: 4px; - right: ${({ hasImage }) => (hasImage ? "120px" : "8px")}; } - - background-color: ${theme.custom.colors.white}; ` const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>( @@ -168,34 +146,73 @@ const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>( type CardProps = { children: ReactNode[] | ReactNode className?: string - href?: string + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * child anchor with data-card-link. + * + * NOTES: + * - By default, Card.Title has `data-card-link="true"`. + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with data-card-link attribute. Its value is irrelevant. + * - Clicks will NOT be forwarded if: + * - The click target is a child of Card.Actions OR an element with + * - The click target is a child of any element with data-card-actions attribute + */ + forwardClicksToLink?: boolean draggable?: boolean -} + onClick?: () => void + as?: React.ElementType +} & AriaAttributes + +/** + * Row-like card component with slots for image, info, title, footer, and actions: + * ```tsx + * + * + * Info + * Title + * Footer + * Actions + * + * ``` + * + * **Links:** Card.Title will be a link if `href` is supplied; the entire card + * will be clickable if `forwardClicksToLink` is `true`. + * + * **Custom Layout:** Use ListCard.Content to create a custom layout. + */ export type Card = FC & { Content: FC<{ children: ReactNode }> Image: FC Info: FC<{ children: ReactNode }> - Title: FC<{ children: ReactNode }> + /** + * Card title with optional `href`. + */ + Title: FC Footer: FC<{ children: ReactNode }> Actions: FC<{ children: ReactNode }> Action: FC } -const ListCard: Card = ({ children, className, href, draggable }) => { - const _Container = draggable - ? DraggableContainer - : href - ? LinkContainer - : Container - - let content, imageProps, info, title, footer, actions +const ListCard: Card = ({ + children, + className, + forwardClicksToLink = false, + draggable, + onClick, + ...others +}) => { + let content, imageProps, info, footer, actions + let title: TitleProps = {} + const handleHrefClick = useClickChildLink(onClick) + const handleClick = forwardClicksToLink ? handleHrefClick : onClick Children.forEach(children, (child) => { if (!isValidElement(child)) return if (child.type === Content) content = child.props.children else if (child.type === Image) imageProps = child.props else if (child.type === Info) info = child.props.children - else if (child.type === Title) title = child.props.children + else if (child.type === Title) title = child.props else if (child.type === Footer) footer = child.props.children else if (child.type === Actions) actions = child.props.children }) @@ -203,37 +220,42 @@ const ListCard: Card = ({ children, className, href, draggable }) => { const classNames = ["MitListCard-root", className ?? ""].join(" ") if (content) { return ( - <_Container className={classNames} href={href!}> + {content} - + ) } return ( - - <_Container href={href!} scroll={!href?.startsWith("?")}> - {draggable && ( - - - - )} - - {info} - - <TruncateText lineClamp={2}>{title}</TruncateText> + <BaseContainer + {...others} + className={classNames} + display="flex" + onClick={handleClick} + > + {draggable && ( + <DragArea> + <RiDraggable /> + </DragArea> + )} + <Body> + <Info>{info}</Info> + {title && ( + <Title data-card-link={!!title.href} {...title} href={title.href}> + <TruncateText lineClamp={2}>{title.children}</TruncateText> - -
    {footer}
    -
    - - {imageProps && ( - // alt text will be checked on ListCard.Image - // eslint-disable-next-line styled-components-a11y/alt-text - )} - - {actions && {actions}} -
    + +
    {footer}
    + {actions && {actions}} +
    + + {imageProps && ( + // alt text will be checked on ListCard.Image + // eslint-disable-next-line styled-components-a11y/alt-text + + )} + ) } diff --git a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx index 58a49dfec5..a3b4ecca01 100644 --- a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx +++ b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx @@ -1,23 +1,34 @@ -import React, { FC, ReactNode, Children, isValidElement } from "react" +import React, { + FC, + ReactNode, + Children, + isValidElement, + AriaAttributes, +} from "react" import styled from "@emotion/styled" import { RiDraggable } from "@remixicon/react" import { theme } from "../ThemeProvider/ThemeProvider" -import { Wrapper } from "./Card" +import { BaseContainer, useClickChildLink } from "./Card" import { TruncateText } from "../TruncateText/TruncateText" import { ListCard, Body as BaseBody, - LinkContainer, - Container, - DraggableContainer, DragArea as BaseDragArea, Info as BaseInfo, Title as BaseTitle, Footer, - Actions as BaseActions, Bottom as BaseBottom, } from "./ListCard" -import type { Card as BaseCard } from "./ListCard" +import type { Card as BaseCard, TitleProps } from "./ListCard" + +const Container = styled(BaseContainer)<{ draggable?: boolean }>( + ({ draggable }) => [ + draggable && { + display: "flex", + flexDirection: "row", + }, + ], +) const DragArea = styled(BaseDragArea)` padding-right: 4px; @@ -56,73 +67,106 @@ const Bottom = styled(BaseBottom)` height: auto; } ` -const Actions = styled(BaseActions)` - bottom: 16px; - right: 16px; +const Actions = styled.div` + display: flex; gap: 16px; - ${theme.breakpoints.down("md")} { - bottom: 16px; - right: 16px; - gap: 16px; - } ` const Content = () => <> type CardProps = { children: ReactNode[] | ReactNode className?: string - href?: string + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * child anchor with data-card-link. + * + * NOTES: + * - By default, Card.Title has `data-card-link="true"`. + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with data-card-link attribute. Its value is irrelevant. + * - Clicks will NOT be forwarded if: + * - The click target is a child of Card.Actions OR an element with + * - The click target is a child of any element with data-card-actions attribute + */ + forwardClicksToLink?: boolean draggable?: boolean -} + onClick?: () => void + as?: React.ElementType +} & AriaAttributes +/** + * Condensed row-like card component with slots for info, title, footer, and actions: + * ```tsx + * + * Info + * Title + * Footer + * Actions + * + * ``` + * + * **Links:** Card.Title will be a link if `href` is supplied; the entire card + * will be clickable if `forwardClicksToLink` is `true`. + * + * **Custom Layout:** Use ListCard.Content to create a custom layout. + */ type Card = FC & Omit -const ListCardCondensed: Card = ({ children, className, href, draggable }) => { - const _Container = draggable - ? DraggableContainer - : href - ? LinkContainer - : Container +const ListCardCondensed: Card = ({ + children, + className, + draggable, + onClick, + forwardClicksToLink = false, + ...others +}) => { + let content, info, footer, actions + let title: TitleProps = {} - let content, info, title, footer, actions + const handleHrefClick = useClickChildLink(onClick) + const handleClick = + forwardClicksToLink && !draggable ? handleHrefClick : onClick Children.forEach(children, (child) => { if (!isValidElement(child)) return if (child.type === Content) content = child.props.children else if (child.type === Info) info = child.props.children - else if (child.type === Title) title = child.props.children + else if (child.type === Title) title = child.props else if (child.type === Footer) footer = child.props.children else if (child.type === Actions) actions = child.props.children }) if (content) { return ( - <_Container className={className} href={href!}> + {content} - + ) } return ( - - <_Container href={href!} scroll={!href?.startsWith("?")}> - {draggable && ( - - - - )} - - {info} - - <TruncateText lineClamp={4}>{title}</TruncateText> - - -
    {footer}
    -
    - - - {actions && {actions}} -
    + + {draggable && ( + + + + )} + + {info} + + <TruncateText lineClamp={4}>{title.children}</TruncateText> + + +
    {footer}
    + {actions && {actions}} +
    + +
    ) } diff --git a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx index b9bee73776..6064544f4c 100644 --- a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx +++ b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx @@ -6,9 +6,10 @@ import { embedlyCardHtml, EmbedlyEventTypes, ensureEmbedlyPlatform, - getEmbedlyKey, } from "./util" +const EMBEDLY_KEY = process.env.NEXT_PUBLIC_EMBEDLY_KEY as string + type EmbedlyCardProps = { url: string className?: string @@ -51,7 +52,6 @@ const Container = styled.div<{ aspectRatio?: number }>` const EmbedlyCard: React.FC = ({ className, url, - embedlyKey, aspectRatio, }) => { const [container, setContainer] = useState(null) @@ -85,11 +85,12 @@ const EmbedlyCard: React.FC = ({ const a = document.createElement("a") a.dataset.cardChrome = "0" a.dataset.cardControls = "0" - a.dataset.cardKey = embedlyKey ?? getEmbedlyKey() ?? "" + a.dataset.cardKey = EMBEDLY_KEY a.href = url a.classList.add("embedly-card") + a.dataset["testid"] = "embedly-card" container.appendChild(a) - }, [embedlyKey, container, url]) + }, [container, url]) return ( { head.appendChild(style) } -const getEmbedlyKey = (): string | null => { - const key = process.env.NEXT_PUBLIC_EMBEDLY_KEY - if (typeof key === "string") return key - console.warn("process.env.NEXT_PUBLIC_EMBEDLY_KEY should be a string.") - return null -} - const embedlyCardHtml = (url: string) => { - const embedlyKey = getEmbedlyKey() return `` } @@ -101,7 +95,6 @@ const embedlyCardHtml = (url: string) => { export { createStylesheet, ensureEmbedlyPlatform, - getEmbedlyKey, EmbedlyEventTypes, dispatchCardCreated, embedlyCardHtml, diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx index 83f15688d2..c00cd755f5 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx @@ -48,7 +48,7 @@ export default meta type Story = StoryObj const priceArgs: Partial = { - excerpt: ["certification", "free", "prices"], + excerpt: ["certification", "free", "resource_prices"], } export const FreeCourseNoCertificate: Story = { diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx index 9659c4812b..5187dbf505 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx @@ -1,5 +1,5 @@ import React from "react" -import { screen, render } from "@testing-library/react" +import { screen, render, within } from "@testing-library/react" import { LearningResourceCard } from "./LearningResourceCard" import type { LearningResourceCardProps } from "./LearningResourceCard" import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities" @@ -14,19 +14,29 @@ const setup = (props: LearningResourceCardProps) => { } describe("Learning Resource Card", () => { - test("Renders resource type, title and start date", () => { - const resource = factories.learningResources.resource({ - resource_type: ResourceTypeEnum.Course, - next_start_date: "2026-01-01", - }) - - setup({ resource }) - - screen.getByText("Course") - screen.getByText(resource.title) - screen.getByText("Starts:") - screen.getByText("January 01, 2026") - }) + test.each([ + { resourceType: ResourceTypeEnum.Course, expectedLabel: "Course" }, + { resourceType: ResourceTypeEnum.Program, expectedLabel: "Program" }, + ])( + "Renders resource type, title and start date as a labeled article", + ({ resourceType, expectedLabel }) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + next_start_date: "2026-01-01", + }) + + setup({ resource }) + + const card = screen.getByRole("article", { + name: `${expectedLabel}: ${resource.title}`, + }) + + within(card).getByText(expectedLabel) + within(card).getByText(resource.title) + within(card).getByText("Starts:") + within(card).getByText("January 01, 2026") + }, + ) test("Displays run start date", () => { const resource = factories.learningResources.resource({ @@ -96,7 +106,7 @@ describe("Learning Resource Card", () => { setup({ resource, href: "/path/to/thing" }) const link = screen.getByRole("link", { - name: new RegExp(resource.title), + name: resource.title, }) expect(new URL(link.href).pathname).toBe("/path/to/thing") }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index 314d2b8912..8db2fffc68 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx @@ -229,8 +229,15 @@ const LearningResourceCard: React.FC = ({ return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + = ({ - + {resource.title} @@ -257,7 +264,7 @@ const LearningResourceCard: React.FC = ({ {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx index d1726edb92..8de6787771 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx @@ -43,7 +43,7 @@ export default meta type Story = StoryObj const priceArgs: Partial = { - excerpt: ["certification", "free", "prices"], + excerpt: ["certification", "free", "resource_prices"], } export const FreeCourseNoCertificate: Story = { diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index d90eae55ae..d22ac80d42 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -1,6 +1,6 @@ import React from "react" import { BrowserRouter } from "react-router-dom" -import { screen, render } from "@testing-library/react" +import { screen, render, within } from "@testing-library/react" import { LearningResourceListCard } from "./LearningResourceListCard" import type { LearningResourceListCardProps } from "./LearningResourceListCard" import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities" @@ -19,19 +19,29 @@ const setup = (props: LearningResourceListCardProps) => { } describe("Learning Resource List Card", () => { - test("Renders resource type, title and start date", () => { - const resource = factories.learningResources.resource({ - resource_type: ResourceTypeEnum.Course, - next_start_date: "2026-01-01", - }) + test.each([ + { resourceType: ResourceTypeEnum.Course, expectedLabel: "Course" }, + { resourceType: ResourceTypeEnum.Program, expectedLabel: "Program" }, + ])( + "Renders resource type, title and start date as a labeled article", + ({ resourceType, expectedLabel }) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + next_start_date: "2026-01-01", + }) - setup({ resource }) + setup({ resource }) - screen.getByText("Course") - screen.getByText(resource.title) - screen.getByText("Starts:") - screen.getByText("January 01, 2026") - }) + const card = screen.getByRole("article", { + name: `${expectedLabel}: ${resource.title}`, + }) + + within(card).getByText(expectedLabel) + within(card).getByText(resource.title) + within(card).getByText("Starts:") + within(card).getByText("January 01, 2026") + }, + ) test("Displays run start date", () => { const resource = factories.learningResources.resource({ @@ -92,11 +102,11 @@ describe("Learning Resource List Card", () => { setup({ resource, href: "/path/to/thing" }) - const card = screen.getByRole("link", { - name: new RegExp(resource.title), + const link = screen.getByRole("link", { + name: resource.title, }) - expect(card).toHaveAttribute("href", "/path/to/thing") + expect(link).toHaveAttribute("href", "/path/to/thing") }) test("Click action buttons", async () => { @@ -201,7 +211,7 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: false, free: true, - prices: ["0"], + resource_prices: [{ amount: "0", currency: "USD" }], }) setup({ resource }) screen.getByText("Free") @@ -211,7 +221,10 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: true, free: true, - prices: ["0", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], }) setup({ resource }) screen.getByText("Certificate") @@ -223,7 +236,11 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: true, free: true, - prices: ["0", "99", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "99", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], }) setup({ resource }) screen.getByText("Certificate") @@ -235,7 +252,7 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: false, free: false, - prices: ["49"], + resource_prices: [{ amount: "49", currency: "USD" }], }) setup({ resource }) screen.getByText("$49") @@ -245,7 +262,7 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: false, free: false, - prices: ["49.50"], + resource_prices: [{ amount: "49.50", currency: "USD" }], }) setup({ resource }) screen.getByText("$49.50") @@ -255,7 +272,7 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: false, free: true, - prices: [], + resource_prices: [], }) setup({ resource }) screen.getByText("Free") @@ -265,7 +282,7 @@ describe("Learning Resource List Card", () => { const resource = factories.learningResources.resource({ certification: false, free: false, - prices: ["0"], + resource_prices: [{ amount: "0", currency: "USD" }], }) setup({ resource }) screen.getByText("Paid") diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index 6778afc08b..f68b78baa4 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -11,7 +11,6 @@ import { ResourceTypeEnum, LearningResource } from "api" import { formatDate, getReadableResourceType, - // embedlyCroppedImage, DEFAULT_RESOURCE_IMG, pluralize, getLearningResourcePrices, @@ -106,14 +105,6 @@ type ResourceIdCallback = ( resourceId: number, ) => void -// TODO confirm use of Next.js image optimizer in place of Embedly -// const getEmbedlyUrl = (url: string, isMobile: boolean) => { -// return embedlyCroppedImage(url, { -// key: process.env.NEXT_PUBLIC_EMBEDLY_KEY!, -// ...IMAGE_SIZES[isMobile ? "mobile" : "desktop"], -// }) -// } - /* This displays a single price for courses with no free option * (price includes the certificate). For free courses with the * option of a paid certificate, the certificate price displayed @@ -307,8 +298,15 @@ const LearningResourceListCard: React.FC = ({ if (!resource) { return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + = ({ - {resource.title} + {resource.title} {onAddToLearningPathClick && ( = ({ {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx index db5b6f575c..1e8b3ceda7 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx @@ -43,7 +43,7 @@ export default meta type Story = StoryObj const priceArgs: Partial = { - excerpt: ["certification", "free", "prices"], + excerpt: ["certification", "free", "resource_prices"], } export const FreeCourseNoCertificate: Story = { diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx index 2bb7c4a1cd..54c9a1a443 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx @@ -115,12 +115,21 @@ const LearningResourceListCardCondensed: React.FC< if (!resource) { return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + - {resource.title} + + {resource.title} + {onAddToLearningPathClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts index da7bbf6b5f..79eacb61b3 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts @@ -48,21 +48,56 @@ const courses = { runs: [factories.learningResources.run()], free: true, certification: false, - prices: ["0"], + resource_prices: [{ amount: "0", currency: "USD" }], }), withCertificateOnePrice: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], free: true, certification: true, - prices: ["0", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], }), withCertificatePriceRange: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], free: true, certification: true, - prices: ["0", "99", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "99", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], + }), + multipleRuns: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [ + factories.learningResources.run(), + factories.learningResources.run(), + factories.learningResources.run(), + factories.learningResources.run(), + ], + free: true, + certification: false, + prices: ["0"], + }), + anytime: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [factories.learningResources.run()], + free: true, + certification: false, + prices: ["0"], + availability: "anytime", + }), + dated: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [factories.learningResources.run()], + free: true, + certification: false, + prices: ["0"], + availability: "dated", }), }, unknownPrice: { @@ -71,14 +106,14 @@ const courses = { runs: [factories.learningResources.run()], free: false, certification: false, - prices: [], + resource_prices: [], }), withCertificate: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], free: false, certification: true, - prices: [], + resource_prices: [], }), }, paid: { @@ -87,21 +122,24 @@ const courses = { runs: [factories.learningResources.run()], free: false, certification: false, - prices: ["49"], + resource_prices: [{ amount: "49", currency: "USD" }], }), withCerticateOnePrice: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], free: false, certification: true, - prices: ["49"], + resource_prices: [{ amount: "49", currency: "USD" }], }), withCertificatePriceRange: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], free: false, certification: true, - prices: ["49", "99"], + resource_prices: [ + { amount: "49", currency: "USD" }, + { amount: "99", currency: "USD" }, + ], }), }, start: { diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.test.tsx similarity index 75% rename from frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.test.tsx rename to frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.test.tsx index 772f55da5e..b8da7fd72c 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.test.tsx @@ -1,12 +1,12 @@ import React from "react" import { render, screen } from "@testing-library/react" import { courses } from "../LearningResourceCard/testUtils" -import InfoSection from "./InfoSection" +import InfoSectionV1 from "./InfoSectionV1" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" describe("Learning resource info section pricing", () => { test("Free course, no certificate", () => { - render(, { + render(, { wrapper: ThemeProvider, }) @@ -17,7 +17,7 @@ describe("Learning resource info section pricing", () => { }) test("Free course, with certificate, one price", () => { - render(, { + render(, { wrapper: ThemeProvider, }) @@ -28,9 +28,12 @@ describe("Learning resource info section pricing", () => { }) test("Free course, with certificate, price range", () => { - render(, { - wrapper: ThemeProvider, - }) + render( + , + { + wrapper: ThemeProvider, + }, + ) screen.getByText("Free") expect(screen.queryByText("Paid")).toBeNull() @@ -39,7 +42,7 @@ describe("Learning resource info section pricing", () => { }) test("Unknown price, no certificate", () => { - render(, { + render(, { wrapper: ThemeProvider, }) @@ -50,7 +53,7 @@ describe("Learning resource info section pricing", () => { }) test("Unknown price, with certificate", () => { - render(, { + render(, { wrapper: ThemeProvider, }) @@ -60,7 +63,7 @@ describe("Learning resource info section pricing", () => { }) test("Paid course, no certificate", () => { - render(, { + render(, { wrapper: ThemeProvider, }) @@ -72,7 +75,7 @@ describe("Learning resource info section pricing", () => { }) test("Paid course, with certificate, one price", () => { - render(, { + render(, { wrapper: ThemeProvider, }) @@ -82,9 +85,12 @@ describe("Learning resource info section pricing", () => { }) test("Paid course, with certificate, price range", () => { - render(, { - wrapper: ThemeProvider, - }) + render( + , + { + wrapper: ThemeProvider, + }, + ) screen.getByText("$49 – $99") expect(screen.queryByText("Paid")).toBeNull() diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.tsx similarity index 99% rename from frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.tsx rename to frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.tsx index 4c6ec179bc..27c3d1f312 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.tsx @@ -237,7 +237,7 @@ const InfoItem = ({ label, Icon, value }: InfoItemProps) => { ) } -const InfoSection = ({ +const InfoSectionV1 = ({ resource, run, user, @@ -307,4 +307,4 @@ const InfoSection = ({ ) } -export default InfoSection +export default InfoSectionV1 diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx new file mode 100644 index 0000000000..d14bac2176 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx @@ -0,0 +1,158 @@ +import React from "react" +import { render, screen, within } from "@testing-library/react" +import { courses } from "../LearningResourceCard/testUtils" +import InfoSectionV2 from "./InfoSectionV2" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { formatRunDate } from "ol-utilities" +import invariant from "tiny-invariant" + +// This is a pipe followed by a zero-width space +const SEPARATOR = "|​" + +describe("Learning resource info section pricing", () => { + test("Free course, no certificate", () => { + render(, { + wrapper: ThemeProvider, + }) + + screen.getByText("Free") + expect(screen.queryByText("Paid")).toBeNull() + expect(screen.queryByText("Earn a certificate:")).toBeNull() + expect(screen.queryByText("Certificate included")).toBeNull() + }) + + test("Free course, with certificate, one price", () => { + render(, { + wrapper: ThemeProvider, + }) + + screen.getByText("Free") + expect(screen.queryByText("Paid")).toBeNull() + screen.getByText("Earn a certificate:") + screen.getByText("$49") + }) + + test("Free course, with certificate, price range", () => { + render( + , + { + wrapper: ThemeProvider, + }, + ) + + screen.getByText("Free") + expect(screen.queryByText("Paid")).toBeNull() + screen.getByText("Earn a certificate:") + screen.getByText("$49 – $99") + }) + + test("Unknown price, no certificate", () => { + render(, { + wrapper: ThemeProvider, + }) + + screen.getByText("Paid") + expect(screen.queryByText("Free")).toBeNull() + expect(screen.queryByText("Earn a certificate:")).toBeNull() + expect(screen.queryByText("Certificate included")).toBeNull() + }) + + test("Unknown price, with certificate", () => { + render(, { + wrapper: ThemeProvider, + }) + + screen.getByText("Paid") + expect(screen.queryByText("Free")).toBeNull() + screen.getByText("Certificate included") + }) + + test("Paid course, no certificate", () => { + render(, { + wrapper: ThemeProvider, + }) + + screen.getByText("$49") + expect(screen.queryByText("Paid")).toBeNull() + expect(screen.queryByText("Free")).toBeNull() + expect(screen.queryByText("Earn a certificate:")).toBeNull() + expect(screen.queryByText("Certificate included")).toBeNull() + }) + + test("Paid course, with certificate, one price", () => { + render(, { + wrapper: ThemeProvider, + }) + + screen.getByText("$49") + expect(screen.queryByText("Paid")).toBeNull() + screen.getByText("Certificate included") + }) + + test("Paid course, with certificate, price range", () => { + render( + , + { + wrapper: ThemeProvider, + }, + ) + + screen.getByText("$49 – $99") + expect(screen.queryByText("Paid")).toBeNull() + screen.getByText("Certificate included") + }) +}) + +describe("Learning resource info section start date", () => { + test("Start date", () => { + const course = courses.free.dated + const run = course.runs?.[0] + invariant(run) + const runDate = formatRunDate(run, false) + invariant(runDate) + render(, { + wrapper: ThemeProvider, + }) + + const section = screen.getByTestId("drawer-info-items") + within(section).getByText("Start Date:") + within(section).getByText(runDate) + }) + + test("As taught in", () => { + const course = courses.free.anytime + const run = course.runs?.[0] + invariant(run) + const runDate = formatRunDate(run, true) + invariant(runDate) + render(, { + wrapper: ThemeProvider, + }) + + const section = screen.getByTestId("drawer-info-items") + within(section).getByText("As taught in:") + within(section).getByText(runDate) + }) + + test("Multiple Runs", () => { + const course = courses.free.multipleRuns + const expectedDateText = course.runs + ?.sort((a, b) => { + if (a?.start_date && b?.start_date) { + return Date.parse(a.start_date) - Date.parse(b.start_date) + } + return 0 + }) + .map((run) => formatRunDate(run, false)) + .join(SEPARATOR) + invariant(expectedDateText) + render(, { + wrapper: ThemeProvider, + }) + + const section = screen.getByTestId("drawer-info-items") + within(section).getByText((_content, node) => { + return node?.textContent === expectedDateText || false + }) + }) +}) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx new file mode 100644 index 0000000000..6c27cf378a --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx @@ -0,0 +1,403 @@ +import React from "react" +import styled from "@emotion/styled" +import ISO6391 from "iso-639-1" +import { + RemixiconComponentType, + RiVerifiedBadgeLine, + RiTimeLine, + RiCalendarLine, + RiListOrdered2, + RiPriceTag3Line, + RiDashboard3Line, + RiGraduationCapLine, + RiTranslate2, + RiPresentationLine, + RiAwardFill, +} from "@remixicon/react" +import { LearningResource, ResourceTypeEnum } from "api" +import { + formatDurationClockTime, + formatRunDate, + getLearningResourcePrices, + showStartAnytime, +} from "ol-utilities" +import { theme } from "../ThemeProvider/ThemeProvider" + +const SeparatorContainer = styled.span({ + padding: "0 8px", + color: theme.custom.colors.silverGray, +}) + +/* + * Pipe followed by zero-width space, ZWSP. + * By doing + * ... + * without whitespace between and , we allow line + * breaks after the pipe but not before it. + */ +const Separator: React.FC = () => ( + |​ +) + +const InfoItems = styled.section({ + display: "flex", + flexDirection: "column", + gap: "16px", + maxWidth: "100%", +}) + +const InfoItemContainer = styled.div({ + display: "flex", + alignSelf: "stretch", + alignItems: "baseline", + gap: "16px", + ...theme.typography.subtitle3, + color: theme.custom.colors.black, + svg: { + color: theme.custom.colors.silverGrayDark, + width: "20px", + height: "20px", + flexShrink: 0, + }, + [theme.breakpoints.down("sm")]: { + gap: "12px", + }, +}) + +const IconContainer = styled.span({ + transform: "translateY(25%)", + svg: { + display: "block", + }, + [theme.breakpoints.down("sm")]: { + display: "none", + }, +}) + +const InfoLabel = styled.div({ + width: "85px", + flexShrink: 0, +}) + +const InfoValue = styled.div({ + display: "inline-block", + color: theme.custom.colors.darkGray2, + rowGap: ".2rem", + ...theme.typography.body3, +}) + +const PriceDisplay = styled.div({ + display: "flex", + alignItems: "center", + gap: "8px", + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + flexWrap: "wrap", + alignItems: "flex-start", + }, +}) + +const Certificate = styled.div({ + display: "flex", + alignItems: "center", + gap: "4px", + borderRadius: "4px", + padding: "4px 8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + backgroundColor: theme.custom.colors.lightGray1, + color: theme.custom.colors.silverGrayDark, + ...theme.typography.subtitle3, + svg: { + width: "16px", + height: "16px", + }, + [theme.breakpoints.down("sm")]: { + padding: "1px 2px", + ...theme.typography.subtitle4, + }, +}) + +type InfoSelector = (resource: LearningResource) => React.ReactNode + +type InfoItemConfig = { + label: string | ((resource: LearningResource) => string) + Icon: RemixiconComponentType | null + selector: InfoSelector +}[] + +type InfoItemValueProps = { + label: string | null + index: number + total: number +} + +const InfoItemValue: React.FC = ({ + label, + index, + total, +}) => { + return ( + <> + {label} + {index < total - 1 && } + + ) +} + +const INFO_ITEMS: InfoItemConfig = [ + { + label: (resource: LearningResource) => { + const asTaughtIn = resource ? showStartAnytime(resource) : false + const label = asTaughtIn ? "As taught in:" : "Start Date:" + return label + }, + Icon: RiCalendarLine, + selector: (resource: LearningResource) => { + const asTaughtIn = resource ? showStartAnytime(resource) : false + if ( + [ResourceTypeEnum.Course, ResourceTypeEnum.Program].includes( + resource.resource_type as "course" | "program", + ) + ) { + const sortedDates = + resource.runs + ?.sort((a, b) => { + if (a?.start_date && b?.start_date) { + return Date.parse(a.start_date) - Date.parse(b.start_date) + } + return 0 + }) + .map((run) => formatRunDate(run, asTaughtIn)) ?? [] + const runDates = + sortedDates.map((runDate, index) => { + return ( + + ) + }) ?? [] + return runDates + } else return null + }, + }, + { + label: "Price:", + Icon: RiPriceTag3Line, + selector: (resource: LearningResource) => { + const prices = getLearningResourcePrices(resource) + + return ( + +
    {prices.course.display}
    + {resource.certification && ( + + + {prices.certificate.display + ? "Earn a certificate:" + : "Certificate included"} + {prices.certificate.display} + + )} +
    + ) + }, + }, + { + label: "Topics:", + Icon: RiPresentationLine, + selector: (resource: LearningResource) => { + const { topics } = resource + if (!topics?.length) { + return null + } + return topics.map((topic, index) => { + return ( + + ) + }) + }, + }, + { + label: "Level:", + Icon: RiDashboard3Line, + selector: (resource: LearningResource) => { + const totalRuns = resource.runs?.length || 0 + const levels = resource.runs?.map((run, index) => { + const level = run?.level?.[0]?.name + if (!level) { + return null + } + return ( + + ) + }) + if (levels?.every((level) => level === null)) { + return null + } + return levels + }, + }, + + { + label: "Instructors:", + Icon: RiGraduationCapLine, + selector: (resource: LearningResource) => { + const instructorNames: string[] = [] + resource.runs?.forEach((run) => { + run.instructors?.forEach((instructor) => { + if (instructor.full_name) { + instructorNames.push(instructor.full_name) + } + }) + }) + const uniqueInstructors = Array.from(new Set(instructorNames)) + if (uniqueInstructors.length === 0) { + return null + } + const totalInstructors = uniqueInstructors.length + const instructors = uniqueInstructors.map((instructor, index) => { + return ( + + ) + }) + return instructors + }, + }, + + { + label: "Languages:", + Icon: RiTranslate2, + selector: (resource: LearningResource) => { + const runLanguages: string[] = [] + resource.runs?.forEach((run) => { + run.languages?.forEach((language) => { + runLanguages.push(language) + }) + }) + const uniqueLanguages = Array.from(new Set(runLanguages)) + if (uniqueLanguages.length === 0) { + return null + } + const totalLanguages = uniqueLanguages.length + return uniqueLanguages.map((language, index) => { + return ( + + ) + }) + }, + }, + + { + label: "Duration:", + Icon: RiTimeLine, + selector: (resource: LearningResource) => { + if (resource.resource_type === ResourceTypeEnum.Video) { + return resource.video.duration + ? formatDurationClockTime(resource.video.duration) + : null + } + if (resource.resource_type === ResourceTypeEnum.PodcastEpisode) { + return resource.podcast_episode.duration + ? formatDurationClockTime(resource.podcast_episode.duration) + : null + } + return null + }, + }, + + { + label: "Offered By:", + Icon: RiVerifiedBadgeLine, + selector: (resource: LearningResource) => { + return resource.offered_by?.name || null + }, + }, + + { + label: "Date Posted:", + Icon: RiCalendarLine, + selector: () => { + // TODO Not seeing any value for this in the API schema for VideoResource. Last modified date is closest available, though likely relates to the data record + return null + }, + }, + + { + label: "Number of Courses:", + Icon: RiListOrdered2, + selector: (resource: LearningResource) => { + if (resource.resource_type === ResourceTypeEnum.Program) { + return resource.program.course_count + } + return null + }, + }, +] + +type InfoItemProps = { + label: string + Icon: RemixiconComponentType | null + value: React.ReactNode +} + +const InfoItem = ({ label, Icon, value }: InfoItemProps) => { + if (!value) { + return null + } + return ( + + {Icon && } + {label} + {value} + + ) +} + +const InfoSectionV2 = ({ resource }: { resource?: LearningResource }) => { + if (!resource) { + return null + } + + const infoItems = INFO_ITEMS.map(({ label, Icon, selector }) => ({ + label: typeof label === "function" ? label(resource) : label, + Icon, + value: selector(resource), + })).filter(({ value }) => value !== null && value !== "") + + if (infoItems.length === 0) { + return null + } + + return ( + + {infoItems.map((props, index) => ( + + ))} + + ) +} + +export default InfoSectionV2 diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx index 33271da082..27d7e2524c 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx @@ -1,6 +1,6 @@ import React from "react" import type { Meta, StoryObj } from "@storybook/react" -import { LearningResourceExpanded } from "./LearningResourceExpanded" +import { LearningResourceExpandedV1 } from "./LearningResourceExpandedV1" import { factories } from "api/test-utils" import { ResourceTypeEnum as LRT } from "api" import invariant from "tiny-invariant" @@ -16,12 +16,11 @@ const makeResource: typeof _makeResource = (overrides) => { return resource } -const meta: Meta = { - title: "smoot-design/LearningResourceExpanded", - component: LearningResourceExpanded, +const meta: Meta = { + title: "smoot-design/LearningResourceExpandedV1", + component: LearningResourceExpandedV1, args: { imgConfig: { - key: "", width: 385, height: 200, }, @@ -54,7 +53,7 @@ const meta: Meta = { return ( - + ) @@ -63,7 +62,7 @@ const meta: Meta = { export default meta -type Story = StoryObj +type Story = StoryObj export const Course: Story = { args: { @@ -139,7 +138,10 @@ export const PricingVariant1: Story = { resource: makeResource({ resource_type: LRT.Course, title: "Free course with paid certificate option", - prices: ["0", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], free: true, certification: true, }), diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx similarity index 96% rename from frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx rename to frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx index 0af5945c86..8a1a54df9d 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx @@ -2,8 +2,8 @@ import React from "react" import { BrowserRouter } from "react-router-dom" import { render, screen, within } from "@testing-library/react" import user from "@testing-library/user-event" -import { LearningResourceExpanded } from "./LearningResourceExpanded" -import type { LearningResourceExpandedProps } from "./LearningResourceExpanded" +import { LearningResourceExpandedV1 } from "./LearningResourceExpandedV1" +import type { LearningResourceExpandedV1Props } from "./LearningResourceExpandedV1" import { ResourceTypeEnum, PodcastEpisodeResource, AvailabilityEnum } from "api" import { factories } from "api/test-utils" import { formatDate } from "ol-utilities" @@ -11,10 +11,9 @@ import { ThemeProvider } from "../ThemeProvider/ThemeProvider" import invariant from "tiny-invariant" import type { LearningResource } from "api" import { faker } from "@faker-js/faker/locale/en" -import { PLATFORMS } from "../Logo/Logo" +import { PLATFORM_LOGOS } from "../Logo/Logo" -const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = { - key: "fake-key", +const IMG_CONFIG: LearningResourceExpandedV1Props["imgConfig"] = { width: 385, height: 200, } @@ -22,7 +21,7 @@ const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = { const setup = (resource: LearningResource) => { return render( - + , { wrapper: ThemeProvider }, ) @@ -151,7 +150,7 @@ describe("Learning Resource Expanded", () => { .find((img) => img.getAttribute("alt")?.includes("xPRO")) expect(xproImage).toBeInTheDocument() - expect(xproImage).toHaveAttribute("alt", PLATFORMS["xpro"].name) + expect(xproImage).toHaveAttribute("alt", PLATFORM_LOGOS["xpro"].name) }, ) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx similarity index 88% rename from frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx rename to frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx index fdd3b2802d..a0155829db 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx @@ -2,26 +2,26 @@ import React, { useEffect, useState } from "react" import styled from "@emotion/styled" import Skeleton from "@mui/material/Skeleton" import Typography from "@mui/material/Typography" +import { default as NextImage } from "next/image" import { ButtonLink } from "../Button/Button" import type { LearningResource, LearningResourceRun } from "api" import { ResourceTypeEnum, PlatformEnum } from "api" import { formatDate, capitalize, - resourceThumbnailSrc, DEFAULT_RESOURCE_IMG, showStartAnytime, } from "ol-utilities" import { RiExternalLinkLine } from "@remixicon/react" -import type { EmbedlyConfig } from "ol-utilities" import { theme } from "../ThemeProvider/ThemeProvider" import { SimpleSelect } from "../SimpleSelect/SimpleSelect" import type { SimpleSelectProps } from "../SimpleSelect/SimpleSelect" -import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard" -import { PlatformLogo, PLATFORMS } from "../Logo/Logo" -import InfoSection from "./InfoSection" +import { PlatformLogo, PLATFORM_LOGOS } from "../Logo/Logo" +import InfoSectionV1 from "./InfoSectionV1" import type { User } from "api/hooks/user" import { LearningResourceCardProps } from "../LearningResourceCard/LearningResourceCard" +import type { ImageConfig } from "../../constants/imgConfigs" +import VideoFrame from "./VideoFrame" const Container = styled.div<{ padTop?: boolean }>` display: flex; @@ -76,8 +76,13 @@ const DateLabel = styled.span` margin-right: 16px; ` -const Image = styled.img<{ aspect: number }>` - aspect-ratio: ${({ aspect }) => aspect}; +const ImageContainer = styled.div<{ aspect: number }>` + position: relative; + width: 100%; + padding-bottom: ${({ aspect }) => 100 / aspect}%; +` + +const Image = styled(NextImage)` border-radius: 8px; width: 100%; object-fit: cover; @@ -138,41 +143,42 @@ const OnPlatform = styled.span` color: ${theme.custom.colors.black}; ` -type LearningResourceExpandedProps = { +type LearningResourceExpandedV1Props = { resource?: LearningResource user?: User - imgConfig: EmbedlyConfig + imgConfig: ImageConfig onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] } const ImageSection: React.FC<{ resource?: LearningResource - config: EmbedlyConfig + config: ImageConfig }> = ({ resource, config }) => { + const aspect = config.width / config.height if (resource?.resource_type === "video" && resource?.url) { return ( - + ) } else if (resource?.image) { return ( - {resource?.image.alt + + {resource?.image.alt + ) } else if (resource) { return ( - {resource.image?.alt + + {resource.image?.alt + ) } else { return ( @@ -218,7 +224,7 @@ const CallToActionSection = ({ (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro ? (offeredBy?.code as PlatformEnum) : (platform?.code as PlatformEnum) - const platformImage = PLATFORMS[platformCode]?.image + const platformImage = PLATFORM_LOGOS[platformCode]?.image const getCallToActionText = (resource: LearningResource): string => { if (resource?.platform?.code === PlatformEnum.Ocw) { @@ -319,7 +325,7 @@ const formatRunDate = ( return null } -const LearningResourceExpanded: React.FC = ({ +const LearningResourceExpandedV1: React.FC = ({ resource, user, imgConfig, @@ -421,7 +427,7 @@ const LearningResourceExpanded: React.FC = ({ - = ({ ) } -export { LearningResourceExpanded } -export type { LearningResourceExpandedProps } +export { LearningResourceExpandedV1 } +export type { LearningResourceExpandedV1Props } diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx new file mode 100644 index 0000000000..0f242e754e --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx @@ -0,0 +1,267 @@ +import React from "react" +import { BrowserRouter } from "react-router-dom" +import { render, screen, within } from "@testing-library/react" + +import { + getCallToActionText, + LearningResourceExpandedV2, +} from "./LearningResourceExpandedV2" +import type { LearningResourceExpandedV2Props } from "./LearningResourceExpandedV2" +import { ResourceTypeEnum, PodcastEpisodeResource } from "api" +import { factories } from "api/test-utils" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import invariant from "tiny-invariant" +import type { LearningResource } from "api" +import { PLATFORM_LOGOS } from "../Logo/Logo" +import _ from "lodash" + +const IMG_CONFIG: LearningResourceExpandedV2Props["imgConfig"] = { + width: 385, + height: 200, +} + +// This is a pipe followed by a zero-width space +const SEPARATOR = "|​" + +const setup = (resource: LearningResource) => { + return render( + + + , + { wrapper: ThemeProvider }, + ) +} + +describe("Learning Resource Expanded", () => { + const RESOURCE_TYPES = Object.values(ResourceTypeEnum) + const isVideo = (resourceType: ResourceTypeEnum) => + resourceType === ResourceTypeEnum.Video || + resourceType === ResourceTypeEnum.VideoPlaylist + + test.each(RESOURCE_TYPES.filter((type) => !isVideo(type)))( + 'Renders image and title for resource type "%s"', + (resourceType) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + }) + + setup(resource) + + const images = screen.getAllByRole("img") + const image = images.find((img) => + img + .getAttribute("src") + ?.includes(encodeURIComponent(resource.image?.url ?? "")), + ) + expect(image).toBeInTheDocument() + invariant(image) + expect(image).toHaveAttribute("alt", resource.image?.alt ?? "") + + screen.getByText(resource.title) + + const linkName = getCallToActionText(resource) + + const url = + resource.resource_type === ResourceTypeEnum.PodcastEpisode + ? (resource as PodcastEpisodeResource).podcast_episode?.episode_link + : resource.url + if (linkName) { + const link = screen.getByRole("link", { + name: linkName, + }) as HTMLAnchorElement + expect(link.target).toBe("_blank") + expect(link.href).toMatch(new RegExp(`^${url}/?$`)) + } + }, + ) + + test(`Renders card and title for resource type "${ResourceTypeEnum.Video}"`, () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Video, + }) + + setup(resource) + + const embedlyCard = screen.getByTestId("embedly-card") + invariant(embedlyCard) + expect(embedlyCard).toHaveAttribute("href", resource.url) + + screen.getByText(resource.title) + }) + + test.each([ResourceTypeEnum.Program, ResourceTypeEnum.LearningPath])( + 'Renders CTA button for resource type "%s"', + (resourceType) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + }) + + setup(resource) + + const linkName = "Learn More" + if (linkName) { + const link = screen.getByRole("link", { + name: linkName, + }) as HTMLAnchorElement + + expect(link.href).toMatch(new RegExp(`^${resource.url}/?$`)) + } + }, + ) + + test.each([ResourceTypeEnum.PodcastEpisode])( + 'Renders CTA button for resource type "%s"', + (resourceType) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + podcast_episode: { + episode_link: "https://example.com", + }, + }) + + setup(resource) + + const link = screen.getByRole("link", { + name: "Listen to Podcast", + }) as HTMLAnchorElement + + expect(link.href).toMatch( + new RegExp( + `^${(resource as PodcastEpisodeResource).podcast_episode?.episode_link}/?$`, + ), + ) + }, + ) + + test.each([ResourceTypeEnum.PodcastEpisode])( + "Renders xpro logo conditionally on offered_by=xpro and not platform.code", + (resourceType) => { + const resource = factories.learningResources.resource({ + resource_type: resourceType, + platform: { code: "test" }, + offered_by: { code: "xpro" }, + podcast_episode: { + episode_link: "https://example.com", + }, + }) + + setup(resource) + const xproImage = screen + .getAllByRole("img") + .find((img) => img.getAttribute("alt")?.includes("xPRO")) + + expect(xproImage).toBeInTheDocument() + expect(xproImage).toHaveAttribute("alt", PLATFORM_LOGOS["xpro"].name) + }, + ) + + test(`Renders info section for resource type "${ResourceTypeEnum.Video}"`, () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Video, + }) + + setup(resource) + + const run = resource.runs![0] + + if (run) { + const section = screen + .getByRole("heading", { name: "Info" })! + .closest("section")! + + const price = run.resource_prices?.[0] + + const displayPrice = + parseFloat(price.amount) === 0 + ? "Free" + : price.amount + ? `$${price.amount}` + : null + if (displayPrice) { + within(section).getByText(displayPrice) + } + + const level = run.level?.[0] + if (level) { + within(section).getByText(level.name) + } + + const instructors = run.instructors + ?.filter((instructor) => instructor.full_name) + .map(({ full_name: name }) => name) + if (instructors?.length) { + within(section!).getByText(instructors.join(", ")) + } + } + }) + + test("Renders info section topics correctly", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + topics: [ + factories.learningResources.topic({ name: "Topic 1" }), + factories.learningResources.topic({ name: "Topic 2" }), + factories.learningResources.topic({ name: "Topic 3" }), + ], + }) + + setup(resource) + + const section = screen.getByTestId("drawer-info-items") + + within(section).getByText((_content, node) => { + return ( + node?.textContent === + ["Topic 1", "Topic 2", "Topic 3"].join(SEPARATOR) || false + ) + }) + }) + + test("Renders info section languages correctly", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + runs: [ + factories.learningResources.run({ + languages: ["en-us", "es-es", "fr-fr"], + }), + ], + }) + + setup(resource) + + const section = screen.getByTestId("drawer-info-items") + + within(section).getByText((_content, node) => { + return ( + node?.textContent === + ["English", "Spanish", "French"].join(SEPARATOR) || false + ) + }) + }) + + test("Renders info section video duration correctly", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Video, + video: { duration: "PT1H13M44S" }, + }) + + setup(resource) + + const section = screen.getByTestId("drawer-info-items") + + within(section).getByText("1:13:44") + }) + + test("Renders info section podcast episode duration correctly", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.PodcastEpisode, + podcast_episode: { duration: "PT13M44S" }, + }) + + setup(resource) + + const section = screen.getByTestId("drawer-info-items") + + within(section).getByText("13:44") + }) +}) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx new file mode 100644 index 0000000000..d273f2f9cb --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx @@ -0,0 +1,460 @@ +import React from "react" +import styled from "@emotion/styled" +import Skeleton from "@mui/material/Skeleton" +import Typography from "@mui/material/Typography" +import { default as NextImage } from "next/image" +import { ActionButton, ButtonLink } from "../Button/Button" +import type { LearningResource } from "api" +import { ResourceTypeEnum, PlatformEnum } from "api" +import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities" +import { + RiBookmarkLine, + RiCloseLargeLine, + RiExternalLinkLine, + RiMenuAddLine, +} from "@remixicon/react" +import type { ImageConfig } from "../../constants/imgConfigs" +import { theme } from "../ThemeProvider/ThemeProvider" +import { PlatformLogo, PLATFORM_LOGOS } from "../Logo/Logo" +import InfoSectionV2 from "./InfoSectionV2" +import type { User } from "api/hooks/user" +import { LearningResourceCardProps } from "../LearningResourceCard/LearningResourceCard" +import { CardActionButton } from "../LearningResourceCard/LearningResourceListCard" +import VideoFrame from "./VideoFrame" + +const Container = styled.div({ + display: "flex", + flexDirection: "column", + padding: "0 32px 160px", + width: "900px", + [theme.breakpoints.down("md")]: { + width: "auto", + padding: "0 16px 160px", + }, +}) + +const TitleSectionContainer = styled.div({ + display: "flex", + position: "sticky", + justifyContent: "space-between", + top: "0", + padding: "24px 32px", + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("md")]: { + padding: "24px 16px", + }, +}) + +const ContentContainer = styled.div({ + display: "flex", + alignItems: "flex-start", + gap: "32px", + alignSelf: "stretch", + [theme.breakpoints.down("md")]: { + alignItems: "center", + flexDirection: "column-reverse", + gap: "16px", + }, +}) + +const LeftContainer = styled.div({ + display: "flex", + flexDirection: "column", + flexGrow: 1, + alignItems: "flex-start", + gap: "24px", + maxWidth: "100%", +}) + +const RightContainer = styled.div({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "flex-start", + gap: "24px", + [theme.breakpoints.down("md")]: { + width: "100%", + alignItems: "center", + }, +}) + +const ImageContainer = styled.div<{ aspect: number }>` + position: relative; + width: 100%; + padding-bottom: ${({ aspect }) => 100 / aspect}%; +` + +const Image = styled(NextImage)({ + borderRadius: "8px", + width: "100%", + objectFit: "cover", +}) + +const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ + borderRadius: "8px", + paddingBottom: `${100 / aspect.aspect}%`, +})) + +const CallToAction = styled.div({ + display: "flex", + width: "350px", + padding: "16px", + flexDirection: "column", + alignItems: "center", + gap: "10px", + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + boxShadow: "0px 2px 10px 0px rgba(37, 38, 43, 0.10)", + [theme.breakpoints.down("md")]: { + width: "100%", + padding: "0", + border: "none", + boxShadow: "none", + }, +}) + +const PlatformContainer = styled.div({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "16px", + alignSelf: "stretch", +}) + +const StyledLink = styled(ButtonLink)({ + textAlign: "center", + width: "100%", + [theme.breakpoints.down("sm")]: { + marginTop: "10px", + marginBottom: "10px", + }, +}) + +const Platform = styled.div({ + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + gap: "16px", +}) + +const Description = styled.p({ + ...theme.typography.body2, + color: theme.custom.colors.black, + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", +}) + +const StyledPlatformLogo = styled(PlatformLogo)({ + height: "26px", + maxWidth: "180px", +}) + +const OnPlatform = styled.span({ + ...theme.typography.body2, + color: theme.custom.colors.black, +}) + +const ListButtonContainer = styled.div({ + display: "flex", + gap: "8px", + flexGrow: 1, + justifyContent: "flex-end", +}) + +type LearningResourceExpandedV2Props = { + resource?: LearningResource + user?: User + imgConfig: ImageConfig + onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] + onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] + closeDrawer?: () => void +} + +const CloseButton = styled(ActionButton)(({ theme }) => ({ + "&&&": { + flexShrink: 0, + backgroundColor: theme.custom.colors.lightGray2, + color: theme.custom.colors.black, + ["&:hover"]: { + backgroundColor: theme.custom.colors.red, + color: theme.custom.colors.white, + }, + }, +})) + +const CloseIcon = styled(RiCloseLargeLine)` + &&& { + width: 18px; + height: 18px; + } +` + +const TitleSection: React.FC<{ + resource?: LearningResource + closeDrawer: () => void +}> = ({ resource, closeDrawer }) => { + const closeButton = ( + closeDrawer()} + aria-label="Close" + > + + + ) + if (resource) { + return ( + +
    + + {getReadableResourceType(resource?.resource_type)} + + + {resource?.title} + +
    + {closeButton} +
    + ) + } else { + return ( + + + + {closeButton} + + ) + } +} + +const ImageSection: React.FC<{ + resource?: LearningResource + config: ImageConfig +}> = ({ resource, config }) => { + const aspect = config.width / config.height + if (resource?.resource_type === "video" && resource?.url) { + return ( + + ) + } else if (resource?.image) { + return ( + + {resource?.image.alt + + ) + } else if (resource) { + return ( + + {resource.image?.alt + + ) + } else { + return ( + + ) + } +} + +const getCallToActionUrl = (resource: LearningResource) => { + switch (resource.resource_type) { + case ResourceTypeEnum.PodcastEpisode: + return resource.podcast_episode?.episode_link + default: + return resource.url + } +} + +const getCallToActionText = (resource: LearningResource): string => { + const accessCourseMaterials = "Access Course Materials" + const watchOnYouTube = "Watch on YouTube" + const listenToPodcast = "Listen to Podcast" + const learnMore = "Learn More" + const callsToAction = { + [ResourceTypeEnum.Course]: learnMore, + [ResourceTypeEnum.Program]: learnMore, + [ResourceTypeEnum.LearningPath]: learnMore, + [ResourceTypeEnum.Video]: watchOnYouTube, + [ResourceTypeEnum.VideoPlaylist]: watchOnYouTube, + [ResourceTypeEnum.Podcast]: listenToPodcast, + [ResourceTypeEnum.PodcastEpisode]: listenToPodcast, + } + if ( + resource?.resource_type === ResourceTypeEnum.Video || + resource?.resource_type === ResourceTypeEnum.VideoPlaylist + ) { + // Video resources should always show "Watch on YouTube" as the CTA + return watchOnYouTube + } else { + if (resource?.platform?.code === PlatformEnum.Ocw) { + // Non-video OCW resources should show "Access Course Materials" as the CTA + return accessCourseMaterials + } else { + // Return the default CTA for the resource type + return callsToAction[resource?.resource_type] || learnMore + } + } +} + +const CallToActionSection = ({ + imgConfig, + resource, + hide, + user, + onAddToLearningPathClick, + onAddToUserListClick, +}: { + imgConfig: ImageConfig + resource?: LearningResource + hide?: boolean + user?: User + onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] + onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] +}) => { + if (hide) { + return null + } + + if (!resource) { + return ( + + + + + ) + } + const inUserList = !!resource?.user_list_parents?.length + const inLearningPath = !!resource?.learning_path_parents?.length + const { platform } = resource! + const offeredBy = resource?.offered_by + const platformCode = + (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro + ? (offeredBy?.code as PlatformEnum) + : (platform?.code as PlatformEnum) + const platformImage = PLATFORM_LOGOS[platformCode]?.image + const cta = getCallToActionText(resource) + return ( + + + } + href={getCallToActionUrl(resource) || ""} + > + {cta} + + + {platformImage ? ( + + on + + + ) : null} + + {user?.is_learning_path_editor && ( + + onAddToLearningPathClick + ? onAddToLearningPathClick(event, resource.id) + : null + } + > + + + )} + onAddToUserListClick?.(event, resource.id) + : undefined + } + > + + + + + + ) +} + +const ResourceDescription = ({ resource }: { resource?: LearningResource }) => { + if (!resource) { + return ( + <> + + + + + + + + ) + } + return ( + + ) +} + +const LearningResourceExpandedV2: React.FC = ({ + resource, + imgConfig, + user, + onAddToLearningPathClick, + onAddToUserListClick, + closeDrawer, +}) => { + return ( + <> + {})} + /> + + + + + + + + + + + + + ) +} + +export { LearningResourceExpandedV2, getCallToActionText } +export type { LearningResourceExpandedV2Props } diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx new file mode 100644 index 0000000000..3f76c24609 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx @@ -0,0 +1,32 @@ +import React from "react" +import styled from "@emotion/styled" +import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard" + +const IFrame = styled.iframe` + border-radius: 8px; + border: none; + width: 100%; + aspect-ratio: 16 / 9; +` + +const VideoFrame: React.FC<{ + src: string + title: string + aspect: number +}> = ({ src, title, aspect }) => { + if (src?.startsWith("https://www.youtube.com/watch?v=")) { + const videoId = src?.split("v=")[1] + return ( +