From f71c91a404c0a434eef52b1368a4ecc3dd7428c7 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 31 Jul 2024 14:26:45 -0400 Subject: [PATCH] Show "Start Anytime" based on resource "availability" property (#1336) * fix storybook embedly, add card root classes * add anytime to LR cards * add docstrings to formatDate * add anytime to LR expanded display * add two card tests * capitalize semester * fix info height --- frontends/mit-open/.storybook/main.ts | 19 +-- .../mit-open/.storybook/preview-head.html | 5 - .../src/components/Card/Card.test.tsx | 32 +++++ .../src/components/Card/Card.tsx | 25 ++-- .../src/components/Card/ListCard.test.tsx | 17 +++ .../src/components/Card/ListCard.tsx | 5 +- .../LearningResourceCard.stories.tsx | 12 ++ .../LearningResourceCard.test.tsx | 79 ++++++++---- .../LearningResourceCard.tsx | 75 +++++------ .../LearningResourceListCard.stories.tsx | 12 ++ .../LearningResourceListCard.test.tsx | 85 ++++++++----- .../LearningResourceListCard.tsx | 44 ++----- .../LearningResourceCard/story_utils.ts | 10 ++ .../components/LearningResourceCard/utils.ts | 19 ++- .../LearningResourceExpanded.stories.tsx | 28 ++++- .../LearningResourceExpanded.test.tsx | 117 +++++++++++++++--- .../LearningResourceExpanded.tsx | 59 ++++++--- frontends/ol-utilities/src/date/format.ts | 11 +- 18 files changed, 464 insertions(+), 190 deletions(-) create mode 100644 frontends/ol-components/src/components/Card/Card.test.tsx create mode 100644 frontends/ol-components/src/components/Card/ListCard.test.tsx diff --git a/frontends/mit-open/.storybook/main.ts b/frontends/mit-open/.storybook/main.ts index 9a386af71c..3fa5a256e5 100644 --- a/frontends/mit-open/.storybook/main.ts +++ b/frontends/mit-open/.storybook/main.ts @@ -1,5 +1,6 @@ import { resolve, join, dirname } from "path" import * as dotenv from "dotenv" +import * as webpack from "webpack" dotenv.config({ path: resolve(__dirname, "../../../.env") }) @@ -32,13 +33,17 @@ const config = { docs: { autodocs: "tag", }, - env: (config: any) => ({ - ...config, - APP_SETTINGS: { - PUBLIC_URL: process.env.PUBLIC_URL || "", - EMBEDLY_KEY: process.env.EMBEDLY_KEY || "", - }, - }), + webpackFinal: async (config: any) => { + config.plugins.push( + new webpack.DefinePlugin({ + APP_SETTINGS: { + EMBEDLY_KEY: JSON.stringify(process.env.EMBEDLY_KEY), + PUBLIC_URL: JSON.stringify(process.env.PUBLIC_URL), + }, + }), + ) + return config + }, } export default config diff --git a/frontends/mit-open/.storybook/preview-head.html b/frontends/mit-open/.storybook/preview-head.html index e061bbea23..3836990ca0 100644 --- a/frontends/mit-open/.storybook/preview-head.html +++ b/frontends/mit-open/.storybook/preview-head.html @@ -3,8 +3,3 @@ WARNING: This is linked to chudzick@mit.edu's Adobe account. --> - diff --git a/frontends/ol-components/src/components/Card/Card.test.tsx b/frontends/ol-components/src/components/Card/Card.test.tsx new file mode 100644 index 0000000000..ce521c464f --- /dev/null +++ b/frontends/ol-components/src/components/Card/Card.test.tsx @@ -0,0 +1,32 @@ +import { render } from "@testing-library/react" +import { Card } from "./Card" +import React from "react" + +describe("Card", () => { + test("has class MitCard-root on root element", () => { + const { container } = render( + + Title + + Info + Footer + Actions + , + ) + const card = container.firstChild as HTMLElement + const title = card.querySelector(".MitCard-title") + const image = card.querySelector(".MitCard-image") + const info = card.querySelector(".MitCard-info") + const footer = card.querySelector(".MitCard-footer") + const actions = card.querySelector(".MitCard-actions") + + expect(card).toHaveClass("MitCard-root") + expect(card).toHaveClass("Foo") + expect(title).toHaveTextContent("Title") + expect(image).toHaveAttribute("src", "https://via.placeholder.com/150") + expect(image).toHaveAttribute("alt", "placeholder") + expect(info).toHaveTextContent("Info") + expect(footer).toHaveTextContent("Footer") + expect(actions).toHaveTextContent("Actions") + }) +}) diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx index 27f2634339..dd2d1b0e0f 100644 --- a/frontends/ol-components/src/components/Card/Card.tsx +++ b/frontends/ol-components/src/components/Card/Card.tsx @@ -117,10 +117,6 @@ const Footer = styled.span` ...theme.typography.body3, color: theme.custom.colors.silverGrayDark, }} - - span { - color: ${theme.custom.colors.black}; - } ` const Bottom = styled.div` @@ -200,9 +196,11 @@ const Card: Card = ({ children, className, size, href }) => { else if (child.type === Actions) actions = child.props }) + const allClassNames = ["MitCard-root", className ?? ""].join(" ") + if (content) { return ( - + <_Container className={className} to={href!}> {content} @@ -211,10 +209,11 @@ const Card: Card = ({ children, className, size, href }) => { } return ( - + <_Container to={href!}> {image && ( )} @@ -222,19 +221,25 @@ const Card: Card = ({ children, className, size, href }) => { )} {info.children && ( - + {info.children} )} - + <Title className="MitCard-title" size={size} {...title}> {title.children} -
{footer.children}
+
+ {footer.children} +
- {actions.children && {actions.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 new file mode 100644 index 0000000000..3fc65edb90 --- /dev/null +++ b/frontends/ol-components/src/components/Card/ListCard.test.tsx @@ -0,0 +1,17 @@ +import { render } from "@testing-library/react" +import { ListCard } from "./ListCard" +import React from "react" + +describe("ListCard", () => { + test("has class MitCard-root on root element", () => { + const { container } = render( + + Hello world + , + ) + const card = container.firstChild + + expect(card).toHaveClass("MitListCard-root") + expect(card).toHaveClass("Foo") + }) +}) diff --git a/frontends/ol-components/src/components/Card/ListCard.tsx b/frontends/ol-components/src/components/Card/ListCard.tsx index f8217f8da1..2f02d0505e 100644 --- a/frontends/ol-components/src/components/Card/ListCard.tsx +++ b/frontends/ol-components/src/components/Card/ListCard.tsx @@ -205,16 +205,17 @@ const ListCard: Card = ({ children, className, href, draggable }) => { else if (child.type === Actions) actions = child.props.children }) + const classNames = ["MitListCard-root", className ?? ""].join(" ") if (content) { return ( - <_Container className={className} to={href!}> + <_Container className={classNames} to={href!}> {content} ) } return ( - + <_Container to={href!}> {draggable && ( diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx index d593689427..e3e9efffe2 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx @@ -154,3 +154,15 @@ export const Loading: Story = { isLoading: true, }, } + +export const StartAnytime: Story = { + args: { + resource: courses.start.anytime, + }, +} + +export const StartDated: Story = { + args: { + resource: courses.start.dated, + }, +} diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx index ad918d683f..8660f1807a 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx @@ -2,17 +2,18 @@ import React from "react" import { BrowserRouter } from "react-router-dom" import { screen, render, act } from "@testing-library/react" import { LearningResourceCard } from "./LearningResourceCard" +import type { LearningResourceCardProps } from "./LearningResourceCard" import { DEFAULT_RESOURCE_IMG, embedlyCroppedImage } from "ol-utilities" -import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" +import { ResourceTypeEnum, PlatformEnum, AvailabilityEnum } from "api" import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" -const setup = (resource: LearningResource) => { +const setup = (props: LearningResourceCardProps) => { return render( , { wrapper: ThemeProvider }, @@ -26,7 +27,7 @@ describe("Learning Resource Card", () => { next_start_date: "2026-01-01", }) - setup(resource) + setup({ resource }) screen.getByText("Course") screen.getByRole("heading", { name: resource.title }) @@ -45,29 +46,53 @@ describe("Learning Resource Card", () => { ], }) - setup(resource) + 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.each([ + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + availability: AvailabilityEnum.Anytime, + }), + showsAnytime: true, + }, + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + availability: AvailabilityEnum.Anytime, + }), + size: "small", + showsAnytime: true, + }, + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Program, + availability: AvailabilityEnum.Anytime, + }), + showsAnytime: true, + }, + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Video, + availability: AvailabilityEnum.Anytime, + }), + showsAnytime: false, + }, + ] as const)( + "Displays 'Anytime' for availability 'Anytime' courses and programs", + ({ resource, size, showsAnytime }) => { + setup({ resource, size }) + + const anytime = screen.queryByText("Anytime") + const starts = screen.queryByText("Starts:") + expect(!!anytime).toEqual(showsAnytime) + expect(!!starts).toBe(showsAnytime) + }, + ) test("Click to navigate", async () => { const resource = factories.learningResources.resource({ @@ -75,7 +100,7 @@ describe("Learning Resource Card", () => { platform: { code: PlatformEnum.Ocw }, }) - setup(resource) + setup({ resource }) const heading = screen.getByRole("heading", { name: resource.title }) await act(async () => { @@ -134,7 +159,7 @@ describe("Learning Resource Card", () => { certification: true, }) - setup(resource) + setup({ resource }) screen.getByText("Certificate") }) @@ -144,7 +169,7 @@ describe("Learning Resource Card", () => { certification: false, }) - setup(resource) + setup({ resource }) const badge = screen.queryByText("Certificate") @@ -175,7 +200,7 @@ describe("Learning Resource Card", () => { ])("Image is displayed if present", ({ expected, image }) => { const resource = factories.learningResources.resource({ image }) - setup(resource) + setup({ resource }) const imageEls = screen.getAllByRole(expected.role) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx index 218a253977..ff0ef3793b 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx @@ -7,9 +7,8 @@ import { RiBookmarkFill, RiAwardFill, } from "@remixicon/react" -import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" +import { LearningResource } from "api" import { - findBestRun, formatDate, getReadableResourceType, embedlyCroppedImage, @@ -21,7 +20,7 @@ import { TruncateText } from "../TruncateText/TruncateText" import { ActionButton, ActionButtonProps } from "../Button/Button" import { imgConfigs } from "../../constants/imgConfigs" import { theme } from "../ThemeProvider/ThemeProvider" -import { getDisplayPrices } from "./utils" +import { getDisplayPrices, getResourceDate, showStartAnytime } from "./utils" import Tooltip from "@mui/material/Tooltip" const EllipsisTitle = styled(TruncateText)({ @@ -32,6 +31,22 @@ const SkeletonImage = styled(Skeleton)<{ aspect: number }>` padding-bottom: ${({ aspect }) => 100 / aspect}%; ` +const Label = styled.span(({ theme }) => ({ + color: theme.custom.colors.silverGrayDark, +})) + +const Value = styled.span<{ size?: Size }>(({ theme, size }) => [ + { + color: theme.custom.colors.darkGray2, + }, + size === "small" && { + color: theme.custom.colors.silverGrayDark, + ".MitCard-root:hover &": { + color: theme.custom.colors.darkGray2, + }, + }, +]) + const getImageDimensions = (size: Size, isMedia: boolean) => { const dimensions = { small: { width: 190, height: isMedia ? 190 : 120 }, @@ -135,41 +150,23 @@ export const Price = styled.div` color: ${theme.custom.colors.darkGray2}; ` -const isOcw = (resource: LearningResource) => - resource.resource_type === ResourceTypeEnum.Course && - resource.platform?.code === PlatformEnum.Ocw - -const getStartDate = (resource: LearningResource, size: Size = "medium") => { - 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, `MMM${size === "medium" ? "M" : ""} DD, YYYY`) -} - const StartDate: React.FC<{ resource: LearningResource; size?: Size }> = ({ resource, size, }) => { - const startDate = getStartDate(resource, size) - - if (!startDate) return null - - const label = - size === "medium" ? (isOcw(resource) ? "As taught in:" : "Starts:") : "" + const anytime = showStartAnytime(resource) + const startDate = getResourceDate(resource) + const format = size === "small" ? "MMM DD, YYYY" : "MMMM DD, YYYY" + const formatted = anytime + ? "Anytime" + : startDate && formatDate(startDate, format) + if (!formatted) return null + const showLabel = size !== "small" || anytime return ( <> - {label} {startDate} + {showLabel ? : null} + {formatted} ) } @@ -204,6 +201,14 @@ const CardActionButton: React.FC< ) } +const StyledCard = styled(Card)<{ size: Size }>(({ size }) => [ + size === "medium" && { + ".MitCard-info": { + height: "18px", + }, + }, +]) + const LearningResourceCard: React.FC = ({ isLoading, resource, @@ -220,21 +225,21 @@ const LearningResourceCard: React.FC = ({ const { width, height } = imgConfigs["column"] const aspect = isMedia ? 1 : width / height return ( - + - + ) } if (!resource) { return null } return ( - + = ({ - + ) } diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx index 04bb29d5f4..2b4298b31e 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx @@ -152,3 +152,15 @@ export const Draggable: Story = { draggable: true, }, } + +export const StartAnytime: Story = { + args: { + resource: courses.start.anytime, + }, +} + +export const StartDated: Story = { + args: { + resource: courses.start.dated, + }, +} diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index a3bf87a5f0..df930f2f53 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -2,17 +2,18 @@ import React from "react" import { BrowserRouter } from "react-router-dom" import { screen, render, act } from "@testing-library/react" import { LearningResourceListCard } from "./LearningResourceListCard" +import type { LearningResourceListCardProps } from "./LearningResourceListCard" import { DEFAULT_RESOURCE_IMG, embedlyCroppedImage } from "ol-utilities" -import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" +import { ResourceTypeEnum, PlatformEnum, AvailabilityEnum } from "api" import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" -const setup = (resource: LearningResource) => { +const setup = (props: LearningResourceListCardProps) => { return render( , { wrapper: ThemeProvider }, @@ -26,7 +27,7 @@ describe("Learning Resource List Card", () => { next_start_date: "2026-01-01", }) - setup(resource) + setup({ resource }) screen.getByText("Course") screen.getByRole("heading", { name: resource.title }) @@ -45,29 +46,45 @@ describe("Learning Resource List Card", () => { ], }) - setup(resource) + 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.each([ + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + availability: AvailabilityEnum.Anytime, + }), + showsAnytime: true, + }, + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Program, + availability: AvailabilityEnum.Anytime, + }), + showsAnytime: true, + }, + { + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Video, + availability: AvailabilityEnum.Anytime, + }), + showsAnytime: false, + }, + ] as const)( + "Displays 'Anytime' for availability 'Anytime' courses and programs", + ({ resource, showsAnytime }) => { + setup({ resource }) + + const anytime = screen.queryByText("Anytime") + const starts = screen.queryByText("Starts:") + expect(!!anytime).toEqual(showsAnytime) + expect(!!starts).toBe(showsAnytime) + }, + ) test("Click to navigate", async () => { const resource = factories.learningResources.resource({ @@ -75,7 +92,7 @@ describe("Learning Resource List Card", () => { platform: { code: PlatformEnum.Ocw }, }) - setup(resource) + setup({ resource }) const heading = screen.getByRole("heading", { name: resource.title }) await act(async () => { @@ -132,7 +149,7 @@ describe("Learning Resource List Card", () => { certification: true, }) - setup(resource) + setup({ resource }) screen.getByText("Certificate") }) @@ -142,7 +159,7 @@ describe("Learning Resource List Card", () => { certification: false, }) - setup(resource) + setup({ resource }) const badge = screen.queryByText("Certificate") @@ -173,7 +190,7 @@ describe("Learning Resource List Card", () => { ])("Image is displayed if present", ({ expected, image }) => { const resource = factories.learningResources.resource({ image }) - setup(resource) + setup({ resource }) const imageEls = screen.getAllByRole(expected.role) @@ -198,7 +215,7 @@ describe("Learning Resource List Card", () => { free: true, prices: ["0"], }) - setup(resource) + setup({ resource }) screen.getByText("Free") }) @@ -208,7 +225,7 @@ describe("Learning Resource List Card", () => { free: true, prices: ["0", "49"], }) - setup(resource) + setup({ resource }) screen.getByText("Certificate") screen.getByText(": $49") screen.getByText("Free") @@ -220,7 +237,7 @@ describe("Learning Resource List Card", () => { free: true, prices: ["0", "99", "49"], }) - setup(resource) + setup({ resource }) screen.getByText("Certificate") screen.getByText(": $49 – $99") screen.getByText("Free") @@ -232,7 +249,7 @@ describe("Learning Resource List Card", () => { free: false, prices: ["49"], }) - setup(resource) + setup({ resource }) screen.getByText("$49") }) @@ -242,7 +259,7 @@ describe("Learning Resource List Card", () => { free: false, prices: ["49.50"], }) - setup(resource) + setup({ resource }) screen.getByText("$49.50") }) @@ -252,7 +269,7 @@ describe("Learning Resource List Card", () => { free: true, prices: [], }) - setup(resource) + setup({ resource }) screen.getByText("Free") }) @@ -262,7 +279,7 @@ describe("Learning Resource List Card", () => { free: false, prices: ["0"], }) - setup(resource) + setup({ resource }) screen.getByText("Paid") }) }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index 3a3fdf37f9..1be6da0e5a 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -7,9 +7,8 @@ import { RiAwardFill, RiBookmarkFill, } from "@remixicon/react" -import { LearningResource, ResourceTypeEnum, PlatformEnum } from "api" +import { LearningResource, ResourceTypeEnum } from "api" import { - findBestRun, formatDate, getReadableResourceType, embedlyCroppedImage, @@ -20,7 +19,7 @@ import { ListCard } from "../Card/ListCard" import { ActionButtonProps } from "../Button/Button" import { theme } from "../ThemeProvider/ThemeProvider" import { useMuiBreakpointAtLeast } from "../../hooks/useBreakpoint" -import { getDisplayPrices } from "./utils" +import { getDisplayPrices, getResourceDate, showStartAnytime } from "./utils" const IMAGE_SIZES = { mobile: { width: 116, height: 104 }, @@ -33,6 +32,9 @@ export const CardLabel = styled.span` display: none; } ` +const CardValue = styled.span(({ theme }) => ({ + color: theme.custom.colors.darkGray2, +})) export const Certificate = styled.div` border-radius: 4px; @@ -148,39 +150,19 @@ export const Count = ({ resource }: { resource: LearningResource }) => { ) } -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") -} - export const StartDate: React.FC<{ resource: LearningResource }> = ({ resource, }) => { - const startDate = getStartDate(resource) - - if (!startDate) return null - - const label = isOcw(resource) ? "As taught in:" : "Starts:" + const anytime = showStartAnytime(resource) + const startDate = getResourceDate(resource) + const formatted = anytime + ? "Anytime" + : startDate && formatDate(startDate, "MMMM DD, YYYY") + if (!formatted) return null return (
- {label} {startDate} + Starts: {formatted}
) } @@ -190,7 +172,7 @@ export const Format = ({ resource }: { resource: LearningResource }) => { if (!format) return null return (
- Format: {format} + Format: {format}
) } diff --git a/frontends/ol-components/src/components/LearningResourceCard/story_utils.ts b/frontends/ol-components/src/components/LearningResourceCard/story_utils.ts index ac995ff1ba..da7bbf6b5f 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/story_utils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/story_utils.ts @@ -104,6 +104,16 @@ const courses = { prices: ["49", "99"], }), }, + start: { + anytime: makeResource({ + resource_type: ResourceTypeEnum.Course, + availability: "anytime", + }), + dated: makeResource({ + resource_type: ResourceTypeEnum.Course, + availability: "dated", + }), + }, } const resourceArgType = { diff --git a/frontends/ol-components/src/components/LearningResourceCard/utils.ts b/frontends/ol-components/src/components/LearningResourceCard/utils.ts index 7679f42b79..02a1b73ce7 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/utils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/utils.ts @@ -1,4 +1,5 @@ -import { LearningResource } from "api" +import { LearningResource, ResourceTypeEnum } from "api" +import { findBestRun } from "ol-utilities" /* * This constant represents the value displayed when a course is free. @@ -82,3 +83,19 @@ export const getDisplayPrices = (resource: LearningResource) => { certificate: getDisplayPrice(prices.certificate), } } + +export const showStartAnytime = (resource: LearningResource) => { + return ( + resource.availability === "anytime" && + ( + [ResourceTypeEnum.Course, ResourceTypeEnum.Program] as ResourceTypeEnum[] + ).includes(resource.resource_type) + ) +} + +export const getResourceDate = (resource: LearningResource): string | null => { + const startDate = + resource.next_start_date ?? findBestRun(resource.runs ?? [])?.start_date + + return startDate ?? null +} diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx index a7c5183a02..c2ce4e4c11 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx @@ -22,7 +22,7 @@ const meta: Meta = { component: LearningResourceExpanded, args: { imgConfig: { - key: process.env.EMBEDLY_KEY!, + key: APP_SETTINGS.EMBEDLY_KEY, width: 385, height: 200, }, @@ -134,3 +134,29 @@ export const Loading: Story = { resource: undefined, }, } + +export const AsTaughtIn: Story = { + args: { + resource: makeResource({ + resource_type: LRT.Course, + availability: "anytime", + runs: [factories.learningResources.run()], + }), + }, +} + +export const AsTaughtInMultipleRuns: Story = { + args: { + resource: makeResource({ + resource_type: LRT.Course, + availability: "anytime", + runs: [ + factories.learningResources.run({ + semester: "Fall", + year: 2023, + }), + factories.learningResources.run(), + ], + }), + }, +} diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx index 5c64fc115d..e4721a08fe 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx @@ -1,14 +1,16 @@ 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 { ResourceTypeEnum, PlatformEnum, PodcastEpisodeResource } from "api" +import { ResourceTypeEnum, PodcastEpisodeResource, AvailabilityEnum } from "api" import { factories } from "api/test-utils" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" import { getReadableResourceType } from "ol-utilities" import invariant from "tiny-invariant" import type { LearningResource } from "api" +import { faker } from "@faker-js/faker/locale/en" const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = { key: "fake-key", @@ -189,30 +191,105 @@ describe("Learning Resource Expanded", () => { } }) - test("Renders taught in date and price free for OCW courses", () => { - const resource = factories.learningResources.resource({ - resource_type: ResourceTypeEnum.Course, - platform: { code: PlatformEnum.Ocw }, - runs: [ - factories.learningResources.run({ - semester: "Fall", - year: 2002, - }), - ], - }) + test.each([ + { + run: factories.learningResources.run({ semester: "Fall", year: 2001 }), + expectedDate: "Fall 2001", + }, + { + run: factories.learningResources.run({ + semester: "Fall", + year: null, + start_date: "2002-09-01", + }), + expectedDate: "Fall 2002", + }, + { + run: factories.learningResources.run({ + semester: "fall", + year: null, + start_date: "2002-09-01", + }), + expectedDate: "Fall 2002", // capitalized + }, + { + run: factories.learningResources.run({ + semester: null, + year: null, + start_date: "2003-09-01", + }), + expectedDate: "September, 2003", + }, + ])( + "Renders 'As taught in' and Month+Year for availability: anytime", + ({ run, expectedDate }) => { + const resource = factories.learningResources.resource({ + resource_type: faker.helpers.arrayElement([ + ResourceTypeEnum.Course, + ResourceTypeEnum.Program, + ]), + runs: [run], + availability: "anytime", + }) - setup(resource) + setup(resource) - const dateSection = screen.getByText("As taught in:")!.closest("div")! + const dateSection = screen.getByText("As taught in:")!.closest("div")! - within(dateSection).getByText("Fall 2002") + within(dateSection).getByText(expectedDate) + }, + ) - const section = screen - .getByRole("heading", { name: "Info" })! - .closest("section")! + test.each([ + { + expectedLabel: "Start Date:", + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + availability: AvailabilityEnum.Dated, + runs: [ + factories.learningResources.run({ start_date: "2024-02-03" }), + factories.learningResources.run({ start_date: "2024-04-05" }), + ], + }), + expectedDates: ["February 03, 2024", "April 05, 2024"], + }, + { + expectedLabel: "As taught in:", + resource: factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + availability: AvailabilityEnum.Anytime, + runs: [ + factories.learningResources.run({ semester: "Fall", year: 2020 }), + factories.learningResources.run({ + semester: "Spring", + year: null, + start_date: "2021-02-03", + }), + factories.learningResources.run({ + semester: null, + year: null, + start_date: "2022-05-06", + }), + ], + }), + expectedDates: ["Fall 2020", "Spring 2021", "May, 2022"], + }, + ])( + "Renders a dropdown for run picker", + async ({ resource, expectedDates, expectedLabel }) => { + setup(resource) - within(section).getByText("Free") - }) + screen.getByText(expectedLabel) + const select = screen.getByRole("combobox") + await user.click(select) + + const options = screen.getAllByRole("option") + + expectedDates.forEach((date, index) => { + expect(options[index]).toHaveTextContent(date) + }) + }, + ) test("Renders info section languages correctly", () => { const resource = factories.learningResources.resource({ diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx index 378f917e54..6b6596aa51 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx @@ -4,10 +4,15 @@ import Skeleton from "@mui/material/Skeleton" import Typography from "@mui/material/Typography" import { ButtonLink } from "../Button/Button" import Chip from "@mui/material/Chip" -import type { LearningResource, LearningResourceTopic } from "api" +import type { + LearningResource, + LearningResourceRun, + LearningResourceTopic, +} from "api" import { ResourceTypeEnum, PlatformEnum } from "api" import { formatDate, + capitalize, resourceThumbnailSrc, getReadableResourceType, DEFAULT_RESOURCE_IMG, @@ -20,6 +25,7 @@ import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard" import { PlatformLogo, PLATFORMS } from "../Logo/Logo" import { ChipLink } from "../Chips/ChipLink" import InfoSection from "./InfoSection" +import { showStartAnytime } from "../LearningResourceCard/utils" const Container = styled.div<{ padTop?: boolean }>` display: flex; @@ -327,6 +333,28 @@ const TopicsSection: React.FC<{ topics?: LearningResourceTopic[] }> = ({ ) } +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 +} + const LearningResourceExpanded: React.FC = ({ resource, imgConfig, @@ -334,6 +362,8 @@ const LearningResourceExpanded: React.FC = ({ const [selectedRun, setSelectedRun] = useState(resource?.runs?.[0]) const multipleRuns = resource?.runs && resource.runs.length > 1 + const asTaughtIn = resource ? showStartAnytime(resource) : false + const label = asTaughtIn ? "As taught in:" : "Start Date:" useEffect(() => { if (resource) { @@ -356,10 +386,12 @@ const LearningResourceExpanded: React.FC = ({ return } const dateOptions: SimpleSelectProps["options"] = - resource.runs?.map((run) => ({ - value: run.id.toString(), - label: formatDate(run.start_date!, "MMMM DD, YYYY"), - })) ?? [] + resource.runs?.map((run) => { + return { + value: run.id.toString(), + label: formatRunDate(run, asTaughtIn), + } + }) ?? [] if ( [ResourceTypeEnum.Course, ResourceTypeEnum.Program].includes( @@ -369,7 +401,7 @@ const LearningResourceExpanded: React.FC = ({ ) { return ( - Start Date: + {label} = ({ ) } - const isOcw = - resource.resource_type === ResourceTypeEnum.Course && - resource.platform?.code === PlatformEnum.Ocw - - const nextStart = resource.next_start_date || selectedRun?.start_date + if (!selectedRun) return - if (!isOcw && !nextStart && !(selectedRun?.semester && selectedRun?.year)) { + const formatted = formatRunDate(selectedRun, asTaughtIn) + if (!formatted) { return } return ( - {isOcw ? "As taught in:" : "Start Date:"} - {isOcw - ? `${selectedRun?.semester} ${selectedRun?.year}` - : formatDate(nextStart!, "MMMM DD, YYYY")} + {label} + {formatted} ) } diff --git a/frontends/ol-utilities/src/date/format.ts b/frontends/ol-utilities/src/date/format.ts index f0986a6bf9..3bd6206487 100644 --- a/frontends/ol-utilities/src/date/format.ts +++ b/frontends/ol-utilities/src/date/format.ts @@ -1,5 +1,14 @@ import moment from "moment" -export const formatDate = (date: string | Date, format = "MMM D, YYYY") => { +export const formatDate = ( + /** + * Date string or date. + */ + date: string | Date, + /** + * A momentjs format string. See https://momentjs.com/docs/#/displaying/format/ + */ + format = "MMM D, YYYY", +) => { return moment(date).format(format) }