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
11 changes: 11 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
Release Notes
=============

Version 0.64.1
--------------

- feat: track Stay Updated button click with PostHog cta_clicked event (#3225)
- feat: implementing the podcast detail page (#3184)
- remove default slide transition from dialogs (#3229)
- Revert "Update dependency opensearch-py to v3 (#2764)" (#3230)
- Update nginx Docker tag to v1.29.7 (#2986)
- Update dependency onnxruntime to v1.24.4 (#2742)
- Update dependency opensearch-py to v3 (#2764)

Version 0.64.0 (Released April 21, 2026)
--------------

Expand Down
1 change: 1 addition & 0 deletions frontends/api/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,5 @@ export {
videoShortsApi,
videoPlaylistsApi,
vectorLearningResourcesSearchApi,
BASE_PATH,
}
14 changes: 14 additions & 0 deletions frontends/api/src/hooks/learningResources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
keepPreviousData,
useMutation,
useQuery,
useInfiniteQuery,
useQueryClient,
} from "@tanstack/react-query"
import { learningResourcesApi } from "../../clients"
Expand All @@ -14,6 +15,7 @@ import type {
FeaturedApiFeaturedListRequest as FeaturedListParams,
LearningResourcesApiLearningResourcesUserlistsPartialUpdateRequest,
LearningResourcesApiLearningResourcesLearningPathsPartialUpdateRequest,
LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest,
LearningResource,
} from "../../generated/v1"
// import learningResources from "./keyFactory"
Expand Down Expand Up @@ -201,6 +203,17 @@ const useVectorSimilarLearningResources = (
})
}

const useInfiniteLearningResourceItems = (
id: number,
params: Omit<ItemsListRequest, "offset">,
opts?: { enabled?: boolean },
) => {
return useInfiniteQuery({
...learningResourceQueries.infiniteItems(id, params),
...opts,
})
}

export {
useLearningResourcesList,
useFeaturedLearningResourcesList,
Expand All @@ -217,6 +230,7 @@ export {
useSchoolsList,
useSimilarLearningResources,
useVectorSimilarLearningResources,
useInfiniteLearningResourceItems,
learningResourceQueries,
offerorQueries,
schoolQueries,
Expand Down
39 changes: 38 additions & 1 deletion frontends/api/src/hooks/learningResources/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
featuredApi,
videoPlaylistsApi,
vectorLearningResourcesSearchApi,
BASE_PATH,
} from "../../clients"

import type {
Expand All @@ -19,10 +20,12 @@ import type {
FeaturedApiFeaturedListRequest as FeaturedListParams,
LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest,
LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest,
PaginatedLearningResourceRelationshipList,
VideoPlaylistResource,
} from "../../generated/v1"
import type { VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieveRequest as VectorLearningResourcesSearchRetrieveRequest } from "../../generated/v0"
import { queryOptions } from "@tanstack/react-query"
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"
import axiosInstance from "../../axios"
import { hasPosition, randomizeGroups } from "./util"

const timedPromise = async <T>(
Expand Down Expand Up @@ -60,6 +63,14 @@ const learningResourceKeys = {
...learningResourceKeys.itemsRoot(id),
params,
],
infiniteItemsRoot: (id: number) => [
...learningResourceKeys.detail(id),
"infiniteItems",
],
infiniteItems: (id: number, params: ItemsListRequest) => [
...learningResourceKeys.infiniteItemsRoot(id),
params,
],
// featured
featuredRoot: () => [...learningResourceKeys.root, "featureds"],
featured: (params: FeaturedListParams) => [
Expand Down Expand Up @@ -129,6 +140,32 @@ const learningResourceQueries = {
.then((res) => res.data.results.map((rel) => rel.resource))
},
}),
infiniteItems: (id: number, params: Omit<ItemsListRequest, "offset">) =>
infiniteQueryOptions({
queryKey: learningResourceKeys.infiniteItems(
id,
params as ItemsListRequest,
),
queryFn: async ({ pageParam }) => {
// We need to investigate why pageParam is always null and that make
Comment on lines +143 to +150
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The infinite query for podcast episodes is broken because pageParam is always null, causing only the first page of results to be fetched repeatedly.
Severity: HIGH

Suggested Fix

Investigate why pageParam is always null in the infiniteItems query. The getNextPageParam function within the useInfiniteQuery options should be corrected to properly extract the URL for the next page from the API response's next field. Ensure this value is returned so subsequent fetches can use it.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: frontends/api/src/hooks/learningResources/queries.ts#L143-L150

Potential issue: The infinite query for podcast episodes is not working correctly due to
an issue where the `pageParam` is always `null`. A developer comment in the code
confirms this behavior, which prevents the logic for fetching subsequent pages from
being executed. As a result, any action intended to load more items, such as a user
clicking a "Load more episodes" button that calls `fetchNextPage()`, will only re-fetch
the first page of results. This effectively breaks the pagination functionality for the
podcast episode list.

Did we get this right? 👍 / 👎 to inform future reviews.

// infinite query not working properly also the api call has port
// being add into the url for RC and PROD.
// https://github.com/mitodl/hq/issues/10999
const request = pageParam
? axiosInstance.request<PaginatedLearningResourceRelationshipList>({
method: "get",
url:
BASE_PATH +
new URL(pageParam, "https://x").pathname +
new URL(pageParam, "https://x").search,
})
: learningResourcesApi.learningResourcesItemsList(params)
const { data } = await request
return data
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.next ?? undefined,
}),
similar: (id: number) =>
queryOptions({
queryKey: learningResourceKeys.similar(id),
Expand Down
14 changes: 14 additions & 0 deletions frontends/main/src/app-pages/PodcastPage/PodcastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Container, styled } from "ol-components"

const PodcastContainer = styled(Container)(({ theme }) => ({
maxWidth: "1080px !important",
padding: "0 !important",
[theme.breakpoints.down("md")]: {
padding: "0 16px !important",
},
[theme.breakpoints.down("sm")]: {
padding: "0 16px !important",
},
}))

export default PodcastContainer
217 changes: 217 additions & 0 deletions frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import React from "react"
import { factories, setMockResponse, urls } from "api/test-utils"
import { ResourceTypeEnum } from "api/v1"
import type { LearningResource, PodcastEpisodeResource } from "api/v1"
import { renderWithProviders, screen, user } from "@/test-utils"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded"
import { PodcastDetailPage } from "./PodcastDetailPage"

jest.mock("posthog-js/react")
jest.mock("@/common/useFeatureFlagsLoaded")

const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)
const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded)

jest.mock(
"@/page-components/LearningResourceDrawer/LearningResourceDrawer",
() => ({
__esModule: true,
default: jest.fn(() => null),
}),
)

jest.mock("./PodcastPlayer", () => ({
__esModule: true,
default: jest.fn(
({ track }: { track: { title: string; podcastName: string } }) => (
<div data-testid="podcast-player">
<span data-testid="player-track-title">{track.title}</span>
<span data-testid="player-podcast-name">{track.podcastName}</span>
</div>
),
),
}))

const EPISODES_PAGE_SIZE = 5

const makeItemsResponse = (
episodes: LearningResource[],
opts: { next?: string | null } = {},
) => ({
count: episodes.length,
next: opts.next ?? null,
previous: null,
results: episodes.map((resource, i) => ({
id: i + 1,
child: resource.id,
parent: 0,
position: i + 1,
resource,
})),
})

const makePodcastEpisodes = (count: number): PodcastEpisodeResource[] =>
Array.from({ length: count }, () =>
factories.learningResources.resource({
resource_type: ResourceTypeEnum.PodcastEpisode,
}),
) as PodcastEpisodeResource[]

const setupApis = ({
episodesPage1,
episodesPage2,
}: {
episodesPage1: LearningResource[]
episodesPage2?: LearningResource[]
}) => {
const podcast = factories.learningResources.resource({
resource_type: ResourceTypeEnum.Podcast,
})

setMockResponse.get(
urls.learningResources.details({ id: podcast.id }),
podcast,
)

// The code normalises the next URL to BASE_PATH + path, where BASE_PATH is ""
// in tests, so both the next value and the page-2 mock use the plain path.
const page2Path = episodesPage2
? `${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}&offset=${EPISODES_PAGE_SIZE}`
: null

setMockResponse.get(
`${urls.learningResources.items({ id: podcast.id })}?limit=${EPISODES_PAGE_SIZE}`,
makeItemsResponse(episodesPage1, { next: page2Path }),
)

if (episodesPage2 && page2Path) {
setMockResponse.get(page2Path, makeItemsResponse(episodesPage2))
}

return { podcast }
}

describe("PodcastDetailPage", () => {
beforeEach(() => {
mockedUseFeatureFlagEnabled.mockReturnValue(true)
mockedUseFeatureFlagsLoaded.mockReturnValue(true)
})

afterEach(() => {
jest.clearAllMocks()
})

test("renders initial episode list", async () => {
const episodes = makePodcastEpisodes(3)
const { podcast } = setupApis({ episodesPage1: episodes })

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

await screen.findByText(episodes[0].title!)
for (const episode of episodes) {
expect(screen.getByText(episode.title!)).toBeInTheDocument()
}
})

test("does not show 'Load more' when there is no next page", async () => {
const episodes = makePodcastEpisodes(3)
const { podcast } = setupApis({ episodesPage1: episodes })

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

await screen.findByText(episodes[0].title!)
expect(
screen.queryByRole("button", { name: /load more episodes/i }),
).not.toBeInTheDocument()
})

test("shows 'Load more' when API returns a next page URL", async () => {
const episodes = makePodcastEpisodes(EPISODES_PAGE_SIZE)
const { podcast } = setupApis({
episodesPage1: episodes,
episodesPage2: [],
})

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

await screen.findByText(episodes[0].title!)
expect(
screen.getByRole("button", { name: /load more episodes/i }),
).toBeInTheDocument()
})

test("loads next page when 'Load more' is clicked", async () => {
const page1 = makePodcastEpisodes(EPISODES_PAGE_SIZE)
const page2 = makePodcastEpisodes(2)
const { podcast } = setupApis({
episodesPage1: page1,
episodesPage2: page2,
})

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

await screen.findByText(page1[0].title!)
await user.click(
screen.getByRole("button", { name: /load more episodes/i }),
)

for (const episode of page2) {
await screen.findByText(episode.title!)
}

// No more pages — button should disappear
expect(
screen.queryByRole("button", { name: /load more episodes/i }),
).not.toBeInTheDocument()
})

test("clicking play renders the player with correct track and podcast name", async () => {
const episodes = makePodcastEpisodes(2)
const { podcast } = setupApis({ episodesPage1: episodes })

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

await screen.findByText(episodes[0].title!)
expect(screen.queryByTestId("podcast-player")).not.toBeInTheDocument()

await user.click(
screen.getByRole("button", { name: `Play ${episodes[0].title}` }),
)

expect(screen.getByTestId("podcast-player")).toBeInTheDocument()
expect(screen.getByTestId("player-track-title")).toHaveTextContent(
episodes[0].title!,
)
expect(screen.getByTestId("player-podcast-name")).toHaveTextContent(
podcast.title!,
)
})

test("shows 'No episodes found' when episode list is empty", async () => {
const { podcast } = setupApis({ episodesPage1: [] })

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

await screen.findByText(/no episodes found/i)
})

test("disables play button for episodes without audio source", async () => {
const [episodeWithoutAudio] = makePodcastEpisodes(1)
if (episodeWithoutAudio.podcast_episode) {
episodeWithoutAudio.podcast_episode.audio_url = ""
episodeWithoutAudio.podcast_episode.episode_link = ""
}

const { podcast } = setupApis({ episodesPage1: [episodeWithoutAudio] })

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

const playButton = await screen.findByRole("button", {
name: `Play ${episodeWithoutAudio.title}`,
})

expect(playButton).toBeDisabled()
expect(screen.queryByTestId("podcast-player")).not.toBeInTheDocument()
})
})
Loading
Loading