diff --git a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts index 79eacb61b3..f6273497c1 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts @@ -1,4 +1,4 @@ -import { ResourceTypeEnum } from "api" +import { DeliveryEnum, DeliveryEnumDescriptions, ResourceTypeEnum } from "api" import { factories } from "api/test-utils" const _makeResource = factories.learningResources.resource @@ -41,6 +41,12 @@ const resources = { }), } +const sameDataRun = factories.learningResources.run({ + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "100", currency: "USD" }, + ], +}) const courses = { free: { noCertificate: makeResource({ @@ -152,6 +158,82 @@ const courses = { availability: "dated", }), }, + multipleRuns: { + sameData: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [ + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + ], + }), + differentData: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [ + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.Online, + name: DeliveryEnumDescriptions.online, + }, + ], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "100", currency: "USD" }, + ], + }), + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.Online, + name: DeliveryEnumDescriptions.online, + }, + ], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "100", currency: "USD" }, + ], + }), + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.InPerson, + name: DeliveryEnumDescriptions.in_person, + }, + ], + resource_prices: [{ amount: "150", currency: "USD" }], + location: "Earth", + }), + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.InPerson, + name: DeliveryEnumDescriptions.in_person, + }, + ], + resource_prices: [{ amount: "150", currency: "USD" }], + location: "Earth", + }), + ], + }), + }, } const resourceArgType = { diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.test.tsx new file mode 100644 index 0000000000..99a832b9f0 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.test.tsx @@ -0,0 +1,39 @@ +import React from "react" +import { render, screen, within } from "@testing-library/react" +import { courses } from "../LearningResourceCard/testUtils" +import InfoSectionV2 from "./InfoSectionV2" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { DeliveryEnumDescriptions } from "api" + +describe("Differing runs comparison table", () => { + test("Does not appear if data is the same", () => { + const course = courses.multipleRuns.sameData + render(, { + wrapper: ThemeProvider, + }) + expect(screen.queryByTestId("differing-runs-table")).toBeNull() + }) + + test("Appears if data is different", () => { + const course = courses.multipleRuns.differentData + render(, { + wrapper: ThemeProvider, + }) + const differingRunsTable = screen.getByTestId("differing-runs-table") + expect(differingRunsTable).toBeInTheDocument() + const onlineLabels = within(differingRunsTable).getAllByText( + DeliveryEnumDescriptions.online, + ) + const inPersonLabels = within(differingRunsTable).getAllByText( + DeliveryEnumDescriptions.in_person, + ) + const onlinePriceLabels = within(differingRunsTable).getAllByText("$100") + const inPersonPriceLabels = within(differingRunsTable).getAllByText("$150") + const earthLocationLabels = within(differingRunsTable).getAllByText("Earth") + expect(onlineLabels).toHaveLength(2) + expect(inPersonLabels).toHaveLength(2) + expect(onlinePriceLabels).toHaveLength(2) + expect(inPersonPriceLabels).toHaveLength(2) + expect(earthLocationLabels).toHaveLength(2) + }) +}) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx new file mode 100644 index 0000000000..179297d187 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx @@ -0,0 +1,138 @@ +import React from "react" +import styled from "@emotion/styled" +import { theme } from "../ThemeProvider/ThemeProvider" +import { LearningResource, LearningResourcePrice } from "api" +import { + formatRunDate, + getDisplayPrice, + getRunPrices, + showStartAnytime, +} from "ol-utilities" + +const DifferingRuns = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + alignSelf: "stretch", + borderRadius: "4px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderBottom: "none", +}) + +const DifferingRun = styled.div({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "16px", + padding: "12px", + alignSelf: "stretch", + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, +}) + +const DifferingRunHeader = styled.div({ + display: "flex", + alignSelf: "stretch", + alignItems: "center", + flex: "1 0 0", + gap: "16px", + padding: "12px", + color: theme.custom.colors.darkGray2, + backgroundColor: theme.custom.colors.lightGray1, + ...theme.typography.subtitle3, +}) + +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 DifferingRunLocation = styled(DifferingRunData)({ + flex: "1 0 100%", + flexDirection: "column", + alignSelf: "stretch", +}) + +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 + ) { + return ( + + + Date + Price + Format + + {resource.runs.map((run, index) => ( + + + {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 && ( + + Location + {run.location} + + )} + + ))} + + ) + } + return null +} + +export default DifferingRunsTable diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx index 6c27cf378a..d0464760bb 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx @@ -22,6 +22,7 @@ import { showStartAnytime, } from "ol-utilities" import { theme } from "../ThemeProvider/ThemeProvider" +import DifferingRunsTable from "./DifferingRunsTable" const SeparatorContainer = styled.span({ padding: "0 8px", @@ -392,11 +393,14 @@ const InfoSectionV2 = ({ resource }: { resource?: LearningResource }) => { } return ( - - {infoItems.map((props, index) => ( - - ))} - + <> + + + {infoItems.map((props, index) => ( + + ))} + + ) } diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx index d273f2f9cb..4a3759b218 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx @@ -88,6 +88,7 @@ const Image = styled(NextImage)({ borderRadius: "8px", width: "100%", objectFit: "cover", + zIndex: -1, }) const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ diff --git a/frontends/ol-utilities/src/learning-resources/pricing.ts b/frontends/ol-utilities/src/learning-resources/pricing.ts index f8f5867a98..7b13bd0e75 100644 --- a/frontends/ol-utilities/src/learning-resources/pricing.ts +++ b/frontends/ol-utilities/src/learning-resources/pricing.ts @@ -1,4 +1,9 @@ -import { LearningResource, LearningResourcePrice, ResourceTypeEnum } from "api" +import { + LearningResource, + LearningResourcePrice, + LearningResourceRun, + ResourceTypeEnum, +} from "api" import { findBestRun } from "ol-utilities" import getSymbolFromCurrency from "currency-symbol-map" @@ -30,20 +35,23 @@ type Prices = { certificate: null | LearningResourcePrice[] } -const getPrices = (resource: LearningResource): Prices => { - const sortedNonzero = resource.resource_prices - ? resource.resource_prices - .sort( - (a: LearningResourcePrice, b: LearningResourcePrice) => - Number(a.amount) - Number(b.amount), - ) - .filter((price: LearningResourcePrice) => Number(price.amount) > 0) - : [] - +const getPrices = (prices: LearningResourcePrice[]) => { + const sortedNonzero = prices + .sort( + (a: LearningResourcePrice, b: LearningResourcePrice) => + Number(a.amount) - Number(b.amount), + ) + .filter((price: LearningResourcePrice) => Number(price.amount) > 0) const priceRange = sortedNonzero.filter( (price, index, arr) => index === 0 || index === arr.length - 1, ) - const prices = priceRange.length > 0 ? priceRange : null + return priceRange.length > 0 ? priceRange : null +} + +const getResourcePrices = (resource: LearningResource): Prices => { + const prices = resource.resource_prices + ? getPrices(resource.resource_prices) + : [] if (resource.free) { return resource.certification @@ -56,6 +64,15 @@ const getPrices = (resource: LearningResource): Prices => { } } +export const getRunPrices = (run: LearningResourceRun): Prices => { + const prices = run.resource_prices ? getPrices(run.resource_prices) : [] + + return { + course: prices ?? PAID, + certificate: null, + } +} + const getDisplayPrecision = (price: number) => { if (Number.isInteger(price)) { return price.toFixed(0) @@ -63,7 +80,9 @@ const getDisplayPrecision = (price: number) => { return price.toFixed(2) } -const getDisplayPrice = (price: Prices["course"] | Prices["certificate"]) => { +export const getDisplayPrice = ( + price: Prices["course"] | Prices["certificate"], +) => { if (price === null) { return null } @@ -82,7 +101,7 @@ const getDisplayPrice = (price: Prices["course"] | Prices["certificate"]) => { } export const getLearningResourcePrices = (resource: LearningResource) => { - const prices = getPrices(resource) + const prices = getResourcePrices(resource) return { course: { value: prices.course,