diff --git a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts index f6273497c1..c1dfaf11c1 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts @@ -42,6 +42,12 @@ const resources = { } const sameDataRun = factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.Online, + name: DeliveryEnumDescriptions.online, + }, + ], resource_prices: [ { amount: "0", currency: "USD" }, { amount: "100", currency: "USD" }, @@ -161,6 +167,7 @@ const courses = { multipleRuns: { sameData: makeResource({ resource_type: ResourceTypeEnum.Course, + free: true, runs: [ factories.learningResources.run({ delivery: sameDataRun.delivery, diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx index 179297d187..57bdaf0843 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx @@ -1,8 +1,9 @@ import React from "react" import styled from "@emotion/styled" import { theme } from "../ThemeProvider/ThemeProvider" -import { LearningResource, LearningResourcePrice } from "api" +import { LearningResource } from "api" import { + allRunsAreIdentical, formatRunDate, getDisplayPrice, getRunPrices, @@ -33,7 +34,6 @@ const DifferingRunHeader = styled.div({ display: "flex", alignSelf: "stretch", alignItems: "center", - flex: "1 0 0", gap: "16px", padding: "12px", color: theme.custom.colors.darkGray2, @@ -43,17 +43,46 @@ const DifferingRunHeader = styled.div({ const DifferingRunData = styled.div({ display: "flex", - flexShrink: 0, - flex: "1 0 0", color: theme.custom.colors.darkGray2, ...theme.typography.body3, }) const DifferingRunLabel = styled.strong({ display: "flex", - flex: "1 0 0", }) +const dateColumnStyle = { + width: "130px", + [theme.breakpoints.down("sm")]: { + width: "auto", + flex: "2 0 0", + }, +} + +const priceColumnStyle = { + width: "110px", + [theme.breakpoints.down("sm")]: { + width: "auto", + flex: "1 0 0", + }, +} + +const formatStyle = { + flex: "1 0 0", +} + +const DateLabel = styled(DifferingRunLabel)(dateColumnStyle) + +const PriceLabel = styled(DifferingRunLabel)(priceColumnStyle) + +const FormatLabel = styled(DifferingRunLabel)(formatStyle) + +const DateData = styled(DifferingRunData)(dateColumnStyle) + +const PriceData = styled(DifferingRunData)(priceColumnStyle) + +const FormatData = styled(DifferingRunData)(formatStyle) + const DifferingRunLocation = styled(DifferingRunData)({ flex: "1 0 100%", flexDirection: "column", @@ -63,62 +92,27 @@ const DifferingRunLocation = styled(DifferingRunData)({ const DifferingRunsTable: React.FC<{ resource: LearningResource }> = ({ resource, }) => { - if (!resource.runs) { - return null - } - if (resource.runs.length === 1) { - return null - } const asTaughtIn = resource ? showStartAnytime(resource) : false - const prices: LearningResourcePrice[] = [] - const deliveryMethods = [] - const locations = [] - for (const run of resource.runs) { - if (run.resource_prices) { - run.resource_prices.forEach((price) => { - if (price.amount !== "0") { - prices.push(price) - } - }) - } - if (run.delivery) { - deliveryMethods.push(run.delivery) - } - if (run.location) { - locations.push(run.location) - } - } - const distinctPrices = [...new Set(prices.map((p) => p.amount).flat())] - const distinctDeliveryMethods = [ - ...new Set(deliveryMethods.flat().map((dm) => dm?.code)), - ] - const distinctLocations = [...new Set(locations.flat().map((l) => l))] - if ( - distinctPrices.length > 1 || - distinctDeliveryMethods.length > 1 || - distinctLocations.length > 1 - ) { + if (!allRunsAreIdentical(resource)) { return ( - Date - Price - Format + Date + Price + Format - {resource.runs.map((run, index) => ( + {resource.runs?.map((run, index) => ( - - {formatRunDate(run, asTaughtIn)} - + {formatRunDate(run, asTaughtIn)} {run.resource_prices && ( - + {getDisplayPrice(getRunPrices(run)["course"])} - + )} {run.delivery && ( - + {run.delivery?.map((dm) => dm?.name).join(", ")} - + )} {run.delivery.filter((d) => d.code === "in_person").length > 0 && run.location && ( diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx index d14bac2176..2ae5b4a370 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx @@ -5,6 +5,7 @@ import InfoSectionV2 from "./InfoSectionV2" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" import { formatRunDate } from "ol-utilities" import invariant from "tiny-invariant" +import user from "@testing-library/user-event" // This is a pipe followed by a zero-width space const SEPARATOR = "|​" @@ -134,9 +135,9 @@ describe("Learning resource info section start date", () => { within(section).getByText(runDate) }) - test("Multiple Runs", () => { - const course = courses.free.multipleRuns - const expectedDateText = course.runs + test("Multiple run dates", () => { + const course = courses.multipleRuns.sameData + 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) @@ -144,15 +145,39 @@ describe("Learning resource info section start date", () => { return 0 }) .map((run) => formatRunDate(run, false)) - .join(SEPARATOR) + .slice(0, 2) + .join(SEPARATOR)}Show more` invariant(expectedDateText) render(, { wrapper: ThemeProvider, }) const section = screen.getByTestId("drawer-info-items") - within(section).getByText((_content, node) => { + within(section).getAllByText((_content, node) => { return node?.textContent === expectedDateText || false }) }) + + test("If data is different, dates are not shown", () => { + const course = courses.multipleRuns.differentData + render(, { + wrapper: ThemeProvider, + }) + const section = screen.getByTestId("drawer-info-items") + expect(within(section).queryByText("Start Date:")).toBeNull() + }) + + test("Clicking the show more button should show more dates", async () => { + const course = courses.multipleRuns.sameData + const totalRuns = course.runs?.length ? course.runs.length : 0 + render(, { + wrapper: ThemeProvider, + }) + + const runDates = screen.getByTestId("drawer-run-dates") + expect(runDates.children.length).toBe(3) + const showMoreLink = within(runDates).getByText("Show more") + await user.click(showMoreLink) + expect(runDates.children.length).toBe(totalRuns + 1) + }) }) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx index d0464760bb..70615e470b 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useState } from "react" import styled from "@emotion/styled" import ISO6391 from "iso-639-1" import { @@ -16,6 +16,7 @@ import { } from "@remixicon/react" import { LearningResource, ResourceTypeEnum } from "api" import { + allRunsAreIdentical, formatDurationClockTime, formatRunDate, getLearningResourcePrices, @@ -23,6 +24,7 @@ import { } from "ol-utilities" import { theme } from "../ThemeProvider/ThemeProvider" import DifferingRunsTable from "./DifferingRunsTable" +import { Link } from "../Link/Link" const SeparatorContainer = styled.span({ padding: "0 8px", @@ -87,6 +89,19 @@ const InfoValue = styled.div({ ...theme.typography.body3, }) +const NoWrap = styled.span({ + whiteSpace: "nowrap", +}) + +const ShowMoreLink = styled(Link)({ + paddingLeft: "12px", +}) + +const ShowLessLink = styled(Link)({ + display: "flex", + paddingTop: "4px", +}) + const PriceDisplay = styled.div({ display: "flex", alignItems: "center", @@ -145,6 +160,77 @@ const InfoItemValue: React.FC = ({ ) } +const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => { + const [showingMore, setShowingMore] = useState(false) + const asTaughtIn = showStartAnytime(resource) + 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 totalDates = sortedDates?.length || 0 + const showMore = totalDates > 2 + if (showMore) { + const ShowHideLink = showingMore ? ShowLessLink : ShowMoreLink + const showMoreLink = ( + + setShowingMore(!showingMore)} + > + {showingMore ? "Show less" : "Show more"} + + + ) + return ( + + {sortedDates?.slice(0, 2).map((runDate, index) => { + return ( + + + + ) + })} + {!showingMore && showMoreLink} + {showingMore && + sortedDates?.slice(2).map((runDate, index) => { + return ( + + + + ) + })} + {showingMore && showMoreLink} + + ) + } else { + const runDates = sortedDates?.map((runDate, index) => { + return ( + + + + ) + }) + return {runDates} + } +} + const INFO_ITEMS: InfoItemConfig = [ { label: (resource: LearningResource) => { @@ -154,33 +240,10 @@ const INFO_ITEMS: InfoItemConfig = [ }, 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 + const totalDatesWithRuns = + resource.runs?.filter((run) => run.start_date !== null).length || 0 + if (allRunsAreIdentical(resource) && totalDatesWithRuns > 0) { + return } else return null }, }, @@ -396,9 +459,11 @@ const InfoSectionV2 = ({ resource }: { resource?: LearningResource }) => { <> - {infoItems.map((props, index) => ( - - ))} + {infoItems + .filter((props) => props.value !== null) + .map((props, index) => ( + + ))} ) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx index 4a3759b218..8c2c0a13ec 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx @@ -78,19 +78,19 @@ const RightContainer = styled.div({ }, }) -const ImageContainer = styled.div<{ aspect: number }>` - position: relative; - width: 100%; - padding-bottom: ${({ aspect }) => 100 / aspect}%; -` - -const Image = styled(NextImage)({ - borderRadius: "8px", +const ImageContainer = styled.div({ width: "100%", - objectFit: "cover", - zIndex: -1, }) +const Image = styled(NextImage)<{ aspect: number }>` + position: relative !important; + border-radius: 8px; + width: 100%; + aspect-ratio: ${({ aspect }) => aspect}; + object-fit: cover; + z-index: -1; +` + const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ borderRadius: "8px", paddingBottom: `${100 / aspect.aspect}%`, @@ -244,20 +244,22 @@ const ImageSection: React.FC<{ ) } else if (resource?.image) { return ( - + {resource?.image.alt ) } else if (resource) { return ( - + {resource.image?.alt diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts index 6d36d2d010..fae76e8f34 100644 --- a/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts +++ b/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts @@ -1,6 +1,7 @@ -import { findBestRun } from "./learning-resources" +import { allRunsAreIdentical, findBestRun } from "./learning-resources" import * as factories from "api/test-utils/factories" import { faker } from "@faker-js/faker/locale/en" +import { CourseResourceDeliveryInnerCodeEnum } from "api" const makeRun = factories.learningResources.run const fromNow = (days: number): string => { @@ -80,3 +81,127 @@ describe("findBestRun", () => { expect(actual).toEqual(expected) }) }) + +describe("allRunsAreIdentical", () => { + test("returns true if no runs", () => { + const resource = factories.learningResources.resource() + resource.runs = [] + expect(allRunsAreIdentical(resource)).toBe(true) + }) + + test("returns true if only one run", () => { + const resource = factories.learningResources.resource() + resource.runs = [makeRun()] + expect(allRunsAreIdentical(resource)).toBe(true) + }) + + test("returns true if all runs are identical", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + ] + expect(allRunsAreIdentical(resource)).toBe(true) + }) + + test("returns false if prices differ", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: [{ amount: "150", currency: "USD" }], + delivery: delivery, + location: location, + }), + ] + expect(allRunsAreIdentical(resource)).toBe(false) + }) + + test("returns false if delivery methods differ", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: [ + { code: CourseResourceDeliveryInnerCodeEnum.Online, name: "Online" }, + ], + location: location, + }), + ] + expect(allRunsAreIdentical(resource)).toBe(false) + }) + + test("returns false if locations differ", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: "San Francisco", + }), + ] + expect(allRunsAreIdentical(resource)).toBe(false) + }) +}) diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.ts index 5c59d48cab..74e6973ec5 100644 --- a/frontends/ol-utilities/src/learning-resources/learning-resources.ts +++ b/frontends/ol-utilities/src/learning-resources/learning-resources.ts @@ -1,6 +1,6 @@ import moment from "moment" import type { LearningResource, LearningResourceRun } from "api" -import { ResourceTypeEnum } from "api" +import { DeliveryEnum, ResourceTypeEnum } from "api" import { capitalize } from "lodash" import { formatDate } from "../date/format" @@ -116,6 +116,52 @@ const formatRunDate = ( return null } +/** + * Checks if all runs of a given learning resource are identical in terms of price, delivery method, and location. + * + * @param resource - The learning resource to check. + * @returns `true` if all runs have the same price, delivery method, and location; otherwise, `false`. + */ +const allRunsAreIdentical = (resource: LearningResource) => { + if (!resource.runs) { + return true + } + if (resource.runs.length <= 1) { + return true + } + const amounts = new Set() + const currencies = new Set() + const deliveryMethods = new Set() + const locations = new Set() + for (const run of resource.runs) { + if (run.resource_prices) { + run.resource_prices.forEach((price) => { + if (!(resource.free && price.amount === "0")) { + amounts.add(price.amount) + currencies.add(price.currency) + } + }) + } + if (run.delivery) { + for (const dm of run.delivery) { + deliveryMethods.add(dm.code) + } + } + if (run.location) { + locations.add(run.location) + } + } + const hasInPerson = [...deliveryMethods].some( + (dm) => dm === DeliveryEnum.InPerson, + ) + return ( + amounts.size === 1 && + currencies.size === 1 && + deliveryMethods.size === 1 && + (hasInPerson ? locations.size === 1 : locations.size === 0) + ) +} + export { DEFAULT_RESOURCE_IMG, embedlyCroppedImage, @@ -123,5 +169,6 @@ export { getReadableResourceType, findBestRun, formatRunDate, + allRunsAreIdentical, } export type { EmbedlyConfig }