Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Release Notes
=============

Version 0.67.2
--------------

- feat: display MicroMasters® label on MITxOnline program product pages (#3318)
- fix: add sitemap for video and podcast page (#3323)
- Show (and count) only published items in userlists/paths (#3321)

Version 0.67.1 (Released May 11, 2026)
--------------

Expand Down
2 changes: 1 addition & 1 deletion frontends/api/src/generated/v0/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions frontends/api/src/generated/v1/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 21 additions & 7 deletions frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Typography,
HEADER_HEIGHT,
Grid2,
Chip,
} from "ol-components"
import { DEFAULT_RESOURCE_IMG, useImageWithFallback } from "ol-utilities"
import { convertToEmbedUrl, hexToRgba } from "@/common/utils"
Expand Down Expand Up @@ -271,6 +272,7 @@ export type ResourceInfo = {

type ProductPageTemplateProps = {
currentBreadcrumbLabel: string
label?: string
title: string
shortDescription: React.ReactNode
imageSrc: string
Expand All @@ -284,6 +286,7 @@ type ProductPageTemplateProps = {
)
const ProductPageTemplate: React.FC<ProductPageTemplateProps> = ({
currentBreadcrumbLabel,
label,
title,
shortDescription,
imageSrc,
Expand Down Expand Up @@ -342,13 +345,24 @@ const ProductPageTemplate: React.FC<ProductPageTemplateProps> = ({
title={title}
/>
</SidebarCol>
<Typography
component="h1"
typography={{ xs: "h4", sm: "h4", md: "h3" }}
style={{ lineHeight: "2.25rem" }}
>
{title}
</Typography>
<Stack alignItems="flex-start" gap="16px">
{label ? (
<Chip
label={label}
data-testid="product-page-label"
variant="outlinedWhite"
size="large"
sx={{ typography: "subtitle2" }}
/>
) : null}
<Typography
component="h1"
typography={{ xs: "h4", sm: "h4", md: "h3" }}
style={{ lineHeight: "2.25rem" }}
>
{title}
</Typography>
</Stack>
<ShortDescription>{shortDescription}</ShortDescription>
<Stack
direction={{ xs: "column", md: "row" }}
Expand Down
42 changes: 42 additions & 0 deletions frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,48 @@ describe("ProgramPage", () => {
delete process.env.NEXT_PUBLIC_POSTHOG_API_KEY
})

describe("Program type label", () => {
test.each(["MicroMasters®", "MicroMasters"])(
"Shows 'MicroMasters®' label when program_type is '%s'",
async (programType) => {
const program = makeProgram({
...makeReqs(),
program_type: programType,
})
const page = makePage({ program_details: program })
setupApis({ program, page })
renderWithProviders(<ProgramPage readableId={program.readable_id} />)

await screen.findByRole("heading", { name: page.title })
const programTypeLabel = screen.getByTestId("product-page-label")
expect(programTypeLabel).toBeInTheDocument()
expect(
within(programTypeLabel).getByText("MicroMasters®"),
).toBeInTheDocument()
},
)

test("Shows no label for unbranded types like 'Series'", async () => {
const program = makeProgram({ ...makeReqs(), program_type: "Series" })
const page = makePage({ program_details: program })
setupApis({ program, page })
renderWithProviders(<ProgramPage readableId={program.readable_id} />)

await screen.findByRole("heading", { name: page.title })
expect(screen.queryByTestId("product-page-label")).not.toBeInTheDocument()
})

test("Shows no label when program_type is null", async () => {
const program = makeProgram({ ...makeReqs(), program_type: null })
const page = makePage({ program_details: program })
setupApis({ program, page })
renderWithProviders(<ProgramPage readableId={program.readable_id} />)

await screen.findByRole("heading", { name: page.title })
expect(screen.queryByTestId("product-page-label")).not.toBeInTheDocument()
})
})

describe("Stay Updated button", () => {
useStayUpdatedEnv()

Expand Down
13 changes: 13 additions & 0 deletions frontends/main/src/app-pages/ProductPages/ProgramPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ const RequirementsSection: React.FC<RequirementsSectionProps> = ({
)
}

const PROGRAM_TYPE_DISPLAY: Record<string, string> = {
"MicroMasters®": "MicroMasters®",
MicroMasters: "MicroMasters®",
}

const formatProgramTypeLabel = (
programType: string | null | undefined,
): string | undefined => {
if (!programType) return undefined
return PROGRAM_TYPE_DISPLAY[programType]
}

const ProgramPage: React.FC<ProgramPageProps> = ({ readableId }) => {
const pages = useQuery(pagesQueries.programPages(readableId))
const programs = useQuery(
Expand Down Expand Up @@ -272,6 +284,7 @@ const ProgramPage: React.FC<ProgramPageProps> = ({ readableId }) => {
return (
<ProductPageTemplate
currentBreadcrumbLabel="Program"
label={formatProgramTypeLabel(program.program_type)}
title={page.title}
shortDescription={
<DescriptionHTML
Expand Down
110 changes: 110 additions & 0 deletions frontends/main/src/app/sitemaps/podcast/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { faker } from "@faker-js/faker/locale/en"
import { generateSitemaps, default as sitemap } from "./sitemap"
import { setMockResponse, urls, factories } from "api/test-utils"
import { ResourceTypeEnum } from "api"

const RESOURCE_TYPES = [
ResourceTypeEnum.Podcast,
ResourceTypeEnum.PodcastEpisode,
]

describe("Podcast Sitemaps", () => {
it("returns expected sitemap params", async () => {
const pages = faker.number.int({ min: 4, max: 6 })
const summaries = factories.learningResources.resourceSummaries({
count: pages * 1_000 - 350,
pageSize: 1,
})

setMockResponse.get(
urls.learningResources.summaryList({
limit: 1,
resource_type: RESOURCE_TYPES,
}),
summaries,
)

const result = await generateSitemaps()
expect(result).toHaveLength(pages)
expect(result).toEqual(
new Array(pages).fill(null).map((_, index) => ({
id: index,
location: `http://test.learn.odl.local:8062/sitemaps/podcast/sitemap/${index}.xml`,
})),
)
})

it("generates expected URLs for podcast resources", async () => {
const page = faker.number.int({ min: 5, max: 10 })
const podcastList = factories.learningResources.podcasts({
count: 3,
pageSize: 3,
})

setMockResponse.get(
urls.learningResources.list({
limit: 1_000,
offset: page * 1_000,
resource_type: RESOURCE_TYPES,
}),
podcastList,
)

const sitemapPage = await sitemap({ id: String(page) })
expect(sitemapPage).toEqual(
podcastList.results.map((resource) => ({
url: `http://test.learn.odl.local:8062/podcast/${resource.id}`,
lastModified: resource.last_modified ?? undefined,
})),
)
})

it("generates expected URLs for podcast episode resources", async () => {
// Use a non-overlapping range from the podcast test (which uses { min: 5, max: 10 })
// to avoid query cache collisions on the same list URL.
const page = faker.number.int({ min: 11, max: 20 })
const podcastId1 = String(faker.number.int())
const podcastId2 = String(faker.number.int())
const episodeWithMultipleParents =
factories.learningResources.podcastEpisode({
podcast_episode: { podcasts: [podcastId1, podcastId2] },
})
const episodeWithOneParent = factories.learningResources.podcastEpisode({
podcast_episode: { podcasts: [podcastId1] },
})
const episodeWithoutParent = factories.learningResources.podcastEpisode({
podcast_episode: { podcasts: [] },
})
const results = [
episodeWithMultipleParents,
episodeWithOneParent,
episodeWithoutParent,
]

setMockResponse.get(
urls.learningResources.list({
limit: 1_000,
offset: page * 1_000,
resource_type: RESOURCE_TYPES,
}),
{ count: results.length, next: null, previous: null, results },
)

const sitemapPage = await sitemap({ id: String(page) })
// episodeWithoutParent should be excluded; episodeWithMultipleParents emits one entry per parent
expect(sitemapPage).toEqual([
{
url: `http://test.learn.odl.local:8062/podcast/${podcastId1}/podcast_episode/${episodeWithMultipleParents.id}`,
lastModified: episodeWithMultipleParents.last_modified ?? undefined,
},
{
url: `http://test.learn.odl.local:8062/podcast/${podcastId2}/podcast_episode/${episodeWithMultipleParents.id}`,
lastModified: episodeWithMultipleParents.last_modified ?? undefined,
},
{
url: `http://test.learn.odl.local:8062/podcast/${podcastId1}/podcast_episode/${episodeWithOneParent.id}`,
lastModified: episodeWithOneParent.last_modified ?? undefined,
},
])
})
})
81 changes: 81 additions & 0 deletions frontends/main/src/app/sitemaps/podcast/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { MetadataRoute } from "next"
import { getQueryClient } from "@/app/getQueryClient"
import { learningResourceQueries } from "api/hooks/learningResources"
import { ResourceTypeEnum } from "api"
import invariant from "tiny-invariant"
import type { GenerateSitemapResult } from "../types"
import { dangerouslyDetectProductionBuildPhase } from "../util"

const BASE_URL = process.env.NEXT_PUBLIC_ORIGIN
invariant(BASE_URL, "NEXT_PUBLIC_ORIGIN must be defined")

const PAGE_SIZE = 1_000

const RESOURCE_TYPES = [
ResourceTypeEnum.Podcast,
ResourceTypeEnum.PodcastEpisode,
]

/**
* As of NextJS 15.5.3, sitemaps are ALWAYS generated at build time, even with
* the force-dynamic below (this may be a NextJS bug?). However, the
* force-dynamic does force re-generation when requests are made in production.
*/
export const dynamic = "force-dynamic"

export async function generateSitemaps(): Promise<GenerateSitemapResult[]> {
/**
* NextJS runs this at build time (despite force-dynamic above).
* Early exit here to avoid the useless build-time API calls.
*/
if (dangerouslyDetectProductionBuildPhase()) return []

const queryClient = getQueryClient()
const { count } = await queryClient.fetchQuery(
learningResourceQueries.summaryList({
limit: 1,
resource_type: RESOURCE_TYPES,
}),
)

const pages = Math.ceil(count / PAGE_SIZE)

return new Array(pages).fill(null).map((_, index) => ({
id: index,
location: `${BASE_URL}/sitemaps/podcast/sitemap/${index}.xml`,
}))
}

export default async function sitemap({
id,
}: {
id: string
}): Promise<MetadataRoute.Sitemap> {
const queryClient = getQueryClient()
const data = await queryClient.fetchQuery(
learningResourceQueries.list({
limit: PAGE_SIZE,
offset: +id * PAGE_SIZE,
resource_type: RESOURCE_TYPES,
}),
)

return data.results.flatMap((resource) => {
if (resource.resource_type === ResourceTypeEnum.Podcast) {
return [
{
url: `${BASE_URL}/podcast/${resource.id}`,
lastModified: resource.last_modified ?? undefined,
},
]
}
if (resource.resource_type === ResourceTypeEnum.PodcastEpisode) {
const parentPodcastIds = resource.podcast_episode?.podcasts ?? []
return parentPodcastIds.map((parentPodcastId) => ({
url: `${BASE_URL}/podcast/${parentPodcastId}/podcast_episode/${resource.id}`,
lastModified: resource.last_modified ?? undefined,
}))
}
return []
})
}
4 changes: 4 additions & 0 deletions frontends/main/src/app/sitemaps/sitemap-index.xml/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import invariant from "tiny-invariant"
import * as resourceSitemap from "../resources/sitemap"
import * as channelsSitemap from "../channels/sitemap"
import * as productsSitemap from "../products/sitemap"
import * as videoSitemap from "../video/sitemap"
import * as podcastSitemap from "../podcast/sitemap"

invariant(process.env.NEXT_PUBLIC_ORIGIN, "NEXT_PUBLIC_ORIGIN must be defined")
const BASE_URL: string = process.env.NEXT_PUBLIC_ORIGIN
Expand All @@ -22,6 +24,8 @@ async function buildSitemapIndex(): Promise<string> {
resourceSitemap.generateSitemaps(),
channelsSitemap.generateSitemaps(),
productsSitemap.generateSitemaps(),
videoSitemap.generateSitemaps(),
podcastSitemap.generateSitemaps(),
])
const sitemapUrls = [
`${BASE_URL}/sitemaps/static/sitemap.xml`,
Expand Down
Loading
Loading