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,