diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index b0bcfc6e32..e5a3c8249b 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -25,6 +25,7 @@ import { import axiosInstance from "./axios" const BASE_PATH = process.env.MITOPEN_AXIOS_BASE_PATH?.replace(/\/+$/, "") ?? "" + const learningResourcesApi = new LearningResourcesApi( undefined, BASE_PATH, diff --git a/frontends/mit-open/.storybook/main.ts b/frontends/mit-open/.storybook/main.ts index 41c6bcdd91..13ef9317e5 100644 --- a/frontends/mit-open/.storybook/main.ts +++ b/frontends/mit-open/.storybook/main.ts @@ -37,7 +37,7 @@ const config = { PUBLIC_URL: process.env.PUBLIC_URL || "", EMBEDLY_KEY: process.env.EMBEDLY_KEY || "", APP_SETTINGS: { - embedlyKey: "fake-embedly-key", + embedlyKey: process.env.EMBEDLY_KEY || "", }, }), } diff --git a/frontends/mit-open/.storybook/preview-head.html b/frontends/mit-open/.storybook/preview-head.html index 1d2287ea27..e061bbea23 100644 --- a/frontends/mit-open/.storybook/preview-head.html +++ b/frontends/mit-open/.storybook/preview-head.html @@ -5,6 +5,6 @@ diff --git a/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx b/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx index cd0686c53c..52689ea190 100644 --- a/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceCard/LearningResourceCard.tsx @@ -1,3 +1,9 @@ +/* + * TODO: This has been replaced by the ol-components LearningResourceCard + * It is still in use by the LearningPathDetailsPage -> ListDetails -> ItemsListing + * though can be removed (and adjacent LearningResourceCardTemplate) once + * the sorting functionality has been refactored across + */ import React, { useCallback } from "react" import * as NiceModal from "@ebay/nice-modal-react" diff --git a/frontends/mit-open/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx b/frontends/mit-open/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx index e509db5ef8..a4cd859725 100644 --- a/frontends/mit-open/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx @@ -93,5 +93,14 @@ const useOpenLearningResourceDrawer = () => { return openLearningResourceDrawer } +const useResourceDrawerHref = () => { + const [search] = useSearchParams() + + return (id: number) => { + search.set(RESOURCE_DRAWER_QUERY_PARAM, id.toString()) + return `?${search.toString()}` + } +} + export default LearningResourceDrawer -export { useOpenLearningResourceDrawer } +export { useOpenLearningResourceDrawer, useResourceDrawerHref } diff --git a/frontends/mit-open/src/page-components/ResourceCarousel/ResourceCarousel.tsx b/frontends/mit-open/src/page-components/ResourceCarousel/ResourceCarousel.tsx index c4cdaa1638..9e5edb21b7 100644 --- a/frontends/mit-open/src/page-components/ResourceCarousel/ResourceCarousel.tsx +++ b/frontends/mit-open/src/page-components/ResourceCarousel/ResourceCarousel.tsx @@ -27,15 +27,7 @@ import { AddToLearningPathDialog, AddToUserListDialog, } from "../Dialogs/AddToListDialog" -import { useOpenLearningResourceDrawer } from "../LearningResourceDrawer/LearningResourceDrawer" - -const LearningResourceCardStyled = styled(LearningResourceCard)({ - boxShadow: "none", - ":hover": { - boxShadow: - "0 2px 4px 0 rgb(37 38 43 / 10%), 0 2px 4px 0 rgb(37 38 43 / 10%)", - }, -}) +import { useResourceDrawerHref } from "../LearningResourceDrawer/LearningResourceDrawer" const StyledCarousel = styled(Carousel)({ /** @@ -272,6 +264,7 @@ const ResourceCarousel: React.FC = ({ const { data: user } = useUserMe() const [tab, setTab] = React.useState("0") const [ref, setRef] = React.useState(null) + const getDrawerHref = useResourceDrawerHref() const showAddToLearningPathDialog = user?.is_authenticated && user?.is_learning_path_editor @@ -286,8 +279,6 @@ const ResourceCarousel: React.FC = ({ } : null - const openLRDrawer = useOpenLearningResourceDrawer() - return ( @@ -314,7 +305,7 @@ const ResourceCarousel: React.FC = ({ {isLoading || childrenLoading ? Array.from({ length: 6 }).map((_, index) => ( - = ({ /> )) : resources.map((resource) => ( - openLRDrawer(resource.id)} /> ))} diff --git a/frontends/mit-open/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/mit-open/src/page-components/SearchDisplay/SearchDisplay.tsx index 8ac3bd1c34..5fd17cd75e 100644 --- a/frontends/mit-open/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/mit-open/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -5,23 +5,21 @@ import { MuiCard, CardContent, PlainList, - Skeleton, Container, Typography, Button, SimpleSelect, truncateText, css, + LearningResourceListCard, } from "ol-components" - import TuneIcon from "@mui/icons-material/Tune" - +import * as NiceModal from "@ebay/nice-modal-react" import { LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, ResourceTypeEnum, } from "api" import { useLearningResourcesSearch } from "api/hooks/learningResources" - import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" import { AvailableFacets, @@ -33,13 +31,16 @@ import type { BooleanFacets, FacetManifest, } from "@mitodl/course-search-utils" -import LearningResourceCard from "@/page-components/LearningResourceCard/LearningResourceCard" import _ from "lodash" - import { ResourceTypeTabs } from "./ResourceTypeTabs" import ProfessionalToggle from "./ProfessionalToggle" - import type { TabConfig } from "./ResourceTypeTabs" +import { useUserMe } from "api/hooks/user" +import { + AddToLearningPathDialog, + AddToUserListDialog, +} from "../Dialogs/AddToListDialog" +import { useResourceDrawerHref } from "@/page-components/LearningResourceDrawer/LearningResourceDrawer" export const StyledDropdown = styled(SimpleSelect)` margin: 8px; @@ -201,10 +202,7 @@ export const FacetsTitleContainer = styled.div` const PaginationContainer = styled.div` display: flex; justify-content: end; -` - -const StyledSkeleton = styled(Skeleton)` - border-radius: 4px; + margin-top: 16px; ` const PAGE_SIZE = 10 @@ -293,6 +291,24 @@ const SearchDisplay: React.FC = ({ }, { keepPreviousData: true }, ) + + const { data: user } = useUserMe() + + const getDrawerHref = useResourceDrawerHref() + + const showAddToLearningPathDialog = + user?.is_authenticated && user?.is_learning_path_editor + ? (resourceId: number) => { + NiceModal.show(AddToLearningPathDialog, { resourceId }) + } + : null + + const showAddToUserListDialog = user?.is_authenticated + ? (resourceId: number) => { + NiceModal.show(AddToUserListDialog, { resourceId }) + } + : null + return ( @@ -357,7 +373,7 @@ const SearchDisplay: React.FC = ({ .fill(null) .map((a, index) => (
  • - +
  • ))} @@ -365,9 +381,11 @@ const SearchDisplay: React.FC = ({ {data.results.map((resource) => (
  • -
  • ))} diff --git a/frontends/mit-open/src/pages/HomePage/NewsEventsSection.tsx b/frontends/mit-open/src/pages/HomePage/NewsEventsSection.tsx index 551fbcb3fa..c1edbdea68 100644 --- a/frontends/mit-open/src/pages/HomePage/NewsEventsSection.tsx +++ b/frontends/mit-open/src/pages/HomePage/NewsEventsSection.tsx @@ -7,7 +7,6 @@ import { Grid, useMuiBreakpointAtLeast, Card, - CardLinkContainer, } from "ol-components" import { useNewsEventsList, @@ -81,7 +80,6 @@ const StoryCard = styled(Card)<{ mobile: boolean }>` display: flex; flex-direction: column; flex-shrink: 0; - overflow: hidden; ${({ mobile }) => (mobile ? "width: 274px" : "")} ` @@ -105,15 +103,18 @@ const MobileEvents = styled(Events)` padding: 0 16px; ` -const EventCard = styled(CardLinkContainer)` +const EventCard = styled(Card)` display: flex; align-items: center; gap: 16px; flex: 1 0 0; align-self: stretch; - padding: 16px; justify-content: space-between; overflow: visible; + + > a { + padding: 16px; + } ` const EventDate = styled.div` @@ -173,7 +174,7 @@ const Story: React.FC<{ item: NewsFeedItem; mobile: boolean }> = ({ mobile, }) => { return ( - + {item.title} @@ -208,22 +209,24 @@ const NewsEventsSection: React.FC = () => { 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} - + + + + {formatDate( + (item as EventFeedItem).event_details?.event_datetime, + "D", + )} + + + {formatDate( + (item as EventFeedItem).event_details?.event_datetime, + "MMM", + )} + + + {item.title} + + )) || [] diff --git a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx index bb8d7f9dc6..4afee0591f 100644 --- a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx +++ b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx @@ -2,33 +2,16 @@ import React from "react" import { faker } from "@faker-js/faker/locale/en" import { factories, urls } from "api/test-utils" import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" -import LearningResourceCardTemplate from "@/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate" import LearningPathListingPage from "./LearningPathListingPage" import { screen, renderWithProviders, setMockResponse, user, - expectProps, waitFor, } from "../../test-utils" import type { User } from "../../types/settings" -jest.mock( - "../../page-components/LearningResourceCardTemplate/LearningResourceCardTemplate", - () => { - const actual = jest.requireActual( - "../../page-components/LearningResourceCardTemplate/LearningResourceCardTemplate", - ) - return { - __esModule: true, - ...actual, - default: jest.fn(actual.default), - } - }, -) -const spyLRCardTemplate = jest.mocked(LearningResourceCardTemplate) - /** * Set up the mock API responses for lists pages. */ @@ -67,10 +50,6 @@ describe("LearningPathListingPage", () => { // for sanity expect(headings.length).toBeGreaterThan(0) expect(titles.length).toBe(headings.length) - - paths.results.forEach((resource) => { - expectProps(spyLRCardTemplate, { resource }) - }) }) it.each([ diff --git a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx index 2c35075887..af8adef293 100644 --- a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx +++ b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useMemo } from "react" -import { useNavigate } from "react-router" import { Button, SimpleMenu, @@ -11,7 +10,8 @@ import { styled, Typography, PlainList, - imgConfigs, + LearningResourceListCard, + theme, } from "ol-components" import type { SimpleMenuItem } from "ol-components" import EditIcon from "@mui/icons-material/Edit" @@ -24,8 +24,6 @@ import { useLearningPathsList } from "api/hooks/learningResources" import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" -import LearningResourceCardTemplate from "@/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate" - import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import * as urls from "@/common/urls" import { useUserMe } from "api/hooks/user" @@ -35,11 +33,16 @@ const ListHeaderGrid = styled(Grid)` margin-bottom: 1rem; ` -type EditListMenuProps = { - resource: LearningPathResource -} +const StyledActionButton = styled(ActionButton)` + ${theme.breakpoints.down("md")} { + width: 16px; + height: 16px; + } +` -const EditListMenu: React.FC = ({ resource }) => { +const EditListMenu: React.FC<{ resource: LearningPathResource }> = ({ + resource, +}) => { const items: SimpleMenuItem[] = useMemo( () => [ { @@ -60,49 +63,25 @@ const EditListMenu: React.FC = ({ resource }) => { return ( - + } items={items} /> ) } -type ListCardProps = { - list: LearningPathResource - onActivate: (resource: LearningPathResource) => void - canEdit: boolean -} -const ListCard: React.FC = ({ list, onActivate, canEdit }) => { - return ( - : null} - onActivate={onActivate} - /> - ) -} - const LearningPathListingPage: React.FC = () => { const listingQuery = useLearningPathsList() const { data: user } = useUserMe() - const navigate = useNavigate() - const handleActivate = useCallback( - (resource: LearningPathResource) => { - const path = urls.learningPathsView(resource.id) - navigate(path) - }, - [navigate], - ) const handleCreate = useCallback(() => { manageListDialogs.upsertLearningPath() }, []) @@ -118,7 +97,7 @@ const LearningPathListingPage: React.FC = () => { Learning Paths - + @@ -144,13 +123,17 @@ const LearningPathListingPage: React.FC = () => { {listingQuery.data && ( - {listingQuery.data.results?.map((list) => { + {listingQuery.data.results?.map((resource) => { return ( -
  • - + + ) : null + } />
  • ) diff --git a/frontends/ol-components/src/components/Button/Button.stories.tsx b/frontends/ol-components/src/components/Button/Button.stories.tsx index 4f822ace5a..42e15ffa07 100644 --- a/frontends/ol-components/src/components/Button/Button.stories.tsx +++ b/frontends/ol-components/src/components/Button/Button.stories.tsx @@ -35,7 +35,7 @@ const meta: Meta = { control: { type: "select" }, }, edge: { - options: ["circular", "rounded"], + options: ["circular", "rounded", "none"], control: { type: "select" }, }, startIcon: { @@ -88,6 +88,7 @@ export const VariantStory: Story = { const SIZES = ["small", "medium", "large"] as const const RESPONSIVE = [true, false] + export const SizeStory: Story = { argTypes: { size: { table: { disable: true } }, @@ -161,6 +162,9 @@ export const EdgeStory: Story = { + ), } @@ -203,7 +207,8 @@ export const IconOnlyStory: Story = { ), } -const EDGES = ["rounded", "circular"] as const +const EDGES = ["rounded", "circular", "none"] as const + const VARIANTS = ["primary", "secondary", "tertiary", "text"] as const const EXTRA_PROPS = [ {}, @@ -292,7 +297,7 @@ const ICONS = [ }, ] export const ActionButtonsShowcase: Story = { - render: () => ( + render: (args) => ( <> {VARIANTS.flatMap((variant) => EDGES.flatMap((edge) => ( @@ -311,6 +316,7 @@ export const ActionButtonsShowcase: Story = { variant={variant} edge={edge} size={size} + {...args} > {icon.component} diff --git a/frontends/ol-components/src/components/Button/Button.tsx b/frontends/ol-components/src/components/Button/Button.tsx index 596220e1c2..15d75b65fc 100644 --- a/frontends/ol-components/src/components/Button/Button.tsx +++ b/frontends/ol-components/src/components/Button/Button.tsx @@ -7,7 +7,7 @@ import type { Theme } from "@mui/material/styles" type ButtonVariant = "primary" | "secondary" | "tertiary" | "text" | "inverted" type ButtonSize = "small" | "medium" | "large" -type ButtonEdge = "circular" | "rounded" +type ButtonEdge = "circular" | "rounded" | "none" type ButtonStyleProps = { variant?: ButtonVariant @@ -159,6 +159,15 @@ const ButtonStyled = styled.button((props) => { // Pill-shaped buttons... Overlapping border radius get clipped to pill. borderRadius: "100vh", }, + edge === "none" && { + border: "none", + ":hover:not(:disabled)": { + "&&": { + backgroundColor: "inherit", + }, + }, + }, + // color color === "secondary" && { color: theme.custom.colors.silverGray, borderColor: theme.custom.colors.silverGray, diff --git a/frontends/ol-components/src/components/Card/Card.stories.tsx b/frontends/ol-components/src/components/Card/Card.stories.tsx index 9e6a102787..9d7f3b630a 100644 --- a/frontends/ol-components/src/components/Card/Card.stories.tsx +++ b/frontends/ol-components/src/components/Card/Card.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react" import { Card } from "./Card" import { ActionButton } from "../Button/Button" import { RiMenuAddLine, RiBookmarkLine } from "@remixicon/react" +import { withRouter } from "storybook-addon-react-router-v6" const meta: Meta = { title: "ol-components/Card", @@ -42,6 +43,7 @@ const meta: Meta = { Footer ), + decorators: [withRouter], } export default meta @@ -66,7 +68,7 @@ export const NoSize: Story = { export const LinkCard: Story = { args: { - link: true, + href: "#link", size: "medium", }, } diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx index dfba9ce485..4d39564103 100644 --- a/frontends/ol-components/src/components/Card/Card.tsx +++ b/frontends/ol-components/src/components/Card/Card.tsx @@ -8,23 +8,20 @@ import React, { import styled from "@emotion/styled" import { theme } from "../ThemeProvider/ThemeProvider" import { pxToRem } from "../ThemeProvider/typography" +import { Link } from "react-router-dom" export type Size = "small" | "medium" -const cardStyles = ` - border-radius: 8px; - border: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.white}; - box-shadow: - 0 2px 4px 0 rgb(37 38 43 / 10%), - 0 2px 4px 0 rgb(37 38 43 / 10%); - overflow: hidden; -` - -const Container = styled.div<{ size?: Size }>` - ${cardStyles} +/* + *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. + */ +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 ` @@ -34,24 +31,34 @@ const Container = styled.div<{ size?: Size }>` }} ` -const LinkContainer = styled.a` - ${cardStyles} +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; - color: ${theme.custom.colors.mitRed}; 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; - - h3, - > p { - color: ${theme.custom.colors.mitRed}; - text-decoration: underline; - } } ` +const Container = styled.div` + ${containerStyles} + display: block; + position: relative; +` + const Content = () => <> const Body = styled.div` @@ -60,13 +67,10 @@ const Body = styled.div` const Image = styled.img<{ size?: Size }>` display: block; - background-size: cover; - background-repeat: no-repeat; - -webkit-background-position: center; - background-position: center; width: 100%; height: ${({ size }) => (size === "small" ? 120 : 170)}px; background-color: ${theme.custom.colors.lightGray1}; + object-fit: cover; ` const Info = styled.div<{ size?: Size }>` @@ -119,13 +123,15 @@ const Bottom = styled.div` const Actions = styled.div` display: flex; gap: 8px; + position: absolute; + bottom: 16px; + right: 16px; ` type CardProps = { children: ReactNode[] | ReactNode className?: string size?: Size - link?: boolean href?: string } type Card = FC & { @@ -137,11 +143,24 @@ type Card = FC & { Actions: FC<{ children: ReactNode }> } -const Card: Card = ({ children, className, size, link, href }) => { - const _Container = link ? LinkContainer : Container - +const Card: Card = ({ children, className, size, href }) => { let content, imageProps, info, title, footer, actions + const _Container = href ? LinkContainer : Container + + /* + * Allows rendering child elements to specific "slots": + * + * + * The Title + * + * + * + * Akin to alternative interface: + * The Title} image={} />. + * + * An RFC here provides rationale: https://github.com/nihgwu/rfcs/blob/neo/slots/text/0000-slots.md + */ Children.forEach(children, (child) => { if (!isValidElement(child)) return if (child.type === Content) content = child.props.children @@ -154,29 +173,33 @@ const Card: Card = ({ children, className, size, link, href }) => { if (content) { return ( - - {content} - + + <_Container className={className} to={href!}> + {content} + + ) } return ( - <_Container className={className} href={href} size={size}> - {imageProps && ( - )} - /> - )} - - {info && {info}} - {title} - - -
    {footer}
    - {actions && {actions}} -
    - + + <_Container to={href!}> + {imageProps && ( + )} + /> + )} + + {info && {info}} + {title} + + +
    {footer}
    +
    + + {actions && {actions}} +
    ) } @@ -187,4 +210,4 @@ Card.Title = Title Card.Footer = Footer Card.Actions = Actions -export { Card, Container as CardContainer, LinkContainer as CardLinkContainer } +export { Card } diff --git a/frontends/ol-components/src/components/Card/ListCard.tsx b/frontends/ol-components/src/components/Card/ListCard.tsx new file mode 100644 index 0000000000..bff62862e8 --- /dev/null +++ b/frontends/ol-components/src/components/Card/ListCard.tsx @@ -0,0 +1,195 @@ +import React, { + FC, + ReactNode, + Children, + ImgHTMLAttributes, + isValidElement, +} from "react" +import styled from "@emotion/styled" +import { theme } from "../ThemeProvider/ThemeProvider" +import { Link } from "react-router-dom" +import { Wrapper, containerStyles } from "./Card" +import { TruncateText } from "../TruncateText/TruncateText" + +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; + } +` + +const Container = styled.div` + ${containerStyles} +` + +const Content = () => <> + +const Body = styled.div` + flex-grow: 1; + overflow: hidden; + margin: 24px; + ${theme.breakpoints.down("md")} { + margin: 12px; + } + + display: flex; + flex-direction: column; + justify-content: space-between; +` + +const Image = styled.img` + display: block; + width: 236px; + height: 122px; + margin: 24px 24px 24px 0; + border-radius: 4px; + object-fit: cover; + ${theme.breakpoints.down("md")} { + width: 111px; + height: 104px; + margin: 0; + border-radius: 0; + } + + background-color: ${theme.custom.colors.lightGray1}; + flex-shrink: 0; +` + +const Info = styled.div` + ${{ ...theme.typography.subtitle3 }} + margin-bottom: 16px; + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.subtitle4 }} + margin-bottom: 8px; + } + + color: ${theme.custom.colors.silverGrayDark}; + display: flex; + justify-content: space-between; + align-items: center; +` + +const Title = styled.h3` + flex-grow: 1; + text-overflow: ellipsis; + ${{ ...theme.typography.subtitle1 }} + height: ${theme.typography.pxToRem(40)}; + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.subtitle3 }} + height: ${theme.typography.pxToRem(32)}; + } + + margin: 0; +` + +const Footer = styled.span` + display: block; + ${{ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + }} + + span { + color: ${theme.custom.colors.black}; + } + + white-space: nowrap; +` + +const Bottom = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-end; + height: ${theme.typography.pxToRem(32)}; + ${theme.breakpoints.down("md")} { + height: ${theme.typography.pxToRem(18)}; + } +` + +const Actions = styled.div<{ hasImage?: boolean }>` + display: flex; + gap: 8px; + position: absolute; + bottom: 24px; + right: ${({ hasImage }) => (hasImage ? "284px" : "24px")}; + ${theme.breakpoints.down("md")} { + bottom: 12px; + right: ${({ hasImage }) => (hasImage ? "124px" : "12px")}; + } + + background-color: ${theme.custom.colors.white}; +` + +type CardProps = { + children: ReactNode[] | ReactNode + className?: string + href?: string +} +type Card = FC & { + Content: FC<{ children: ReactNode }> + Image: FC> + Info: FC<{ children: ReactNode }> + Title: FC<{ children: ReactNode }> + Footer: FC<{ children: ReactNode }> + Actions: FC<{ children: ReactNode }> +} + +const ListCard: Card = ({ children, className, href }) => { + const _Container = href ? LinkContainer : Container + + let content, imageProps, info, title, footer, actions + + 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 === Footer) footer = child.props.children + else if (child.type === Actions) actions = child.props.children + }) + + if (content) { + return ( + <_Container className={className} to={href!}> + {content} + + ) + } + + return ( + + <_Container to={href!}> + + {info} + + <TruncateText lineClamp={2}>{title}</TruncateText> + + +
    {footer}
    +
    + + {imageProps && ( + )} /> + )} + + {actions && {actions}} +
    + ) +} + +ListCard.Content = Content +ListCard.Image = Image +ListCard.Info = Info +ListCard.Title = Title +ListCard.Footer = Footer +ListCard.Actions = Actions + +export { ListCard } diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx index 7bd948128d..307bc4c962 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx @@ -4,6 +4,7 @@ import { LearningResourceCard } from "./LearningResourceCard" import { ResourceTypeEnum } from "api" import styled from "@emotion/styled" import { factories } from "api/test-utils" +import { withRouter } from "storybook-addon-react-router-v6" const _makeResource = factories.learningResources.resource @@ -58,9 +59,6 @@ const meta: Meta = { options: ["small", "medium"], control: { type: "select" }, }, - onActivate: { - action: "click-activate", - }, onAddToLearningPathClick: { action: "click-add-to-learning-path", }, @@ -72,7 +70,6 @@ const meta: Meta = { resource, isLoading, size, - onActivate, onAddToLearningPathClick, onAddToUserListClick, }) => ( @@ -80,11 +77,11 @@ const meta: Meta = { resource={resource} isLoading={isLoading} size={size} - onActivate={onActivate} onAddToLearningPathClick={onAddToLearningPathClick} onAddToUserListClick={onAddToUserListClick} /> ), + decorators: [withRouter], } export default meta diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx index 96ab30f22f..faafe7b105 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx @@ -1,11 +1,24 @@ import React from "react" -import { render, screen } from "@testing-library/react" +import { BrowserRouter } from "react-router-dom" +import { screen, render, act } from "@testing-library/react" import { LearningResourceCard } from "./LearningResourceCard" -import { DEFAULT_RESOURCE_IMG } from "ol-utilities" -import { ResourceTypeEnum, PlatformEnum } from "api" +import { DEFAULT_RESOURCE_IMG, embedlyCroppedImage } from "ol-utilities" +import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +const setup = (resource: LearningResource) => { + return render( + + + , + { wrapper: ThemeProvider }, + ) +} + describe("Learning Resource Card", () => { test("Renders resource type, title and start date", () => { const resource = factories.learningResources.resource({ @@ -13,7 +26,7 @@ describe("Learning Resource Card", () => { next_start_date: "2026-01-01", }) - render() + setup(resource) screen.getByText("Course") screen.getByRole("heading", { name: resource.title }) @@ -32,7 +45,7 @@ describe("Learning Resource Card", () => { ], }) - render() + setup(resource) screen.getByText("Starts:") screen.getByText("January 01, 2026") @@ -50,41 +63,48 @@ describe("Learning Resource Card", () => { ], }) - render() + setup(resource) - screen.getByText("As taught in:") - screen.getByText("Fall 2002") + expect(screen.getByRole("link")).toHaveTextContent("As taught in:") + expect(screen.getByRole("link")).toHaveTextContent("Fall 2002") }) - test("Click to activate and action buttons", async () => { + test("Click to navigate", async () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + platform: { code: PlatformEnum.Ocw }, + }) + + setup(resource) + + const heading = screen.getByRole("heading", { name: resource.title }) + await act(async () => { + await heading.click() + }) + + expect(window.location.search).toBe(`?resource=${resource.id}`) + }) + + test("Click action buttons", async () => { const resource = factories.learningResources.resource({ resource_type: ResourceTypeEnum.Course, platform: { code: PlatformEnum.Ocw }, - runs: [ - factories.learningResources.run({ - semester: "Fall", - year: 2002, - }), - ], }) - const onActivate = jest.fn() const onAddToLearningPathClick = jest.fn() const onAddToUserListClick = jest.fn() render( - , + + + , { wrapper: ThemeProvider }, ) - const heading = screen.getByRole("link", { name: resource.title }) - await heading.click() - const addToLearningPathButton = screen.getByLabelText( "Add to Learning Path", ) @@ -93,7 +113,6 @@ describe("Learning Resource Card", () => { const addToUserListButton = screen.getByLabelText("Add to User List") await addToUserListButton.click() - expect(onActivate).toHaveBeenCalledWith(resource.id) expect(onAddToLearningPathClick).toHaveBeenCalledWith(resource.id) expect(onAddToUserListClick).toHaveBeenCalledWith(resource.id) }) @@ -103,7 +122,7 @@ describe("Learning Resource Card", () => { certification: true, }) - render() + setup(resource) screen.getByText("Certificate") }) @@ -113,7 +132,7 @@ describe("Learning Resource Card", () => { certification: false, }) - render() + setup(resource) const badge = screen.queryByText("Certificate") @@ -133,10 +152,20 @@ describe("Learning Resource Card", () => { ])("Image is displayed if present", ({ expected, image }) => { const resource = factories.learningResources.resource({ image }) - render() + setup(resource) const imageEls = screen.getAllByRole("img") - const matching = imageEls.filter((im) => im.src === expected.src) + + const matching = imageEls.filter((im) => + expected.src === DEFAULT_RESOURCE_IMG + ? im.src === DEFAULT_RESOURCE_IMG + : im.src === + embedlyCroppedImage(expected.src, { + width: 298, + height: 170, + key: "fake-embedly-key", + }), + ) expect(matching.length).toBe(1) expect(matching[0]).toHaveAttribute("alt", expected.alt) }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index 4dfcb69178..58712a9e77 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx @@ -7,6 +7,7 @@ import { findBestRun, formatDate, getReadableResourceType, + embedlyCroppedImage, DEFAULT_RESOURCE_IMG, } from "ol-utilities" import { Card } from "../Card/Card" @@ -20,19 +21,21 @@ const EllipsisTitle = styled(TruncateText)({ margin: 0, }) -const TitleLink = styled.a` - display: block; - text-align: left; - - &:hover { - cursor: pointer; - } -` - const SkeletonImage = styled(Skeleton)<{ aspect: number }>` padding-bottom: ${({ aspect }) => 100 / aspect}%; ` +const getEmbedlyUrl = (resource: LearningResource, size: Size) => { + const dimensions = { + small: { width: 190, height: 120 }, + medium: { width: 298, height: 170 }, + } + return embedlyCroppedImage(resource.image!.url!, { + key: APP_SETTINGS.embedlyKey || process.env.EMBEDLY_KEY!, + ...dimensions[size], + }) +} + type ResourceIdCallback = (resourceId: number) => void const Info = ({ resource }: { resource: LearningResource }) => { @@ -49,25 +52,6 @@ const Info = ({ resource }: { resource: LearningResource }) => { ) } -const Title = ({ - resource, - size, - onActivate, -}: { - resource: LearningResource - size?: Size - onActivate?: ResourceIdCallback -}) => { - const lines = size === "small" ? 2 : 3 - return onActivate ? ( - onActivate(resource.id)} role="link"> - {resource.title} - - ) : ( - {resource.title} - ) -} - const Certificate = styled.div` border-radius: 4px; background-color: ${theme.custom.colors.lightGray1}; @@ -85,36 +69,44 @@ const Certificate = styled.div` gap: 4px; ` -const Footer: React.FC<{ resource: LearningResource; size?: Size }> = ({ - resource, - size, -}) => { - const isOcw = - resource.resource_type === ResourceTypeEnum.Course && - resource.platform?.code === PlatformEnum.Ocw +const isOcw = (resource: LearningResource) => + resource.resource_type === ResourceTypeEnum.Course && + resource.platform?.code === PlatformEnum.Ocw +const getStartDate = (resource: LearningResource) => { let startDate = resource.next_start_date if (!startDate) { const bestRun = findBestRun(resource.runs ?? []) - if (isOcw && bestRun?.semester && bestRun?.year) { - return ( - <> - {size === "medium" ? "As taught in:" : ""}{" "} - {`${bestRun?.semester} ${bestRun?.year}`} - - ) + if (isOcw(resource) && bestRun?.semester && bestRun?.year) { + return `${bestRun?.semester} ${bestRun?.year}` } startDate = bestRun?.start_date } if (!startDate) return null + return formatDate(startDate, "MMMM DD, YYYY") +} + +const StartDate: React.FC<{ resource: LearningResource; size?: Size }> = ({ + resource, + size, +}) => { + const startDate = getStartDate(resource) + + if (!startDate) return null + + const label = isOcw(resource) + ? size === "medium" + ? "As taught in:" + : "" + : "Starts:" + return ( <> - {size === "medium" ? "Starts:" : ""}{" "} - {formatDate(startDate, "MMMM DD, YYYY")} + {label} {startDate} ) } @@ -124,7 +116,7 @@ interface LearningResourceCardProps { resource?: LearningResource | null className?: string size?: Size - onActivate?: ResourceIdCallback + href?: string onAddToLearningPathClick?: ResourceIdCallback | null onAddToUserListClick?: ResourceIdCallback | null } @@ -134,7 +126,7 @@ const LearningResourceCard: React.FC = ({ resource, className, size = "medium", - onActivate, + href, onAddToLearningPathClick, onAddToUserListClick, }) => { @@ -155,16 +147,22 @@ const LearningResourceCard: React.FC = ({ return null } return ( - + - + <EllipsisTitle lineClamp={size === "small" ? 2 : 3}> + {resource.title} + </EllipsisTitle> </Card.Title> <Card.Actions> {onAddToLearningPathClick && ( @@ -193,7 +191,7 @@ const LearningResourceCard: React.FC<LearningResourceCardProps> = ({ )} </Card.Actions> <Card.Footer> - <Footer resource={resource} size={size} /> + <StartDate resource={resource} size={size} /> </Card.Footer> </Card> ) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx new file mode 100644 index 0000000000..716fce9c09 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx @@ -0,0 +1,130 @@ +import React from "react" +import type { Meta, StoryObj } from "@storybook/react" +import { LearningResourceListCard } from "./LearningResourceListCard" +import { ResourceTypeEnum } from "api" +import { factories } from "api/test-utils" +import { withRouter } from "storybook-addon-react-router-v6" + +const _makeResource = factories.learningResources.resource + +const makeResource: typeof _makeResource = (overrides) => { + const resource = _makeResource(overrides) + resource.image!.url = + "https://ocw.mit.edu/courses/res-hso-001-mit-haystack-observatory-k12-stem-lesson-plans/mitres_hso_001.jpg" + return resource +} + +const meta: Meta<typeof LearningResourceListCard> = { + title: "ol-components/LearningResourceListCard", + argTypes: { + resource: { + options: ["Loading", ...Object.values(ResourceTypeEnum)], + mapping: { + Loading: undefined, + [ResourceTypeEnum.Course]: makeResource({ + resource_type: ResourceTypeEnum.Course, + }), + [ResourceTypeEnum.Program]: makeResource({ + resource_type: ResourceTypeEnum.Program, + }), + [ResourceTypeEnum.Video]: makeResource({ + resource_type: ResourceTypeEnum.Video, + url: "https://www.youtube.com/watch?v=-E9hf5RShzQ", + }), + [ResourceTypeEnum.VideoPlaylist]: makeResource({ + resource_type: ResourceTypeEnum.VideoPlaylist, + }), + [ResourceTypeEnum.Podcast]: makeResource({ + resource_type: ResourceTypeEnum.Podcast, + }), + [ResourceTypeEnum.PodcastEpisode]: makeResource({ + resource_type: ResourceTypeEnum.PodcastEpisode, + }), + [ResourceTypeEnum.LearningPath]: makeResource({ + resource_type: ResourceTypeEnum.LearningPath, + }), + }, + }, + onAddToLearningPathClick: { + action: "click-add-to-learning-path", + }, + onAddToUserListClick: { + action: "click-add-to-user-list", + }, + }, + render: ({ + resource, + isLoading, + onAddToLearningPathClick, + onAddToUserListClick, + }) => ( + <LearningResourceListCard + resource={resource} + isLoading={isLoading} + href={`/?resource=${resource?.id}`} + onAddToLearningPathClick={onAddToLearningPathClick} + onAddToUserListClick={onAddToUserListClick} + /> + ), + decorators: [withRouter], +} + +export default meta + +type Story = StoryObj<typeof LearningResourceListCard> + +export const Course: Story = { + args: { + resource: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [factories.learningResources.run()], + }), + }, +} + +export const LearningPath: Story = { + args: { + resource: makeResource({ resource_type: ResourceTypeEnum.LearningPath }), + }, +} + +export const Program: Story = { + args: { + resource: makeResource({ resource_type: ResourceTypeEnum.Program }), + }, +} + +export const Podcast: Story = { + args: { + resource: makeResource({ resource_type: ResourceTypeEnum.Podcast }), + }, +} + +export const PodcastEpisode: Story = { + args: { + resource: makeResource({ resource_type: ResourceTypeEnum.PodcastEpisode }), + }, +} + +export const Video: Story = { + args: { + resource: makeResource({ + resource_type: ResourceTypeEnum.Video, + url: "https://www.youtube.com/watch?v=4A9bGL-_ilA", + }), + }, +} + +export const VideoPlaylist: Story = { + args: { + resource: makeResource({ + resource_type: ResourceTypeEnum.VideoPlaylist, + }), + }, +} + +export const Loading: Story = { + args: { + isLoading: true, + }, +} diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx new file mode 100644 index 0000000000..9e9171c0ea --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -0,0 +1,172 @@ +import React from "react" +import { BrowserRouter } from "react-router-dom" +import { screen, render, act } from "@testing-library/react" +import { LearningResourceListCard } from "./LearningResourceListCard" +import { DEFAULT_RESOURCE_IMG, embedlyCroppedImage } from "ol-utilities" +import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" +import { factories } from "api/test-utils" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" + +const setup = (resource: LearningResource) => { + return render( + <BrowserRouter> + <LearningResourceListCard + resource={resource} + href={`?resource=${resource.id}`} + /> + </BrowserRouter>, + { wrapper: ThemeProvider }, + ) +} + +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", + }) + + setup(resource) + + screen.getByText("Course") + screen.getByRole("heading", { name: resource.title }) + screen.getByText("Starts:") + screen.getByText("January 01, 2026") + }) + + test("Displays run start date", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + next_start_date: null, + runs: [ + factories.learningResources.run({ + start_date: "2026-01-01", + }), + ], + }) + + setup(resource) + + screen.getByText("Starts:") + screen.getByText("January 01, 2026") + }) + + test("Displays taught in date for OCW", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + platform: { code: PlatformEnum.Ocw }, + runs: [ + factories.learningResources.run({ + semester: "Fall", + year: 2002, + }), + ], + }) + + setup(resource) + + expect(screen.getByRole("link")).toHaveTextContent("As taught in:") + expect(screen.getByRole("link")).toHaveTextContent("Fall 2002") + }) + + test("Click to navigate", async () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + platform: { code: PlatformEnum.Ocw }, + }) + + setup(resource) + + const heading = screen.getByRole("heading", { name: resource.title }) + await act(async () => { + await heading.click() + }) + + expect(window.location.search).toBe(`?resource=${resource.id}`) + }) + + test("Click action buttons", async () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + platform: { code: PlatformEnum.Ocw }, + }) + + const onAddToLearningPathClick = jest.fn() + const onAddToUserListClick = jest.fn() + + render( + <BrowserRouter> + <LearningResourceListCard + resource={resource} + onAddToLearningPathClick={onAddToLearningPathClick} + onAddToUserListClick={onAddToUserListClick} + /> + </BrowserRouter>, + { wrapper: ThemeProvider }, + ) + + const addToLearningPathButton = screen.getByLabelText( + "Add to Learning Path", + ) + await addToLearningPathButton.click() + + const addToUserListButton = screen.getByLabelText("Add to User List") + await addToUserListButton.click() + + expect(onAddToLearningPathClick).toHaveBeenCalledWith(resource.id) + expect(onAddToUserListClick).toHaveBeenCalledWith(resource.id) + }) + + test("Displays certificate badge", () => { + const resource = factories.learningResources.resource({ + certification: true, + }) + + setup(resource) + + screen.getByText("Certificate") + }) + + test("Does not display certificate badge", () => { + const resource = factories.learningResources.resource({ + certification: false, + }) + + setup(resource) + + const badge = screen.queryByText("Certificate") + + expect(badge).not.toBeInTheDocument() + }) + + test.each([ + { image: null, expected: { src: DEFAULT_RESOURCE_IMG, alt: "" } }, + { + image: { url: "https://example.com/image.jpg", alt: "An image" }, + expected: { src: "https://example.com/image.jpg", alt: "An image" }, + }, + { + image: { url: "https://example.com/image.jpg", alt: null }, + expected: { src: "https://example.com/image.jpg", alt: "" }, + }, + ])("Image is displayed if present", ({ expected, image }) => { + const resource = factories.learningResources.resource({ image }) + + setup(resource) + + const imageEls = screen.getAllByRole<HTMLImageElement>("img") + + const matching = imageEls.filter((im) => + expected.src === DEFAULT_RESOURCE_IMG + ? im.src === DEFAULT_RESOURCE_IMG + : im.src === + embedlyCroppedImage(expected.src, { + width: 116, + height: 104, + key: "fake-embedly-key", + }), + ) + expect(matching.length).toBe(1) + expect(matching[0]).toHaveAttribute("alt", expected.alt) + }) +}) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx new file mode 100644 index 0000000000..16f5c9a843 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -0,0 +1,316 @@ +import React from "react" +import styled from "@emotion/styled" +import Skeleton from "@mui/material/Skeleton" +import { RiMenuAddLine, RiBookmarkLine, RiAwardFill } from "@remixicon/react" +import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" +import { + findBestRun, + formatDate, + getReadableResourceType, + embedlyCroppedImage, + DEFAULT_RESOURCE_IMG, + pluralize, +} from "ol-utilities" +import { ListCard } from "../Card/ListCard" +import { ActionButton } from "../Button/Button" +import { theme } from "../ThemeProvider/ThemeProvider" +import { useMuiBreakpointAtLeast } from "../../hooks/useBreakpoint" + +const IMAGE_SIZES = { + mobile: { width: 116, height: 104 }, + desktop: { width: 236, height: 122 }, +} + +const Certificate = styled.div` + border-radius: 4px; + background-color: ${theme.custom.colors.lightGray1}; + padding: 4px; + color: ${theme.custom.colors.silverGrayDark}; + gap: 4px; + margin: 0 8px 0 auto; + + ${{ ...theme.typography.subtitle3 }} + + svg { + width: 16px; + height: 16px; + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.body4 }} + background: none; + color: ${theme.custom.colors.darkGray2}; + gap: 2px; + + svg { + width: 12px; + height: 12px; + fill: ${theme.custom.colors.silverGrayDark}; + } + + margin: 0 12px 0 auto; + } + + display: flex; + align-items: center; +` + +const Price = styled.div` + ${{ ...theme.typography.subtitle2 }} + color: ${theme.custom.colors.darkGray2}; + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.subtitle3 }} + color: ${theme.custom.colors.mitRed}; + } +` + +const BorderSeparator = styled.div` + div { + display: inline; + } + + div + div { + margin-left: 8px; + padding-left: 8px; + border-left: 1px solid ${theme.custom.colors.lightGray2}; + } +` + +const StyledActionButton = styled(ActionButton)<{ edge: string }>` + ${({ edge }) => + edge === "none" + ? ` + width: 16px; + height: 16px;` + : ""} +` + +type ResourceIdCallback = (resourceId: number) => void + +const getEmbedlyUrl = (url: string, isMobile: boolean) => { + return embedlyCroppedImage(url, { + key: APP_SETTINGS.embedlyKey || process.env.EMBEDLY_KEY!, + ...IMAGE_SIZES[isMobile ? "mobile" : "desktop"], + }) +} + +const getPrice = (resource: LearningResource) => { + if (!resource) { + return null + } + const price = resource.prices?.[0] + if (resource.platform?.code === PlatformEnum.Ocw || price === 0) { + return "Free" + } + return price ? `$${price}` : null +} + +const Info = ({ resource }: { resource: LearningResource }) => { + const price = getPrice(resource) + return ( + <> + <span>{getReadableResourceType(resource.resource_type)}</span> + {resource.certification && ( + <Certificate> + <RiAwardFill /> + Certificate + </Certificate> + )} + {price && <Price>{price}</Price>} + </> + ) +} + +const Count = ({ resource }: { resource: LearningResource }) => { + if (resource.resource_type !== ResourceTypeEnum.LearningPath) { + return null + } + const count = resource.learning_path.item_count + return ( + <div> + <span>{count}</span> {pluralize("item", count)} + </div> + ) +} + +const isOcw = (resource: LearningResource) => + resource.resource_type === ResourceTypeEnum.Course && + resource.platform?.code === PlatformEnum.Ocw + +const getStartDate = (resource: LearningResource) => { + let startDate = resource.next_start_date + + if (!startDate) { + const bestRun = findBestRun(resource.runs ?? []) + + if (isOcw(resource) && bestRun?.semester && bestRun?.year) { + return `${bestRun?.semester} ${bestRun?.year}` + } + startDate = bestRun?.start_date + } + + if (!startDate) return null + + return formatDate(startDate, "MMMM DD, YYYY") +} + +const StartDate: React.FC<{ resource: LearningResource }> = ({ resource }) => { + const startDate = getStartDate(resource) + + if (!startDate) return null + + const label = isOcw(resource) ? "As taught in:" : "Starts:" + + return ( + <div> + {label} <span>{startDate}</span> + </div> + ) +} + +const Format = ({ resource }: { resource: LearningResource }) => { + const format = resource.learning_format?.[0]?.name + if (!format) return null + return ( + <div> + Format: <span>{format}</span> + </div> + ) +} + +const Loading = styled.div<{ mobile?: boolean }>` + display: flex; + padding: 24px; + justify-content: space-between; + + > div { + width: calc(100% - 236px); + } + + > span { + flex-grow: 0; + margin-left: auto; + } + ${({ mobile }) => + mobile + ? ` + padding: 0px; + > div { + padding: 12px; + }` + : ""} +` + +const LoadingView = ({ isMobile }: { isMobile: boolean }) => { + const { width, height } = IMAGE_SIZES[isMobile ? "mobile" : "desktop"] + return ( + <Loading mobile={isMobile}> + <div> + <Skeleton + variant="text" + width="15%" + style={{ marginBottom: isMobile ? 4 : 10 }} + /> + <Skeleton + variant="text" + width="75%" + style={{ marginBottom: isMobile ? 16 : 51 }} + /> + <Skeleton variant="text" width="20%" /> + </div> + <Skeleton + variant="rectangular" + width={width} + height={height} + style={{ borderRadius: 4 }} + /> + </Loading> + ) +} + +interface LearningResourceListCardProps { + isLoading?: boolean + resource?: LearningResource | null + className?: string + href?: string + onAddToLearningPathClick?: ResourceIdCallback | null + onAddToUserListClick?: ResourceIdCallback | null + editMenu?: React.ReactNode | null +} + +const LearningResourceListCard: React.FC<LearningResourceListCardProps> = ({ + isLoading, + resource, + className, + href, + onAddToLearningPathClick, + onAddToUserListClick, + editMenu, +}) => { + const isMobile = !useMuiBreakpointAtLeast("md") + + if (isLoading) { + return ( + <ListCard className={className}> + <ListCard.Content> + <LoadingView isMobile={isMobile} /> + </ListCard.Content> + </ListCard> + ) + } + if (!resource) { + return null + } + return ( + <ListCard href={href} className={className}> + <ListCard.Image + src={ + resource.image?.url + ? getEmbedlyUrl(resource.image.url!, isMobile) + : DEFAULT_RESOURCE_IMG + } + alt={resource.image?.alt ?? ""} + /> + <ListCard.Info> + <Info resource={resource} /> + </ListCard.Info> + <ListCard.Title>{resource.title}</ListCard.Title> + <ListCard.Actions> + {onAddToLearningPathClick && ( + <StyledActionButton + variant="secondary" + edge={isMobile ? "none" : "circular"} + color="secondary" + size="small" + aria-label="Add to Learning Path" + onClick={() => onAddToLearningPathClick(resource.id)} + > + <RiMenuAddLine /> + </StyledActionButton> + )} + {onAddToUserListClick && ( + <StyledActionButton + variant="secondary" + edge={isMobile ? "none" : "circular"} + color="secondary" + size="small" + aria-label="Add to User List" + onClick={() => onAddToUserListClick(resource.id)} + > + <RiBookmarkLine /> + </StyledActionButton> + )} + {editMenu} + </ListCard.Actions> + <ListCard.Footer> + <BorderSeparator> + <Count resource={resource} /> + <StartDate resource={resource} /> + <Format resource={resource} /> + </BorderSeparator> + </ListCard.Footer> + </ListCard> + ) +} + +export { LearningResourceListCard } diff --git a/frontends/ol-components/src/components/TruncateText/TruncateText.tsx b/frontends/ol-components/src/components/TruncateText/TruncateText.tsx index 6a1b9f269b..4f7cd33a25 100644 --- a/frontends/ol-components/src/components/TruncateText/TruncateText.tsx +++ b/frontends/ol-components/src/components/TruncateText/TruncateText.tsx @@ -15,6 +15,12 @@ const truncateText = (lines?: number | "none") => overflow: "hidden", textOverflow: "ellipsis", WebkitLineClamp: lines, + [`@supports (-webkit-line-clamp: ${lines})`]: { + whiteSpace: "initial", + display: "-webkit-box", + WebkitLineClamp: `${lines}`, // cast to any to avoid typechecking error in lines, + WebkitBoxOrient: "vertical", + }, }) /** diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index fa09c2d095..251dfde5b3 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -162,6 +162,7 @@ export * from "./components/Chips/ChipLink" export * from "./components/EmbedlyCard/EmbedlyCard" export * from "./components/FormDialog/FormDialog" export * from "./components/LearningResourceCard/LearningResourceCard" +export * from "./components/LearningResourceCard/LearningResourceListCard" export * from "./components/LearningResourceExpanded/LearningResourceExpanded" export * from "./components/LoadingSpinner/LoadingSpinner" export * from "./components/Logo/Logo"