Skip to content
Open
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.77.2
--------------

- Fix flaky url-matching tests with unique factory urls (#3724)
- Add course code to learning resource embedding context (#3700)
- Sort learning resources by denormalized view_count instead of live Count() (#3711)
- refactor: V1 — Clean up and de-duplicate the VideoPlaylistCollectionPage folder (#3706)
- added a fallback to populate run readable ids for contentfiles withou… (#3719)
- Include facets from urls in channel pages (#3695)
- mitxonline course numbers (#3703)

Version 0.77.1 (Released August 05, 2026)
--------------

Expand Down
56 changes: 56 additions & 0 deletions frontends/main/src/app-pages/ChannelPage/ChannelSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,62 @@ describe("ChannelSearch", () => {
},
)

test("Shows and aggregates a facet that is in the URL but not shown by default", async () => {
const { channel } = setMockApiResponses({
channelPatch: { channel_type: ChannelTypeEnum.Unit },
search: {
count: 700,
metadata: {
aggregations: {
level: [{ key: "graduate", doc_count: 100 }],
},
suggestions: [],
},
},
})

renderWithProviders(<ChannelPage />, {
url: `/c/${channel.channel_type}/${channel.name}/?level=graduate`,
})

await waitFor(() => {
expect(makeRequest.mock.calls.length > 0).toBe(true)
})

// "level" is not a default facet for this channel type, but it is requested
// as an aggregation because it is present in the URL.
const apiSearchParams = getLastApiSearchParams()
expect(apiSearchParams.getAll("aggregations")).toContain("level")

// ...and it renders as a facet.
const facetsContainer = screen.getByTestId("facets-container")
await within(facetsContainer).findByText("Level")
})

test("Does not duplicate a facet already shown by default as an extra URL facet", async () => {
const { channel } = setMockApiResponses({
channelPatch: { channel_type: ChannelTypeEnum.Unit },
search: {
count: 700,
metadata: {
aggregations: {
topic: [{ key: "physics", doc_count: 100 }],
},
suggestions: [],
},
},
})

// "topic" is a default facet for Unit channels and is also present in the
// URL; it must appear exactly once.
renderWithProviders(<ChannelPage />, {
url: `/c/${channel.channel_type}/${channel.name}/?topic=physics`,
})

const facetsContainer = await screen.findByTestId("facets-container")
expect(await within(facetsContainer).findAllByText("Topic")).toHaveLength(1)
})

test("Submitting search text updates URL correctly", async () => {
const resources = factories.learningResources.resources({
count: 10,
Expand Down
16 changes: 14 additions & 2 deletions frontends/main/src/app-pages/ChannelPage/ChannelSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,20 @@ const ChannelSearch: React.FC<ChannelSearchProps> = ({

const { facetNames, facetManifest } = useMemo(
() =>
getFacets(channelType, offerors, constantSearchParams, resourceTypeGroup),
[offerors, channelType, constantSearchParams, resourceTypeGroup],
getFacets(
channelType,
offerors,
constantSearchParams,
resourceTypeGroup,
searchParams,
),
[
offerors,
channelType,
constantSearchParams,
resourceTypeGroup,
searchParams,
],
)

const setPage = useCallback(
Expand Down
24 changes: 22 additions & 2 deletions frontends/main/src/app-pages/ChannelPage/searchRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import { LearningResourceOfferor } from "api"
import { ChannelTypeEnum } from "api/v0"
import { getFacetManifest } from "@/page-components/SearchDisplay/getFacetManifest"
import { getExtraFacetNames } from "@/app-pages/SearchPage/searchRequests"

export const getConstantSearchParams = (searchFilter?: string) => {
const searchParams: Facets & BooleanFacets = {}
Expand Down Expand Up @@ -63,9 +64,16 @@ const getFacetManifestForChannelType = (
offerors: Record<string, LearningResourceOfferor>,
constantSearchParams: Facets,
resourceTypeGroup: string | null,
extraFacetNames: string[] = [],
): FacetManifest => {
const facets = FACETS_BY_CHANNEL_TYPE[channelType] || []
return getFacetManifest(offerors, resourceTypeGroup)
// Facets shown by default for this channel type plus any additional facets
// present in the URL. Facets hard-coded by the channel config
// (constantSearchParams) are filtered out below.
const facets = [
...(FACETS_BY_CHANNEL_TYPE[channelType] || []),
...extraFacetNames,
]
return getFacetManifest(offerors, resourceTypeGroup, extraFacetNames)
.filter(
(facetSetting) =>
!Object.keys(constantSearchParams).includes(facetSetting.name) &&
Expand All @@ -81,12 +89,24 @@ export const getFacets = (
offerors: Record<string, LearningResourceOfferor>,
constantSearchParams: Facets,
resourceTypeGroup: string | null,
searchParams?: URLSearchParams,
) => {
// Facets already surfaced by the channel type or pinned by the channel config
// should not be duplicated as extra facets from the URL.
const baseFacetNames = [
...(FACETS_BY_CHANNEL_TYPE[channelType] || []),
...Object.keys(constantSearchParams),
] as UseResourceSearchParamsProps["facets"]
const extraFacetNames = searchParams
? (getExtraFacetNames(searchParams, baseFacetNames) ?? [])
: []

const facetManifest = getFacetManifestForChannelType(
channelType,
offerors,
constantSearchParams,
resourceTypeGroup,
extraFacetNames,
)

const facetNames = Array.from(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import VideoContainer from "./VideoContainer"
import { RiPlayFill } from "@remixicon/react"
import { formatDurationClockTime } from "ol-utilities"
import type { VideoResource } from "api/v1"
import { DurationBadge } from "./shared.styled"

const PLACEHOLDER_IMG = "/images/mit-open-learning-logo.svg"

Expand Down Expand Up @@ -56,6 +57,8 @@ const ImageWrapper = styled(Link, {
}),
}))

// Distinct from the shared `PlayOverlay`: this one is always visible and scales
// on hover (see ImageWrapper above) rather than fading a scrim in.
const PlayOverlay = styled.div({
position: "absolute",
inset: 0,
Expand All @@ -76,18 +79,6 @@ const PlayCircle = styled.div({
justifyContent: "center",
})

const DurationBadge = styled.span(({ theme }) => ({
...theme.typography.body3,
position: "absolute",
bottom: 0,
right: 0,
backgroundColor: theme.custom.colors.darkGray2,
color: "#fff",
fontWeight: theme.typography.fontWeightMedium,
padding: "8px",
zIndex: 1,
}))

const TextSide = styled.div(({ theme }) => ({
[theme.breakpoints.down("sm")]: {
padding: "16px 0 0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from "react"
import Image from "next/image"
import { Skeleton } from "ol-components"
import { formatDurationClockTime } from "ol-utilities"
import type { VideoResource } from "api/v1"
import { videoDetailPageView, videoPlaylistPageView } from "@/common/urls"
import * as Styled from "./VideoDetailPage.styled"

type MoreFromPlaylistProps = {
playlistId: number
/** Display name of the playlist, used in headings and labels. */
playlistLabel: string
playlistTitle?: string
/** Sibling videos to list, already filtered and capped by the caller. */
videos: VideoResource[]
/** Total videos in the playlist, used to decide whether to link to the rest. */
totalVideos: number
isLoading: boolean
}

/** One row: thumbnail with duration badge and hover overlay, plus title/description. */
const MoreFromPlaylistItem: React.FC<{
video: VideoResource
playlistId: number
}> = ({ video, playlistId }) => {
const duration = video.video?.duration
? formatDurationClockTime(video.video.duration)
: null
const imageUrl = video.image?.url ?? null
const topicNames = (video.topics ?? [])
.map((topic) => topic.name)
.filter(Boolean)
.join(" · ")

return (
<Styled.MoreFromItem
href={videoDetailPageView(video.id, playlistId, video.title)}
aria-label={`Open video ${video.title}`}
>
<Styled.ThumbnailWrapper>
{imageUrl && (
<Image
src={imageUrl}
alt={`Video thumbnail for ${video.title}. Duration: ${duration || "Unknown duration"}. Topics: ${topicNames || "No topics listed"}`}
fill
sizes="160px"
style={{ objectFit: "cover" }}
/>
)}
{duration && (
<Styled.DurationBadge $dense>{duration}</Styled.DurationBadge>
)}
<Styled.PlayOverlay className="play-overlay">
<Styled.PlayIcon />
</Styled.PlayOverlay>
</Styled.ThumbnailWrapper>
<Styled.MoreFromTextSide>
<Styled.MoreFromItemTitle className="mf-title">
{video.title}
</Styled.MoreFromItemTitle>
{video.description && (
<Styled.MoreFromItemMeta
dangerouslySetInnerHTML={{ __html: video.description }}
/>
)}
</Styled.MoreFromTextSide>
</Styled.MoreFromItem>
)
}

/**
* "More from <playlist>" — the sibling-video list at the foot of the video detail
* page. Renders nothing once loaded if the playlist has no other videos.
*/
const MoreFromPlaylist: React.FC<MoreFromPlaylistProps> = ({
playlistId,
playlistLabel,
playlistTitle,
videos,
totalVideos,
isLoading,
}) => {
if (isLoading) {
return (
<>
<Skeleton
variant="text"
width={220}
height={24}
style={{ marginBottom: 8 }}
/>
{Array.from({ length: 3 }).map((_, i) => (
<Styled.MoreFromSkeletonRow key={i}>
<Skeleton variant="rectangular" width={160} height={90} />
<div style={{ flex: 1 }}>
<Skeleton variant="text" width="70%" height={20} />
<Skeleton variant="text" width="50%" height={16} />
</div>
</Styled.MoreFromSkeletonRow>
))}
</>
)
}

if (videos.length === 0) return null

// +1 accounts for the video currently being watched, which is excluded from
// `videos` — without it a fully-listed playlist would still offer "View all".
const hasMore = totalVideos > videos.length + 1

return (
<>
<Styled.MoreFromTitle>More from {playlistLabel}</Styled.MoreFromTitle>
<Styled.MoreFromList>
{videos.map((video) => (
<React.Fragment key={video.id}>
<MoreFromPlaylistItem video={video} playlistId={playlistId} />
<Styled.SpacerBlock className="spacer-block" />
</React.Fragment>
))}
</Styled.MoreFromList>
{hasMore && (
<Styled.SeeAllLink
href={videoPlaylistPageView(String(playlistId), playlistTitle)}
aria-label={`View all videos in ${playlistLabel}`}
>
View all in {playlistLabel} →
</Styled.SeeAllLink>
)}
</>
)
}

export default MoreFromPlaylist

This file was deleted.

Loading
Loading