@@ -248,6 +250,7 @@ const UnitsListingPage: React.FC = () => {
}
title="Academic & Professional Learning"
header="Non-degree learning resources tailored to the needs of students and working professionals."
+ backgroundUrl={backgroundSrcSetCSS(backgroundSteps)}
/>
diff --git a/frontends/main/src/app/c/[channelType]/[name]/page.tsx b/frontends/main/src/app/c/[channelType]/[name]/page.tsx
index 5c33fb3e1a..5cc73220e1 100644
--- a/frontends/main/src/app/c/[channelType]/[name]/page.tsx
+++ b/frontends/main/src/app/c/[channelType]/[name]/page.tsx
@@ -5,6 +5,10 @@ import { ChannelTypeEnum } from "api/v0"
import { getMetadataAsync } from "@/common/metadata"
import handleNotFound from "@/common/handleNotFound"
+type SearchParams = {
+ [key: string]: string | string[] | undefined
+}
+
type RouteParams = {
channelType: ChannelTypeEnum
name: string
@@ -14,10 +18,10 @@ export async function generateMetadata({
searchParams,
params,
}: {
- searchParams: { [key: string]: string | string[] | undefined }
- params: RouteParams
+ searchParams: Promise
+ params: Promise
}) {
- const { channelType, name } = params
+ const { channelType, name } = await params
const { data } = await handleNotFound(
channelsApi.channelsTypeRetrieve({ channel_type: channelType, name: name }),
diff --git a/frontends/main/src/app/dashboard/[tab]/page.tsx b/frontends/main/src/app/dashboard/[tab]/page.tsx
index 3586bb6208..082710c443 100644
--- a/frontends/main/src/app/dashboard/[tab]/page.tsx
+++ b/frontends/main/src/app/dashboard/[tab]/page.tsx
@@ -10,6 +10,7 @@ export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})
+
const Page: React.FC = () => {
return (
diff --git a/frontends/main/src/app/page.tsx b/frontends/main/src/app/page.tsx
index f6406e519a..34dd27b718 100644
--- a/frontends/main/src/app/page.tsx
+++ b/frontends/main/src/app/page.tsx
@@ -3,10 +3,14 @@ import type { Metadata } from "next"
import HomePage from "@/app-pages/HomePage/HomePage"
import { getMetadataAsync } from "@/common/metadata"
+type SearchParams = {
+ [key: string]: string | string[] | undefined
+}
+
export async function generateMetadata({
searchParams,
}: {
- searchParams: { [key: string]: string | string[] | undefined }
+ searchParams: Promise
}): Promise {
return await getMetadataAsync({
title: "Learn with MIT",
diff --git a/frontends/main/src/app/program_letter/[id]/view/page.tsx b/frontends/main/src/app/program_letter/[id]/view/page.tsx
index faa058688c..849a6050e5 100644
--- a/frontends/main/src/app/program_letter/[id]/view/page.tsx
+++ b/frontends/main/src/app/program_letter/[id]/view/page.tsx
@@ -1,5 +1,5 @@
import React from "react"
-import ProgramLetterPage from "@/app-pages/ProgramLetterPage/[id]/view/ProgramLetterPage"
+import ProgramLetterPage from "@/app-pages/ProgramLetterPage/ProgramLetterPage"
const Page: React.FC = () => {
return
diff --git a/frontends/main/src/app/search/page.tsx b/frontends/main/src/app/search/page.tsx
index adc7151f65..71c7723373 100644
--- a/frontends/main/src/app/search/page.tsx
+++ b/frontends/main/src/app/search/page.tsx
@@ -2,10 +2,14 @@ import React from "react"
import { getMetadataAsync } from "@/common/metadata"
import SearchPage from "@/app-pages/SearchPage/SearchPage"
+type SearchParams = {
+ [key: string]: string | string[] | undefined
+}
+
export async function generateMetadata({
searchParams,
}: {
- searchParams: { [key: string]: string | string[] | undefined }
+ searchParams: Promise
}) {
return await getMetadataAsync({
title: "Search",
@@ -18,7 +22,7 @@ export async function generateMetadata({
* 1. wrap the in Suspense
* 2. or force-dynamic.
*
- * (1) caused a hydration error for authenticated users. We haven not found
+ * (1) caused a hydration error for authenticated users. We have not found
* the root cause of the hydration error.
*
* (2) seems to work well.
diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts
index 8d6e479d1e..0a21e9c50c 100644
--- a/frontends/main/src/common/feature_flags.ts
+++ b/frontends/main/src/common/feature_flags.ts
@@ -3,4 +3,5 @@
export enum FeatureFlags {
EnableEcommerce = "enable-ecommerce",
+ DrawerV2Enabled = "lr_drawer_v2",
}
diff --git a/frontends/main/src/common/metadata.ts b/frontends/main/src/common/metadata.ts
index c112873175..583e5a6383 100644
--- a/frontends/main/src/common/metadata.ts
+++ b/frontends/main/src/common/metadata.ts
@@ -10,7 +10,7 @@ type MetadataAsyncProps = {
description?: string
image?: string
imageAlt?: string
- searchParams?: { [key: string]: string | string[] | undefined }
+ searchParams?: Promise<{ [key: string]: string | string[] | undefined }>
social?: boolean
} & Metadata
@@ -28,7 +28,7 @@ export const getMetadataAsync = async ({
...otherMeta
}: MetadataAsyncProps) => {
// The learning resource drawer is open
- const learningResourceId = searchParams?.[RESOURCE_DRAWER_QUERY_PARAM]
+ const learningResourceId = (await searchParams)?.[RESOURCE_DRAWER_QUERY_PARAM]
if (learningResourceId) {
const { data } = await handleNotFound(
learningResourcesApi.learningResourcesRetrieve({
diff --git a/frontends/main/src/page-components/Header/Header.tsx b/frontends/main/src/page-components/Header/Header.tsx
index e5cb4b7584..93ef5bcdb9 100644
--- a/frontends/main/src/page-components/Header/Header.tsx
+++ b/frontends/main/src/page-components/Header/Header.tsx
@@ -295,7 +295,7 @@ const Header: FunctionComponent = () => {
desktopTrigger.current,
mobileTrigger.current,
]}
- navdata={navData}
+ navData={navData}
open={drawerOpen}
onClose={toggleDrawer.off}
/>
diff --git a/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx b/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx
index f44c66a218..91b245197a 100644
--- a/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx
+++ b/frontends/main/src/page-components/HeroSearch/HeroSearch.tsx
@@ -101,7 +101,6 @@ const ImageContainer = styled.div(({ theme }) => ({
alignItems: "center",
justifyContent: "center",
marginTop: "44px",
- transform: "translateX(48px)",
width: "513px",
aspectRatio: "513 / 522",
[theme.breakpoints.down("md")]: {
@@ -109,6 +108,7 @@ const ImageContainer = styled.div(({ theme }) => ({
},
img: {
width: "100%",
+ transform: "translateX(48px)",
},
position: "relative",
}))
@@ -209,13 +209,7 @@ const HeroImage: React.FC = () => {
return (
-
+
)
}
diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx
index eea8943d73..5eee0c4a99 100644
--- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx
+++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.tsx
@@ -1,132 +1,14 @@
-import React, { Suspense, useCallback, useEffect, useMemo } from "react"
-import {
- RoutedDrawer,
- LearningResourceExpanded,
- imgConfigs,
-} from "ol-components"
-import type {
- LearningResourceCardProps,
- RoutedDrawerProps,
-} from "ol-components"
-import { useLearningResourcesDetail } from "api/hooks/learningResources"
-import { useSearchParams, ReadonlyURLSearchParams } from "next/navigation"
-
+import React, { useCallback } from "react"
import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
-import { useUserMe } from "api/hooks/user"
-import NiceModal from "@ebay/nice-modal-react"
-import {
- AddToLearningPathDialog,
- AddToUserListDialog,
-} from "../Dialogs/AddToListDialog"
-import { SignupPopover } from "../SignupPopover/SignupPopover"
-import { usePostHog } from "posthog-js/react"
-
-const RESOURCE_DRAWER_PARAMS = [RESOURCE_DRAWER_QUERY_PARAM] as const
-
-const useCapturePageView = (resourceId: number) => {
- const { data, isSuccess } = useLearningResourcesDetail(Number(resourceId))
- const posthog = usePostHog()
- const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY
-
- useEffect(() => {
- if (!apiKey || apiKey.length < 1) return
- if (!isSuccess) return
- posthog.capture("lrd_view", {
- resourceId: data?.id,
- readableId: data?.readable_id,
- platformCode: data?.platform?.code,
- resourceType: data?.resource_type,
- })
- }, [
- isSuccess,
- posthog,
- data?.id,
- data?.readable_id,
- data?.platform?.code,
- data?.resource_type,
- apiKey,
- ])
-}
-
-/**
- * Convert HTML to plaintext, removing any HTML tags.
- * This conversion method has some issues:
- * 1. It is unsafe for untrusted HTML
- * 2. It must be run in a browser, not on a server.
- */
-// eslint-disable-next-line camelcase
-// const unsafe_html2plaintext = (text: string) => {
-// const div = document.createElement("div")
-// div.innerHTML = text
-// return div.textContent || div.innerText || ""
-// }
-
-const DrawerContent: React.FC<{
- resourceId: number
-}> = ({ resourceId }) => {
- const resource = useLearningResourcesDetail(Number(resourceId))
- const [signupEl, setSignupEl] = React.useState(null)
- const { data: user } = useUserMe()
- const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] =
- useMemo(() => {
- if (user?.is_learning_path_editor) {
- return (event, resourceId: number) => {
- NiceModal.show(AddToLearningPathDialog, { resourceId })
- }
- }
- return null
- }, [user])
- const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] =
- useMemo(() => {
- return (event, resourceId: number) => {
- if (!user?.is_authenticated) {
- setSignupEl(event.currentTarget)
- return
- }
- NiceModal.show(AddToUserListDialog, { resourceId })
- }
- }, [user])
- useCapturePageView(Number(resourceId))
-
- return (
- <>
-
- setSignupEl(null)} />
- >
- )
-}
-
-const PAPER_PROPS: RoutedDrawerProps["PaperProps"] = {
- sx: {
- maxWidth: (theme) => theme.breakpoints.values.sm,
- minWidth: (theme) => ({
- [theme.breakpoints.down("sm")]: {
- minWidth: "100%",
- },
- }),
- },
-}
+import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation"
+import { useFeatureFlagEnabled } from "posthog-js/react"
+import LearningResourceDrawerV2 from "./LearningResourceDrawerV2"
+import LearningResourceDrawerV1 from "./LearningResourceDrawerV1"
+import { FeatureFlags } from "@/common/feature_flags"
const LearningResourceDrawer = () => {
- return (
-
-
- {({ params }) => {
- return
- }}
-
-
- )
+ const drawerV2 = useFeatureFlagEnabled(FeatureFlags.DrawerV2Enabled)
+ return drawerV2 ? :
}
const getOpenDrawerSearchParams = (
diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.test.tsx
similarity index 83%
rename from frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx
rename to frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.test.tsx
index 391c2a25e9..b056bd0cc7 100644
--- a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx
+++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.test.tsx
@@ -6,9 +6,9 @@ import {
waitFor,
within,
} from "@/test-utils"
-import LearningResourceDrawer from "./LearningResourceDrawer"
+import LearningResourceDrawerV1 from "./LearningResourceDrawerV1"
import { urls, factories, setMockResponse } from "api/test-utils"
-import { LearningResourceExpanded } from "ol-components"
+import { LearningResourceExpandedV1 } from "ol-components"
import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
import { ResourceTypeEnum } from "api"
import invariant from "tiny-invariant"
@@ -17,7 +17,7 @@ jest.mock("ol-components", () => {
const actual = jest.requireActual("ol-components")
return {
...actual,
- LearningResourceExpanded: jest.fn(actual.LearningResourceExpanded),
+ LearningResourceExpandedV1: jest.fn(actual.LearningResourceExpandedV1),
}
})
@@ -33,7 +33,7 @@ jest.mock("posthog-js/react", () => ({
},
}))
-describe("LearningResourceDrawer", () => {
+describe("LearningResourceDrawerV1", () => {
it.each([
{ descriptor: "is enabled", enablePostHog: true },
{ descriptor: "is not enabled", enablePostHog: false },
@@ -50,12 +50,12 @@ describe("LearningResourceDrawer", () => {
resource,
)
- renderWithProviders(, {
+ renderWithProviders(, {
url: `?dog=woof&${RESOURCE_DRAWER_QUERY_PARAM}=${resource.id}`,
})
- expect(LearningResourceExpanded).toHaveBeenCalled()
+ expect(LearningResourceExpandedV1).toHaveBeenCalled()
await waitFor(() => {
- expectProps(LearningResourceExpanded, { resource })
+ expectProps(LearningResourceExpandedV1, { resource })
})
await screen.findByRole("heading", { name: resource.title })
@@ -68,10 +68,10 @@ describe("LearningResourceDrawer", () => {
)
it("Does not render drawer content when resource=id is NOT in the URL", async () => {
- renderWithProviders(, {
+ renderWithProviders(, {
url: "?dog=woof",
})
- expect(LearningResourceExpanded).not.toHaveBeenCalled()
+ expect(LearningResourceExpandedV1).not.toHaveBeenCalled()
})
test.each([
@@ -118,14 +118,14 @@ describe("LearningResourceDrawer", () => {
setMockResponse.get(urls.userMe.get(), null, { code: 403 })
}
- renderWithProviders(, {
+ renderWithProviders(, {
url: `?resource=${resource.id}`,
})
- expect(LearningResourceExpanded).toHaveBeenCalled()
+ expect(LearningResourceExpandedV1).toHaveBeenCalled()
await waitFor(() => {
- expectProps(LearningResourceExpanded, { resource })
+ expectProps(LearningResourceExpandedV1, { resource })
})
const section = screen
diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx
new file mode 100644
index 0000000000..cade51dba8
--- /dev/null
+++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV1.tsx
@@ -0,0 +1,131 @@
+import React, { Suspense, useEffect, useMemo } from "react"
+import {
+ RoutedDrawer,
+ LearningResourceExpandedV1,
+ imgConfigs,
+} from "ol-components"
+import type {
+ LearningResourceCardProps,
+ RoutedDrawerProps,
+} from "ol-components"
+import { useLearningResourcesDetail } from "api/hooks/learningResources"
+
+import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
+import { useUserMe } from "api/hooks/user"
+import NiceModal from "@ebay/nice-modal-react"
+import {
+ AddToLearningPathDialog,
+ AddToUserListDialog,
+} from "../Dialogs/AddToListDialog"
+import { SignupPopover } from "../SignupPopover/SignupPopover"
+import { usePostHog } from "posthog-js/react"
+
+const RESOURCE_DRAWER_PARAMS = [RESOURCE_DRAWER_QUERY_PARAM] as const
+
+const useCapturePageView = (resourceId: number) => {
+ const { data, isSuccess } = useLearningResourcesDetail(Number(resourceId))
+ const posthog = usePostHog()
+ const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY
+
+ useEffect(() => {
+ if (!apiKey || apiKey.length < 1) return
+ if (!isSuccess) return
+ posthog.capture("lrd_view", {
+ resourceId: data?.id,
+ readableId: data?.readable_id,
+ platformCode: data?.platform?.code,
+ resourceType: data?.resource_type,
+ })
+ }, [
+ isSuccess,
+ posthog,
+ data?.id,
+ data?.readable_id,
+ data?.platform?.code,
+ data?.resource_type,
+ apiKey,
+ ])
+}
+
+/**
+ * Convert HTML to plaintext, removing any HTML tags.
+ * This conversion method has some issues:
+ * 1. It is unsafe for untrusted HTML
+ * 2. It must be run in a browser, not on a server.
+ */
+// eslint-disable-next-line camelcase
+// const unsafe_html2plaintext = (text: string) => {
+// const div = document.createElement("div")
+// div.innerHTML = text
+// return div.textContent || div.innerText || ""
+// }
+
+const DrawerContent: React.FC<{
+ resourceId: number
+}> = ({ resourceId }) => {
+ const resource = useLearningResourcesDetail(Number(resourceId))
+ const [signupEl, setSignupEl] = React.useState(null)
+ const { data: user } = useUserMe()
+ const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] =
+ useMemo(() => {
+ if (user?.is_learning_path_editor) {
+ return (event, resourceId: number) => {
+ NiceModal.show(AddToLearningPathDialog, { resourceId })
+ }
+ }
+ return null
+ }, [user])
+ const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] =
+ useMemo(() => {
+ return (event, resourceId: number) => {
+ if (!user?.is_authenticated) {
+ setSignupEl(event.currentTarget)
+ return
+ }
+ NiceModal.show(AddToUserListDialog, { resourceId })
+ }
+ }, [user])
+ useCapturePageView(Number(resourceId))
+
+ return (
+ <>
+
+ setSignupEl(null)} />
+ >
+ )
+}
+
+const PAPER_PROPS: RoutedDrawerProps["PaperProps"] = {
+ sx: {
+ maxWidth: (theme) => theme.breakpoints.values.sm,
+ minWidth: (theme) => ({
+ [theme.breakpoints.down("sm")]: {
+ minWidth: "100%",
+ },
+ }),
+ },
+}
+
+const LearningResourceDrawerV1 = () => {
+ return (
+
+
+ {({ params }) => {
+ return
+ }}
+
+
+ )
+}
+
+export default LearningResourceDrawerV1
diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx
new file mode 100644
index 0000000000..dfdb890a32
--- /dev/null
+++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.test.tsx
@@ -0,0 +1,142 @@
+import React from "react"
+import {
+ expectProps,
+ renderWithProviders,
+ screen,
+ waitFor,
+ within,
+} from "@/test-utils"
+import LearningResourceDrawerV2 from "./LearningResourceDrawerV2"
+import { urls, factories, setMockResponse } from "api/test-utils"
+import { LearningResourceExpandedV2 } from "ol-components"
+import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
+import { ResourceTypeEnum } from "api"
+
+jest.mock("ol-components", () => {
+ const actual = jest.requireActual("ol-components")
+ return {
+ ...actual,
+ LearningResourceExpandedV2: jest.fn(actual.LearningResourceExpandedV2),
+ }
+})
+
+const mockedPostHogCapture = jest.fn()
+
+jest.mock("posthog-js/react", () => ({
+ PostHogProvider: (props: { children: React.ReactNode }) => (
+ {props.children}
+ ),
+
+ usePostHog: () => {
+ return { capture: mockedPostHogCapture }
+ },
+}))
+
+describe("LearningResourceDrawerV2", () => {
+ it.each([
+ { descriptor: "is enabled", enablePostHog: true },
+ { descriptor: "is not enabled", enablePostHog: false },
+ ])(
+ "Renders drawer content when resource=id is in the URL and captures the view if PostHog $descriptor",
+ async ({ enablePostHog }) => {
+ setMockResponse.get(urls.userMe.get(), {})
+ process.env.NEXT_PUBLIC_POSTHOG_API_KEY = enablePostHog
+ ? "12345abcdef" // pragma: allowlist secret
+ : ""
+ const resource = factories.learningResources.resource()
+ setMockResponse.get(
+ urls.learningResources.details({ id: resource.id }),
+ resource,
+ )
+
+ renderWithProviders(, {
+ url: `?dog=woof&${RESOURCE_DRAWER_QUERY_PARAM}=${resource.id}`,
+ })
+ expect(LearningResourceExpandedV2).toHaveBeenCalled()
+ await waitFor(() => {
+ expectProps(LearningResourceExpandedV2, { resource })
+ })
+ await screen.findByText(resource.title)
+
+ if (enablePostHog) {
+ expect(mockedPostHogCapture).toHaveBeenCalled()
+ } else {
+ expect(mockedPostHogCapture).not.toHaveBeenCalled()
+ }
+ },
+ )
+
+ it("Does not render drawer content when resource=id is NOT in the URL", async () => {
+ renderWithProviders(, {
+ url: "?dog=woof",
+ })
+ expect(LearningResourceExpandedV2).not.toHaveBeenCalled()
+ })
+
+ test.each([
+ {
+ isLearningPathEditor: true,
+ isAuthenticated: true,
+ expectAddToLearningPathButton: true,
+ },
+ {
+ isLearningPathEditor: false,
+ isAuthenticated: true,
+ expectAddToLearningPathButton: false,
+ },
+ {
+ isLearningPathEditor: false,
+ isAuthenticated: false,
+ expectAddToLearningPathButton: false,
+ },
+ ])(
+ "Renders call to action section list buttons correctly",
+ async ({
+ isLearningPathEditor,
+ isAuthenticated,
+ expectAddToLearningPathButton,
+ }) => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ runs: [
+ factories.learningResources.run({
+ languages: ["en-us", "es-es", "fr-fr"],
+ }),
+ ],
+ })
+ setMockResponse.get(
+ urls.learningResources.details({ id: resource.id }),
+ resource,
+ )
+ const user = factories.user.user({
+ is_learning_path_editor: isLearningPathEditor,
+ })
+ if (isAuthenticated) {
+ setMockResponse.get(urls.userMe.get(), user)
+ } else {
+ setMockResponse.get(urls.userMe.get(), null, { code: 403 })
+ }
+
+ renderWithProviders(, {
+ url: `?resource=${resource.id}`,
+ })
+
+ expect(LearningResourceExpandedV2).toHaveBeenCalled()
+
+ await waitFor(() => {
+ expectProps(LearningResourceExpandedV2, { resource })
+ })
+
+ const section = screen.getByTestId("drawer-cta")
+
+ const buttons = within(section).getAllByRole("button")
+ const expectedButtons = expectAddToLearningPathButton ? 2 : 1
+ expect(buttons).toHaveLength(expectedButtons)
+ expect(
+ !!within(section).queryByRole("button", {
+ name: "Add to Learning Path",
+ }),
+ ).toBe(expectAddToLearningPathButton)
+ },
+ )
+})
diff --git a/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx
new file mode 100644
index 0000000000..a6bf8352b5
--- /dev/null
+++ b/frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawerV2.tsx
@@ -0,0 +1,146 @@
+import React, { Suspense, useEffect, useMemo } from "react"
+import {
+ RoutedDrawer,
+ LearningResourceExpandedV2,
+ imgConfigs,
+} from "ol-components"
+import type {
+ LearningResourceCardProps,
+ RoutedDrawerProps,
+} from "ol-components"
+import { useLearningResourcesDetail } from "api/hooks/learningResources"
+
+import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
+import { useUserMe } from "api/hooks/user"
+import NiceModal from "@ebay/nice-modal-react"
+import {
+ AddToLearningPathDialog,
+ AddToUserListDialog,
+} from "../Dialogs/AddToListDialog"
+import { SignupPopover } from "../SignupPopover/SignupPopover"
+import { usePostHog } from "posthog-js/react"
+
+const RESOURCE_DRAWER_PARAMS = [RESOURCE_DRAWER_QUERY_PARAM] as const
+
+const useCapturePageView = (resourceId: number) => {
+ const { data, isSuccess } = useLearningResourcesDetail(Number(resourceId))
+ const posthog = usePostHog()
+ const apiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY
+
+ useEffect(() => {
+ if (!apiKey || apiKey.length < 1) return
+ if (!isSuccess) return
+ posthog.capture("lrd_view", {
+ resourceId: data?.id,
+ readableId: data?.readable_id,
+ platformCode: data?.platform?.code,
+ resourceType: data?.resource_type,
+ })
+ }, [
+ isSuccess,
+ posthog,
+ data?.id,
+ data?.readable_id,
+ data?.platform?.code,
+ data?.resource_type,
+ apiKey,
+ ])
+}
+
+/**
+ * Convert HTML to plaintext, removing any HTML tags.
+ * This conversion method has some issues:
+ * 1. It is unsafe for untrusted HTML
+ * 2. It must be run in a browser, not on a server.
+ */
+// eslint-disable-next-line camelcase
+// const unsafe_html2plaintext = (text: string) => {
+// const div = document.createElement("div")
+// div.innerHTML = text
+// return div.textContent || div.innerText || ""
+// }
+
+const DrawerContent: React.FC<{
+ resourceId: number
+ closeDrawer: () => void
+}> = ({ resourceId, closeDrawer }) => {
+ const resource = useLearningResourcesDetail(Number(resourceId))
+ const [signupEl, setSignupEl] = React.useState(null)
+ const { data: user } = useUserMe()
+ const handleAddToLearningPathClick: LearningResourceCardProps["onAddToLearningPathClick"] =
+ useMemo(() => {
+ if (user?.is_learning_path_editor) {
+ return (event, resourceId: number) => {
+ NiceModal.show(AddToLearningPathDialog, { resourceId })
+ }
+ }
+ return null
+ }, [user])
+ const handleAddToUserListClick: LearningResourceCardProps["onAddToUserListClick"] =
+ useMemo(() => {
+ return (event, resourceId: number) => {
+ if (!user?.is_authenticated) {
+ setSignupEl(event.currentTarget)
+ return
+ }
+ NiceModal.show(AddToUserListDialog, { resourceId })
+ }
+ }, [user])
+ useCapturePageView(Number(resourceId))
+
+ return (
+ <>
+
+ setSignupEl(null)} />
+ >
+ )
+}
+
+const PAPER_PROPS: RoutedDrawerProps["PaperProps"] = {
+ sx: {
+ maxWidth: (theme) => ({
+ [theme.breakpoints.up("md")]: {
+ maxWidth: theme.breakpoints.values.md,
+ },
+ [theme.breakpoints.down("sm")]: {
+ maxWidth: "100%",
+ },
+ }),
+ minWidth: (theme) => ({
+ [theme.breakpoints.down("md")]: {
+ minWidth: "100%",
+ },
+ }),
+ },
+}
+
+const LearningResourceDrawerV2 = () => {
+ return (
+
+
+ {({ params, closeDrawer }) => {
+ return (
+
+ )
+ }}
+
+
+ )
+}
+
+export default LearningResourceDrawerV2
diff --git a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx
index 71a7bedde3..79ef2ade33 100644
--- a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx
+++ b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx
@@ -15,6 +15,7 @@ import {
Drawer,
Checkbox,
VisuallyHidden,
+ Stack,
} from "ol-components"
import {
@@ -60,8 +61,6 @@ const StyledResourceTabs = styled(ResourceCategoryTabs.TabList)`
`
const DesktopSortContainer = styled.div`
- float: right;
-
${({ theme }) => theme.breakpoints.down("md")} {
display: none;
}
@@ -439,10 +438,17 @@ const MobileFacetsTitleContainer = styled.div`
}
`
-const StyledGridContainer = styled(GridContainer)`
+const ReversedGridContainer = styled(GridContainer)`
max-width: 1272px !important;
margin-left: 0 !important;
width: 100% !important;
+
+ /**
+ We want the facets to be visually on left, but occur second in the DOM / tab
+ order. This makes it easier for keyboard navigators to get directly to the
+ search results.
+ */
+ flex-direction: row-reverse;
`
const ExplanationContainer = styled.div`
@@ -860,33 +866,8 @@ const SearchDisplay: React.FC = ({
return (
-
+
-
-
-
-
- Filter
-
-
-
- {hasFacets ? (
-
- ) : null}
-
- {filterContents}
-
Search Results
@@ -901,13 +882,15 @@ const SearchDisplay: React.FC = ({
*/}
{isFetching || isLoading ? "" : `${data?.count} results`}
- {sortDropdown}
- setPage(1)}
- />
+
+ setPage(1)}
+ />
+ {sortDropdown}
+
+
+
+
+
+ Filter
+
+
+
+ {hasFacets ? (
+
+ ) : null}
+
+ {filterContents}
+
-
+
)
}
diff --git a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx
index 562600c4c3..be64937987 100644
--- a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx
+++ b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.test.tsx
@@ -3,7 +3,8 @@ import { screen } from "@testing-library/react"
import UserListCardCondensed from "./UserListCardCondensed"
import * as factories from "api/test-utils/factories"
import { userListView } from "@/common/urls"
-import { renderWithProviders } from "@/test-utils"
+import { renderWithProviders, user } from "@/test-utils"
+import invariant from "tiny-invariant"
const userListFactory = factories.userLists
@@ -18,4 +19,16 @@ describe("UserListCard", () => {
)
screen.getByText(userList.title)
})
+
+ test("Clicking card navigates to href", async () => {
+ const userList = userListFactory.userList()
+ renderWithProviders(
+ ,
+ )
+ const link = screen.getByRole("link", { name: userList.title })
+ expect(link).toHaveAttribute("href", "#test")
+ invariant(link.parentElement)
+ await user.click(link.parentElement)
+ expect(window.location.hash).toBe("#test")
+ })
})
diff --git a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx
index 1689b5bd71..fb6514c113 100644
--- a/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx
+++ b/frontends/main/src/page-components/UserListCard/UserListCardCondensed.tsx
@@ -3,6 +3,7 @@ import { UserList } from "api"
import { pluralize } from "ol-utilities"
import { RiListCheck3 } from "@remixicon/react"
import { ListCardCondensed, styled, theme, Typography } from "ol-components"
+import Link from "next/link"
const StyledCard = styled(ListCardCondensed)({
display: "flex",
@@ -37,24 +38,29 @@ const IconContainer = styled.div(({ theme }) => ({
},
}))
-type UserListCardCondensedProps = {
- userList: U
- href?: string
+type UserListCardCondensedProps = {
+ userList: UserList
+ href: string
className?: string
}
-const UserListCardCondensed = ({
+const UserListCardCondensed = ({
userList,
href,
className,
-}: UserListCardCondensedProps) => {
+}: UserListCardCondensedProps) => {
return (
-
+
-
- {userList.title}
-
+
+
+ {userList.title}
+
+
{userList.item_count} {pluralize("item", userList.item_count)}
diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json
index 3cdaeab6f4..78045e5752 100644
--- a/frontends/ol-components/package.json
+++ b/frontends/ol-components/package.json
@@ -16,11 +16,11 @@
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
- "@mui/base": "5.0.0-beta.60",
- "@mui/lab": "^5.0.0-alpha.172",
- "@mui/material": "^5.16.1",
- "@mui/material-nextjs": "^5.16.6",
- "@mui/system": "^5.16.1",
+ "@mui/base": "5.0.0-beta.61",
+ "@mui/lab": "6.0.0-beta.14",
+ "@mui/material": "^6.1.6",
+ "@mui/material-nextjs": "^6.1.6",
+ "@mui/system": "^6.1.6",
"@remixicon/react": "^4.2.0",
"@testing-library/dom": "^10.4.0",
"@types/react-dom": "^18.3.0",
@@ -30,7 +30,7 @@
"iso-639-1": "^3.1.2",
"lodash": "^4.17.21",
"material-ui-popup-state": "^5.1.0",
- "next": "^14.2.7",
+ "next": "^15.0.2",
"ol-test-utilities": "0.0.0",
"ol-utilities": "0.0.0",
"react": "18.3.1",
diff --git a/frontends/ol-components/src/components/Banner/Banner.tsx b/frontends/ol-components/src/components/Banner/Banner.tsx
index a908fb5a74..22dd0bdaa6 100644
--- a/frontends/ol-components/src/components/Banner/Banner.tsx
+++ b/frontends/ol-components/src/components/Banner/Banner.tsx
@@ -27,26 +27,32 @@ const BannerBackground = styled.div(
backgroundUrl = DEFAULT_BACKGROUND_IMAGE_URL,
backgroundSize = "cover",
backgroundDim = 0,
- }) => ({
- backgroundAttachment: "fixed",
- backgroundImage: backgroundDim
- ? `linear-gradient(rgba(0 0 0 / ${backgroundDim}%), rgba(0 0 0 / ${backgroundDim}%)), url('${backgroundUrl}')`
- : `url(${backgroundUrl})`,
- backgroundSize: backgroundSize,
- backgroundPosition: "center top",
- backgroundRepeat: "no-repeat",
- color: theme.custom.colors.white,
- padding: "48px 0 48px 0",
- [theme.breakpoints.up("lg")]: {
- backgroundSize:
- backgroundUrl === DEFAULT_BACKGROUND_IMAGE_URL
- ? "140%"
- : backgroundSize,
- },
- [theme.breakpoints.down("sm")]: {
- padding: "32px 0 32px 0",
- },
- }),
+ }) => {
+ const backgroundUrlFn = backgroundUrl.startsWith("image-set(")
+ ? backgroundUrl
+ : `url('${backgroundUrl}')`
+
+ return {
+ backgroundAttachment: "fixed",
+ backgroundImage: backgroundDim
+ ? `linear-gradient(rgba(0 0 0 / ${backgroundDim}%), rgba(0 0 0 / ${backgroundDim}%)), ${backgroundUrlFn}`
+ : backgroundUrlFn,
+ backgroundSize: backgroundSize,
+ backgroundPosition: "center top",
+ backgroundRepeat: "no-repeat",
+ color: theme.custom.colors.white,
+ padding: "48px 0 48px 0",
+ [theme.breakpoints.up("lg")]: {
+ backgroundSize:
+ backgroundUrl === DEFAULT_BACKGROUND_IMAGE_URL
+ ? "140%"
+ : backgroundSize,
+ },
+ [theme.breakpoints.down("sm")]: {
+ padding: "32px 0 32px 0",
+ },
+ }
+ },
)
const InnerContainer = styled.div(({ theme }) => ({
diff --git a/frontends/ol-components/src/components/Card/Card.stories.tsx b/frontends/ol-components/src/components/Card/Card.stories.tsx
index 2d309b5996..9e07de7686 100644
--- a/frontends/ol-components/src/components/Card/Card.stories.tsx
+++ b/frontends/ol-components/src/components/Card/Card.stories.tsx
@@ -1,10 +1,11 @@
import React from "react"
import type { Meta, StoryObj } from "@storybook/react"
import { Card } from "./Card"
+import type { CardProps } from "./Card"
import { ActionButton } from "../Button/Button"
import { RiMenuAddLine, RiBookmarkLine } from "@remixicon/react"
-const meta: Meta = {
+const meta: Meta = {
title: "smoot-design/Cards/Card",
argTypes: {
size: {
@@ -19,7 +20,7 @@ const meta: Meta = {
alt="Provide a meaningful description or leave this blank."
/>
Info
-
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit
@@ -51,7 +52,7 @@ const meta: Meta = {
export default meta
-type Story = StoryObj
+type Story = StoryObj
export const Medium: Story = {
args: {
diff --git a/frontends/ol-components/src/components/Card/Card.test.tsx b/frontends/ol-components/src/components/Card/Card.test.tsx
index a087bedb6d..8c1afc94cf 100644
--- a/frontends/ol-components/src/components/Card/Card.test.tsx
+++ b/frontends/ol-components/src/components/Card/Card.test.tsx
@@ -1,8 +1,10 @@
-import { render } from "@testing-library/react"
+import { render, screen } from "@testing-library/react"
+import user from "@testing-library/user-event"
import { Card } from "./Card"
import React from "react"
import { getOriginalSrc } from "ol-test-utilities"
import invariant from "tiny-invariant"
+import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
describe("Card", () => {
test("has class MitCard-root on root element", () => {
@@ -14,6 +16,7 @@ describe("Card", () => {
Footer
Actions
,
+ { wrapper: ThemeProvider },
)
const card = container.firstChild as HTMLElement
const title = card.querySelector(".MitCard-title")
@@ -37,4 +40,99 @@ describe("Card", () => {
expect(footer).toHaveTextContent("Footer")
expect(actions).toHaveTextContent("Actions")
})
+
+ test.each([
+ { forwardClicksToLink: true, finalHref: "#woof" },
+ { forwardClicksToLink: false, finalHref: "" },
+ ])(
+ "The whole card is clickable as a link if forwardClicksToLink ($forwardClicksToLink)",
+ async ({ forwardClicksToLink, finalHref }) => {
+ render(
+
+ Title
+
+ Info
+ Footer
+ Actions
+ ,
+ { wrapper: ThemeProvider },
+ )
+ const card = document.querySelector(".MitCard-root")
+ invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor
+
+ await user.click(card)
+ expect(window.location.hash).toBe(finalHref)
+ },
+ )
+
+ test.each([
+ { forwardClicksToLink: true, finalHref: "#meow" },
+ { forwardClicksToLink: false, finalHref: "" },
+ ])(
+ "The whole card is clickable as a link when using Content when forwardClicksToLink ($forwardClicksToLink), except buttons and links",
+ async ({ finalHref, forwardClicksToLink }) => {
+ const href = "#meow"
+ const onClick = jest.fn()
+ render(
+
+
+ Hello!
+
+
+
+
+ Link
+
+
+ ,
+ { wrapper: ThemeProvider },
+ )
+ const button = screen.getByRole("button", { name: "Button" })
+ await user.click(button)
+ expect(onClick).toHaveBeenCalled()
+ expect(window.location.hash).toBe("")
+
+ // outermost wrapper is not actually clickable
+ const card = document.querySelector(".MitCard-root")
+ invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor
+
+ await user.click(card)
+ expect(window.location.hash).toBe(finalHref)
+ },
+ )
+
+ test("Clicks on interactive elements are not forwarded", async () => {
+ const btnOnClick = jest.fn()
+ const divOnClick = jest.fn()
+ render(
+
+ Title
+
+ Info
+
+
+ Link Two
+ {/*
+ eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
+ */}
+
+ Interactive Div
+
+
+ ,
+ { wrapper: ThemeProvider },
+ )
+ const button = screen.getByRole("button", { name: "Button" })
+ screen.getByRole("link", { name: "Title" })
+ const link2 = screen.getByRole("link", { name: "Link Two" })
+ const div = screen.getByText("Interactive Div")
+ await user.click(button)
+ expect(btnOnClick).toHaveBeenCalled()
+ expect(window.location.hash).toBe("")
+ await user.click(link2)
+ expect(window.location.hash).toBe("#two")
+ await user.click(div)
+ expect(divOnClick).toHaveBeenCalled()
+ expect(window.location.hash).toBe("#two")
+ })
})
diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx
index a04dc0302a..c2b6876f83 100644
--- a/frontends/ol-components/src/components/Card/Card.tsx
+++ b/frontends/ol-components/src/components/Card/Card.tsx
@@ -4,6 +4,8 @@ import React, {
Children,
isValidElement,
CSSProperties,
+ useCallback,
+ AriaAttributes,
} from "react"
import styled from "@emotion/styled"
import { theme } from "../ThemeProvider/ThemeProvider"
@@ -13,52 +15,69 @@ import { default as NextImage, ImageProps as NextImageProps } from "next/image"
export type Size = "small" | "medium"
-/*
- *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.
+type LinkableProps = {
+ href?: string
+ children?: ReactNode
+ className?: string
+}
+/**
+ * Render a NextJS link if href is provided, otherwise a span.
+ * Does not scroll if the href is a query string.
*/
-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 `
- min-width: ${width}px;
- max-width: ${width}px;
- `
- }}
-`
-
-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;
- 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;
+export const Linkable: React.FC = ({
+ href,
+ children,
+ className,
+ ...others
+}) => {
+ if (href) {
+ return (
+
+ {children}
+
+ )
}
-`
+ return (
+
+ {children}
+
+ )
+}
-const Container = styled.div`
- ${containerStyles}
- display: block;
- position: relative;
-`
+export const BaseContainer = styled.div<{ display?: CSSProperties["display"] }>(
+ ({ theme, onClick, display = "block" }) => [
+ {
+ borderRadius: "8px",
+ border: `1px solid ${theme.custom.colors.lightGray2}`,
+ background: theme.custom.colors.white,
+ display,
+ overflow: "hidden", // to clip image so they match border radius
+ },
+ onClick && {
+ "&:hover": {
+ borderColor: theme.custom.colors.silverGrayLight,
+ boxShadow:
+ "0 2px 4px 0 rgb(37 38 43 / 10%), 0 2px 4px 0 rgb(37 38 43 / 10%)",
+ cursor: "pointer",
+ },
+ },
+ ],
+)
+const CONTAINER_WIDTHS: Record = {
+ small: 192,
+ medium: 300,
+}
+const Container = styled(BaseContainer)<{ size?: Size }>(({ size }) => [
+ size && {
+ minWidth: CONTAINER_WIDTHS[size],
+ maxWidth: CONTAINER_WIDTHS[size],
+ },
+])
const Content = () => <>>
@@ -83,7 +102,10 @@ const Info = styled.div<{ size?: Size }>`
margin-bottom: ${({ size }) => (size === "small" ? 4 : 8)}px;
`
-const Title = styled.span<{ lines?: number; size?: Size }>`
+const titleOpts = {
+ shouldForwardProp: (prop: string) => prop !== "lines" && prop !== "size",
+}
+const Title = styled(Linkable, titleOpts)<{ lines?: number; size?: Size }>`
text-overflow: ellipsis;
height: ${({ lines, size }) => {
const lineHeightPx = size === "small" ? 18 : 20
@@ -130,17 +152,70 @@ const Bottom = styled.div`
const Actions = styled.div`
display: flex;
gap: 8px;
- position: absolute;
- bottom: 16px;
- right: 16px;
`
+/**
+ * Click the child anchor element if the click event target is not the anchor itself.
+ *
+ * Allows making a whole region clickable as a link, even if the link is not the
+ * direct target of the click event.
+ */
+export const useClickChildLink = (
+ onClick?: React.MouseEventHandler,
+): React.MouseEventHandler => {
+ return useCallback(
+ (e) => {
+ onClick?.(e)
+ if (!e.currentTarget.contains(e.target as Node)) {
+ // This happens if click target is a child in React tree but not DOM tree
+ // This can happen with portals.
+ // In such cases, data-card-actions won't be a parent of the target.
+ return
+ }
+ const anchor = e.currentTarget.querySelector(
+ 'a[data-card-link="true"]',
+ )
+ const target = e.target as HTMLElement
+ if (!anchor || target.closest("a, button, [data-card-action]")) return
+ if (e.metaKey || e.ctrlKey) {
+ /**
+ * Enables ctrl+click to open card's link in new tab.
+ * Without this, ctrl+click only works on the anchor itself.
+ */
+ const opts: PointerEventInit = {
+ bubbles: false,
+ metaKey: e.metaKey,
+ ctrlKey: e.ctrlKey,
+ }
+ anchor.dispatchEvent(new PointerEvent("click", opts))
+ } else {
+ anchor.click()
+ }
+ },
+ [onClick],
+ )
+}
+
type CardProps = {
children: ReactNode[] | ReactNode
className?: string
size?: Size
- href?: string
-}
+ /**
+ * Defaults to `false`. If `true`, clicking the whole card will click the
+ * child anchor with data-card-link="true".
+ *
+ * NOTES:
+ * - By default, Card.Title has `data-card-link="true"`.
+ * - If using Card.Content to customize, you must ensure the content includes
+ * an anchor with data-card-link attribute. Its value is irrelevant.
+ * - Clicks will NOT be forwarded if it is (or is a child of):
+ * - an anchor or button element
+ * - OR an element with data-card-action
+ */
+ forwardClicksToLink?: boolean
+ onClick?: React.MouseEventHandler
+ as?: React.ElementType
+} & AriaAttributes
export type ImageProps = NextImageProps & {
size?: Size
@@ -149,21 +224,50 @@ export type ImageProps = NextImageProps & {
}
type TitleProps = {
children?: ReactNode
+ href?: string
lines?: number
style?: CSSProperties
}
+
type SlotProps = { children?: ReactNode; style?: CSSProperties }
+/**
+ * Card component with slots for image, info, title, footer, and actions:
+ * ```tsx
+ *
+ *
+ * Info
+ * Title
+ * Footer
+ * Actions
+ *
+ * ```
+ *
+ * **Links:** Card.Title will be a link if `href` is supplied; the entire card
+ * will be clickable if `forwardClicksToLink` is `true`.
+ *
+ * **Custom Layout:** Use Card.Content to create a custom layout.
+ */
type Card = FC & {
Content: FC<{ children: ReactNode }>
Image: FC
Info: FC
+ /**
+ * Card title with optional `href`.
+ */
Title: FC
Footer: FC
Actions: FC
}
-const Card: Card = ({ children, className, size, href }) => {
+const Card: Card = ({
+ children,
+ className,
+ size,
+ onClick,
+ forwardClicksToLink = false,
+ ...others
+}) => {
let content,
image: ImageProps | null = null,
info: SlotProps = {},
@@ -171,8 +275,6 @@ const Card: Card = ({ children, className, size, href }) => {
footer: SlotProps = {},
actions: SlotProps = {}
- const _Container = href ? LinkContainer : Container
-
/*
* Allows rendering child elements to specific "slots":
*
@@ -196,54 +298,68 @@ const Card: Card = ({ children, className, size, href }) => {
else if (child.type === Actions) actions = child.props
})
+ const handleHrefClick = useClickChildLink(onClick)
+ const handleClick = forwardClicksToLink ? handleHrefClick : onClick
+
const allClassNames = ["MitCard-root", className ?? ""].join(" ")
if (content) {
return (
-
- <_Container className={className} href={href!}>
- {content}
-
-
+
+ {content}
+
)
}
return (
-
- <_Container href={href!} scroll={!href?.startsWith("?")}>
- {image && (
- // alt text will be checked on Card.Image
- // eslint-disable-next-line styled-components-a11y/alt-text
-
- )}
-
- {info.children && (
-
- {info.children}
-
- )}
-
- {title.children}
-
-
-
-
-
-
- {actions.children && (
-
- {actions.children}
-
+
+ {image && (
+ // alt text will be checked on Card.Image
+ // eslint-disable-next-line styled-components-a11y/alt-text
+
)}
-
+
+ {info.children && (
+
+ {info.children}
+
+ )}
+
+ {title.children}
+
+
+
+
+ {actions.children && (
+
+ {actions.children}
+
+ )}
+
+
)
}
@@ -255,3 +371,4 @@ Card.Footer = Footer
Card.Actions = Actions
export { Card }
+export type { CardProps }
diff --git a/frontends/ol-components/src/components/Card/ListCard.test.tsx b/frontends/ol-components/src/components/Card/ListCard.test.tsx
index 3fc65edb90..ecd730ab4d 100644
--- a/frontends/ol-components/src/components/Card/ListCard.test.tsx
+++ b/frontends/ol-components/src/components/Card/ListCard.test.tsx
@@ -1,17 +1,114 @@
-import { render } from "@testing-library/react"
+import { render, screen } from "@testing-library/react"
+import user from "@testing-library/user-event"
import { ListCard } from "./ListCard"
import React from "react"
+import invariant from "tiny-invariant"
+import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
describe("ListCard", () => {
- test("has class MitCard-root on root element", () => {
+ test("has class MitListCard-root on root element", () => {
const { container } = render(
Hello world
,
+ { wrapper: ThemeProvider },
)
const card = container.firstChild
expect(card).toHaveClass("MitListCard-root")
expect(card).toHaveClass("Foo")
})
+
+ test.each([
+ { forwardClicksToLink: true, finalHref: "#woof" },
+ { forwardClicksToLink: false, finalHref: "" },
+ ])(
+ "The whole card is clickable as a link",
+ async ({ forwardClicksToLink, finalHref }) => {
+ const href = "#woof"
+ render(
+
+ Title
+ Info
+ Footer
+ Actions
+ ,
+ { wrapper: ThemeProvider },
+ )
+ // outermost wrapper is not actually clickable
+ const card = document.querySelector(".MitListCard-root > *")
+ invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor
+
+ await user.click(card)
+ expect(window.location.hash).toBe(finalHref)
+ },
+ )
+
+ test.each([
+ { forwardClicksToLink: true, finalHref: "#meow" },
+ { forwardClicksToLink: false, finalHref: "" },
+ ])(
+ "The whole card is clickable as a link when using Content, except buttons and links",
+ async ({ forwardClicksToLink, finalHref }) => {
+ const onClick = jest.fn()
+ const href = "#meow"
+ render(
+
+
+ Hello!
+
+
+ Link
+
+
+ ,
+ { wrapper: ThemeProvider },
+ )
+ const button = screen.getByRole("button", { name: "Button" })
+ await user.click(button)
+ expect(onClick).toHaveBeenCalled()
+ expect(window.location.hash).toBe("")
+
+ // outermost wrapper is not actually clickable
+ const card = document.querySelector(".MitListCard-root > *")
+ invariant(card instanceof HTMLDivElement) // Sanity: Chceck it's not an anchor
+
+ await user.click(card)
+ expect(window.location.hash).toBe(finalHref)
+ },
+ )
+
+ test("Clicks on interactive elements are not forwarded", async () => {
+ const btnOnClick = jest.fn()
+ const divOnClick = jest.fn()
+ render(
+
+ Title
+ Info
+
+
+ Link Two
+ {/*
+ eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
+ */}
+
+ Interactive Div
+
+
+ ,
+ { wrapper: ThemeProvider },
+ )
+ const button = screen.getByRole("button", { name: "Button" })
+ screen.getByRole("link", { name: "Title" })
+ const link2 = screen.getByRole("link", { name: "Link Two" })
+ const div = screen.getByText("Interactive Div")
+ await user.click(button)
+ expect(btnOnClick).toHaveBeenCalled()
+ expect(window.location.hash).toBe("")
+ await user.click(link2)
+ expect(window.location.hash).toBe("#two")
+ await user.click(div)
+ expect(divOnClick).toHaveBeenCalled()
+ expect(window.location.hash).toBe("#two")
+ })
})
diff --git a/frontends/ol-components/src/components/Card/ListCard.tsx b/frontends/ol-components/src/components/Card/ListCard.tsx
index 18f0ff33f1..fbb0c387ed 100644
--- a/frontends/ol-components/src/components/Card/ListCard.tsx
+++ b/frontends/ol-components/src/components/Card/ListCard.tsx
@@ -1,41 +1,22 @@
-import React, { FC, ReactNode, Children, isValidElement } from "react"
-import Link from "next/link"
+import React, {
+ FC,
+ ReactNode,
+ Children,
+ isValidElement,
+ AriaAttributes,
+} from "react"
import styled from "@emotion/styled"
import { RiDraggable } from "@remixicon/react"
import { theme } from "../ThemeProvider/ThemeProvider"
-import { Wrapper, containerStyles, ImageProps } from "./Card"
+import { BaseContainer, ImageProps, useClickChildLink, Linkable } from "./Card"
import { TruncateText } from "../TruncateText/TruncateText"
import { ActionButton, ActionButtonProps } from "../Button/Button"
import { default as NextImage } from "next/image"
-export 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;
- }
-`
-
-export const Container = styled.div`
- ${containerStyles}
-`
-
-export const DraggableContainer = styled.div`
- ${containerStyles}
- display: flex;
-`
-
const Content = () => <>>
export const Body = styled.div`
flex-grow: 1;
- overflow: hidden;
margin: 24px;
${theme.breakpoints.down("md")} {
margin: 12px;
@@ -102,7 +83,11 @@ export const Info = styled.div`
align-items: center;
`
-export const Title = styled.span`
+export type TitleProps = {
+ children?: ReactNode
+ href?: string
+}
+export const Title: React.FC
= styled(Linkable)`
flex-grow: 1;
color: ${theme.custom.colors.darkGray2};
text-overflow: ellipsis;
@@ -136,19 +121,12 @@ export const Bottom = styled.div`
/**
* Slot intended to contain ListCardAction buttons.
*/
-export const Actions = styled.div<{ hasImage?: boolean }>`
+export const Actions = styled.div`
display: flex;
gap: 8px;
- position: absolute;
- bottom: 24px;
- right: ${({ hasImage }) => (hasImage ? "284px" : "24px")};
${theme.breakpoints.down("md")} {
- bottom: 8px;
gap: 4px;
- right: ${({ hasImage }) => (hasImage ? "120px" : "8px")};
}
-
- background-color: ${theme.custom.colors.white};
`
const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>(
@@ -168,34 +146,73 @@ const ListCardActionButton = styled(ActionButton)<{ isMobile?: boolean }>(
type CardProps = {
children: ReactNode[] | ReactNode
className?: string
- href?: string
+ /**
+ * Defaults to `false`. If `true`, clicking the whole card will click the
+ * child anchor with data-card-link.
+ *
+ * NOTES:
+ * - By default, Card.Title has `data-card-link="true"`.
+ * - If using Card.Content to customize, you must ensure the content includes
+ * an anchor with data-card-link attribute. Its value is irrelevant.
+ * - Clicks will NOT be forwarded if:
+ * - The click target is a child of Card.Actions OR an element with
+ * - The click target is a child of any element with data-card-actions attribute
+ */
+ forwardClicksToLink?: boolean
draggable?: boolean
-}
+ onClick?: () => void
+ as?: React.ElementType
+} & AriaAttributes
+
+/**
+ * Row-like card component with slots for image, info, title, footer, and actions:
+ * ```tsx
+ *
+ *
+ * Info
+ * Title
+ * Footer
+ * Actions
+ *
+ * ```
+ *
+ * **Links:** Card.Title will be a link if `href` is supplied; the entire card
+ * will be clickable if `forwardClicksToLink` is `true`.
+ *
+ * **Custom Layout:** Use ListCard.Content to create a custom layout.
+ */
export type Card = FC & {
Content: FC<{ children: ReactNode }>
Image: FC
Info: FC<{ children: ReactNode }>
- Title: FC<{ children: ReactNode }>
+ /**
+ * Card title with optional `href`.
+ */
+ Title: FC
Footer: FC<{ children: ReactNode }>
Actions: FC<{ children: ReactNode }>
Action: FC
}
-const ListCard: Card = ({ children, className, href, draggable }) => {
- const _Container = draggable
- ? DraggableContainer
- : href
- ? LinkContainer
- : Container
-
- let content, imageProps, info, title, footer, actions
+const ListCard: Card = ({
+ children,
+ className,
+ forwardClicksToLink = false,
+ draggable,
+ onClick,
+ ...others
+}) => {
+ let content, imageProps, info, footer, actions
+ let title: TitleProps = {}
+ const handleHrefClick = useClickChildLink(onClick)
+ const handleClick = forwardClicksToLink ? handleHrefClick : onClick
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 === Title) title = child.props
else if (child.type === Footer) footer = child.props.children
else if (child.type === Actions) actions = child.props.children
})
@@ -203,37 +220,42 @@ const ListCard: Card = ({ children, className, href, draggable }) => {
const classNames = ["MitListCard-root", className ?? ""].join(" ")
if (content) {
return (
- <_Container className={classNames} href={href!}>
+
{content}
-
+
)
}
return (
-
- <_Container href={href!} scroll={!href?.startsWith("?")}>
- {draggable && (
-
-
-
- )}
-
- {info}
-
- {title}
+
+ {draggable && (
+
+
+
+ )}
+
+ {info}
+ {title && (
+
+ {title.children}
-
-
-
-
- {imageProps && (
- // alt text will be checked on ListCard.Image
- // eslint-disable-next-line styled-components-a11y/alt-text
-
)}
-
- {actions && {actions}}
-
+
+
+ {actions && {actions}}
+
+
+ {imageProps && (
+ // alt text will be checked on ListCard.Image
+ // eslint-disable-next-line styled-components-a11y/alt-text
+
+ )}
+
)
}
diff --git a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx
index 58a49dfec5..a3b4ecca01 100644
--- a/frontends/ol-components/src/components/Card/ListCardCondensed.tsx
+++ b/frontends/ol-components/src/components/Card/ListCardCondensed.tsx
@@ -1,23 +1,34 @@
-import React, { FC, ReactNode, Children, isValidElement } from "react"
+import React, {
+ FC,
+ ReactNode,
+ Children,
+ isValidElement,
+ AriaAttributes,
+} from "react"
import styled from "@emotion/styled"
import { RiDraggable } from "@remixicon/react"
import { theme } from "../ThemeProvider/ThemeProvider"
-import { Wrapper } from "./Card"
+import { BaseContainer, useClickChildLink } from "./Card"
import { TruncateText } from "../TruncateText/TruncateText"
import {
ListCard,
Body as BaseBody,
- LinkContainer,
- Container,
- DraggableContainer,
DragArea as BaseDragArea,
Info as BaseInfo,
Title as BaseTitle,
Footer,
- Actions as BaseActions,
Bottom as BaseBottom,
} from "./ListCard"
-import type { Card as BaseCard } from "./ListCard"
+import type { Card as BaseCard, TitleProps } from "./ListCard"
+
+const Container = styled(BaseContainer)<{ draggable?: boolean }>(
+ ({ draggable }) => [
+ draggable && {
+ display: "flex",
+ flexDirection: "row",
+ },
+ ],
+)
const DragArea = styled(BaseDragArea)`
padding-right: 4px;
@@ -56,73 +67,106 @@ const Bottom = styled(BaseBottom)`
height: auto;
}
`
-const Actions = styled(BaseActions)`
- bottom: 16px;
- right: 16px;
+const Actions = styled.div`
+ display: flex;
gap: 16px;
- ${theme.breakpoints.down("md")} {
- bottom: 16px;
- right: 16px;
- gap: 16px;
- }
`
const Content = () => <>>
type CardProps = {
children: ReactNode[] | ReactNode
className?: string
- href?: string
+ /**
+ * Defaults to `false`. If `true`, clicking the whole card will click the
+ * child anchor with data-card-link.
+ *
+ * NOTES:
+ * - By default, Card.Title has `data-card-link="true"`.
+ * - If using Card.Content to customize, you must ensure the content includes
+ * an anchor with data-card-link attribute. Its value is irrelevant.
+ * - Clicks will NOT be forwarded if:
+ * - The click target is a child of Card.Actions OR an element with
+ * - The click target is a child of any element with data-card-actions attribute
+ */
+ forwardClicksToLink?: boolean
draggable?: boolean
-}
+ onClick?: () => void
+ as?: React.ElementType
+} & AriaAttributes
+/**
+ * Condensed row-like card component with slots for info, title, footer, and actions:
+ * ```tsx
+ *
+ * Info
+ * Title
+ * Footer
+ * Actions
+ *
+ * ```
+ *
+ * **Links:** Card.Title will be a link if `href` is supplied; the entire card
+ * will be clickable if `forwardClicksToLink` is `true`.
+ *
+ * **Custom Layout:** Use ListCard.Content to create a custom layout.
+ */
type Card = FC & Omit
-const ListCardCondensed: Card = ({ children, className, href, draggable }) => {
- const _Container = draggable
- ? DraggableContainer
- : href
- ? LinkContainer
- : Container
+const ListCardCondensed: Card = ({
+ children,
+ className,
+ draggable,
+ onClick,
+ forwardClicksToLink = false,
+ ...others
+}) => {
+ let content, info, footer, actions
+ let title: TitleProps = {}
- let content, info, title, footer, actions
+ const handleHrefClick = useClickChildLink(onClick)
+ const handleClick =
+ forwardClicksToLink && !draggable ? handleHrefClick : onClick
Children.forEach(children, (child) => {
if (!isValidElement(child)) return
if (child.type === Content) content = child.props.children
else if (child.type === Info) info = child.props.children
- else if (child.type === Title) title = child.props.children
+ else if (child.type === Title) title = child.props
else if (child.type === Footer) footer = child.props.children
else if (child.type === Actions) actions = child.props.children
})
if (content) {
return (
- <_Container className={className} href={href!}>
+
{content}
-
+
)
}
return (
-
- <_Container href={href!} scroll={!href?.startsWith("?")}>
- {draggable && (
-
-
-
- )}
-
- {info}
-
- {title}
-
-
-
-
-
-
- {actions && {actions}}
-
+
+ {draggable && (
+
+
+
+ )}
+
+ {info}
+
+ {title.children}
+
+
+
+ {actions && {actions}}
+
+
+
)
}
diff --git a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx
index b9bee73776..6064544f4c 100644
--- a/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx
+++ b/frontends/ol-components/src/components/EmbedlyCard/EmbedlyCard.tsx
@@ -6,9 +6,10 @@ import {
embedlyCardHtml,
EmbedlyEventTypes,
ensureEmbedlyPlatform,
- getEmbedlyKey,
} from "./util"
+const EMBEDLY_KEY = process.env.NEXT_PUBLIC_EMBEDLY_KEY as string
+
type EmbedlyCardProps = {
url: string
className?: string
@@ -51,7 +52,6 @@ const Container = styled.div<{ aspectRatio?: number }>`
const EmbedlyCard: React.FC = ({
className,
url,
- embedlyKey,
aspectRatio,
}) => {
const [container, setContainer] = useState(null)
@@ -85,11 +85,12 @@ const EmbedlyCard: React.FC = ({
const a = document.createElement("a")
a.dataset.cardChrome = "0"
a.dataset.cardControls = "0"
- a.dataset.cardKey = embedlyKey ?? getEmbedlyKey() ?? ""
+ a.dataset.cardKey = EMBEDLY_KEY
a.href = url
a.classList.add("embedly-card")
+ a.dataset["testid"] = "embedly-card"
container.appendChild(a)
- }, [embedlyKey, container, url])
+ }, [container, url])
return (
{
head.appendChild(style)
}
-const getEmbedlyKey = (): string | null => {
- const key = process.env.NEXT_PUBLIC_EMBEDLY_KEY
- if (typeof key === "string") return key
- console.warn("process.env.NEXT_PUBLIC_EMBEDLY_KEY should be a string.")
- return null
-}
-
const embedlyCardHtml = (url: string) => {
- const embedlyKey = getEmbedlyKey()
return ``
}
@@ -101,7 +95,6 @@ const embedlyCardHtml = (url: string) => {
export {
createStylesheet,
ensureEmbedlyPlatform,
- getEmbedlyKey,
EmbedlyEventTypes,
dispatchCardCreated,
embedlyCardHtml,
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx
index 83f15688d2..c00cd755f5 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.stories.tsx
@@ -48,7 +48,7 @@ export default meta
type Story = StoryObj
const priceArgs: Partial = {
- excerpt: ["certification", "free", "prices"],
+ excerpt: ["certification", "free", "resource_prices"],
}
export const FreeCourseNoCertificate: Story = {
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx
index 9659c4812b..5187dbf505 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.test.tsx
@@ -1,5 +1,5 @@
import React from "react"
-import { screen, render } from "@testing-library/react"
+import { screen, render, within } from "@testing-library/react"
import { LearningResourceCard } from "./LearningResourceCard"
import type { LearningResourceCardProps } from "./LearningResourceCard"
import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities"
@@ -14,19 +14,29 @@ const setup = (props: LearningResourceCardProps) => {
}
describe("Learning Resource 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.getByText(resource.title)
- screen.getByText("Starts:")
- screen.getByText("January 01, 2026")
- })
+ test.each([
+ { resourceType: ResourceTypeEnum.Course, expectedLabel: "Course" },
+ { resourceType: ResourceTypeEnum.Program, expectedLabel: "Program" },
+ ])(
+ "Renders resource type, title and start date as a labeled article",
+ ({ resourceType, expectedLabel }) => {
+ const resource = factories.learningResources.resource({
+ resource_type: resourceType,
+ next_start_date: "2026-01-01",
+ })
+
+ setup({ resource })
+
+ const card = screen.getByRole("article", {
+ name: `${expectedLabel}: ${resource.title}`,
+ })
+
+ within(card).getByText(expectedLabel)
+ within(card).getByText(resource.title)
+ within(card).getByText("Starts:")
+ within(card).getByText("January 01, 2026")
+ },
+ )
test("Displays run start date", () => {
const resource = factories.learningResources.resource({
@@ -96,7 +106,7 @@ describe("Learning Resource Card", () => {
setup({ resource, href: "/path/to/thing" })
const link = screen.getByRole("link", {
- name: new RegExp(resource.title),
+ name: resource.title,
})
expect(new URL(link.href).pathname).toBe("/path/to/thing")
})
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx
index 314d2b8912..8db2fffc68 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceCard.tsx
@@ -229,8 +229,15 @@ const LearningResourceCard: React.FC = ({
return null
}
+ const readableType = getReadableResourceType(resource.resource_type)
return (
-
+
= ({
-
+
{resource.title}
@@ -257,7 +264,7 @@ const LearningResourceCard: React.FC = ({
{onAddToUserListClick && (
onAddToUserListClick(event, resource.id)}
>
{inUserList ? (
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx
index d1726edb92..8de6787771 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx
@@ -43,7 +43,7 @@ export default meta
type Story = StoryObj
const priceArgs: Partial = {
- excerpt: ["certification", "free", "prices"],
+ excerpt: ["certification", "free", "resource_prices"],
}
export const FreeCourseNoCertificate: Story = {
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx
index d90eae55ae..d22ac80d42 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx
@@ -1,6 +1,6 @@
import React from "react"
import { BrowserRouter } from "react-router-dom"
-import { screen, render } from "@testing-library/react"
+import { screen, render, within } from "@testing-library/react"
import { LearningResourceListCard } from "./LearningResourceListCard"
import type { LearningResourceListCardProps } from "./LearningResourceListCard"
import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities"
@@ -19,19 +19,29 @@ const setup = (props: LearningResourceListCardProps) => {
}
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",
- })
+ test.each([
+ { resourceType: ResourceTypeEnum.Course, expectedLabel: "Course" },
+ { resourceType: ResourceTypeEnum.Program, expectedLabel: "Program" },
+ ])(
+ "Renders resource type, title and start date as a labeled article",
+ ({ resourceType, expectedLabel }) => {
+ const resource = factories.learningResources.resource({
+ resource_type: resourceType,
+ next_start_date: "2026-01-01",
+ })
- setup({ resource })
+ setup({ resource })
- screen.getByText("Course")
- screen.getByText(resource.title)
- screen.getByText("Starts:")
- screen.getByText("January 01, 2026")
- })
+ const card = screen.getByRole("article", {
+ name: `${expectedLabel}: ${resource.title}`,
+ })
+
+ within(card).getByText(expectedLabel)
+ within(card).getByText(resource.title)
+ within(card).getByText("Starts:")
+ within(card).getByText("January 01, 2026")
+ },
+ )
test("Displays run start date", () => {
const resource = factories.learningResources.resource({
@@ -92,11 +102,11 @@ describe("Learning Resource List Card", () => {
setup({ resource, href: "/path/to/thing" })
- const card = screen.getByRole("link", {
- name: new RegExp(resource.title),
+ const link = screen.getByRole("link", {
+ name: resource.title,
})
- expect(card).toHaveAttribute("href", "/path/to/thing")
+ expect(link).toHaveAttribute("href", "/path/to/thing")
})
test("Click action buttons", async () => {
@@ -201,7 +211,7 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: false,
free: true,
- prices: ["0"],
+ resource_prices: [{ amount: "0", currency: "USD" }],
})
setup({ resource })
screen.getByText("Free")
@@ -211,7 +221,10 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: true,
free: true,
- prices: ["0", "49"],
+ resource_prices: [
+ { amount: "0", currency: "USD" },
+ { amount: "49", currency: "USD" },
+ ],
})
setup({ resource })
screen.getByText("Certificate")
@@ -223,7 +236,11 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: true,
free: true,
- prices: ["0", "99", "49"],
+ resource_prices: [
+ { amount: "0", currency: "USD" },
+ { amount: "99", currency: "USD" },
+ { amount: "49", currency: "USD" },
+ ],
})
setup({ resource })
screen.getByText("Certificate")
@@ -235,7 +252,7 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: false,
free: false,
- prices: ["49"],
+ resource_prices: [{ amount: "49", currency: "USD" }],
})
setup({ resource })
screen.getByText("$49")
@@ -245,7 +262,7 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: false,
free: false,
- prices: ["49.50"],
+ resource_prices: [{ amount: "49.50", currency: "USD" }],
})
setup({ resource })
screen.getByText("$49.50")
@@ -255,7 +272,7 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: false,
free: true,
- prices: [],
+ resource_prices: [],
})
setup({ resource })
screen.getByText("Free")
@@ -265,7 +282,7 @@ describe("Learning Resource List Card", () => {
const resource = factories.learningResources.resource({
certification: false,
free: false,
- prices: ["0"],
+ resource_prices: [{ amount: "0", currency: "USD" }],
})
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 6778afc08b..f68b78baa4 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx
@@ -11,7 +11,6 @@ import { ResourceTypeEnum, LearningResource } from "api"
import {
formatDate,
getReadableResourceType,
- // embedlyCroppedImage,
DEFAULT_RESOURCE_IMG,
pluralize,
getLearningResourcePrices,
@@ -106,14 +105,6 @@ type ResourceIdCallback = (
resourceId: number,
) => void
-// TODO confirm use of Next.js image optimizer in place of Embedly
-// const getEmbedlyUrl = (url: string, isMobile: boolean) => {
-// return embedlyCroppedImage(url, {
-// key: process.env.NEXT_PUBLIC_EMBEDLY_KEY!,
-// ...IMAGE_SIZES[isMobile ? "mobile" : "desktop"],
-// })
-// }
-
/* This displays a single price for courses with no free option
* (price includes the certificate). For free courses with the
* option of a paid certificate, the certificate price displayed
@@ -307,8 +298,15 @@ const LearningResourceListCard: React.FC = ({
if (!resource) {
return null
}
+ const readableType = getReadableResourceType(resource.resource_type)
return (
-
+
= ({
- {resource.title}
+ {resource.title}
{onAddToLearningPathClick && (
= ({
{onAddToUserListClick && (
onAddToUserListClick(event, resource.id)}
>
{inUserList ? (
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx
index db5b6f575c..1e8b3ceda7 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.stories.tsx
@@ -43,7 +43,7 @@ export default meta
type Story = StoryObj
const priceArgs: Partial = {
- excerpt: ["certification", "free", "prices"],
+ excerpt: ["certification", "free", "resource_prices"],
}
export const FreeCourseNoCertificate: Story = {
diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx
index 2bb7c4a1cd..54c9a1a443 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx
+++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCardCondensed.tsx
@@ -115,12 +115,21 @@ const LearningResourceListCardCondensed: React.FC<
if (!resource) {
return null
}
+ const readableType = getReadableResourceType(resource.resource_type)
return (
-
+
- {resource.title}
+
+ {resource.title}
+
{onAddToLearningPathClick && (
onAddToUserListClick(event, resource.id)}
>
{inUserList ? (
diff --git a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts
index da7bbf6b5f..79eacb61b3 100644
--- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts
+++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts
@@ -48,21 +48,56 @@ const courses = {
runs: [factories.learningResources.run()],
free: true,
certification: false,
- prices: ["0"],
+ resource_prices: [{ amount: "0", currency: "USD" }],
}),
withCertificateOnePrice: makeResource({
resource_type: ResourceTypeEnum.Course,
runs: [factories.learningResources.run()],
free: true,
certification: true,
- prices: ["0", "49"],
+ resource_prices: [
+ { amount: "0", currency: "USD" },
+ { amount: "49", currency: "USD" },
+ ],
}),
withCertificatePriceRange: makeResource({
resource_type: ResourceTypeEnum.Course,
runs: [factories.learningResources.run()],
free: true,
certification: true,
- prices: ["0", "99", "49"],
+ resource_prices: [
+ { amount: "0", currency: "USD" },
+ { amount: "99", currency: "USD" },
+ { amount: "49", currency: "USD" },
+ ],
+ }),
+ multipleRuns: makeResource({
+ resource_type: ResourceTypeEnum.Course,
+ runs: [
+ factories.learningResources.run(),
+ factories.learningResources.run(),
+ factories.learningResources.run(),
+ factories.learningResources.run(),
+ ],
+ free: true,
+ certification: false,
+ prices: ["0"],
+ }),
+ anytime: makeResource({
+ resource_type: ResourceTypeEnum.Course,
+ runs: [factories.learningResources.run()],
+ free: true,
+ certification: false,
+ prices: ["0"],
+ availability: "anytime",
+ }),
+ dated: makeResource({
+ resource_type: ResourceTypeEnum.Course,
+ runs: [factories.learningResources.run()],
+ free: true,
+ certification: false,
+ prices: ["0"],
+ availability: "dated",
}),
},
unknownPrice: {
@@ -71,14 +106,14 @@ const courses = {
runs: [factories.learningResources.run()],
free: false,
certification: false,
- prices: [],
+ resource_prices: [],
}),
withCertificate: makeResource({
resource_type: ResourceTypeEnum.Course,
runs: [factories.learningResources.run()],
free: false,
certification: true,
- prices: [],
+ resource_prices: [],
}),
},
paid: {
@@ -87,21 +122,24 @@ const courses = {
runs: [factories.learningResources.run()],
free: false,
certification: false,
- prices: ["49"],
+ resource_prices: [{ amount: "49", currency: "USD" }],
}),
withCerticateOnePrice: makeResource({
resource_type: ResourceTypeEnum.Course,
runs: [factories.learningResources.run()],
free: false,
certification: true,
- prices: ["49"],
+ resource_prices: [{ amount: "49", currency: "USD" }],
}),
withCertificatePriceRange: makeResource({
resource_type: ResourceTypeEnum.Course,
runs: [factories.learningResources.run()],
free: false,
certification: true,
- prices: ["49", "99"],
+ resource_prices: [
+ { amount: "49", currency: "USD" },
+ { amount: "99", currency: "USD" },
+ ],
}),
},
start: {
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.test.tsx
similarity index 75%
rename from frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.test.tsx
rename to frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.test.tsx
index 772f55da5e..b8da7fd72c 100644
--- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.test.tsx
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.test.tsx
@@ -1,12 +1,12 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import { courses } from "../LearningResourceCard/testUtils"
-import InfoSection from "./InfoSection"
+import InfoSectionV1 from "./InfoSectionV1"
import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
describe("Learning resource info section pricing", () => {
test("Free course, no certificate", () => {
- render(, {
+ render(, {
wrapper: ThemeProvider,
})
@@ -17,7 +17,7 @@ describe("Learning resource info section pricing", () => {
})
test("Free course, with certificate, one price", () => {
- render(, {
+ render(, {
wrapper: ThemeProvider,
})
@@ -28,9 +28,12 @@ describe("Learning resource info section pricing", () => {
})
test("Free course, with certificate, price range", () => {
- render(, {
- wrapper: ThemeProvider,
- })
+ render(
+ ,
+ {
+ wrapper: ThemeProvider,
+ },
+ )
screen.getByText("Free")
expect(screen.queryByText("Paid")).toBeNull()
@@ -39,7 +42,7 @@ describe("Learning resource info section pricing", () => {
})
test("Unknown price, no certificate", () => {
- render(, {
+ render(, {
wrapper: ThemeProvider,
})
@@ -50,7 +53,7 @@ describe("Learning resource info section pricing", () => {
})
test("Unknown price, with certificate", () => {
- render(, {
+ render(, {
wrapper: ThemeProvider,
})
@@ -60,7 +63,7 @@ describe("Learning resource info section pricing", () => {
})
test("Paid course, no certificate", () => {
- render(, {
+ render(, {
wrapper: ThemeProvider,
})
@@ -72,7 +75,7 @@ describe("Learning resource info section pricing", () => {
})
test("Paid course, with certificate, one price", () => {
- render(, {
+ render(, {
wrapper: ThemeProvider,
})
@@ -82,9 +85,12 @@ describe("Learning resource info section pricing", () => {
})
test("Paid course, with certificate, price range", () => {
- render(, {
- wrapper: ThemeProvider,
- })
+ render(
+ ,
+ {
+ wrapper: ThemeProvider,
+ },
+ )
screen.getByText("$49 – $99")
expect(screen.queryByText("Paid")).toBeNull()
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.tsx
similarity index 99%
rename from frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.tsx
rename to frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.tsx
index 4c6ec179bc..27c3d1f312 100644
--- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSection.tsx
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV1.tsx
@@ -237,7 +237,7 @@ const InfoItem = ({ label, Icon, value }: InfoItemProps) => {
)
}
-const InfoSection = ({
+const InfoSectionV1 = ({
resource,
run,
user,
@@ -307,4 +307,4 @@ const InfoSection = ({
)
}
-export default InfoSection
+export default InfoSectionV1
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx
new file mode 100644
index 0000000000..d14bac2176
--- /dev/null
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx
@@ -0,0 +1,158 @@
+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 { formatRunDate } from "ol-utilities"
+import invariant from "tiny-invariant"
+
+// This is a pipe followed by a zero-width space
+const SEPARATOR = "|"
+
+describe("Learning resource info section pricing", () => {
+ test("Free course, no certificate", () => {
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ screen.getByText("Free")
+ expect(screen.queryByText("Paid")).toBeNull()
+ expect(screen.queryByText("Earn a certificate:")).toBeNull()
+ expect(screen.queryByText("Certificate included")).toBeNull()
+ })
+
+ test("Free course, with certificate, one price", () => {
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ screen.getByText("Free")
+ expect(screen.queryByText("Paid")).toBeNull()
+ screen.getByText("Earn a certificate:")
+ screen.getByText("$49")
+ })
+
+ test("Free course, with certificate, price range", () => {
+ render(
+ ,
+ {
+ wrapper: ThemeProvider,
+ },
+ )
+
+ screen.getByText("Free")
+ expect(screen.queryByText("Paid")).toBeNull()
+ screen.getByText("Earn a certificate:")
+ screen.getByText("$49 – $99")
+ })
+
+ test("Unknown price, no certificate", () => {
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ screen.getByText("Paid")
+ expect(screen.queryByText("Free")).toBeNull()
+ expect(screen.queryByText("Earn a certificate:")).toBeNull()
+ expect(screen.queryByText("Certificate included")).toBeNull()
+ })
+
+ test("Unknown price, with certificate", () => {
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ screen.getByText("Paid")
+ expect(screen.queryByText("Free")).toBeNull()
+ screen.getByText("Certificate included")
+ })
+
+ test("Paid course, no certificate", () => {
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ screen.getByText("$49")
+ expect(screen.queryByText("Paid")).toBeNull()
+ expect(screen.queryByText("Free")).toBeNull()
+ expect(screen.queryByText("Earn a certificate:")).toBeNull()
+ expect(screen.queryByText("Certificate included")).toBeNull()
+ })
+
+ test("Paid course, with certificate, one price", () => {
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ screen.getByText("$49")
+ expect(screen.queryByText("Paid")).toBeNull()
+ screen.getByText("Certificate included")
+ })
+
+ test("Paid course, with certificate, price range", () => {
+ render(
+ ,
+ {
+ wrapper: ThemeProvider,
+ },
+ )
+
+ screen.getByText("$49 – $99")
+ expect(screen.queryByText("Paid")).toBeNull()
+ screen.getByText("Certificate included")
+ })
+})
+
+describe("Learning resource info section start date", () => {
+ test("Start date", () => {
+ const course = courses.free.dated
+ const run = course.runs?.[0]
+ invariant(run)
+ const runDate = formatRunDate(run, false)
+ invariant(runDate)
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("Start Date:")
+ within(section).getByText(runDate)
+ })
+
+ test("As taught in", () => {
+ const course = courses.free.anytime
+ const run = course.runs?.[0]
+ invariant(run)
+ const runDate = formatRunDate(run, true)
+ invariant(runDate)
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("As taught in:")
+ within(section).getByText(runDate)
+ })
+
+ test("Multiple Runs", () => {
+ const course = courses.free.multipleRuns
+ 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)
+ }
+ return 0
+ })
+ .map((run) => formatRunDate(run, false))
+ .join(SEPARATOR)
+ invariant(expectedDateText)
+ render(, {
+ wrapper: ThemeProvider,
+ })
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText((_content, node) => {
+ return node?.textContent === expectedDateText || false
+ })
+ })
+})
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx
new file mode 100644
index 0000000000..6c27cf378a
--- /dev/null
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx
@@ -0,0 +1,403 @@
+import React from "react"
+import styled from "@emotion/styled"
+import ISO6391 from "iso-639-1"
+import {
+ RemixiconComponentType,
+ RiVerifiedBadgeLine,
+ RiTimeLine,
+ RiCalendarLine,
+ RiListOrdered2,
+ RiPriceTag3Line,
+ RiDashboard3Line,
+ RiGraduationCapLine,
+ RiTranslate2,
+ RiPresentationLine,
+ RiAwardFill,
+} from "@remixicon/react"
+import { LearningResource, ResourceTypeEnum } from "api"
+import {
+ formatDurationClockTime,
+ formatRunDate,
+ getLearningResourcePrices,
+ showStartAnytime,
+} from "ol-utilities"
+import { theme } from "../ThemeProvider/ThemeProvider"
+
+const SeparatorContainer = styled.span({
+ padding: "0 8px",
+ color: theme.custom.colors.silverGray,
+})
+
+/*
+ * Pipe followed by zero-width space, ZWSP.
+ * By doing
+ * - ...
+ * without whitespace between
- and , we allow line
+ * breaks after the pipe but not before it.
+ */
+const Separator: React.FC = () => (
+ |
+)
+
+const InfoItems = styled.section({
+ display: "flex",
+ flexDirection: "column",
+ gap: "16px",
+ maxWidth: "100%",
+})
+
+const InfoItemContainer = styled.div({
+ display: "flex",
+ alignSelf: "stretch",
+ alignItems: "baseline",
+ gap: "16px",
+ ...theme.typography.subtitle3,
+ color: theme.custom.colors.black,
+ svg: {
+ color: theme.custom.colors.silverGrayDark,
+ width: "20px",
+ height: "20px",
+ flexShrink: 0,
+ },
+ [theme.breakpoints.down("sm")]: {
+ gap: "12px",
+ },
+})
+
+const IconContainer = styled.span({
+ transform: "translateY(25%)",
+ svg: {
+ display: "block",
+ },
+ [theme.breakpoints.down("sm")]: {
+ display: "none",
+ },
+})
+
+const InfoLabel = styled.div({
+ width: "85px",
+ flexShrink: 0,
+})
+
+const InfoValue = styled.div({
+ display: "inline-block",
+ color: theme.custom.colors.darkGray2,
+ rowGap: ".2rem",
+ ...theme.typography.body3,
+})
+
+const PriceDisplay = styled.div({
+ display: "flex",
+ alignItems: "center",
+ gap: "8px",
+ [theme.breakpoints.down("sm")]: {
+ flexDirection: "column",
+ flexWrap: "wrap",
+ alignItems: "flex-start",
+ },
+})
+
+const Certificate = styled.div({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ borderRadius: "4px",
+ padding: "4px 8px",
+ border: `1px solid ${theme.custom.colors.lightGray2}`,
+ backgroundColor: theme.custom.colors.lightGray1,
+ color: theme.custom.colors.silverGrayDark,
+ ...theme.typography.subtitle3,
+ svg: {
+ width: "16px",
+ height: "16px",
+ },
+ [theme.breakpoints.down("sm")]: {
+ padding: "1px 2px",
+ ...theme.typography.subtitle4,
+ },
+})
+
+type InfoSelector = (resource: LearningResource) => React.ReactNode
+
+type InfoItemConfig = {
+ label: string | ((resource: LearningResource) => string)
+ Icon: RemixiconComponentType | null
+ selector: InfoSelector
+}[]
+
+type InfoItemValueProps = {
+ label: string | null
+ index: number
+ total: number
+}
+
+const InfoItemValue: React.FC = ({
+ label,
+ index,
+ total,
+}) => {
+ return (
+ <>
+ {label}
+ {index < total - 1 && }
+ >
+ )
+}
+
+const INFO_ITEMS: InfoItemConfig = [
+ {
+ label: (resource: LearningResource) => {
+ const asTaughtIn = resource ? showStartAnytime(resource) : false
+ const label = asTaughtIn ? "As taught in:" : "Start Date:"
+ return label
+ },
+ 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
+ } else return null
+ },
+ },
+ {
+ label: "Price:",
+ Icon: RiPriceTag3Line,
+ selector: (resource: LearningResource) => {
+ const prices = getLearningResourcePrices(resource)
+
+ return (
+
+
{prices.course.display}
+ {resource.certification && (
+
+
+ {prices.certificate.display
+ ? "Earn a certificate:"
+ : "Certificate included"}
+ {prices.certificate.display}
+
+ )}
+
+ )
+ },
+ },
+ {
+ label: "Topics:",
+ Icon: RiPresentationLine,
+ selector: (resource: LearningResource) => {
+ const { topics } = resource
+ if (!topics?.length) {
+ return null
+ }
+ return topics.map((topic, index) => {
+ return (
+
+ )
+ })
+ },
+ },
+ {
+ label: "Level:",
+ Icon: RiDashboard3Line,
+ selector: (resource: LearningResource) => {
+ const totalRuns = resource.runs?.length || 0
+ const levels = resource.runs?.map((run, index) => {
+ const level = run?.level?.[0]?.name
+ if (!level) {
+ return null
+ }
+ return (
+
+ )
+ })
+ if (levels?.every((level) => level === null)) {
+ return null
+ }
+ return levels
+ },
+ },
+
+ {
+ label: "Instructors:",
+ Icon: RiGraduationCapLine,
+ selector: (resource: LearningResource) => {
+ const instructorNames: string[] = []
+ resource.runs?.forEach((run) => {
+ run.instructors?.forEach((instructor) => {
+ if (instructor.full_name) {
+ instructorNames.push(instructor.full_name)
+ }
+ })
+ })
+ const uniqueInstructors = Array.from(new Set(instructorNames))
+ if (uniqueInstructors.length === 0) {
+ return null
+ }
+ const totalInstructors = uniqueInstructors.length
+ const instructors = uniqueInstructors.map((instructor, index) => {
+ return (
+
+ )
+ })
+ return instructors
+ },
+ },
+
+ {
+ label: "Languages:",
+ Icon: RiTranslate2,
+ selector: (resource: LearningResource) => {
+ const runLanguages: string[] = []
+ resource.runs?.forEach((run) => {
+ run.languages?.forEach((language) => {
+ runLanguages.push(language)
+ })
+ })
+ const uniqueLanguages = Array.from(new Set(runLanguages))
+ if (uniqueLanguages.length === 0) {
+ return null
+ }
+ const totalLanguages = uniqueLanguages.length
+ return uniqueLanguages.map((language, index) => {
+ return (
+
+ )
+ })
+ },
+ },
+
+ {
+ label: "Duration:",
+ Icon: RiTimeLine,
+ selector: (resource: LearningResource) => {
+ if (resource.resource_type === ResourceTypeEnum.Video) {
+ return resource.video.duration
+ ? formatDurationClockTime(resource.video.duration)
+ : null
+ }
+ if (resource.resource_type === ResourceTypeEnum.PodcastEpisode) {
+ return resource.podcast_episode.duration
+ ? formatDurationClockTime(resource.podcast_episode.duration)
+ : null
+ }
+ return null
+ },
+ },
+
+ {
+ label: "Offered By:",
+ Icon: RiVerifiedBadgeLine,
+ selector: (resource: LearningResource) => {
+ return resource.offered_by?.name || null
+ },
+ },
+
+ {
+ label: "Date Posted:",
+ Icon: RiCalendarLine,
+ selector: () => {
+ // TODO Not seeing any value for this in the API schema for VideoResource. Last modified date is closest available, though likely relates to the data record
+ return null
+ },
+ },
+
+ {
+ label: "Number of Courses:",
+ Icon: RiListOrdered2,
+ selector: (resource: LearningResource) => {
+ if (resource.resource_type === ResourceTypeEnum.Program) {
+ return resource.program.course_count
+ }
+ return null
+ },
+ },
+]
+
+type InfoItemProps = {
+ label: string
+ Icon: RemixiconComponentType | null
+ value: React.ReactNode
+}
+
+const InfoItem = ({ label, Icon, value }: InfoItemProps) => {
+ if (!value) {
+ return null
+ }
+ return (
+
+ {Icon && }
+ {label}
+ {value}
+
+ )
+}
+
+const InfoSectionV2 = ({ resource }: { resource?: LearningResource }) => {
+ if (!resource) {
+ return null
+ }
+
+ const infoItems = INFO_ITEMS.map(({ label, Icon, selector }) => ({
+ label: typeof label === "function" ? label(resource) : label,
+ Icon,
+ value: selector(resource),
+ })).filter(({ value }) => value !== null && value !== "")
+
+ if (infoItems.length === 0) {
+ return null
+ }
+
+ return (
+
+ {infoItems.map((props, index) => (
+
+ ))}
+
+ )
+}
+
+export default InfoSectionV2
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx
index 33271da082..27d7e2524c 100644
--- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.stories.tsx
@@ -1,6 +1,6 @@
import React from "react"
import type { Meta, StoryObj } from "@storybook/react"
-import { LearningResourceExpanded } from "./LearningResourceExpanded"
+import { LearningResourceExpandedV1 } from "./LearningResourceExpandedV1"
import { factories } from "api/test-utils"
import { ResourceTypeEnum as LRT } from "api"
import invariant from "tiny-invariant"
@@ -16,12 +16,11 @@ const makeResource: typeof _makeResource = (overrides) => {
return resource
}
-const meta: Meta = {
- title: "smoot-design/LearningResourceExpanded",
- component: LearningResourceExpanded,
+const meta: Meta = {
+ title: "smoot-design/LearningResourceExpandedV1",
+ component: LearningResourceExpandedV1,
args: {
imgConfig: {
- key: "",
width: 385,
height: 200,
},
@@ -54,7 +53,7 @@ const meta: Meta = {
return (
-
+
)
@@ -63,7 +62,7 @@ const meta: Meta = {
export default meta
-type Story = StoryObj
+type Story = StoryObj
export const Course: Story = {
args: {
@@ -139,7 +138,10 @@ export const PricingVariant1: Story = {
resource: makeResource({
resource_type: LRT.Course,
title: "Free course with paid certificate option",
- prices: ["0", "49"],
+ resource_prices: [
+ { amount: "0", currency: "USD" },
+ { amount: "49", currency: "USD" },
+ ],
free: true,
certification: true,
}),
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx
similarity index 96%
rename from frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx
rename to frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx
index 0af5945c86..8a1a54df9d 100644
--- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.test.tsx
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx
@@ -2,8 +2,8 @@ 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 { LearningResourceExpandedV1 } from "./LearningResourceExpandedV1"
+import type { LearningResourceExpandedV1Props } from "./LearningResourceExpandedV1"
import { ResourceTypeEnum, PodcastEpisodeResource, AvailabilityEnum } from "api"
import { factories } from "api/test-utils"
import { formatDate } from "ol-utilities"
@@ -11,10 +11,9 @@ import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
import invariant from "tiny-invariant"
import type { LearningResource } from "api"
import { faker } from "@faker-js/faker/locale/en"
-import { PLATFORMS } from "../Logo/Logo"
+import { PLATFORM_LOGOS } from "../Logo/Logo"
-const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = {
- key: "fake-key",
+const IMG_CONFIG: LearningResourceExpandedV1Props["imgConfig"] = {
width: 385,
height: 200,
}
@@ -22,7 +21,7 @@ const IMG_CONFIG: LearningResourceExpandedProps["imgConfig"] = {
const setup = (resource: LearningResource) => {
return render(
-
+
,
{ wrapper: ThemeProvider },
)
@@ -151,7 +150,7 @@ describe("Learning Resource Expanded", () => {
.find((img) => img.getAttribute("alt")?.includes("xPRO"))
expect(xproImage).toBeInTheDocument()
- expect(xproImage).toHaveAttribute("alt", PLATFORMS["xpro"].name)
+ expect(xproImage).toHaveAttribute("alt", PLATFORM_LOGOS["xpro"].name)
},
)
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx
similarity index 88%
rename from frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx
rename to frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx
index fdd3b2802d..a0155829db 100644
--- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpanded.tsx
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx
@@ -2,26 +2,26 @@ import React, { useEffect, useState } from "react"
import styled from "@emotion/styled"
import Skeleton from "@mui/material/Skeleton"
import Typography from "@mui/material/Typography"
+import { default as NextImage } from "next/image"
import { ButtonLink } from "../Button/Button"
import type { LearningResource, LearningResourceRun } from "api"
import { ResourceTypeEnum, PlatformEnum } from "api"
import {
formatDate,
capitalize,
- resourceThumbnailSrc,
DEFAULT_RESOURCE_IMG,
showStartAnytime,
} from "ol-utilities"
import { RiExternalLinkLine } from "@remixicon/react"
-import type { EmbedlyConfig } from "ol-utilities"
import { theme } from "../ThemeProvider/ThemeProvider"
import { SimpleSelect } from "../SimpleSelect/SimpleSelect"
import type { SimpleSelectProps } from "../SimpleSelect/SimpleSelect"
-import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard"
-import { PlatformLogo, PLATFORMS } from "../Logo/Logo"
-import InfoSection from "./InfoSection"
+import { PlatformLogo, PLATFORM_LOGOS } from "../Logo/Logo"
+import InfoSectionV1 from "./InfoSectionV1"
import type { User } from "api/hooks/user"
import { LearningResourceCardProps } from "../LearningResourceCard/LearningResourceCard"
+import type { ImageConfig } from "../../constants/imgConfigs"
+import VideoFrame from "./VideoFrame"
const Container = styled.div<{ padTop?: boolean }>`
display: flex;
@@ -76,8 +76,13 @@ const DateLabel = styled.span`
margin-right: 16px;
`
-const Image = styled.img<{ aspect: number }>`
- aspect-ratio: ${({ aspect }) => aspect};
+const ImageContainer = styled.div<{ aspect: number }>`
+ position: relative;
+ width: 100%;
+ padding-bottom: ${({ aspect }) => 100 / aspect}%;
+`
+
+const Image = styled(NextImage)`
border-radius: 8px;
width: 100%;
object-fit: cover;
@@ -138,41 +143,42 @@ const OnPlatform = styled.span`
color: ${theme.custom.colors.black};
`
-type LearningResourceExpandedProps = {
+type LearningResourceExpandedV1Props = {
resource?: LearningResource
user?: User
- imgConfig: EmbedlyConfig
+ imgConfig: ImageConfig
onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"]
onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"]
}
const ImageSection: React.FC<{
resource?: LearningResource
- config: EmbedlyConfig
+ config: ImageConfig
}> = ({ resource, config }) => {
+ const aspect = config.width / config.height
if (resource?.resource_type === "video" && resource?.url) {
return (
-
+
)
} else if (resource?.image) {
return (
-
+
+
+
)
} else if (resource) {
return (
-
+
+
+
)
} else {
return (
@@ -218,7 +224,7 @@ const CallToActionSection = ({
(offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro
? (offeredBy?.code as PlatformEnum)
: (platform?.code as PlatformEnum)
- const platformImage = PLATFORMS[platformCode]?.image
+ const platformImage = PLATFORM_LOGOS[platformCode]?.image
const getCallToActionText = (resource: LearningResource): string => {
if (resource?.platform?.code === PlatformEnum.Ocw) {
@@ -319,7 +325,7 @@ const formatRunDate = (
return null
}
-const LearningResourceExpanded: React.FC = ({
+const LearningResourceExpandedV1: React.FC = ({
resource,
user,
imgConfig,
@@ -421,7 +427,7 @@ const LearningResourceExpanded: React.FC = ({
- = ({
)
}
-export { LearningResourceExpanded }
-export type { LearningResourceExpandedProps }
+export { LearningResourceExpandedV1 }
+export type { LearningResourceExpandedV1Props }
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx
new file mode 100644
index 0000000000..0f242e754e
--- /dev/null
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx
@@ -0,0 +1,267 @@
+import React from "react"
+import { BrowserRouter } from "react-router-dom"
+import { render, screen, within } from "@testing-library/react"
+
+import {
+ getCallToActionText,
+ LearningResourceExpandedV2,
+} from "./LearningResourceExpandedV2"
+import type { LearningResourceExpandedV2Props } from "./LearningResourceExpandedV2"
+import { ResourceTypeEnum, PodcastEpisodeResource } from "api"
+import { factories } from "api/test-utils"
+import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
+import invariant from "tiny-invariant"
+import type { LearningResource } from "api"
+import { PLATFORM_LOGOS } from "../Logo/Logo"
+import _ from "lodash"
+
+const IMG_CONFIG: LearningResourceExpandedV2Props["imgConfig"] = {
+ width: 385,
+ height: 200,
+}
+
+// This is a pipe followed by a zero-width space
+const SEPARATOR = "|"
+
+const setup = (resource: LearningResource) => {
+ return render(
+
+
+ ,
+ { wrapper: ThemeProvider },
+ )
+}
+
+describe("Learning Resource Expanded", () => {
+ const RESOURCE_TYPES = Object.values(ResourceTypeEnum)
+ const isVideo = (resourceType: ResourceTypeEnum) =>
+ resourceType === ResourceTypeEnum.Video ||
+ resourceType === ResourceTypeEnum.VideoPlaylist
+
+ test.each(RESOURCE_TYPES.filter((type) => !isVideo(type)))(
+ 'Renders image and title for resource type "%s"',
+ (resourceType) => {
+ const resource = factories.learningResources.resource({
+ resource_type: resourceType,
+ })
+
+ setup(resource)
+
+ const images = screen.getAllByRole("img")
+ const image = images.find((img) =>
+ img
+ .getAttribute("src")
+ ?.includes(encodeURIComponent(resource.image?.url ?? "")),
+ )
+ expect(image).toBeInTheDocument()
+ invariant(image)
+ expect(image).toHaveAttribute("alt", resource.image?.alt ?? "")
+
+ screen.getByText(resource.title)
+
+ const linkName = getCallToActionText(resource)
+
+ const url =
+ resource.resource_type === ResourceTypeEnum.PodcastEpisode
+ ? (resource as PodcastEpisodeResource).podcast_episode?.episode_link
+ : resource.url
+ if (linkName) {
+ const link = screen.getByRole("link", {
+ name: linkName,
+ }) as HTMLAnchorElement
+ expect(link.target).toBe("_blank")
+ expect(link.href).toMatch(new RegExp(`^${url}/?$`))
+ }
+ },
+ )
+
+ test(`Renders card and title for resource type "${ResourceTypeEnum.Video}"`, () => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Video,
+ })
+
+ setup(resource)
+
+ const embedlyCard = screen.getByTestId("embedly-card")
+ invariant(embedlyCard)
+ expect(embedlyCard).toHaveAttribute("href", resource.url)
+
+ screen.getByText(resource.title)
+ })
+
+ test.each([ResourceTypeEnum.Program, ResourceTypeEnum.LearningPath])(
+ 'Renders CTA button for resource type "%s"',
+ (resourceType) => {
+ const resource = factories.learningResources.resource({
+ resource_type: resourceType,
+ })
+
+ setup(resource)
+
+ const linkName = "Learn More"
+ if (linkName) {
+ const link = screen.getByRole("link", {
+ name: linkName,
+ }) as HTMLAnchorElement
+
+ expect(link.href).toMatch(new RegExp(`^${resource.url}/?$`))
+ }
+ },
+ )
+
+ test.each([ResourceTypeEnum.PodcastEpisode])(
+ 'Renders CTA button for resource type "%s"',
+ (resourceType) => {
+ const resource = factories.learningResources.resource({
+ resource_type: resourceType,
+ podcast_episode: {
+ episode_link: "https://example.com",
+ },
+ })
+
+ setup(resource)
+
+ const link = screen.getByRole("link", {
+ name: "Listen to Podcast",
+ }) as HTMLAnchorElement
+
+ expect(link.href).toMatch(
+ new RegExp(
+ `^${(resource as PodcastEpisodeResource).podcast_episode?.episode_link}/?$`,
+ ),
+ )
+ },
+ )
+
+ test.each([ResourceTypeEnum.PodcastEpisode])(
+ "Renders xpro logo conditionally on offered_by=xpro and not platform.code",
+ (resourceType) => {
+ const resource = factories.learningResources.resource({
+ resource_type: resourceType,
+ platform: { code: "test" },
+ offered_by: { code: "xpro" },
+ podcast_episode: {
+ episode_link: "https://example.com",
+ },
+ })
+
+ setup(resource)
+ const xproImage = screen
+ .getAllByRole("img")
+ .find((img) => img.getAttribute("alt")?.includes("xPRO"))
+
+ expect(xproImage).toBeInTheDocument()
+ expect(xproImage).toHaveAttribute("alt", PLATFORM_LOGOS["xpro"].name)
+ },
+ )
+
+ test(`Renders info section for resource type "${ResourceTypeEnum.Video}"`, () => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Video,
+ })
+
+ setup(resource)
+
+ const run = resource.runs![0]
+
+ if (run) {
+ const section = screen
+ .getByRole("heading", { name: "Info" })!
+ .closest("section")!
+
+ const price = run.resource_prices?.[0]
+
+ const displayPrice =
+ parseFloat(price.amount) === 0
+ ? "Free"
+ : price.amount
+ ? `$${price.amount}`
+ : null
+ if (displayPrice) {
+ within(section).getByText(displayPrice)
+ }
+
+ const level = run.level?.[0]
+ if (level) {
+ within(section).getByText(level.name)
+ }
+
+ const instructors = run.instructors
+ ?.filter((instructor) => instructor.full_name)
+ .map(({ full_name: name }) => name)
+ if (instructors?.length) {
+ within(section!).getByText(instructors.join(", "))
+ }
+ }
+ })
+
+ test("Renders info section topics correctly", () => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ topics: [
+ factories.learningResources.topic({ name: "Topic 1" }),
+ factories.learningResources.topic({ name: "Topic 2" }),
+ factories.learningResources.topic({ name: "Topic 3" }),
+ ],
+ })
+
+ setup(resource)
+
+ const section = screen.getByTestId("drawer-info-items")
+
+ within(section).getByText((_content, node) => {
+ return (
+ node?.textContent ===
+ ["Topic 1", "Topic 2", "Topic 3"].join(SEPARATOR) || false
+ )
+ })
+ })
+
+ test("Renders info section languages correctly", () => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Course,
+ runs: [
+ factories.learningResources.run({
+ languages: ["en-us", "es-es", "fr-fr"],
+ }),
+ ],
+ })
+
+ setup(resource)
+
+ const section = screen.getByTestId("drawer-info-items")
+
+ within(section).getByText((_content, node) => {
+ return (
+ node?.textContent ===
+ ["English", "Spanish", "French"].join(SEPARATOR) || false
+ )
+ })
+ })
+
+ test("Renders info section video duration correctly", () => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.Video,
+ video: { duration: "PT1H13M44S" },
+ })
+
+ setup(resource)
+
+ const section = screen.getByTestId("drawer-info-items")
+
+ within(section).getByText("1:13:44")
+ })
+
+ test("Renders info section podcast episode duration correctly", () => {
+ const resource = factories.learningResources.resource({
+ resource_type: ResourceTypeEnum.PodcastEpisode,
+ podcast_episode: { duration: "PT13M44S" },
+ })
+
+ setup(resource)
+
+ const section = screen.getByTestId("drawer-info-items")
+
+ within(section).getByText("13:44")
+ })
+})
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx
new file mode 100644
index 0000000000..d273f2f9cb
--- /dev/null
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx
@@ -0,0 +1,460 @@
+import React from "react"
+import styled from "@emotion/styled"
+import Skeleton from "@mui/material/Skeleton"
+import Typography from "@mui/material/Typography"
+import { default as NextImage } from "next/image"
+import { ActionButton, ButtonLink } from "../Button/Button"
+import type { LearningResource } from "api"
+import { ResourceTypeEnum, PlatformEnum } from "api"
+import { DEFAULT_RESOURCE_IMG, getReadableResourceType } from "ol-utilities"
+import {
+ RiBookmarkLine,
+ RiCloseLargeLine,
+ RiExternalLinkLine,
+ RiMenuAddLine,
+} from "@remixicon/react"
+import type { ImageConfig } from "../../constants/imgConfigs"
+import { theme } from "../ThemeProvider/ThemeProvider"
+import { PlatformLogo, PLATFORM_LOGOS } from "../Logo/Logo"
+import InfoSectionV2 from "./InfoSectionV2"
+import type { User } from "api/hooks/user"
+import { LearningResourceCardProps } from "../LearningResourceCard/LearningResourceCard"
+import { CardActionButton } from "../LearningResourceCard/LearningResourceListCard"
+import VideoFrame from "./VideoFrame"
+
+const Container = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ padding: "0 32px 160px",
+ width: "900px",
+ [theme.breakpoints.down("md")]: {
+ width: "auto",
+ padding: "0 16px 160px",
+ },
+})
+
+const TitleSectionContainer = styled.div({
+ display: "flex",
+ position: "sticky",
+ justifyContent: "space-between",
+ top: "0",
+ padding: "24px 32px",
+ backgroundColor: theme.custom.colors.white,
+ [theme.breakpoints.down("md")]: {
+ padding: "24px 16px",
+ },
+})
+
+const ContentContainer = styled.div({
+ display: "flex",
+ alignItems: "flex-start",
+ gap: "32px",
+ alignSelf: "stretch",
+ [theme.breakpoints.down("md")]: {
+ alignItems: "center",
+ flexDirection: "column-reverse",
+ gap: "16px",
+ },
+})
+
+const LeftContainer = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ flexGrow: 1,
+ alignItems: "flex-start",
+ gap: "24px",
+ maxWidth: "100%",
+})
+
+const RightContainer = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "center",
+ alignItems: "flex-start",
+ gap: "24px",
+ [theme.breakpoints.down("md")]: {
+ width: "100%",
+ alignItems: "center",
+ },
+})
+
+const ImageContainer = styled.div<{ aspect: number }>`
+ position: relative;
+ width: 100%;
+ padding-bottom: ${({ aspect }) => 100 / aspect}%;
+`
+
+const Image = styled(NextImage)({
+ borderRadius: "8px",
+ width: "100%",
+ objectFit: "cover",
+})
+
+const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({
+ borderRadius: "8px",
+ paddingBottom: `${100 / aspect.aspect}%`,
+}))
+
+const CallToAction = styled.div({
+ display: "flex",
+ width: "350px",
+ padding: "16px",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "10px",
+ borderRadius: "8px",
+ border: `1px solid ${theme.custom.colors.lightGray2}`,
+ boxShadow: "0px 2px 10px 0px rgba(37, 38, 43, 0.10)",
+ [theme.breakpoints.down("md")]: {
+ width: "100%",
+ padding: "0",
+ border: "none",
+ boxShadow: "none",
+ },
+})
+
+const PlatformContainer = styled.div({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: "16px",
+ alignSelf: "stretch",
+})
+
+const StyledLink = styled(ButtonLink)({
+ textAlign: "center",
+ width: "100%",
+ [theme.breakpoints.down("sm")]: {
+ marginTop: "10px",
+ marginBottom: "10px",
+ },
+})
+
+const Platform = styled.div({
+ display: "flex",
+ justifyContent: "flex-end",
+ alignItems: "center",
+ gap: "16px",
+})
+
+const Description = styled.p({
+ ...theme.typography.body2,
+ color: theme.custom.colors.black,
+ margin: 0,
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+})
+
+const StyledPlatformLogo = styled(PlatformLogo)({
+ height: "26px",
+ maxWidth: "180px",
+})
+
+const OnPlatform = styled.span({
+ ...theme.typography.body2,
+ color: theme.custom.colors.black,
+})
+
+const ListButtonContainer = styled.div({
+ display: "flex",
+ gap: "8px",
+ flexGrow: 1,
+ justifyContent: "flex-end",
+})
+
+type LearningResourceExpandedV2Props = {
+ resource?: LearningResource
+ user?: User
+ imgConfig: ImageConfig
+ onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"]
+ onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"]
+ closeDrawer?: () => void
+}
+
+const CloseButton = styled(ActionButton)(({ theme }) => ({
+ "&&&": {
+ flexShrink: 0,
+ backgroundColor: theme.custom.colors.lightGray2,
+ color: theme.custom.colors.black,
+ ["&:hover"]: {
+ backgroundColor: theme.custom.colors.red,
+ color: theme.custom.colors.white,
+ },
+ },
+}))
+
+const CloseIcon = styled(RiCloseLargeLine)`
+ &&& {
+ width: 18px;
+ height: 18px;
+ }
+`
+
+const TitleSection: React.FC<{
+ resource?: LearningResource
+ closeDrawer: () => void
+}> = ({ resource, closeDrawer }) => {
+ const closeButton = (
+ closeDrawer()}
+ aria-label="Close"
+ >
+
+
+ )
+ if (resource) {
+ return (
+
+
+
+ {getReadableResourceType(resource?.resource_type)}
+
+
+ {resource?.title}
+
+
+ {closeButton}
+
+ )
+ } else {
+ return (
+
+
+
+ {closeButton}
+
+ )
+ }
+}
+
+const ImageSection: React.FC<{
+ resource?: LearningResource
+ config: ImageConfig
+}> = ({ resource, config }) => {
+ const aspect = config.width / config.height
+ if (resource?.resource_type === "video" && resource?.url) {
+ return (
+
+ )
+ } else if (resource?.image) {
+ return (
+
+
+
+ )
+ } else if (resource) {
+ return (
+
+
+
+ )
+ } else {
+ return (
+
+ )
+ }
+}
+
+const getCallToActionUrl = (resource: LearningResource) => {
+ switch (resource.resource_type) {
+ case ResourceTypeEnum.PodcastEpisode:
+ return resource.podcast_episode?.episode_link
+ default:
+ return resource.url
+ }
+}
+
+const getCallToActionText = (resource: LearningResource): string => {
+ const accessCourseMaterials = "Access Course Materials"
+ const watchOnYouTube = "Watch on YouTube"
+ const listenToPodcast = "Listen to Podcast"
+ const learnMore = "Learn More"
+ const callsToAction = {
+ [ResourceTypeEnum.Course]: learnMore,
+ [ResourceTypeEnum.Program]: learnMore,
+ [ResourceTypeEnum.LearningPath]: learnMore,
+ [ResourceTypeEnum.Video]: watchOnYouTube,
+ [ResourceTypeEnum.VideoPlaylist]: watchOnYouTube,
+ [ResourceTypeEnum.Podcast]: listenToPodcast,
+ [ResourceTypeEnum.PodcastEpisode]: listenToPodcast,
+ }
+ if (
+ resource?.resource_type === ResourceTypeEnum.Video ||
+ resource?.resource_type === ResourceTypeEnum.VideoPlaylist
+ ) {
+ // Video resources should always show "Watch on YouTube" as the CTA
+ return watchOnYouTube
+ } else {
+ if (resource?.platform?.code === PlatformEnum.Ocw) {
+ // Non-video OCW resources should show "Access Course Materials" as the CTA
+ return accessCourseMaterials
+ } else {
+ // Return the default CTA for the resource type
+ return callsToAction[resource?.resource_type] || learnMore
+ }
+ }
+}
+
+const CallToActionSection = ({
+ imgConfig,
+ resource,
+ hide,
+ user,
+ onAddToLearningPathClick,
+ onAddToUserListClick,
+}: {
+ imgConfig: ImageConfig
+ resource?: LearningResource
+ hide?: boolean
+ user?: User
+ onAddToLearningPathClick?: LearningResourceCardProps["onAddToLearningPathClick"]
+ onAddToUserListClick?: LearningResourceCardProps["onAddToUserListClick"]
+}) => {
+ if (hide) {
+ return null
+ }
+
+ if (!resource) {
+ return (
+
+
+
+
+ )
+ }
+ const inUserList = !!resource?.user_list_parents?.length
+ const inLearningPath = !!resource?.learning_path_parents?.length
+ const { platform } = resource!
+ const offeredBy = resource?.offered_by
+ const platformCode =
+ (offeredBy?.code as PlatformEnum) === PlatformEnum.Xpro
+ ? (offeredBy?.code as PlatformEnum)
+ : (platform?.code as PlatformEnum)
+ const platformImage = PLATFORM_LOGOS[platformCode]?.image
+ const cta = getCallToActionText(resource)
+ return (
+
+
+ }
+ href={getCallToActionUrl(resource) || ""}
+ >
+ {cta}
+
+
+ {platformImage ? (
+
+ on
+
+
+ ) : null}
+
+ {user?.is_learning_path_editor && (
+
+ onAddToLearningPathClick
+ ? onAddToLearningPathClick(event, resource.id)
+ : null
+ }
+ >
+
+
+ )}
+ onAddToUserListClick?.(event, resource.id)
+ : undefined
+ }
+ >
+
+
+
+
+
+ )
+}
+
+const ResourceDescription = ({ resource }: { resource?: LearningResource }) => {
+ if (!resource) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+ }
+ return (
+
+ )
+}
+
+const LearningResourceExpandedV2: React.FC = ({
+ resource,
+ imgConfig,
+ user,
+ onAddToLearningPathClick,
+ onAddToUserListClick,
+ closeDrawer,
+}) => {
+ return (
+ <>
+ {})}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export { LearningResourceExpandedV2, getCallToActionText }
+export type { LearningResourceExpandedV2Props }
diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx
new file mode 100644
index 0000000000..3f76c24609
--- /dev/null
+++ b/frontends/ol-components/src/components/LearningResourceExpanded/VideoFrame.tsx
@@ -0,0 +1,32 @@
+import React from "react"
+import styled from "@emotion/styled"
+import { EmbedlyCard } from "../EmbedlyCard/EmbedlyCard"
+
+const IFrame = styled.iframe`
+ border-radius: 8px;
+ border: none;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+`
+
+const VideoFrame: React.FC<{
+ src: string
+ title: string
+ aspect: number
+}> = ({ src, title, aspect }) => {
+ if (src?.startsWith("https://www.youtube.com/watch?v=")) {
+ const videoId = src?.split("v=")[1]
+ return (
+
+ )
+ }
+ return
+}
+
+export default VideoFrame
diff --git a/frontends/ol-components/src/components/Logo/Logo.stories.tsx b/frontends/ol-components/src/components/Logo/Logo.stories.tsx
index 7f210685a6..b08359741d 100644
--- a/frontends/ol-components/src/components/Logo/Logo.stories.tsx
+++ b/frontends/ol-components/src/components/Logo/Logo.stories.tsx
@@ -1,6 +1,6 @@
import React from "react"
import type { Meta, StoryObj } from "@storybook/react"
-import { PlatformLogo, PLATFORMS } from "./Logo"
+import { PlatformLogo, PLATFORM_LOGOS } from "./Logo"
import Grid from "@mui/material/Grid"
import styled from "@emotion/styled"
import { PlatformEnum } from "api"
@@ -27,7 +27,7 @@ const meta: Meta = {
iconHeight
args are only for this story. Not applicable to the actual component.
- {Object.entries(PLATFORMS).map(([platformCode, platform]) => (
+ {Object.entries(PLATFORM_LOGOS).map(([platformCode, platform]) => (
{platformCode}
diff --git a/frontends/ol-components/src/components/Logo/Logo.tsx b/frontends/ol-components/src/components/Logo/Logo.tsx
index 6524b292e3..5c9d8f52fc 100644
--- a/frontends/ol-components/src/components/Logo/Logo.tsx
+++ b/frontends/ol-components/src/components/Logo/Logo.tsx
@@ -1,5 +1,5 @@
import React from "react"
-import { PlatformEnum } from "api"
+import { PlatformEnum, OfferedByEnum } from "api"
import Image from "next/image"
type WithImage = {
@@ -13,50 +13,61 @@ type WithoutImage = {
image?: null
}
-type PlatformObject = WithImage | WithoutImage
+type LogoObject = WithImage | WithoutImage
-export const PLATFORMS: Record = {
- [PlatformEnum.Ocw]: {
- name: "MIT OpenCourseWare",
- image: "/unit_logos/ocw.svg",
- aspect: 6.03,
- },
- [PlatformEnum.Edx]: {
- name: "edX",
- image: "/platform_logos/edx.svg",
- aspect: 1.77,
- },
- [PlatformEnum.Mitxonline]: {
+export const UNIT_LOGOS: Record = {
+ [OfferedByEnum.Mitx]: {
name: "MITx Online",
- image: "/unit_logos/mitx.svg",
+ image: "/images/unit_logos/mitx.svg",
aspect: 3.32,
},
- [PlatformEnum.Bootcamps]: {
+ [OfferedByEnum.Ocw]: {
+ name: "MIT OpenCourseWare",
+ image: "/images/unit_logos/ocw.svg",
+ aspect: 6.03,
+ },
+ [OfferedByEnum.Bootcamps]: {
name: "Bootcamps",
- image: "/platform_logos/bootcamps.svg",
+ image: "/images/platform_logos/bootcamps.svg",
aspect: 5.25,
},
- [PlatformEnum.Xpro]: {
+ [OfferedByEnum.Xpro]: {
name: "MIT xPRO",
- image: "/unit_logos/xpro.svg",
+ image: "/images/unit_logos/xpro.svg",
aspect: 3.56,
},
+ [OfferedByEnum.Mitpe]: {
+ name: "MIT Professional Education",
+ image: "/images/unit_logos/mitpe.svg",
+ aspect: 5.23,
+ },
+ [OfferedByEnum.See]: {
+ name: "MIT Sloan Executive Education",
+ image: "/images/unit_logos/see.svg",
+ aspect: 7.61,
+ },
+}
+
+export const PLATFORM_LOGOS: Record = {
+ [PlatformEnum.Ocw]: UNIT_LOGOS[OfferedByEnum.Ocw],
+ [PlatformEnum.Edx]: {
+ name: "edX",
+ image: "/images/platform_logos/edx.svg",
+ aspect: 1.77,
+ },
+ [PlatformEnum.Mitxonline]: UNIT_LOGOS[OfferedByEnum.Mitx],
+ [PlatformEnum.Bootcamps]: UNIT_LOGOS[OfferedByEnum.Bootcamps],
+ [PlatformEnum.Xpro]: UNIT_LOGOS[OfferedByEnum.Xpro],
[PlatformEnum.Podcast]: {
name: "Podcast",
},
[PlatformEnum.Csail]: {
name: "CSAIL",
- image: "/platform_logos/csail.svg",
+ image: "/images/platform_logos/csail.svg",
aspect: 1.76,
},
- [PlatformEnum.Mitpe]: {
- name: "MIT Professional Education",
- },
- [PlatformEnum.See]: {
- name: "MIT Sloan Executive Education",
- image: "/unit_logos/see.svg",
- aspect: 7.73,
- },
+ [PlatformEnum.Mitpe]: UNIT_LOGOS[OfferedByEnum.Mitpe],
+ [PlatformEnum.See]: UNIT_LOGOS[OfferedByEnum.See],
[PlatformEnum.Scc]: {
name: "Schwarzman College of Computing",
},
@@ -80,7 +91,7 @@ export const PLATFORMS: Record = {
},
[PlatformEnum.Oll]: {
name: "Open Learning Library",
- image: "/platform_logos/oll.svg",
+ image: "/images/platform_logos/oll.svg",
aspect: 5.25,
},
[PlatformEnum.Youtube]: {
@@ -90,14 +101,15 @@ export const PLATFORMS: Record = {
const DEFAULT_WIDTH = 200
-export const PlatformLogo: React.FC<{
- platformCode?: PlatformEnum
+const Logo: React.FC<{
+ name: string
+ image: string
+ aspect: number
className?: string
width?: number
height?: number
-}> = ({ platformCode, className, width, height }) => {
- const platform = PLATFORMS[platformCode!]
- if (!platform?.image) {
+}> = ({ name, image, aspect, className, width, height }) => {
+ if (!image) {
return null
}
@@ -109,21 +121,63 @@ export const PlatformLogo: React.FC<{
* not actually applying - "Using `
` could result in slower LCP and higher bandwidth.".
*/
if (width && !height) {
- height = width / platform.aspect
+ height = width / aspect
}
if (!width && height) {
- width = height * platform.aspect
+ width = height * aspect
}
if (!width) {
width = DEFAULT_WIDTH
- height = width / platform.aspect
+ height = width / aspect
}
return (
+ )
+}
+
+export const UnitLogo: React.FC<{
+ unitCode: OfferedByEnum
+ className?: string
+ width?: number
+ height?: number
+}> = ({ unitCode, className, width, height }) => {
+ const unit = UNIT_LOGOS[unitCode]
+ if (!unit?.image) return null
+ const { name, image, aspect } = unit
+ return (
+
+ )
+}
+
+export const PlatformLogo: React.FC<{
+ platformCode: PlatformEnum
+ className?: string
+ width?: number
+ height?: number
+}> = ({ platformCode, className, width, height }) => {
+ const platform = PLATFORM_LOGOS[platformCode]
+ if (!platform?.image) return null
+ const { name, image, aspect } = platform
+ return (
+
diff --git a/frontends/ol-components/src/components/NavDrawer/NavDrawer.stories.tsx b/frontends/ol-components/src/components/NavDrawer/NavDrawer.stories.tsx
index 1421e4d897..366876f94f 100644
--- a/frontends/ol-components/src/components/NavDrawer/NavDrawer.stories.tsx
+++ b/frontends/ol-components/src/components/NavDrawer/NavDrawer.stories.tsx
@@ -1,29 +1,44 @@
-import React from "react"
+import React, { MouseEvent } from "react"
import type { Meta, StoryObj } from "@storybook/react"
import { NavData, NavDrawer } from "./NavDrawer"
import MuiButton from "@mui/material/Button"
import styled from "@emotion/styled"
+import { RiPencilRulerLine } from "@remixicon/react"
import { useToggle } from "ol-utilities"
const NavDrawerDemo = () => {
const [open, setOpen] = useToggle(false)
- const handleClickOpen = () => setOpen(!open)
+ const handleClickOpen = (event: MouseEvent) => {
+ setOpen(true)
+ event.stopPropagation()
+ }
const navData: NavData = {
sections: [
{
- title: "TEST",
+ title: "Nav Drawer Title",
items: [
{
- title: "Link and description",
- description: "This item has a link and a description",
+ title: "Link with description",
+ description: "This link has a description",
href: "https://mit.edu",
},
{
- title: "Link but no description",
+ title: "Link with no description",
href: "https://ocw.mit.edu",
},
+ {
+ title: "Link with icon",
+ icon: ,
+ href: "https://mit.edu",
+ },
+ {
+ title: "Link with icon and description",
+ description: "This link has an icon and a description",
+ icon: ,
+ href: "https://mit.edu",
+ },
],
},
],
@@ -39,7 +54,7 @@ const NavDrawerDemo = () => {
Toggle drawer
-
+
)
}
diff --git a/frontends/ol-components/src/components/NavDrawer/NavDrawer.test.tsx b/frontends/ol-components/src/components/NavDrawer/NavDrawer.test.tsx
index 3e20d8a100..09f771f012 100644
--- a/frontends/ol-components/src/components/NavDrawer/NavDrawer.test.tsx
+++ b/frontends/ol-components/src/components/NavDrawer/NavDrawer.test.tsx
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"
import user from "@testing-library/user-event"
import React from "react"
import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
+import Image from "next/image"
describe("NavDrawer", () => {
it("Renders the expected drawer contents", () => {
@@ -13,7 +14,15 @@ describe("NavDrawer", () => {
items: [
{
title: "Link and description with icon",
- icon: "/path/to/image.svg",
+ icon: (
+