From 2e2193bf48dfd4ca0ae3f8d9bae41013fb911ce9 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 30 Oct 2024 13:33:45 -0400 Subject: [PATCH 01/13] Card Accessibility Improvements (#1778) * make only title the anchor * only use title as link * add resourceType label to resource cards * dont clip title outline * tweak two tests * remove absolute positioning from Card * remove absolute positioning from ListCard etc * Make link forwarding opt-in; LR cards articles * remove posthog console.log * add a comment --- .../src/app-pages/HomePage/HomePage.test.tsx | 4 +- .../app-pages/HomePage/NewsEventsSection.tsx | 55 ++-- .../UserListCard/UserListCardCondensed.tsx | 22 +- .../src/components/Card/Card.test.tsx | 98 ++++++- .../src/components/Card/Card.tsx | 265 ++++++++++++------ .../src/components/Card/ListCard.test.tsx | 98 ++++++- .../src/components/Card/ListCard.tsx | 148 +++++----- .../src/components/Card/ListCardCondensed.tsx | 105 ++++--- .../LearningResourceCard.test.tsx | 40 ++- .../LearningResourceCard.tsx | 12 +- .../LearningResourceListCard.test.tsx | 40 ++- .../LearningResourceListCard.tsx | 12 +- .../LearningResourceListCardCondensed.tsx | 12 +- 13 files changed, 631 insertions(+), 280 deletions(-) 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/NewsEventsSection.tsx b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx index 9c6923bd8a..2a5939b82d 100644 --- a/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx +++ b/frontends/main/src/app-pages/HomePage/NewsEventsSection.tsx @@ -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,7 +188,7 @@ const Story: React.FC<{ item: NewsFeedItem; mobile: boolean }> = ({ mobile, }) => { return ( - + {item.image.url ? ( ) : null} @@ -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 (
diff --git a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx index 1689b5bd71..976e13e696 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/src/components/Card/Card.test.tsx b/frontends/ol-components/src/components/Card/Card.test.tsx index a087bedb6d..d0b9827580 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,97 @@ 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 }) => { + const href = "#woof" + 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" }) + const link = 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(link) + 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..c4ed39316e 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,59 @@ 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, +}) => { + 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 +92,7 @@ const Info = styled.div<{ size?: Size }>` margin-bottom: ${({ size }) => (size === "small" ? 4 : 8)}px; ` -const Title = styled.span<{ lines?: number; size?: Size }>` +const Title = styled(Linkable)<{ lines?: number; size?: Size }>` text-overflow: ellipsis; height: ${({ lines, size }) => { const lineHeightPx = size === "small" ? 18 : 20 @@ -130,17 +139,76 @@ 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 useClickChildHref = ( + href?: string, + 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[href="${href}"]`, + ) + 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() + } + }, + [href, onClick], + ) +} + type CardProps = { children: ReactNode[] | ReactNode className?: string size?: Size + /** + * If provided, the card will render its title as a link. + * + * Clicks on the entire card can be forwarded to the link via `forwardClicksToLink`. + */ href?: string -} + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * href link as well. + * + * NOTES: + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with the card's href. + * - 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 @@ -152,6 +220,7 @@ type TitleProps = { lines?: number style?: CSSProperties } + type SlotProps = { children?: ReactNode; style?: CSSProperties } type Card = FC & { @@ -163,7 +232,15 @@ type Card = FC & { Actions: FC } -const Card: Card = ({ children, className, size, href }) => { +const Card: Card = ({ + children, + className, + size, + href, + onClick, + forwardClicksToLink = false, + ...others +}) => { let content, image: ImageProps | null = null, info: SlotProps = {}, @@ -171,8 +248,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 +271,64 @@ const Card: Card = ({ children, className, size, href }) => { else if (child.type === Actions) actions = child.props }) + const hasHref = typeof href === "string" + const handleHrefClick = useClickChildHref(href, onClick) + const handleClick = hasHref && 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} + + )} +
+ ) } diff --git a/frontends/ol-components/src/components/Card/ListCard.test.tsx b/frontends/ol-components/src/components/Card/ListCard.test.tsx index 3fc65edb90..bf18d6385a 100644 --- a/frontends/ol-components/src/components/Card/ListCard.test.tsx +++ b/frontends/ol-components/src/components/Card/ListCard.test.tsx @@ -1,17 +1,111 @@ -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 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(".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" }) + const link = 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(link) + 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..31f44e5079 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, useClickChildHref, 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,7 @@ export const Info = styled.div` align-items: center; ` -export const Title = styled.span` +export const Title = styled(Linkable)` flex-grow: 1; color: ${theme.custom.colors.darkGray2}; text-overflow: ellipsis; @@ -136,19 +117,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 +142,63 @@ const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>( type CardProps = { children: ReactNode[] | ReactNode className?: string + /** + * If provided, the card will render its title as a link. + * + * Clicks on the entire card can be forwarded to the link via `forwardClicksToLink`. + */ href?: string + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * href link as well. + * + * NOTES: + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with the card's href. + * - 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 +type TitleProps = { + children?: ReactNode } + export type Card = FC & { Content: FC<{ children: ReactNode }> Image: FC Info: FC<{ children: ReactNode }> - Title: FC<{ children: ReactNode }> + 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, + href, + forwardClicksToLink = false, + draggable, + onClick, + ...others +}) => { + let content, imageProps, info, footer, actions + let title: TitleProps = {} + const hasHref = typeof href === "string" + const handleHrefClick = useClickChildHref(href, onClick) + const handleClick = hasHref && 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 +206,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 {...title} href={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..e4529fa535 100644 --- a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx +++ b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx @@ -1,20 +1,22 @@ -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, useClickChildHref } 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" @@ -56,36 +58,55 @@ 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 + /** + * If provided, the card will render its title as a link. + * + * Clicks on the entire card can be forwarded to the link via `forwardClicksToLink`. + */ href?: string + /** + * Defaults to `false`. If `true`, clicking the whole card will click the + * href link as well. + * + * NOTES: + * - If using Card.Content to customize, you must ensure the content includes + * an anchor with the card's href. + * - 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 type Card = FC & Omit -const ListCardCondensed: Card = ({ children, className, href, draggable }) => { - const _Container = draggable - ? DraggableContainer - : href - ? LinkContainer - : Container - +const ListCardCondensed: Card = ({ + children, + className, + href, + draggable, + onClick, + forwardClicksToLink = false, + ...others +}) => { let content, info, title, footer, actions + const hasHref = typeof href === "string" + const handleHrefClick = useClickChildHref(href, onClick) + const handleClick = hasHref && forwardClicksToLink ? handleHrefClick : onClick + Children.forEach(children, (child) => { if (!isValidElement(child)) return if (child.type === Content) content = child.props.children @@ -97,32 +118,30 @@ const ListCardCondensed: Card = ({ children, className, href, draggable }) => { 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}</TruncateText> + + +
{footer}
+ {actions && {actions}} +
+ +
) } 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..059253ad62 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx @@ -229,8 +229,16 @@ const LearningResourceCard: React.FC = ({ return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + = ({ {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index d90eae55ae..7c635b098d 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 () => { diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index 6778afc08b..1757cffa63 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -307,8 +307,16 @@ const LearningResourceListCard: React.FC = ({ if (!resource) { return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + = ({ {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx index 2bb7c4a1cd..1b0bd72d94 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx @@ -115,8 +115,16 @@ const LearningResourceListCardCondensed: React.FC< if (!resource) { return null } + const readableType = getReadableResourceType(resource.resource_type) return ( - + @@ -134,7 +142,7 @@ const LearningResourceListCardCondensed: React.FC< {onAddToUserListClick && ( onAddToUserListClick(event, resource.id)} > {inUserList ? ( From dd8fd99922a7e2fe71226b8e7514b4f020c694fb Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 30 Oct 2024 13:34:50 -0400 Subject: [PATCH 02/13] move facets first in tab order (#1751) --- .../ChannelPage/ChannelPage.test.tsx | 4 +- .../app-pages/SearchPage/SearchPage.test.tsx | 16 +++- .../SearchDisplay/SearchDisplay.tsx | 82 ++++++++++--------- 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx b/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx index 1835398066..fdd6177a76 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) @@ -436,8 +436,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/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/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} + - + ) } From e460daa5b523b1803cba7618fe400f62e2d93d10 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Thu, 31 Oct 2024 11:17:48 -0400 Subject: [PATCH 03/13] Change frontend to use resource_prices field instead of prices field (#1737) --- .../LearningResourceCard.stories.tsx | 2 +- .../LearningResourceListCard.stories.tsx | 2 +- .../LearningResourceListCard.test.tsx | 21 +++-- ...rningResourceListCardCondensed.stories.tsx | 2 +- .../LearningResourceCard/testUtils.ts | 26 ++++-- .../LearningResourceExpanded.stories.tsx | 5 +- .../LearningResourceExpanded.test.tsx | 8 +- frontends/ol-utilities/package.json | 1 + .../src/learning-resources/pricing.test.ts | 88 +++++++++++++++---- .../src/learning-resources/pricing.ts | 38 +++++--- yarn.lock | 8 ++ 11 files changed, 152 insertions(+), 49 deletions(-) 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/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 7c635b098d..d22ac80d42 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -211,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") @@ -221,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") @@ -233,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") @@ -245,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") @@ -255,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") @@ -265,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") @@ -275,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/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/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts index da7bbf6b5f..0ebaa910e7 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts @@ -48,21 +48,28 @@ 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" }, + ], }), }, unknownPrice: { @@ -71,14 +78,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 +94,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/LearningResourceExpanded.stories.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx index 33271da082..876b532281 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx @@ -139,7 +139,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/LearningResourceExpanded.test.tsx index 0af5945c86..e8d33eca46 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx @@ -169,10 +169,14 @@ describe("Learning Resource Expanded", () => { .getByRole("heading", { name: "Info" })! .closest("section")! - const price = run.prices?.[0] + const price = run.resource_prices?.[0] const displayPrice = - parseFloat(price!) === 0 ? "Free" : price ? `$${price}` : null + parseFloat(price.amount) === 0 + ? "Free" + : price.amount + ? `$${price.amount}` + : null if (displayPrice) { within(section).getByText(displayPrice) } diff --git a/frontends/ol-utilities/package.json b/frontends/ol-utilities/package.json index a81c595f3a..43115e1a00 100644 --- a/frontends/ol-utilities/package.json +++ b/frontends/ol-utilities/package.json @@ -16,6 +16,7 @@ "@faker-js/faker": "^9.0.0", "api": "workspace:*", "classnames": "^2.3.2", + "currency-symbol-map": "^5.1.0", "decimal.js-light": "^2.5.1", "html-entities": "^2.4.0", "iso-639-1": "^3.1.0", diff --git a/frontends/ol-utilities/src/learning-resources/pricing.test.ts b/frontends/ol-utilities/src/learning-resources/pricing.test.ts index c6d62025a1..47e7c2adb6 100644 --- a/frontends/ol-utilities/src/learning-resources/pricing.test.ts +++ b/frontends/ol-utilities/src/learning-resources/pricing.test.ts @@ -6,10 +6,10 @@ describe("getLearningResourcePrices", () => { const resource = factories.learningResources.resource({ free: true, certification: false, - prices: ["0"], + resource_prices: [{ amount: "0", currency: "USD" }], }) expect(getLearningResourcePrices(resource)).toEqual({ - course: { value: [0], display: "Free" }, + course: { value: [{ amount: "0", currency: "USD" }], display: "Free" }, certificate: { value: null, display: null }, }) }) @@ -18,11 +18,17 @@ describe("getLearningResourcePrices", () => { const resource = factories.learningResources.resource({ free: true, certification: true, - prices: ["0", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], }) expect(getLearningResourcePrices(resource)).toEqual({ - course: { value: [0], display: "Free" }, - certificate: { value: [49], display: "$49" }, + course: { value: [{ amount: "0", currency: "USD" }], display: "Free" }, + certificate: { + value: [{ amount: "49", currency: "USD" }], + display: "$49", + }, }) }) @@ -30,11 +36,21 @@ describe("getLearningResourcePrices", () => { const resource = factories.learningResources.resource({ free: true, certification: true, - prices: ["0", "99", "49"], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "99", currency: "USD" }, + { amount: "49", currency: "USD" }, + ], }) expect(getLearningResourcePrices(resource)).toEqual({ - course: { value: [0], display: "Free" }, - certificate: { value: [49, 99], display: "$49 – $99" }, + course: { value: [{ amount: "0", currency: "USD" }], display: "Free" }, + certificate: { + value: [ + { amount: "49", currency: "USD" }, + { amount: "99", currency: "USD" }, + ], + display: "$49 – $99", + }, }) }) @@ -42,10 +58,10 @@ describe("getLearningResourcePrices", () => { const resource = factories.learningResources.resource({ free: false, certification: false, - prices: ["49"], + resource_prices: [{ amount: "49", currency: "USD" }], }) expect(getLearningResourcePrices(resource)).toEqual({ - course: { value: [49], display: "$49" }, + course: { value: [{ amount: "49", currency: "USD" }], display: "$49" }, certificate: { value: null, display: null }, }) }) @@ -54,22 +70,64 @@ describe("getLearningResourcePrices", () => { const resource = factories.learningResources.resource({ free: false, certification: true, - prices: ["49"], + resource_prices: [{ amount: "49", currency: "USD" }], }) expect(getLearningResourcePrices(resource)).toEqual({ - course: { value: [49], display: "$49" }, + course: { value: [{ amount: "49", currency: "USD" }], display: "$49" }, certificate: { value: null, display: null }, }) }) - it("paid course with certificate range", async () => { + it("paid course with bad currency code should default to $", async () => { const resource = factories.learningResources.resource({ free: false, certification: true, - prices: ["49", "99"], + resource_prices: [{ amount: "49", currency: "YYY" }], }) expect(getLearningResourcePrices(resource)).toEqual({ - course: { value: [49, 99], display: "$49 – $99" }, + course: { value: [{ amount: "49", currency: "YYY" }], display: "$49" }, + certificate: { value: null, display: null }, + }) + }) + + it("paid course with certificate range and Euro currency", async () => { + const resource = factories.learningResources.resource({ + free: false, + certification: true, + resource_prices: [ + { amount: "49", currency: "EUR" }, + { amount: "99", currency: "EUR" }, + ], + }) + expect(getLearningResourcePrices(resource)).toEqual({ + course: { + value: [ + { amount: "49", currency: "EUR" }, + { amount: "99", currency: "EUR" }, + ], + display: "€49 – €99", + }, + certificate: { value: null, display: null }, + }) + }) + + it("paid course with certificate range and ETB currency", async () => { + const resource = factories.learningResources.resource({ + free: false, + certification: true, + resource_prices: [ + { amount: "49", currency: "ETB" }, + { amount: "99", currency: "ETB" }, + ], + }) + expect(getLearningResourcePrices(resource)).toEqual({ + course: { + value: [ + { amount: "49", currency: "ETB" }, + { amount: "99", currency: "ETB" }, + ], + display: "Br 49 – Br 99", + }, certificate: { value: null, display: null }, }) }) diff --git a/frontends/ol-utilities/src/learning-resources/pricing.ts b/frontends/ol-utilities/src/learning-resources/pricing.ts index 2681b060a1..f8f5867a98 100644 --- a/frontends/ol-utilities/src/learning-resources/pricing.ts +++ b/frontends/ol-utilities/src/learning-resources/pricing.ts @@ -1,5 +1,6 @@ -import { LearningResource, ResourceTypeEnum } from "api" +import { LearningResource, LearningResourcePrice, ResourceTypeEnum } from "api" import { findBestRun } from "ol-utilities" +import getSymbolFromCurrency from "currency-symbol-map" /* * This constant represents the value displayed when a course is free. @@ -16,24 +17,28 @@ type Prices = { * The price of the course, which can be a number or a range of numbers. * If the course is free, the value is 0. If the course is paid, the value is "Paid". * - * @type {null | number[] | typeof PAID} + * @type {null | LearningResourcePrice[] | typeof PAID} * @memberof Prices */ - course: null | number[] | typeof PAID + course: null | LearningResourcePrice[] | typeof PAID /** * The price of the certificate, which can be a number or a range of numbers. * * @type {null | number[]} * @memberof Prices */ - certificate: null | number[] + certificate: null | LearningResourcePrice[] } const getPrices = (resource: LearningResource): Prices => { - const sortedNonzero = resource.prices - .map((price) => Number(price)) - .sort((a, b) => a - b) - .filter((price) => price > 0) + const sortedNonzero = resource.resource_prices + ? resource.resource_prices + .sort( + (a: LearningResourcePrice, b: LearningResourcePrice) => + Number(a.amount) - Number(b.amount), + ) + .filter((price: LearningResourcePrice) => Number(price.amount) > 0) + : [] const priceRange = sortedNonzero.filter( (price, index, arr) => index === 0 || index === arr.length - 1, @@ -42,8 +47,8 @@ const getPrices = (resource: LearningResource): Prices => { if (resource.free) { return resource.certification - ? { course: [0], certificate: prices } - : { course: [0], certificate: null } + ? { course: [{ amount: "0", currency: "USD" }], certificate: prices } + : { course: [{ amount: "0", currency: "USD" }], certificate: null } } return { course: prices ?? PAID, @@ -66,12 +71,12 @@ const getDisplayPrice = (price: Prices["course"] | Prices["certificate"]) => { return PAID } if (price.length > 1) { - return `$${getDisplayPrecision(price[0])} – $${getDisplayPrecision(price[1])}` + return `${getCurrencySymbol(price[0].currency)}${getDisplayPrecision(Number(price[0].amount))} – ${getCurrencySymbol(price[0].currency)}${getDisplayPrecision(Number(price[1].amount))}` } else if (price.length === 1) { - if (price[0] === 0) { + if (Number(price[0].amount) === 0) { return FREE } - return `$${getDisplayPrecision(price[0])}` + return `${getCurrencySymbol(price[0].currency)}${getDisplayPrecision(Number(price[0].amount))}` } return null } @@ -105,3 +110,10 @@ export const getResourceDate = (resource: LearningResource): string | null => { return startDate ?? null } + +export const getCurrencySymbol = (currencyCode: string) => { + const symbol = getSymbolFromCurrency(currencyCode) ?? "$" + // Some currency symbols are just chars (like CHF). In that case, + // append a space to separate the symbol from the amount. + return !symbol.match(/^[0-9A-Za-z]+$/) ? symbol : `${symbol} ` +} diff --git a/yarn.lock b/yarn.lock index 6d6590acce..0bf1fd5463 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9300,6 +9300,13 @@ __metadata: languageName: node linkType: hard +"currency-symbol-map@npm:^5.1.0": + version: 5.1.0 + resolution: "currency-symbol-map@npm:5.1.0" + checksum: 10/7f6a4c58af8dc0150568a32bc5baa5dd8481abe6ae28918c2d7ccd86e8bd7e9dfa08e3bdeeb256da5dffb9974a91c11ce430023bf47fd9a19ea6f126668a4a20 + languageName: node + linkType: hard + "damerau-levenshtein@npm:^1.0.8": version: 1.0.8 resolution: "damerau-levenshtein@npm:1.0.8" @@ -16112,6 +16119,7 @@ __metadata: casual-browserify: "npm:^1.5.19-2" classnames: "npm:^2.3.2" css-mediaquery: "npm:^0.1.2" + currency-symbol-map: "npm:^5.1.0" decimal.js-light: "npm:^2.5.1" html-entities: "npm:^2.4.0" iso-639-1: "npm:^3.1.0" From 71bee99ff3ddf9971e42de3b6690d6b94d9a748b Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Thu, 31 Oct 2024 14:12:52 -0400 Subject: [PATCH 04/13] add new learning resource drawer layout (#1711) * new width and title section * move close button into drawer content * use flex instead of absolute positioning for close button * reorganize top level layout and remove lorem ipsum * info section styles * call to action styling * simply flip flex direction at medium for now on drawer content * switch out certificate icon * use new separator style on instructors in info section * remove extraneous container gap * mobile styling * add back bookmark and learning path buttons * fix certificate display and hide info item icons on mobile * align info item labels center * fix tests, add new tests * move title container outside of content container so that when you scroll and it stays on top the box shadow doesn't leak outside the padding boundary * convert LearningResourceExpanded styled element to object notation because it's cleaner and prevents errors about incorrect CSS rule names * remove remaining traces of run selection dropdown logic, display unique list of values based on all runs for levels, languages and instructors * wrap embedly card in a container to restrict its width in cta section * remove extraneous extra top padding * move start date above price * use padding for separator instead of spaces * updated boxShadow value for cta section * fix rebase issue with closeDrawer parameter * refactor getCallToActionText * prevent wrapping mid-item * fix wrapping in Chrome * re-add hideCloseButton argument on LearningResourceDrawer's RoutedDrawer component that got removed during rebase * default CTA for non-OCW courses should be "Learn More" * adjust description line height to 1.2rem * fix info items alignment * further fix icon alignment * remove incorrectly placed line height directive and set row-gap on info section values * load the playlist URL onto the playlist learning resource in ETL * set the data during the transform step instead, and also pull in the playlist thumbnail * fix alignment of list buttons when platform is not present * fix description pushing call to action off the drawer * grow the left side * fix test * fix tests * mobile style fixes * fix key prop issue * fix drawer max width * fix info item word wrap * make tablet view a little better * refine info section value items text wrapping strategy * add a zero width space to the end of the separator * fix tests * wrap price / certificate display on mobile * add another comment explaining the separator * also reduce font size of the certificate section on mobile so it doesn't wrap in the worst case scenario * put the new drawer behind a posthog feature flag * remove debug code * fix tests --- frontends/main/src/common/feature_flags.ts | 1 + .../LearningResourceDrawer.tsx | 134 +---- ....tsx => LearningResourceDrawerV1.test.tsx} | 24 +- .../LearningResourceDrawerV1.tsx | 131 +++++ .../LearningResourceDrawerV2.test.tsx | 142 ++++++ .../LearningResourceDrawerV2.tsx | 146 ++++++ .../components/EmbedlyCard/EmbedlyCard.tsx | 1 + .../LearningResourceCard/testUtils.ts | 28 ++ ...ection.test.tsx => InfoSectionV1.test.tsx} | 32 +- .../{InfoSection.tsx => InfoSectionV1.tsx} | 4 +- .../InfoSectionV2.test.tsx | 159 ++++++ .../InfoSectionV2.tsx | 403 +++++++++++++++ .../LearningResourceExpanded.stories.tsx | 12 +- ...sx => LearningResourceExpandedV1.test.tsx} | 16 +- ...ded.tsx => LearningResourceExpandedV1.tsx} | 12 +- .../LearningResourceExpandedV2.test.tsx | 268 ++++++++++ .../LearningResourceExpandedV2.tsx | 464 ++++++++++++++++++ .../components/RoutedDrawer/RoutedDrawer.tsx | 33 +- frontends/ol-components/src/index.ts | 3 +- .../learning-resources/learning-resources.ts | 25 + learning_resources/etl/loaders.py | 6 + learning_resources/etl/loaders_test.py | 22 +- learning_resources/etl/youtube.py | 5 + learning_resources/etl/youtube_test.py | 13 + main/templates/email/email_base.html | 6 +- .../email/subscribed_channel_digest.html | 2 +- 26 files changed, 1891 insertions(+), 201 deletions(-) rename frontends/main/src/page-components/LearningResourceDrawer/{LearningResourceDrawer.test.tsx => LearningResourceDrawerV1.test.tsx} (83%) create mode 100644 frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx create mode 100644 frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx create mode 100644 frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx rename frontends/ol-components/src/components/LearningResourceExpanded/{InfoSection.test.tsx => InfoSectionV1.test.tsx} (75%) rename frontends/ol-components/src/components/LearningResourceExpanded/{InfoSection.tsx => InfoSectionV1.tsx} (99%) create mode 100644 frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx create mode 100644 frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx rename frontends/ol-components/src/components/LearningResourceExpanded/{LearningResourceExpanded.test.tsx => LearningResourceExpandedV1.test.tsx} (96%) rename frontends/ol-components/src/components/LearningResourceExpanded/{LearningResourceExpanded.tsx => LearningResourceExpandedV1.tsx} (97%) create mode 100644 frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx create mode 100644 frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx 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/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/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx index b9bee73776..94c36354b7 100644 --- a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx +++ b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx @@ -88,6 +88,7 @@ const EmbedlyCard: React.FC = ({ a.dataset.cardKey = embedlyKey ?? getEmbedlyKey() ?? "" a.href = url a.classList.add("embedly-card") + a.dataset["testid"] = "embedly-card" container.appendChild(a) }, [embedlyKey, container, url]) diff --git a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts index 0ebaa910e7..79eacb61b3 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts @@ -71,6 +71,34 @@ const courses = { { 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: { noCertificate: makeResource({ 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..e438cffb77 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx @@ -0,0 +1,159 @@ +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) => { + console.log(node?.textContent) + 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 876b532281..c6af08ffb4 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,9 +16,9 @@ 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: "", @@ -54,7 +54,7 @@ const meta: Meta = { return ( - + ) @@ -63,7 +63,7 @@ const meta: Meta = { export default meta -type Story = StoryObj +type Story = StoryObj export const Course: Story = { args: { 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 e8d33eca46..1e38706ff4 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" @@ -13,7 +13,7 @@ import type { LearningResource } from "api" import { faker } from "@faker-js/faker/locale/en" import { PLATFORMS } from "../Logo/Logo" -const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = { +const IMG_CONFIG: LearningResourceExpandedV1Props["imgConfig"] = { key: "fake-key", width: 385, height: 200, @@ -22,7 +22,7 @@ const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = { const setup = (resource: LearningResource) => { return render( - + , { wrapper: ThemeProvider }, ) @@ -169,14 +169,10 @@ describe("Learning Resource Expanded", () => { .getByRole("heading", { name: "Info" })! .closest("section")! - const price = run.resource_prices?.[0] + const price = run.prices?.[0] const displayPrice = - parseFloat(price.amount) === 0 - ? "Free" - : price.amount - ? `$${price.amount}` - : null + parseFloat(price!) === 0 ? "Free" : price ? `$${price}` : null if (displayPrice) { within(section).getByText(displayPrice) } diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx similarity index 97% rename from frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx rename to frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx index fdd3b2802d..5af9ac6066 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx @@ -19,7 +19,7 @@ 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 InfoSectionV1 from "./InfoSectionV1" import type { User } from "api/hooks/user" import { LearningResourceCardProps } from "../LearningResourceCard/LearningResourceCard" @@ -138,7 +138,7 @@ const OnPlatform = styled.span` color: ${theme.custom.colors.black}; ` -type LearningResourceExpandedProps = { +type LearningResourceExpandedV1Props = { resource?: LearningResource user?: User imgConfig: EmbedlyConfig @@ -319,7 +319,7 @@ const formatRunDate = ( return null } -const LearningResourceExpanded: React.FC = ({ +const LearningResourceExpandedV1: React.FC = ({ resource, user, imgConfig, @@ -421,7 +421,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..1d40a84d27 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx @@ -0,0 +1,268 @@ +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 { PLATFORMS } from "../Logo/Logo" +import _ from "lodash" + +const IMG_CONFIG: LearningResourceExpandedV2Props["imgConfig"] = { + key: "fake-key", + 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", PLATFORMS["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..5b3506d9a8 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx @@ -0,0 +1,464 @@ +import React from "react" +import styled from "@emotion/styled" +import Skeleton from "@mui/material/Skeleton" +import Typography from "@mui/material/Typography" +import { ActionButton, ButtonLink } from "../Button/Button" +import type { LearningResource } from "api" +import { ResourceTypeEnum, PlatformEnum } from "api" +import { + resourceThumbnailSrc, + DEFAULT_RESOURCE_IMG, + getReadableResourceType, +} from "ol-utilities" +import { + RiBookmarkLine, + RiCloseLargeLine, + RiExternalLinkLine, + RiMenuAddLine, +} from "@remixicon/react" +import type { EmbedlyConfig } from "ol-utilities" +import { theme } from "../ThemeProvider/ThemeProvider" +import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard" +import { PlatformLogo, PLATFORMS } from "../Logo/Logo" +import InfoSectionV2 from "./InfoSectionV2" +import type { User } from "api/hooks/user" +import { LearningResourceCardProps } from "../LearningResourceCard/LearningResourceCard" +import { CardActionButton } from "../LearningResourceCard/LearningResourceListCard" + +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 EmbedlyContainer = styled.div({ + width: "100%", + overflow: "hidden", +}) + +const Image = styled.img<{ aspect: number }>((aspect) => ({ + aspectRatio: aspect.aspect, + 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: EmbedlyConfig + 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: EmbedlyConfig +}> = ({ resource, config }) => { + 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: EmbedlyConfig + 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 = PLATFORMS[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/RoutedDrawer/RoutedDrawer.tsx b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx index 59e73ae447..a1727ec441 100644 --- a/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx +++ b/frontends/ol-components/src/components/RoutedDrawer/RoutedDrawer.tsx @@ -11,18 +11,11 @@ import { } from "next/navigation" import { useToggle } from "ol-utilities" -const closeSx: React.CSSProperties = { +const CloseButton = styled(ActionButton)({ position: "absolute", top: "16px", right: "22px", -} - -const CloseIcon = styled(RiCloseLargeLine)` - &&& { - width: 18px; - height: 18px; - } -` +}) type ChildParams = Record & Record @@ -31,6 +24,7 @@ type RoutedDrawerProps = { params?: readonly K[] requiredParams: readonly R[] onView?: () => void + hideCloseButton?: boolean children: (childProps: { params: ChildParams closeDrawer: () => void @@ -40,7 +34,7 @@ type RoutedDrawerProps = { const RoutedDrawer = ( props: RoutedDrawerProps, ) => { - const { requiredParams, children, onView, ...others } = props + const { requiredParams, children, onView, hideCloseButton, ...others } = props const { params = requiredParams } = props const [open, setOpen] = useToggle(false) @@ -111,15 +105,16 @@ const RoutedDrawer = ( params: childParams as Record, closeDrawer: setOpen.off, })} - - - + {!hideCloseButton && ( + + + + )} } diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 9c48ffe86c..b064100e50 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -178,7 +178,8 @@ export * from "./components/LearningResourceCard/LearningResourceCard" export { LearningResourceListCard } from "./components/LearningResourceCard/LearningResourceListCard" export type { LearningResourceListCardProps } from "./components/LearningResourceCard/LearningResourceListCard" export * from "./components/LearningResourceCard/LearningResourceListCardCondensed" -export * from "./components/LearningResourceExpanded/LearningResourceExpanded" +export * from "./components/LearningResourceExpanded/LearningResourceExpandedV1" +export * from "./components/LearningResourceExpanded/LearningResourceExpandedV2" export * from "./components/LoadingSpinner/LoadingSpinner" export * from "./components/Logo/Logo" export * from "./components/NavDrawer/NavDrawer" diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.ts index 1f9160fc5e..5c59d48cab 100644 --- a/frontends/ol-utilities/src/learning-resources/learning-resources.ts +++ b/frontends/ol-utilities/src/learning-resources/learning-resources.ts @@ -1,6 +1,8 @@ import moment from "moment" import type { LearningResource, LearningResourceRun } from "api" import { ResourceTypeEnum } from "api" +import { capitalize } from "lodash" +import { formatDate } from "../date/format" const readableResourceTypes: Record = { [ResourceTypeEnum.Course]: "Course", @@ -92,11 +94,34 @@ const findBestRun = ( return past[0] ?? sorted[0] } +const formatRunDate = ( + run: LearningResourceRun, + asTaughtIn: boolean, +): string | null => { + if (asTaughtIn) { + const semester = capitalize(run.semester ?? "") + if (semester && run.year) { + return `${semester} ${run.year}` + } + if (semester && run.start_date) { + return `${semester} ${formatDate(run.start_date, "YYYY")}` + } + if (run.start_date) { + return formatDate(run.start_date, "MMMM, YYYY") + } + } + if (run.start_date) { + return formatDate(run.start_date, "MMMM DD, YYYY") + } + return null +} + export { DEFAULT_RESOURCE_IMG, embedlyCroppedImage, resourceThumbnailSrc, getReadableResourceType, findBestRun, + formatRunDate, } export type { EmbedlyConfig } diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py index 9def0aafe3..0b92941366 100644 --- a/learning_resources/etl/loaders.py +++ b/learning_resources/etl/loaders.py @@ -988,10 +988,16 @@ def load_playlist(video_channel: VideoChannel, playlist_data: dict) -> LearningR """ playlist_id = playlist_data.pop("playlist_id") + thumbnail_data = playlist_data.pop("image", None) videos_data = playlist_data.pop("videos", []) offered_bys_data = playlist_data.pop("offered_by", None) with transaction.atomic(): + image, _ = LearningResourceImage.objects.update_or_create( + url=thumbnail_data.get("url"), + alt=thumbnail_data.get("alt"), + ) + playlist_data["image"] = image playlist_resource, created = LearningResource.objects.update_or_create( readable_id=playlist_id, resource_type=LearningResourceType.video_playlist.name, diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 70acf2e8e8..262bc24233 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -1318,6 +1318,11 @@ def test_load_playlist(mocker): "platform": PlatformType.youtube.name, "offered_by": {"code": LearningResourceOfferorFactory.create().code}, "playlist_id": playlist.readable_id, + "url": f"https://youtube.com/playlist?list={playlist.readable_id}", + "image": { + "url": f"https://i.ytimg.com/vi/{playlist.readable_id}/hqdefault.jpg", + "alt": playlist.title, + }, "videos": videos_data, } @@ -1342,10 +1347,17 @@ def test_load_playlists_unpublish(mocker): VideoPlaylistFactory.create_batch(4, channel=channel), key=lambda playlist: playlist.id, ) + playlist_id = playlists[0].learning_resource.readable_id + playlist_title = playlists[0].learning_resource.title assert playlists[0].learning_resource.published is True playlists_data = [ { - "playlist_id": playlists[0].learning_resource.readable_id, + "playlist_id": playlist_id, + "url": f"https://youtube.com/playlist?list={playlist_id}", + "image": { + "url": f"https://i.ytimg.com/vi/{playlist_id}/hqdefault.jpg", + "alt": playlist_title, + }, "published": True, "videos": [], } @@ -1378,7 +1390,13 @@ def test_load_video_channels(): playlist = VideoPlaylistFactory.build() playlist_data = model_to_dict(playlist) - playlist_data["playlist_id"] = playlist.learning_resource.readable_id + playlist_id = playlist.learning_resource.readable_id + playlist_data["playlist_id"] = playlist_id + playlist_data["url"] = f"https://youtube.com/playlist?list={playlist_id}" + playlist_data["image"] = { + "url": f"https://i.ytimg.com/vi/{playlist_id}/hqdefault.jpg", + "alt": playlist.learning_resource.title, + } del playlist_data["id"] del playlist_data["channel"] del playlist_data["learning_resource"] diff --git a/learning_resources/etl/youtube.py b/learning_resources/etl/youtube.py index c6446a3fd2..56ff8a358b 100644 --- a/learning_resources/etl/youtube.py +++ b/learning_resources/etl/youtube.py @@ -458,6 +458,11 @@ def transform_playlist( transform_video(extracted_video, offered_by_code) for extracted_video in videos ), + "url": f"https://www.youtube.com/playlist?list={playlist_data['id']}", + "image": { + "url": playlist_data["snippet"]["thumbnails"]["high"]["url"], + "alt": playlist_data["snippet"]["title"], + }, "availability": Availability.anytime.name, } diff --git a/learning_resources/etl/youtube_test.py b/learning_resources/etl/youtube_test.py index fd7a90c771..7f3b1ccefd 100644 --- a/learning_resources/etl/youtube_test.py +++ b/learning_resources/etl/youtube_test.py @@ -201,11 +201,16 @@ def extracted_and_transformed_values(youtube_api_responses): "title": playlist["snippet"]["title"], "platform": PlatformType.youtube.name, "etl_source": ETLSource.youtube.name, + "image": { + "url": playlist["snippet"]["thumbnails"]["high"]["url"], + "alt": playlist["snippet"]["title"], + }, "offered_by": {"code": offered_by} if offered_by != "csail" else None, "availability": Availability.anytime.name, "published": True, + "url": f"https://www.youtube.com/playlist?list={playlist['id']}", "videos": [ { "readable_id": video["id"], @@ -455,6 +460,14 @@ def test_transform_playlist( assert {**result, "videos": list(result["videos"])} == { **transformed[0]["playlists"][0], } + assert ( + result["url"] + == f"https://www.youtube.com/playlist?list={extracted[0][2][0][0]['id']}" + ) + assert result["image"] == { + "url": extracted[0][2][0][0]["snippet"]["thumbnails"]["high"]["url"], + "alt": extracted[0][2][0][0]["snippet"]["title"], + } def test_transform(extracted_and_transformed_values): diff --git a/main/templates/email/email_base.html b/main/templates/email/email_base.html index 5ee577f21f..9c1126da3b 100644 --- a/main/templates/email/email_base.html +++ b/main/templates/email/email_base.html @@ -261,7 +261,7 @@ MIT-Learn MIT-Learnunsubscribe.

diff --git a/main/templates/email/subscribed_channel_digest.html b/main/templates/email/subscribed_channel_digest.html index cf2ac84f78..ccf8ec4b39 100644 --- a/main/templates/email/subscribed_channel_digest.html +++ b/main/templates/email/subscribed_channel_digest.html @@ -23,7 +23,7 @@ font-weight: bold; " > - {{short_subject}} + {{ short_subject }} From c5e50c493cede1654248f3943c7fec4bb292d790 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 31 Oct 2024 19:14:56 +0100 Subject: [PATCH 05/13] Fix issue with hero image causing horizontal scroll (#1777) --- frontends/main/src/app-pages/HomePage/HomePage.tsx | 10 ++++++++-- .../main/src/page-components/HeroSearch/HeroSearch.tsx | 10 ++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontends/main/src/app-pages/HomePage/HomePage.tsx b/frontends/main/src/app-pages/HomePage/HomePage.tsx index 553c797d70..ac4e7891a0 100644 --- a/frontends/main/src/app-pages/HomePage/HomePage.tsx +++ b/frontends/main/src/app-pages/HomePage/HomePage.tsx @@ -38,12 +38,18 @@ const MediaCarousel = styled(ResourceCarousel)(({ theme }) => ({ }, })) +const StyledContainer = styled(Container)({ + "@media (max-width: 1365px)": { + overflow: "hidden", + }, +}) + const HomePage: React.FC = () => { return ( <> - +
{ config={carousels.FEATURED_RESOURCES_CAROUSEL} />
-
+
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 ( - + ) } From 53411ea1219e43e2e63ca4d0be7e08ab2c7f4caf Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Fri, 1 Nov 2024 18:57:45 +0100 Subject: [PATCH 06/13] Upgrade to Next.js v15 (#1776) * Upgrade to Next.js v15. Page params are now async * Upgrade @mui/material-nextjs for Next.js v15 * Suspense boundaries needed around carousels (useSearchParams() should be wrapped error) * Upgrade @mui dependencies * CKEditor display not needed and causing error * Update test * Next peer dependency and relax version range. MUI V15 pointer. --- frontends/main/package.json | 4 +- .../main/src/app-pages/HomePage/HomePage.tsx | 26 +- .../[id]/view/ProgramLetter.test.tsx | 2 +- .../[id]/view/ProgramLetterPage.tsx | 23 +- .../src/app/c/[channelType]/[name]/page.tsx | 10 +- .../main/src/app/dashboard/[tab]/page.tsx | 1 + frontends/main/src/app/page.tsx | 6 +- frontends/main/src/app/search/page.tsx | 8 +- frontends/main/src/common/metadata.ts | 4 +- frontends/ol-components/package.json | 12 +- .../InfoSectionV2.test.tsx | 1 - frontends/ol-components/src/index.ts | 2 +- frontends/ol-utilities/package.json | 1 + yarn.lock | 781 +++++++++--------- 14 files changed, 434 insertions(+), 447 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index dec56e6013..b466d99309 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", diff --git a/frontends/main/src/app-pages/HomePage/HomePage.tsx b/frontends/main/src/app-pages/HomePage/HomePage.tsx index ac4e7891a0..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" @@ -52,21 +52,25 @@ const HomePage: React.FC = () => {
- + + +
- + + + diff --git a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetter.test.tsx b/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/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/[id]/view/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/[id]/view/ProgramLetterPage.tsx index f9a793e40c..c77597f94b 100644 --- a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage.tsx +++ b/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/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/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/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/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/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/LearningResourceExpanded/InfoSectionV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx index e438cffb77..d14bac2176 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx @@ -152,7 +152,6 @@ describe("Learning resource info section start date", () => { const section = screen.getByTestId("drawer-info-items") within(section).getByText((_content, node) => { - console.log(node?.textContent) return node?.textContent === expectedDateText || false }) }) diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index b064100e50..1ca28cab61 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -231,4 +231,4 @@ export { MITLearnGlobalStyles } from "./components/ThemeProvider/MITLearnGlobalS export { default as styled } from "@emotion/styled" export { css, Global } from "@emotion/react" -export { AppRouterCacheProvider as NextJsAppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter" +export { AppRouterCacheProvider as NextJsAppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter" diff --git a/frontends/ol-utilities/package.json b/frontends/ol-utilities/package.json index 43115e1a00..b9af50c386 100644 --- a/frontends/ol-utilities/package.json +++ b/frontends/ol-utilities/package.json @@ -7,6 +7,7 @@ "./test-utils/factories": "./src/test-utils/factories.ts" }, "peerDependencies": { + "next": "*", "react": "18.3.1" }, "dependencies": { diff --git a/yarn.lock b/yarn.lock index 0bf1fd5463..7f49a37188 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,7 +1394,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.25.7 resolution: "@babel/runtime@npm:7.25.7" dependencies: @@ -1403,7 +1403,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.25.7": +"@babel/runtime@npm:^7.26.0": version: 7.26.0 resolution: "@babel/runtime@npm:7.26.0" dependencies: @@ -2435,7 +2435,7 @@ __metadata: languageName: node linkType: hard -"@emotion/cache@npm:^11.11.0, @emotion/cache@npm:^11.13.0, @emotion/cache@npm:^11.13.1, @emotion/cache@npm:^11.4.0": +"@emotion/cache@npm:^11.13.0, @emotion/cache@npm:^11.13.1, @emotion/cache@npm:^11.4.0": version: 11.13.1 resolution: "@emotion/cache@npm:11.13.1" dependencies: @@ -2492,7 +2492,7 @@ __metadata: languageName: node linkType: hard -"@emotion/serialize@npm:^1.2.0, @emotion/serialize@npm:^1.3.0, @emotion/serialize@npm:^1.3.1": +"@emotion/serialize@npm:^1.2.0, @emotion/serialize@npm:^1.3.0, @emotion/serialize@npm:^1.3.1, @emotion/serialize@npm:^1.3.2": version: 1.3.2 resolution: "@emotion/serialize@npm:1.3.2" dependencies: @@ -2952,7 +2952,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.0.8, @floating-ui/react-dom@npm:^2.1.1": +"@floating-ui/react-dom@npm:^2.1.1": version: 2.1.2 resolution: "@floating-ui/react-dom@npm:2.1.2" dependencies: @@ -3578,36 +3578,14 @@ __metadata: languageName: node linkType: hard -"@mui/base@npm:5.0.0-beta.40": - version: 5.0.0-beta.40 - resolution: "@mui/base@npm:5.0.0-beta.40" +"@mui/base@npm:5.0.0-beta.61": + version: 5.0.0-beta.61 + resolution: "@mui/base@npm:5.0.0-beta.61" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@floating-ui/react-dom": "npm:^2.0.8" - "@mui/types": "npm:^7.2.14" - "@mui/utils": "npm:^5.15.14" - "@popperjs/core": "npm:^2.11.8" - clsx: "npm:^2.1.0" - prop-types: "npm:^15.8.1" - peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10/ebee3d9e1136710dcb2af5828acc6bd8d54f6b124785d011585c2665a48dc66e35ccb344d5ebc7fd8bfd776cccb8ea434911f151a62bee193677ee9dc67fc7fc - languageName: node - linkType: hard - -"@mui/base@npm:5.0.0-beta.60": - version: 5.0.0-beta.60 - resolution: "@mui/base@npm:5.0.0-beta.60" - dependencies: - "@babel/runtime": "npm:^7.25.7" + "@babel/runtime": "npm:^7.26.0" "@floating-ui/react-dom": "npm:^2.1.1" - "@mui/types": "npm:^7.2.18" - "@mui/utils": "npm:^6.1.5" + "@mui/types": "npm:^7.2.19" + "@mui/utils": "npm:^6.1.6" "@popperjs/core": "npm:^2.11.8" clsx: "npm:^2.1.1" prop-types: "npm:^15.8.1" @@ -3618,58 +3596,61 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/03700f9a9bb4bfaa99daa525c0ecd56060479a918be8b7d38b97dda2217de3f38877c360a4200a09f87a384554d4a896a68472102c0f090e7c3780af265f5201 + checksum: 10/32dbea09ceae6f53f88d753c58690b2824770c4b7c02089d216d769c8f5e8a9a2a837d6ce500f8772a706b942426558223c371dc438940815fdee9e4c820a400 languageName: node linkType: hard -"@mui/core-downloads-tracker@npm:^5.16.7": - version: 5.16.7 - resolution: "@mui/core-downloads-tracker@npm:5.16.7" - checksum: 10/b65c48ba2bf6bba6435ba9f2d6c33db0c8a85b3ff7599136a9682b72205bec76470ab5ed5e6e625d5bd012ed9bcbc641ed677548be80d217c9fb5d0435567062 +"@mui/core-downloads-tracker@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/core-downloads-tracker@npm:6.1.6" + checksum: 10/c09af6c9888756ae4bef802ef6fe9a23504731d6181790fdcb3ff41a6c936ef1fc0a1afe28320f4696bdc136ddefea4f89b230f0ee4e47e294dcdec8293d5d07 languageName: node linkType: hard -"@mui/lab@npm:^5.0.0-alpha.172": - version: 5.0.0-alpha.173 - resolution: "@mui/lab@npm:5.0.0-alpha.173" +"@mui/lab@npm:6.0.0-beta.14": + version: 6.0.0-beta.14 + resolution: "@mui/lab@npm:6.0.0-beta.14" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@mui/base": "npm:5.0.0-beta.40" - "@mui/system": "npm:^5.16.5" - "@mui/types": "npm:^7.2.15" - "@mui/utils": "npm:^5.16.5" - clsx: "npm:^2.1.0" + "@babel/runtime": "npm:^7.26.0" + "@mui/base": "npm:5.0.0-beta.61" + "@mui/system": "npm:^6.1.6" + "@mui/types": "npm:^7.2.19" + "@mui/utils": "npm:^6.1.6" + clsx: "npm:^2.1.1" prop-types: "npm:^15.8.1" peerDependencies: "@emotion/react": ^11.5.0 "@emotion/styled": ^11.3.0 - "@mui/material": ">=5.15.0" - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + "@mui/material": ^6.1.6 + "@mui/material-pigment-css": ^6.1.6 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true "@emotion/styled": optional: true + "@mui/material-pigment-css": + optional: true "@types/react": optional: true - checksum: 10/6b5bd0665f524d074bba41f76ea70d2938be16dbb7c0360d4d6be6c61d540a4fc06d765ed277ca205a40686212361e528c945b9150caefe40bac483cdec525e0 + checksum: 10/f5d43c7dfdc455a433878991ef1ea4e42443bf9eddb08443cba2196c6e1612460d7cdf5f15a7e554c2a83646a334867a456d3662d2252fb083d2eeaee8cd3b9b languageName: node linkType: hard -"@mui/material-nextjs@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/material-nextjs@npm:5.16.6" +"@mui/material-nextjs@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/material-nextjs@npm:6.1.6" dependencies: - "@babel/runtime": "npm:^7.23.9" + "@babel/runtime": "npm:^7.26.0" peerDependencies: "@emotion/cache": ^11.11.0 + "@emotion/react": ^11.11.4 "@emotion/server": ^11.11.0 - "@mui/material": ^5.0.0 - "@types/react": ^17.0.0 || ^18.0.0 - next: ^13.0.0 || ^14.0.0 - react: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + next: ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/cache": optional: true @@ -3677,22 +3658,22 @@ __metadata: optional: true "@types/react": optional: true - checksum: 10/bb58bd301944a0e8569ffc06e62f546c0aa89b025cd563030f4e7fba20b668d3b215a8ebf1c12bbf0df0449674414908e708a504c4dfa45345f9b8cd33f10e55 + checksum: 10/de3d6fafd60c6fa7450306e0f27d4aaddceca501477ceccce85ca2b0658c84e12c540efed35dcb6a1a43fb5fba8efec9922419e15f73d2da6f2c0c4c2dd4441f languageName: node linkType: hard -"@mui/material@npm:^5.16.1": - version: 5.16.7 - resolution: "@mui/material@npm:5.16.7" +"@mui/material@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/material@npm:6.1.6" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@mui/core-downloads-tracker": "npm:^5.16.7" - "@mui/system": "npm:^5.16.7" - "@mui/types": "npm:^7.2.15" - "@mui/utils": "npm:^5.16.6" + "@babel/runtime": "npm:^7.26.0" + "@mui/core-downloads-tracker": "npm:^6.1.6" + "@mui/system": "npm:^6.1.6" + "@mui/types": "npm:^7.2.19" + "@mui/utils": "npm:^6.1.6" "@popperjs/core": "npm:^2.11.8" - "@types/react-transition-group": "npm:^4.4.10" - clsx: "npm:^2.1.0" + "@types/react-transition-group": "npm:^4.4.11" + clsx: "npm:^2.1.1" csstype: "npm:^3.1.3" prop-types: "npm:^15.8.1" react-is: "npm:^18.3.1" @@ -3700,75 +3681,80 @@ __metadata: peerDependencies: "@emotion/react": ^11.5.0 "@emotion/styled": ^11.3.0 - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + "@mui/material-pigment-css": ^6.1.6 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true "@emotion/styled": optional: true + "@mui/material-pigment-css": + optional: true "@types/react": optional: true - checksum: 10/67f118e5a4bc89553d87b1b5bfe8c37b979ee981415dfda39fba0b27d08636be91fa9f270ea674d19f5a23186f53be67e3eb397f03333a7342170f43db8d0058 + checksum: 10/3a805c86fd3bc475ccd185904fb68e46358a67a9f72b88987d497d65ec982f78a5ebf6c6a3c5b52383b0208a25f1a6ead8955dec7b054f854c9f77701cafc7b9 languageName: node linkType: hard -"@mui/private-theming@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/private-theming@npm:5.16.6" +"@mui/private-theming@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/private-theming@npm:6.1.6" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@mui/utils": "npm:^5.16.6" + "@babel/runtime": "npm:^7.26.0" + "@mui/utils": "npm:^6.1.6" prop-types: "npm:^15.8.1" peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 10/3a7ba9fc5c2f0c8311b5ecadd967e5529ce43c1c5682bfc88d4fe37efdac75e986dd33a45cfecea9561370ad5be659dc32e457e1aff31b861ac93ddd1172a720 + checksum: 10/b79c7d130925f91be863700c442936b51e27c102535e3502005328c30fad11ffa9f80240b213b2094107f9240d3836b583a6e4a98337a912b558d144fd6e29b4 languageName: node linkType: hard -"@mui/styled-engine@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/styled-engine@npm:5.16.6" +"@mui/styled-engine@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/styled-engine@npm:6.1.6" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@emotion/cache": "npm:^11.11.0" + "@babel/runtime": "npm:^7.26.0" + "@emotion/cache": "npm:^11.13.1" + "@emotion/serialize": "npm:^1.3.2" + "@emotion/sheet": "npm:^1.4.0" csstype: "npm:^3.1.3" prop-types: "npm:^15.8.1" peerDependencies: "@emotion/react": ^11.4.1 "@emotion/styled": ^11.3.0 - react: ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true "@emotion/styled": optional: true - checksum: 10/8e241269c2f95038102f4b6b44eda71f5dd5c2e99c5a5902fe41778f609ae83c75ca8c77f94aaf61f07c7275d0d333e53ae9d9ea7a7a402602ec594045c30be3 + checksum: 10/0506a3d771d117d0c422c74295cf19338b00030f98707082e7c87b48931587a194a12eb5ff899407d37cb4c130c93b936731e874be938c3883993ad28baa13c9 languageName: node linkType: hard -"@mui/system@npm:^5.16.1, @mui/system@npm:^5.16.5, @mui/system@npm:^5.16.7": - version: 5.16.7 - resolution: "@mui/system@npm:5.16.7" +"@mui/system@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/system@npm:6.1.6" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@mui/private-theming": "npm:^5.16.6" - "@mui/styled-engine": "npm:^5.16.6" - "@mui/types": "npm:^7.2.15" - "@mui/utils": "npm:^5.16.6" - clsx: "npm:^2.1.0" + "@babel/runtime": "npm:^7.26.0" + "@mui/private-theming": "npm:^6.1.6" + "@mui/styled-engine": "npm:^6.1.6" + "@mui/types": "npm:^7.2.19" + "@mui/utils": "npm:^6.1.6" + clsx: "npm:^2.1.1" csstype: "npm:^3.1.3" prop-types: "npm:^15.8.1" peerDependencies: "@emotion/react": ^11.5.0 "@emotion/styled": ^11.3.0 - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true @@ -3776,48 +3762,28 @@ __metadata: optional: true "@types/react": optional: true - checksum: 10/736d8a7e22b6682fa791caad485462914f0f395043e168e4a09067a2d4f3e3320a6b33fa764b85244bd648d016ec7b539a6d5dfab45302e45f377c64d9c342ca + checksum: 10/00e55bcff7228fa668fe517f74deeb22fa57155f3c181617519e729b36fc7ffb9d1aac7450c33f36456d78fdd369e5ffda6747d459569848915fd69c0b39a8d9 languageName: node linkType: hard -"@mui/types@npm:^7.2.14, @mui/types@npm:^7.2.15, @mui/types@npm:^7.2.18": - version: 7.2.18 - resolution: "@mui/types@npm:7.2.18" +"@mui/types@npm:^7.2.19": + version: 7.2.19 + resolution: "@mui/types@npm:7.2.19" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 10/662b0e2858b1675cff54e69082f2265d4f2be247627f97333a9e6afa7199b6d520f57b3f366c8b62ade1593228a95a7618d21ac229102d58dd4906f804fcd7b6 + checksum: 10/a23bc280c0722527ce5e264b0dcb44271441e4016eb2285acc1f0d236cf78c73ecc3ec7abba81876c2eadf45b905b55eb26e0e824ea6afc233efce2ef5a34f7d languageName: node linkType: hard -"@mui/utils@npm:^5.15.14, @mui/utils@npm:^5.16.5, @mui/utils@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/utils@npm:5.16.6" +"@mui/utils@npm:^6.1.6": + version: 6.1.6 + resolution: "@mui/utils@npm:6.1.6" dependencies: - "@babel/runtime": "npm:^7.23.9" - "@mui/types": "npm:^7.2.15" - "@types/prop-types": "npm:^15.7.12" - clsx: "npm:^2.1.1" - prop-types: "npm:^15.8.1" - react-is: "npm:^18.3.1" - peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10/214bc3e9fe49579c5aee264477c802e5f5ced3473cafb1ed0aacd63db223e2668a08fb1f7304e70ea0511f68200dd80c3b49cc58050c7b0962228758a003371d - languageName: node - linkType: hard - -"@mui/utils@npm:^6.1.5": - version: 6.1.5 - resolution: "@mui/utils@npm:6.1.5" - dependencies: - "@babel/runtime": "npm:^7.25.7" - "@mui/types": "npm:^7.2.18" + "@babel/runtime": "npm:^7.26.0" + "@mui/types": "npm:^7.2.19" "@types/prop-types": "npm:^15.7.13" clsx: "npm:^2.1.1" prop-types: "npm:^15.8.1" @@ -3828,7 +3794,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/8d99b7f0d54ae9b1ac57dfae695afcd15b446364cda0c0ba3ca076a719881ff556564eba62962168719862ee086e12d8ba107440e8aa86fdecfe2eca5826e051 + checksum: 10/0af3d8b03ccfce126f05e1ffa8a01ab993a6e9c0255142c6427e4f8f208083173a3ca71a2c00b687e8cb1c4f97f2c51e1eb8fd593cc0e51a6e32f8bd5590f7c6 languageName: node linkType: hard @@ -3841,10 +3807,10 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:14.2.15": - version: 14.2.15 - resolution: "@next/env@npm:14.2.15" - checksum: 10/76257d838aa8d6ede9240e4e8fd21847304b4d593fb758ea91c96e38818784e4f059d3b4c154e83b21983ea452fc7f4d1dc257d607ebba97c80db06ca4f9148a +"@next/env@npm:15.0.2": + version: 15.0.2 + resolution: "@next/env@npm:15.0.2" + checksum: 10/90da751da2921ee2e455d4d786aa38207c7dcc24f9faa3ad08f79d8ea040198b0d7a9a296feb8921df32024f9e5870eed1b1edc462282ec4d446ae98f87152d0 languageName: node linkType: hard @@ -3857,65 +3823,58 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-darwin-arm64@npm:14.2.15" +"@next/swc-darwin-arm64@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-darwin-arm64@npm:15.0.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-darwin-x64@npm:14.2.15" +"@next/swc-darwin-x64@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-darwin-x64@npm:15.0.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-linux-arm64-gnu@npm:14.2.15" +"@next/swc-linux-arm64-gnu@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-linux-arm64-gnu@npm:15.0.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-linux-arm64-musl@npm:14.2.15" +"@next/swc-linux-arm64-musl@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-linux-arm64-musl@npm:15.0.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-linux-x64-gnu@npm:14.2.15" +"@next/swc-linux-x64-gnu@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-linux-x64-gnu@npm:15.0.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-linux-x64-musl@npm:14.2.15" +"@next/swc-linux-x64-musl@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-linux-x64-musl@npm:15.0.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-win32-arm64-msvc@npm:14.2.15" +"@next/swc-win32-arm64-msvc@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-win32-arm64-msvc@npm:15.0.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-ia32-msvc@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-win32-ia32-msvc@npm:14.2.15" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@next/swc-win32-x64-msvc@npm:14.2.15": - version: 14.2.15 - resolution: "@next/swc-win32-x64-msvc@npm:14.2.15" +"@next/swc-win32-x64-msvc@npm:15.0.2": + version: 15.0.2 + resolution: "@next/swc-win32-x64-msvc@npm:15.0.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4106,7 +4065,16 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.8, @opentelemetry/api@npm:^1.9.0": +"@opentelemetry/api-logs@npm:0.54.0": + version: 0.54.0 + resolution: "@opentelemetry/api-logs@npm:0.54.0" + dependencies: + "@opentelemetry/api": "npm:^1.3.0" + checksum: 10/891c592c93e1eb32d7dfb7588f04bee59671f60f3a685d9aab2a8ec927e237076af49f809056d537eb591c11e66b070a62730e986d9d87cf2f763732ef3d3ca4 + languageName: node + linkType: hard + +"@opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.8, @opentelemetry/api@npm:^1.9.0": version: 1.9.0 resolution: "@opentelemetry/api@npm:1.9.0" checksum: 10/a607f0eef971893c4f2ee2a4c2069aade6ec3e84e2a1f5c2aac19f65c5d9eeea41aa72db917c1029faafdd71789a1a040bdc18f40d63690e22ccae5d7070f194 @@ -4146,17 +4114,17 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation-connect@npm:0.39.0": - version: 0.39.0 - resolution: "@opentelemetry/instrumentation-connect@npm:0.39.0" +"@opentelemetry/instrumentation-connect@npm:0.40.0": + version: 0.40.0 + resolution: "@opentelemetry/instrumentation-connect@npm:0.40.0" dependencies: "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.53.0" + "@opentelemetry/instrumentation": "npm:^0.54.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" "@types/connect": "npm:3.4.36" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/76c62eead2d07673bdd0bf9e87302abf5b74f7a9c300d76e06743a94d591823fbd90b81abc92fb6321beb0aa707f175c501dd091f80f91d0e6d2ba86ef939618 + checksum: 10/31d6adb3fbc04d4e831730562f57f8c54c6844e5214a31c70d5e855b7363202822d320cca603132ff9e4b4a597ecd4dcfb32ca2725cf5cd226fa239bf8fcd779 languageName: node linkType: hard @@ -4171,41 +4139,41 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation-express@npm:0.42.0": - version: 0.42.0 - resolution: "@opentelemetry/instrumentation-express@npm:0.42.0" +"@opentelemetry/instrumentation-express@npm:0.44.0": + version: 0.44.0 + resolution: "@opentelemetry/instrumentation-express@npm:0.44.0" dependencies: "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.53.0" + "@opentelemetry/instrumentation": "npm:^0.54.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/60dfa0491f6ef553c20eb598e672ea7eb9b454a60cb595aab0aae36d763c96284224518ee98008500f87d669dbac55d0da700cdd03d6764e79d6ec59f869551b + checksum: 10/a2ae344c1c2b8346f6957dfadbe4c789a0abf08a5dbcd424c41b320faa5b72d9a399406041792ab6a18093b428958b067d3c66dcd492d9cc5d97a17347d3f88a languageName: node linkType: hard -"@opentelemetry/instrumentation-fastify@npm:0.39.0": - version: 0.39.0 - resolution: "@opentelemetry/instrumentation-fastify@npm:0.39.0" +"@opentelemetry/instrumentation-fastify@npm:0.40.0": + version: 0.40.0 + resolution: "@opentelemetry/instrumentation-fastify@npm:0.40.0" dependencies: "@opentelemetry/core": "npm:^1.8.0" "@opentelemetry/instrumentation": "npm:^0.53.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/125dd788e855fa879a801cd6cbc1162d0caa3290ab1205b488e0d05972d16b1e1fc2b1976357fc50afd0bac014b7e70861e0c3fa6ec3eea8407284722a656f58 + checksum: 10/38ec436b802464ec94e730a117e5472d62114b15987040fd39567257258a4e6f028f0a2e9a3625302a48ea794914378d46df7d41dfc8125222c5d1a01daf26fd languageName: node linkType: hard -"@opentelemetry/instrumentation-fs@npm:0.15.0": - version: 0.15.0 - resolution: "@opentelemetry/instrumentation-fs@npm:0.15.0" +"@opentelemetry/instrumentation-fs@npm:0.16.0": + version: 0.16.0 + resolution: "@opentelemetry/instrumentation-fs@npm:0.16.0" dependencies: "@opentelemetry/core": "npm:^1.8.0" - "@opentelemetry/instrumentation": "npm:^0.53.0" + "@opentelemetry/instrumentation": "npm:^0.54.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/06869fd101d40bb350ea20f39e535e5dcfd134dc4d5b330f2b4ae517f05c027f3ee02b9681937736ed9558974ccec1d89eee0707f80a3ecc8a6c8503f2f9d692 + checksum: 10/01ac3a8c488a85cbd63e8cdb62e4ab228af569c05d731c4615ff90a4fe699e2e619b626d6838f03e7aaeb715a695d6e45a5ba4c5a976e748c04276719924efb9 languageName: node linkType: hard @@ -4271,15 +4239,15 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation-kafkajs@npm:0.3.0": - version: 0.3.0 - resolution: "@opentelemetry/instrumentation-kafkajs@npm:0.3.0" +"@opentelemetry/instrumentation-kafkajs@npm:0.4.0": + version: 0.4.0 + resolution: "@opentelemetry/instrumentation-kafkajs@npm:0.4.0" dependencies: - "@opentelemetry/instrumentation": "npm:^0.53.0" + "@opentelemetry/instrumentation": "npm:^0.54.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/871107d47836ee5bff9bb4719d2c84dd46e1b2eecce3a4cf71b2735a1c53bd8d5f2c1ff66e5add8aa59e7da53ab26c74c766dac54bfd19cbe252bb038385bc56 + checksum: 10/e5abcbbf2a458c3754d8a5790cf364384c84f51929ec66973ae1390020ef945a4be3d42db214a6738362a9d319e03ad6df0abc9470b2107568728d1e42f7ea94 languageName: node linkType: hard @@ -4443,6 +4411,22 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/instrumentation@npm:^0.54.0": + version: 0.54.0 + resolution: "@opentelemetry/instrumentation@npm:0.54.0" + dependencies: + "@opentelemetry/api-logs": "npm:0.54.0" + "@types/shimmer": "npm:^1.2.0" + import-in-the-middle: "npm:^1.8.1" + require-in-the-middle: "npm:^7.1.1" + semver: "npm:^7.5.2" + shimmer: "npm:^1.2.1" + peerDependencies: + "@opentelemetry/api": ^1.3.0 + checksum: 10/bd42bb41a26423d3948156dfc9b51297bd365a70081fadf73cccd4b5fc9741a63bfb4e1f7ceb04fc632aa0a8a957a27cc7c3be5f1d4aaf2cea3dbd249aa86e40 + languageName: node + linkType: hard + "@opentelemetry/redis-common@npm:^0.36.2": version: 0.36.2 resolution: "@opentelemetry/redis-common@npm:0.36.2" @@ -4646,150 +4630,150 @@ __metadata: languageName: node linkType: hard -"@sentry-internal/browser-utils@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry-internal/browser-utils@npm:8.34.0" +"@sentry-internal/browser-utils@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry-internal/browser-utils@npm:8.36.0" dependencies: - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/b7561d84bce5c4a78218fd4062cbdc9950fd97c8e3a48ec8ff917703fb65c248245859993123d09b1b07cb94b544953337b59c869603e7d34573388d9e844997 + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/f886260292e22dd936fbd7d2f4bb1325e3197a418a88b352f064517286677b68e26a993a9cac908faac344f4ea460074578b73fff91746a9f65e18b7c44e0dc6 languageName: node linkType: hard -"@sentry-internal/feedback@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry-internal/feedback@npm:8.34.0" +"@sentry-internal/feedback@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry-internal/feedback@npm:8.36.0" dependencies: - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/2ad1ae70afeaa4f8716ef5ae64a99c00c896ebfd5081bcacf6e78f4de0e91d1d3a5315d862e839ae6d50b3dcba326b4b6d01552ccb374c1a52c3c82e3910aee9 + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/72cb38adae9939ce90963044cfc39051de0f2c8e037efb6c416299afd7ce66d63374f63c549ed054c245bb93ff337bb2a3eaba532dbad2a712b5a9910af3e4a5 languageName: node linkType: hard -"@sentry-internal/replay-canvas@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry-internal/replay-canvas@npm:8.34.0" +"@sentry-internal/replay-canvas@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry-internal/replay-canvas@npm:8.36.0" dependencies: - "@sentry-internal/replay": "npm:8.34.0" - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/6ce07a242ea9050f6f4b4ac050cc385872e60b41ce2ef10bafd1fc12fe4d14c85591c86f884829951d7d010de88baa178bc0652d95d2d64fb91b665b38ad25a8 + "@sentry-internal/replay": "npm:8.36.0" + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/f7725523339dfadadd55a2c025de82d73b2035b65b1bf34395997204bbd69e4665fcf94e90fb53b279c2bb1c005729f0ef9fb4d9946a20fa542b6cd9d2d2e9ea languageName: node linkType: hard -"@sentry-internal/replay@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry-internal/replay@npm:8.34.0" +"@sentry-internal/replay@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry-internal/replay@npm:8.36.0" dependencies: - "@sentry-internal/browser-utils": "npm:8.34.0" - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/917f194fdc7d3638bf9a9bbd78ba6efce910e55d140f4180c9084ce161bff50c0d5e39dd37e2d98aef4e626e4e273a57e8b92977ed5500f334ca026cc977b7c8 + "@sentry-internal/browser-utils": "npm:8.36.0" + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/558f9f277aef5232c43ce0711d85aa8ca8ed584435c9bbe7e08e59c3af2ed1da137d104b47e6f5c32b69b5b78ed516e627ec096ee4a950f2492c43fcf4a78170 languageName: node linkType: hard -"@sentry/babel-plugin-component-annotate@npm:2.22.3": - version: 2.22.3 - resolution: "@sentry/babel-plugin-component-annotate@npm:2.22.3" - checksum: 10/481b6ec6fa6a744557bc1847f9150e779b3a04d4bd12d29d8f8e816fdcd4033bb983e97a728524605fdf663e0381f2587a49f0b922e4b7d6e35edc2577bf6a37 +"@sentry/babel-plugin-component-annotate@npm:2.22.6": + version: 2.22.6 + resolution: "@sentry/babel-plugin-component-annotate@npm:2.22.6" + checksum: 10/895c9e03a576721805494f1292ed1282027cdd81e0963b621be94c613451f628767ea28e5b5a2bc803df86de6b95a5835209ac30d81486916b4da87cc9853ac7 languageName: node linkType: hard -"@sentry/browser@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/browser@npm:8.34.0" +"@sentry/browser@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/browser@npm:8.36.0" dependencies: - "@sentry-internal/browser-utils": "npm:8.34.0" - "@sentry-internal/feedback": "npm:8.34.0" - "@sentry-internal/replay": "npm:8.34.0" - "@sentry-internal/replay-canvas": "npm:8.34.0" - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/d4c27ea3ef82e957580f5f930a783c9649d81ea617129265d5c49ca5129f9d2c01f04e9f3c409f1b2e92d7644695d25d41abebd15e732e1daf318db06af32ad1 + "@sentry-internal/browser-utils": "npm:8.36.0" + "@sentry-internal/feedback": "npm:8.36.0" + "@sentry-internal/replay": "npm:8.36.0" + "@sentry-internal/replay-canvas": "npm:8.36.0" + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/46af4ade98628d93dd184b42deb5bec267407ebdd4a42c5803ca97cbb25f96c03db06c1c724438238fe0ae89d84ea64b686cae63b156d5aefe7e6c07b7c6ebaa languageName: node linkType: hard -"@sentry/bundler-plugin-core@npm:2.22.3": - version: 2.22.3 - resolution: "@sentry/bundler-plugin-core@npm:2.22.3" +"@sentry/bundler-plugin-core@npm:2.22.6": + version: 2.22.6 + resolution: "@sentry/bundler-plugin-core@npm:2.22.6" dependencies: "@babel/core": "npm:^7.18.5" - "@sentry/babel-plugin-component-annotate": "npm:2.22.3" - "@sentry/cli": "npm:^2.33.1" + "@sentry/babel-plugin-component-annotate": "npm:2.22.6" + "@sentry/cli": "npm:^2.36.1" dotenv: "npm:^16.3.1" find-up: "npm:^5.0.0" glob: "npm:^9.3.2" magic-string: "npm:0.30.8" unplugin: "npm:1.0.1" - checksum: 10/721bb4e711461d34908751d9f4cf1f770e907d06e4165e09662be18d4769f2c375de92069ae7cd7ce140110d82f85cc3db7f5d607856694ff3d9748d1404c753 + checksum: 10/a5fbc2814d621d7b3885c20bf3ea99698a1ee3889af5ba34876864bc87ff36dbc39f0d5a0894433e18899b0c444cc177ffd4228b136cdd705445b582b68d5396 languageName: node linkType: hard -"@sentry/cli-darwin@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-darwin@npm:2.37.0" +"@sentry/cli-darwin@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-darwin@npm:2.38.1" conditions: os=darwin languageName: node linkType: hard -"@sentry/cli-linux-arm64@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-linux-arm64@npm:2.37.0" +"@sentry/cli-linux-arm64@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-linux-arm64@npm:2.38.1" conditions: (os=linux | os=freebsd) & cpu=arm64 languageName: node linkType: hard -"@sentry/cli-linux-arm@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-linux-arm@npm:2.37.0" +"@sentry/cli-linux-arm@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-linux-arm@npm:2.38.1" conditions: (os=linux | os=freebsd) & cpu=arm languageName: node linkType: hard -"@sentry/cli-linux-i686@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-linux-i686@npm:2.37.0" +"@sentry/cli-linux-i686@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-linux-i686@npm:2.38.1" conditions: (os=linux | os=freebsd) & (cpu=x86 | cpu=ia32) languageName: node linkType: hard -"@sentry/cli-linux-x64@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-linux-x64@npm:2.37.0" +"@sentry/cli-linux-x64@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-linux-x64@npm:2.38.1" conditions: (os=linux | os=freebsd) & cpu=x64 languageName: node linkType: hard -"@sentry/cli-win32-i686@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-win32-i686@npm:2.37.0" +"@sentry/cli-win32-i686@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-win32-i686@npm:2.38.1" conditions: os=win32 & (cpu=x86 | cpu=ia32) languageName: node linkType: hard -"@sentry/cli-win32-x64@npm:2.37.0": - version: 2.37.0 - resolution: "@sentry/cli-win32-x64@npm:2.37.0" +"@sentry/cli-win32-x64@npm:2.38.1": + version: 2.38.1 + resolution: "@sentry/cli-win32-x64@npm:2.38.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@sentry/cli@npm:^2.33.1": - version: 2.37.0 - resolution: "@sentry/cli@npm:2.37.0" +"@sentry/cli@npm:^2.36.1": + version: 2.38.1 + resolution: "@sentry/cli@npm:2.38.1" dependencies: - "@sentry/cli-darwin": "npm:2.37.0" - "@sentry/cli-linux-arm": "npm:2.37.0" - "@sentry/cli-linux-arm64": "npm:2.37.0" - "@sentry/cli-linux-i686": "npm:2.37.0" - "@sentry/cli-linux-x64": "npm:2.37.0" - "@sentry/cli-win32-i686": "npm:2.37.0" - "@sentry/cli-win32-x64": "npm:2.37.0" + "@sentry/cli-darwin": "npm:2.38.1" + "@sentry/cli-linux-arm": "npm:2.38.1" + "@sentry/cli-linux-arm64": "npm:2.38.1" + "@sentry/cli-linux-i686": "npm:2.38.1" + "@sentry/cli-linux-x64": "npm:2.38.1" + "@sentry/cli-win32-i686": "npm:2.38.1" + "@sentry/cli-win32-x64": "npm:2.38.1" https-proxy-agent: "npm:^5.0.0" node-fetch: "npm:^2.6.7" progress: "npm:^2.0.3" @@ -4812,70 +4796,67 @@ __metadata: optional: true bin: sentry-cli: bin/sentry-cli - checksum: 10/ab9b2899f8448e50632e090f1c32c902d8a7571f4a2638fa55c86e5ef08e9ac7b3a71c953d0e5b40731a7e05dbb8d67f27971ee0b00bd40439dcda173cc3d263 + checksum: 10/79cde14f65be6ce14b3d1e788190695c72f0f286983216527330750de89d4ffce643b8b6dcb4d661a710b95be597255be84204c0f8b2a9d327656b7d4ef010a5 languageName: node linkType: hard -"@sentry/core@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/core@npm:8.34.0" +"@sentry/core@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/core@npm:8.36.0" dependencies: - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/071a382202101132f3e5ed53b72898ca1eb5f4b85bb025f3e1347d66031d7b6db4daf1b62d3ecf4c59776a03920fa24307225aae29827e5ca94d1631e47e5e9d + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/26d9a926c6a76526cc3ed895370604c88a7fb9f152866362cb0eef348fcada9ae78706a678f081ed26b0bb29fd0293627d458dbc598b630962550ce4924c584d languageName: node linkType: hard -"@sentry/nextjs@npm:^8": - version: 8.34.0 - resolution: "@sentry/nextjs@npm:8.34.0" +"@sentry/nextjs@npm:^8.36.0": + version: 8.36.0 + resolution: "@sentry/nextjs@npm:8.36.0" dependencies: + "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/instrumentation-http": "npm:0.53.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" "@rollup/plugin-commonjs": "npm:26.0.1" - "@sentry-internal/browser-utils": "npm:8.34.0" - "@sentry/core": "npm:8.34.0" - "@sentry/node": "npm:8.34.0" - "@sentry/opentelemetry": "npm:8.34.0" - "@sentry/react": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - "@sentry/vercel-edge": "npm:8.34.0" - "@sentry/webpack-plugin": "npm:2.22.3" + "@sentry-internal/browser-utils": "npm:8.36.0" + "@sentry/core": "npm:8.36.0" + "@sentry/node": "npm:8.36.0" + "@sentry/opentelemetry": "npm:8.36.0" + "@sentry/react": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + "@sentry/vercel-edge": "npm:8.36.0" + "@sentry/webpack-plugin": "npm:2.22.6" chalk: "npm:3.0.0" resolve: "npm:1.22.8" rollup: "npm:3.29.5" stacktrace-parser: "npm:^0.1.10" peerDependencies: next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 - webpack: ">=5.0.0" - peerDependenciesMeta: - webpack: - optional: true - checksum: 10/e6bc1eae0cb3d37fc5406577906663bc2df475c172c87636320b0e053babe3d88c7598fa942412c9423b26b846b712b180c03a4fb0ef0bf800ebcf88cc662115 + checksum: 10/9b23465582ce0e29574d312d47ff8c2ce2b4aa186d28b980a8769641435a0d84eb0dd80fa3ac69d10a43a4fdf5a5f58fbbb7d691d35bc4b6ea9ded722d975a14 languageName: node linkType: hard -"@sentry/node@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/node@npm:8.34.0" +"@sentry/node@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/node@npm:8.36.0" dependencies: "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/context-async-hooks": "npm:^1.25.1" "@opentelemetry/core": "npm:^1.25.1" "@opentelemetry/instrumentation": "npm:^0.53.0" "@opentelemetry/instrumentation-amqplib": "npm:^0.42.0" - "@opentelemetry/instrumentation-connect": "npm:0.39.0" + "@opentelemetry/instrumentation-connect": "npm:0.40.0" "@opentelemetry/instrumentation-dataloader": "npm:0.12.0" - "@opentelemetry/instrumentation-express": "npm:0.42.0" - "@opentelemetry/instrumentation-fastify": "npm:0.39.0" - "@opentelemetry/instrumentation-fs": "npm:0.15.0" + "@opentelemetry/instrumentation-express": "npm:0.44.0" + "@opentelemetry/instrumentation-fastify": "npm:0.40.0" + "@opentelemetry/instrumentation-fs": "npm:0.16.0" "@opentelemetry/instrumentation-generic-pool": "npm:0.39.0" "@opentelemetry/instrumentation-graphql": "npm:0.43.0" "@opentelemetry/instrumentation-hapi": "npm:0.41.0" "@opentelemetry/instrumentation-http": "npm:0.53.0" "@opentelemetry/instrumentation-ioredis": "npm:0.43.0" - "@opentelemetry/instrumentation-kafkajs": "npm:0.3.0" + "@opentelemetry/instrumentation-kafkajs": "npm:0.4.0" "@opentelemetry/instrumentation-koa": "npm:0.43.0" "@opentelemetry/instrumentation-lru-memoizer": "npm:0.40.0" "@opentelemetry/instrumentation-mongodb": "npm:0.47.0" @@ -4890,84 +4871,85 @@ __metadata: "@opentelemetry/sdk-trace-base": "npm:^1.26.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" "@prisma/instrumentation": "npm:5.19.1" - "@sentry/core": "npm:8.34.0" - "@sentry/opentelemetry": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - import-in-the-middle: "npm:^1.11.0" - checksum: 10/bb22e56925ef67f927aef923c2b68f079411f44977e68c677aae04e4f1f88f18da2c53c9d199bc4d19a5368de120ec4811e597b2043fb4308f8a5836ba334ca6 + "@sentry/core": "npm:8.36.0" + "@sentry/opentelemetry": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + import-in-the-middle: "npm:^1.11.2" + checksum: 10/ee70d919de725502e58664257074c29f60dc212c85b699ff30f6252c6be20771a38cf614295a6d9c533c9a9361df9bd0346be0476fd3005e567efa5058510acf languageName: node linkType: hard -"@sentry/opentelemetry@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/opentelemetry@npm:8.34.0" +"@sentry/opentelemetry@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/opentelemetry@npm:8.36.0" dependencies: - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" peerDependencies: "@opentelemetry/api": ^1.9.0 "@opentelemetry/core": ^1.25.1 "@opentelemetry/instrumentation": ^0.53.0 "@opentelemetry/sdk-trace-base": ^1.26.0 "@opentelemetry/semantic-conventions": ^1.27.0 - checksum: 10/57044186671ba2ea54df190b3711e892159240d4d551155d2dd24709096be21d97d82e07a99dbf04c163d235ec0a6489f2250343a64df2236b0f7202eb604bd0 + checksum: 10/fea1e9ed77925b0dbac4c2e63aed63f37dc413bea397ee75dd952f16bff465f0b82706b79d6d1a2f030cc0b0f2158acfd539bf29db7a15851dd418c1d47aeee7 languageName: node linkType: hard -"@sentry/react@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/react@npm:8.34.0" +"@sentry/react@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/react@npm:8.36.0" dependencies: - "@sentry/browser": "npm:8.34.0" - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" + "@sentry/browser": "npm:8.36.0" + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" hoist-non-react-statics: "npm:^3.3.2" peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - checksum: 10/b944695f9d671cc733a66e35f1486d2ed30a2569c8f150f433c3d73ef592159be5a280164196c42a593b9a2ed173b49aa91b9fe708616a1dcfe1a4ce82f083ab + checksum: 10/c5edf3f4cb53e7fcecd3aa11512e125b90531ae41f04c2ade26f052e3138f217b3e254433eb28776d5b845f98fe7678e7961dc7770c063e62f8eda61868bc191 languageName: node linkType: hard -"@sentry/types@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/types@npm:8.34.0" - checksum: 10/d41274c613f99f1ea4235d68922ec8d597ef044c7ca3fa9e757017cd7eae69bed944cc24ff8eb0d178656232efac00964479883502fe9c6db5dacadde51d6f96 +"@sentry/types@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/types@npm:8.36.0" + checksum: 10/6c91218f5355e5d9396cf863d66c21edd305075ea5408e31ca52dbc0eae5e39a1247882d515856f9ad05fb7c5f0509c184c048a26086f23dfd41ef4a4eeeb38b languageName: node linkType: hard -"@sentry/utils@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/utils@npm:8.34.0" +"@sentry/utils@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/utils@npm:8.36.0" dependencies: - "@sentry/types": "npm:8.34.0" - checksum: 10/b7838aee701f3865f5a893702a7db71e70064bfc5b25bade38bad7faebc3daea10aabd0a6f8636d7b943660aed38b691ba9aca0610746b95a5d34819f4a43459 + "@sentry/types": "npm:8.36.0" + checksum: 10/5b58bb34ed4e13b71f322a4455702ae5dab7b597410ac7774abce0dd67dcc84ee088be05e1c8d694ec795b8a84baad0467e31afd0c8026aaacac93f8b5a3701f languageName: node linkType: hard -"@sentry/vercel-edge@npm:8.34.0": - version: 8.34.0 - resolution: "@sentry/vercel-edge@npm:8.34.0" +"@sentry/vercel-edge@npm:8.36.0": + version: 8.36.0 + resolution: "@sentry/vercel-edge@npm:8.36.0" dependencies: - "@sentry/core": "npm:8.34.0" - "@sentry/types": "npm:8.34.0" - "@sentry/utils": "npm:8.34.0" - checksum: 10/c51377fc2b7f36db958e181fc0878fa3e570bc5a7f6e08d4fc74fc5dcdcfb85233b280625b208b647be3c569d04708e996fb757677d7caeb30fbb4246a8fd8a5 + "@opentelemetry/api": "npm:^1.9.0" + "@sentry/core": "npm:8.36.0" + "@sentry/types": "npm:8.36.0" + "@sentry/utils": "npm:8.36.0" + checksum: 10/b0ff6da7b9ea379aca71844c6ae9689df95c3e38135d4fa14d686a2462a2650e055b69dd51c9ffddcaf2ce9dd5eda840314fc8c0c85db817795b67ff6365e707 languageName: node linkType: hard -"@sentry/webpack-plugin@npm:2.22.3": - version: 2.22.3 - resolution: "@sentry/webpack-plugin@npm:2.22.3" +"@sentry/webpack-plugin@npm:2.22.6": + version: 2.22.6 + resolution: "@sentry/webpack-plugin@npm:2.22.6" dependencies: - "@sentry/bundler-plugin-core": "npm:2.22.3" + "@sentry/bundler-plugin-core": "npm:2.22.6" unplugin: "npm:1.0.1" uuid: "npm:^9.0.0" peerDependencies: webpack: ">=4.40.0" - checksum: 10/c459ffa5f13b83683479dbb9e7e919de6e59a9fce3426d662734832472f61248b77c9cf084e0499b0d9fb8f31cecdda478e997be02ba25ce45c1cf8b39b7a100 + checksum: 10/dd701dba4037eed458c80cc4b8f5fd0e90b1e6221436148d5e9d20944b69c6572ce05943cf70317631499bf3c22f624c47d7207e4fa02e3a383e4bba1898356d languageName: node linkType: hard @@ -5699,20 +5681,19 @@ __metadata: languageName: node linkType: hard -"@swc/counter@npm:^0.1.3": +"@swc/counter@npm:0.1.3, @swc/counter@npm:^0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" checksum: 10/df8f9cfba9904d3d60f511664c70d23bb323b3a0803ec9890f60133954173047ba9bdeabce28cd70ba89ccd3fd6c71c7b0bd58be85f611e1ffbe5d5c18616598 languageName: node linkType: hard -"@swc/helpers@npm:0.5.5": - version: 0.5.5 - resolution: "@swc/helpers@npm:0.5.5" +"@swc/helpers@npm:0.5.13": + version: 0.5.13 + resolution: "@swc/helpers@npm:0.5.13" dependencies: - "@swc/counter": "npm:^0.1.3" tslib: "npm:^2.4.0" - checksum: 10/1c5ef04f642542212df28c669438f3e0f459dcde7b448a5b1fcafb2e9e4f13e76d8428535a270e91ed123dd2a21189dbed34086b88a8cf68baf84984d6d0e39b + checksum: 10/6ba2f7e215d32d71fce139e2cfc426b3ed7eaa709febdeb07b97260a4c9eea4784cf047cc1271be273990b08220b576b94a42b5780947c0b3be84973a847a24d languageName: node linkType: hard @@ -6331,7 +6312,7 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.13, @types/prop-types@npm:^15.7.3": +"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.13, @types/prop-types@npm:^15.7.3": version: 15.7.13 resolution: "@types/prop-types@npm:15.7.13" checksum: 10/8935cad87c683c665d09a055919d617fe951cb3b2d5c00544e3a913f861a2bd8d2145b51c9aa6d2457d19f3107ab40784c40205e757232f6a80cc8b1c815513c @@ -6379,7 +6360,7 @@ __metadata: languageName: node linkType: hard -"@types/react-transition-group@npm:^4.4.0, @types/react-transition-group@npm:^4.4.10": +"@types/react-transition-group@npm:^4.4.0, @types/react-transition-group@npm:^4.4.11": version: 4.4.11 resolution: "@types/react-transition-group@npm:4.4.11" dependencies: @@ -8646,7 +8627,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^2.1.0, clsx@npm:^2.1.1": +"clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" checksum: 10/cdfb57fa6c7649bbff98d9028c2f0de2f91c86f551179541cf784b1cfdc1562dcb951955f46d54d930a3879931a980e32a46b598acaea274728dbe068deca919 @@ -12258,7 +12239,7 @@ __metadata: languageName: node linkType: hard -"import-in-the-middle@npm:^1.11.0, import-in-the-middle@npm:^1.8.1": +"import-in-the-middle@npm:^1.11.2, import-in-the-middle@npm:^1.8.1": version: 1.11.2 resolution: "import-in-the-middle@npm:1.11.2" dependencies: @@ -14146,7 +14127,7 @@ __metadata: "@mitodl/course-search-utils": "npm:^3.3.1" "@next/bundle-analyzer": "npm:^14.2.15" "@remixicon/react": "npm:^4.2.0" - "@sentry/nextjs": "npm:^8" + "@sentry/nextjs": "npm:^8.36.0" "@tanstack/react-query": "npm:^4.36.1" "@testing-library/jest-dom": "npm:^6.4.8" "@testing-library/react": "npm:^16.0.0" @@ -14166,7 +14147,7 @@ __metadata: jest: "npm:^29.7.0" jest-extended: "npm:^4.0.2" lodash: "npm:^4.17.21" - next: "npm:^14.2.15" + next: "npm:^15.0.2" ol-ckeditor: "npm:0.0.0" ol-components: "npm:0.0.0" ol-test-utilities: "npm:0.0.0" @@ -15626,31 +15607,32 @@ __metadata: languageName: node linkType: hard -"next@npm:^14.2.15, next@npm:^14.2.7": - version: 14.2.15 - resolution: "next@npm:14.2.15" - dependencies: - "@next/env": "npm:14.2.15" - "@next/swc-darwin-arm64": "npm:14.2.15" - "@next/swc-darwin-x64": "npm:14.2.15" - "@next/swc-linux-arm64-gnu": "npm:14.2.15" - "@next/swc-linux-arm64-musl": "npm:14.2.15" - "@next/swc-linux-x64-gnu": "npm:14.2.15" - "@next/swc-linux-x64-musl": "npm:14.2.15" - "@next/swc-win32-arm64-msvc": "npm:14.2.15" - "@next/swc-win32-ia32-msvc": "npm:14.2.15" - "@next/swc-win32-x64-msvc": "npm:14.2.15" - "@swc/helpers": "npm:0.5.5" +"next@npm:^15.0.2": + version: 15.0.2 + resolution: "next@npm:15.0.2" + dependencies: + "@next/env": "npm:15.0.2" + "@next/swc-darwin-arm64": "npm:15.0.2" + "@next/swc-darwin-x64": "npm:15.0.2" + "@next/swc-linux-arm64-gnu": "npm:15.0.2" + "@next/swc-linux-arm64-musl": "npm:15.0.2" + "@next/swc-linux-x64-gnu": "npm:15.0.2" + "@next/swc-linux-x64-musl": "npm:15.0.2" + "@next/swc-win32-arm64-msvc": "npm:15.0.2" + "@next/swc-win32-x64-msvc": "npm:15.0.2" + "@swc/counter": "npm:0.1.3" + "@swc/helpers": "npm:0.5.13" busboy: "npm:1.6.0" caniuse-lite: "npm:^1.0.30001579" - graceful-fs: "npm:^4.2.11" postcss: "npm:8.4.31" - styled-jsx: "npm:5.1.1" + sharp: "npm:^0.33.5" + styled-jsx: "npm:5.1.6" peerDependencies: "@opentelemetry/api": ^1.1.0 "@playwright/test": ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-02c0e824-20241028 + react-dom: ^18.2.0 || 19.0.0-rc-02c0e824-20241028 sass: ^1.3.0 dependenciesMeta: "@next/swc-darwin-arm64": @@ -15667,20 +15649,22 @@ __metadata: optional: true "@next/swc-win32-arm64-msvc": optional: true - "@next/swc-win32-ia32-msvc": - optional: true "@next/swc-win32-x64-msvc": optional: true + sharp: + optional: true peerDependenciesMeta: "@opentelemetry/api": optional: true "@playwright/test": optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true bin: next: dist/bin/next - checksum: 10/5c5ed27888540f3ace732c2645a84b60d9e9c572cb335c5e9ff2a78a2eba704705e92e3c3d22586fd18d1621c70a5fb7ca8c8499550734d243fdec5d2a9c8a93 + checksum: 10/c621c89c342eee8594692bacbaca68879826c9c0a6544a95039a36d8b4383e11952afa3639b539f014f55f59a603e009bb36033e286281007e7ce4896972504a languageName: node linkType: hard @@ -16040,11 +16024,11 @@ __metadata: "@emotion/react": "npm:^11.11.1" "@emotion/styled": "npm:^11.11.0" "@faker-js/faker": "npm:^9.0.0" - "@mui/base": "npm:5.0.0-beta.60" - "@mui/lab": "npm:^5.0.0-alpha.172" - "@mui/material": "npm:^5.16.1" - "@mui/material-nextjs": "npm:^5.16.6" - "@mui/system": "npm:^5.16.1" + "@mui/base": "npm:5.0.0-beta.61" + "@mui/lab": "npm:6.0.0-beta.14" + "@mui/material": "npm:^6.1.6" + "@mui/material-nextjs": "npm:^6.1.6" + "@mui/system": "npm:^6.1.6" "@remixicon/react": "npm:^4.2.0" "@storybook/addon-actions": "npm:^8.2.9" "@storybook/addon-essentials": "npm:^8.2.9" @@ -16071,7 +16055,7 @@ __metadata: iso-639-1: "npm:^3.1.2" lodash: "npm:^4.17.21" material-ui-popup-state: "npm:^5.1.0" - next: "npm:^14.2.7" + next: "npm:^15.0.2" ol-test-utilities: "npm:0.0.0" ol-utilities: "npm:0.0.0" prop-types: "npm:^15.8.1" @@ -16130,6 +16114,7 @@ __metadata: striptags: "npm:^3.2.0" tiny-invariant: "npm:^1.3.1" peerDependencies: + next: "*" react: 18.3.1 languageName: unknown linkType: soft @@ -18671,7 +18656,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.33.3": +"sharp@npm:^0.33.3, sharp@npm:^0.33.5": version: 0.33.5 resolution: "sharp@npm:0.33.5" dependencies: @@ -19397,23 +19382,7 @@ __metadata: languageName: node linkType: hard -"styled-jsx@npm:5.1.1": - version: 5.1.1 - resolution: "styled-jsx@npm:5.1.1" - dependencies: - client-only: "npm:0.0.1" - peerDependencies: - react: ">= 16.8.0 || 17.x.x || ^18.0.0-0" - peerDependenciesMeta: - "@babel/core": - optional: true - babel-plugin-macros: - optional: true - checksum: 10/4f6a5d0010770fdeea1183d919d528fd46c484e23c0535ef3e1dd49488116f639c594f3bd4440e3bc8a8686c9f8d53c5761599870ff039ede11a5c3bfe08a4be - languageName: node - linkType: hard - -"styled-jsx@npm:^5.1.6": +"styled-jsx@npm:5.1.6, styled-jsx@npm:^5.1.6": version: 5.1.6 resolution: "styled-jsx@npm:5.1.6" dependencies: From 5951883cb8e0ffb260bac5e169fa2896cb141dd2 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Fri, 1 Nov 2024 19:43:38 +0100 Subject: [PATCH 07/13] Image and video optimizations (#1769) * Use Next.js Image component in the resource drawer * Differentiate NavItem props from config type * Replace about page image * Next.js images for channel page logos. Unit image config in Logo component * Remove NavDrawer image paths (not used). Fix Storybook page * Upgrade to Next.js v15. Page params are now async * Upgrade @mui/material-nextjs for Next.js v15 * Utility for CSS background image-set() strings. Apply to homepage personalize section * Pass background static import src to banner * Use Logo component for unit cards * Suspense boundaries needed around carousels (useSearchParams() should be wrapped error) * Type fix * Display YouTube videos with simple iframe * 16:9 aspect ratio for videos * Revert "Suspense boundaries needed around carousels (useSearchParams() should be wrapped error)" This reverts commit b96fb61dda988f114abed671b46bf1692eb95a34. * Revert "Upgrade @mui/material-nextjs for Next.js v15" This reverts commit 18fee782bc9022e43827fd92a6f2895795f85f1c. * Revert "Upgrade to Next.js v15. Page params are now async" This reverts commit 20f0f054ff4ad3464911fc31b857967f24e503fd. * Update test for unit logo * Add Nextjs dependency in ol-utilities * Remove comments * Remove unnecessary truthy check * Separate Unit/Platform Logo * Move to peer dependency * Bckground src set for topic banner * Background src set on other banner backgrounds * Remove redundant display breakpoints * Apply changes to LearningResourceExpandedV2 * Update test --- .../src/app-pages/AboutPage/AboutPage.tsx | 19 ++- .../ChannelPage/ChannelPage.test.tsx | 21 ++- .../ChannelPage/DefaultChannelTemplate.tsx | 8 +- .../ChannelPage/TopicChannelTemplate.tsx | 6 +- .../ChannelPage/UnitChannelTemplate.tsx | 16 ++- .../DepartmentListingPage.tsx | 6 +- .../app-pages/HomePage/PersonalizeSection.tsx | 7 +- .../LearningPathListingPage.tsx | 5 - .../{[id]/view => }/ProgramLetter.test.tsx | 0 .../{[id]/view => }/ProgramLetterPage.tsx | 0 .../src/app-pages/TermsPage/TermsPage.tsx | 2 +- .../TopicsListingPage/TopicsListingPage.tsx | 5 +- .../app-pages/UnitsListingPage/UnitCard.tsx | 41 +++--- .../UnitsListingPage/UnitsListingPage.tsx | 4 +- .../src/app/program_letter/[id]/view/page.tsx | 2 +- .../src/page-components/Header/Header.tsx | 2 +- .../src/components/Banner/Banner.tsx | 46 +++--- .../components/EmbedlyCard/EmbedlyCard.tsx | 8 +- .../src/components/EmbedlyCard/util.ts | 13 +- .../LearningResourceListCard.tsx | 9 -- .../LearningResourceExpanded.stories.tsx | 1 - .../LearningResourceExpandedV1.test.tsx | 5 +- .../LearningResourceExpandedV1.tsx | 54 +++---- .../LearningResourceExpandedV2.test.tsx | 5 +- .../LearningResourceExpandedV2.tsx | 68 +++++---- .../LearningResourceExpanded/VideoFrame.tsx | 32 +++++ .../src/components/Logo/Logo.stories.tsx | 4 +- .../src/components/Logo/Logo.tsx | 132 ++++++++++++------ .../NavDrawer/NavDrawer.stories.tsx | 29 +++- .../components/NavDrawer/NavDrawer.test.tsx | 21 ++- .../src/components/NavDrawer/NavDrawer.tsx | 35 ++--- .../ol-components/src/constants/imgConfigs.ts | 14 +- frontends/ol-utilities/package.json | 2 +- .../src/images/backgroundImages.ts | 27 ++++ frontends/ol-utilities/src/index.ts | 1 + yarn.lock | 2 +- 36 files changed, 397 insertions(+), 255 deletions(-) rename frontends/main/src/app-pages/ProgramLetterPage/{[id]/view => }/ProgramLetter.test.tsx (100%) rename frontends/main/src/app-pages/ProgramLetterPage/{[id]/view => }/ProgramLetterPage.tsx (100%) create mode 100644 frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx create mode 100644 frontends/ol-utilities/src/images/backgroundImages.ts 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 fdd6177a76..2c85049974 100644 --- a/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx +++ b/frontends/main/src/app-pages/ChannelPage/ChannelPage.test.tsx @@ -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", 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/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 100% rename from frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetter.test.tsx rename to frontends/main/src/app-pages/ProgramLetterPage/ProgramLetter.test.tsx diff --git a/frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage.tsx b/frontends/main/src/app-pages/ProgramLetterPage/ProgramLetterPage.tsx similarity index 100% rename from frontends/main/src/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage.tsx rename to frontends/main/src/app-pages/ProgramLetterPage/ProgramLetterPage.tsx 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..39e5e5ee2a 100644 --- a/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx +++ b/frontends/main/src/app-pages/UnitsListingPage/UnitCard.tsx @@ -1,6 +1,13 @@ 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" const CardStyled = styled(Card)({ @@ -34,14 +41,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", + }, }, }) @@ -108,15 +113,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,13 +121,12 @@ 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 @@ -144,7 +139,7 @@ const UnitCard: React.FC = (props) => { - + @@ -197,14 +192,10 @@ 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.tsx b/frontends/main/src/app-pages/UnitsListingPage/UnitsListingPage.tsx index 574ccb3b42..112de6ea7e 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" @@ -248,6 +249,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/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/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/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/EmbedlyCard/EmbedlyCard.tsx b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx index 94c36354b7..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,12 +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/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index 1757cffa63..d80859d49a 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 diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx index c6af08ffb4..27d7e2524c 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx @@ -21,7 +21,6 @@ const meta: Meta = { component: LearningResourceExpandedV1, args: { imgConfig: { - key: "", width: 385, height: 200, }, diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx index 1e38706ff4..8a1a54df9d 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx @@ -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: LearningResourceExpandedV1Props["imgConfig"] = { - key: "fake-key", width: 385, height: 200, } @@ -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/LearningResourceExpandedV1.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx index 5af9ac6066..a0155829db 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.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 { 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; @@ -141,38 +146,39 @@ const OnPlatform = styled.span` 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) { diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx index 1d40a84d27..0f242e754e 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx @@ -12,11 +12,10 @@ import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" import invariant from "tiny-invariant" import type { LearningResource } from "api" -import { PLATFORMS } from "../Logo/Logo" +import { PLATFORM_LOGOS } from "../Logo/Logo" import _ from "lodash" const IMG_CONFIG: LearningResourceExpandedV2Props["imgConfig"] = { - key: "fake-key", width: 385, height: 200, } @@ -152,7 +151,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/LearningResourceExpandedV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx index 5b3506d9a8..d273f2f9cb 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx @@ -2,28 +2,25 @@ 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 { - resourceThumbnailSrc, - DEFAULT_RESOURCE_IMG, - getReadableResourceType, -} from "ol-utilities" +import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities" import { RiBookmarkLine, RiCloseLargeLine, RiExternalLinkLine, RiMenuAddLine, } from "@remixicon/react" -import type { EmbedlyConfig } from "ol-utilities" +import type { ImageConfig } from "../../constants/imgConfigs" import { theme } from "../ThemeProvider/ThemeProvider" -import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard" -import { PlatformLogo, PLATFORMS } from "../Logo/Logo" +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", @@ -81,17 +78,17 @@ const RightContainer = styled.div({ }, }) -const EmbedlyContainer = styled.div({ - width: "100%", - overflow: "hidden", -}) +const ImageContainer = styled.div<{ aspect: number }>` + position: relative; + width: 100%; + padding-bottom: ${({ aspect }) => 100 / aspect}%; +` -const Image = styled.img<{ aspect: number }>((aspect) => ({ - aspectRatio: aspect.aspect, +const Image = styled(NextImage)({ borderRadius: "8px", width: "100%", objectFit: "cover", -})) +}) const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ borderRadius: "8px", @@ -168,7 +165,7 @@ const ListButtonContainer = styled.div({ type LearningResourceExpandedV2Props = { resource?: LearningResource user?: User - imgConfig: EmbedlyConfig + imgConfig: ImageConfig onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"] onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"] closeDrawer?: () => void @@ -237,33 +234,32 @@ const TitleSection: React.FC<{ 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 ( @@ -323,7 +319,7 @@ const CallToActionSection = ({ onAddToLearningPathClick, onAddToUserListClick, }: { - imgConfig: EmbedlyConfig + imgConfig: ImageConfig resource?: LearningResource hide?: boolean user?: User @@ -350,7 +346,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 cta = getCallToActionText(resource) return ( 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 ( +