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.75.1
--------------

- display programs without display_mode set on the program dashboard (#3656)
- Sanitize LLM-generated summaries/flashcards before saving ContentFile (#3629)
- feat: improve CSV export UX auto-dismiss success alert (#3652)
- index content files for programs (#3621)
- fix: block meta-externalads/1.1 crawler in robots.txt (#3653)
- fix: emit OpenTelemetry HTTP server spans under ASGI (#3641)
- refactor: Extract a PodcastPageShell for shared player/breadcrumb wiring and Extract a shared audio engine from the two podcast players (#3628)

Version 0.75.0 (Released July 21, 2026)
--------------

Expand Down
2 changes: 1 addition & 1 deletion frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@mitodl/course-search-utils": "^3.5.2",
"@mitodl/hacksnack": "^0.1.0",
"@mitodl/mitxonline-api-axios": "2026.7.7",
"@mitodl/smoot-design": "^6.28.1",
"@mitodl/smoot-design": "^6.30.0",
"@mui/base": "5.0.0-beta.70",
"@mui/material": "^6.4.5",
"@mui/material-nextjs": "^6.4.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react"
import { renderWithProviders, screen, user } from "@/test-utils"
import { waitFor } from "@testing-library/react"
import { act, waitFor } from "@testing-library/react"
import { setMockResponse } from "api/test-utils"
import { factories, urls } from "api/mitxonline-test-utils"
import { useFeatureFlagEnabled } from "posthog-js/react"
Expand Down Expand Up @@ -482,6 +482,164 @@ describe("ContractAdminPage", () => {

await screen.findByText("Could not export CSV. Please try again.")
})

const assertiveLiveRegionText = () =>
[...document.querySelectorAll('[aria-live="assertive"]')]
.map((el) => el.textContent ?? "")
.join(" ")

// The smoot-design Alert only exposes its aria-describedby ("success/error
// message") to NVDA, not its children, so the real message is mirrored into
// an assertive live region. It must be assertive: the Alert's hardcoded
// role="alert" already fires assertively, and a polite mirror lands right
// after it and gets dropped by NVDA.
test("announces a successful export via an assertive live region", async () => {
const { org, contract } = makeOrgWithContract()
setupPage(org, contract, { total_codes: 1 })

setMockResponse.get(
urls.contracts.managerContractCodes(org.id, contract.id, {
page: 1,
page_size: 500,
}),
factories.contracts.paginatedContractCodes([
factories.contracts.contractCode({
assigned_to: "alice@example.com",
}),
]),
)

renderWithProviders(
<ContractAdminPage orgSlug={org.slug} contractSlug={contract.slug} />,
)

await screen.findByRole("button", { name: "Export CSV" })
await user.click(screen.getByRole("button", { name: "Export CSV" }))

await waitFor(() => {
expect(assertiveLiveRegionText()).toContain("CSV download started.")
})
})

test("announces an export failure via an assertive live region", async () => {
allowConsoleErrors()
const { org, contract } = makeOrgWithContract()
setupPage(org, contract, { total_codes: 1 })

setMockResponse.get(
urls.contracts.managerContractCodes(org.id, contract.id, {
page: 1,
page_size: 500,
}),
"Internal Server Error",
{ code: 500 },
)

renderWithProviders(
<ContractAdminPage orgSlug={org.slug} contractSlug={contract.slug} />,
)

await screen.findByRole("button", { name: "Export CSV" })
await user.click(screen.getByRole("button", { name: "Export CSV" }))

await waitFor(() => {
expect(assertiveLiveRegionText()).toContain(
"Could not export CSV. Please try again.",
)
})
})

// The visual Alert (role="alert") is the auto-dismissing element; the
// message also lives in the aria-live mirror region (no role), so querying
// by role="alert" targets only the toast, not the mirror.
test("auto-dismisses the success alert after its timeout", async () => {
jest.useFakeTimers()
try {
const timerUser = user.setup({
advanceTimers: jest.advanceTimersByTime,
})
const { org, contract } = makeOrgWithContract()
setupPage(org, contract, { total_codes: 1 })

setMockResponse.get(
urls.contracts.managerContractCodes(org.id, contract.id, {
page: 1,
page_size: 500,
}),
factories.contracts.paginatedContractCodes([
factories.contracts.contractCode({
assigned_to: "alice@example.com",
}),
]),
)

renderWithProviders(
<ContractAdminPage orgSlug={org.slug} contractSlug={contract.slug} />,
)

await timerUser.click(
await screen.findByRole("button", { name: "Export CSV" }),
)

const alert = await screen.findByRole("alert")
expect(alert).toHaveTextContent("CSV download started.")

act(() => {
jest.advanceTimersByTime(5000)
})

await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument()
})
} finally {
jest.useRealTimers()
}
})

test("does not auto-dismiss the error alert", async () => {
allowConsoleErrors()
jest.useFakeTimers()
try {
const timerUser = user.setup({
advanceTimers: jest.advanceTimersByTime,
})
const { org, contract } = makeOrgWithContract()
setupPage(org, contract, { total_codes: 1 })

setMockResponse.get(
urls.contracts.managerContractCodes(org.id, contract.id, {
page: 1,
page_size: 500,
}),
"Internal Server Error",
{ code: 500 },
)

renderWithProviders(
<ContractAdminPage orgSlug={org.slug} contractSlug={contract.slug} />,
)

await timerUser.click(
await screen.findByRole("button", { name: "Export CSV" }),
)

const alert = await screen.findByRole("alert")
expect(alert).toHaveTextContent(
"Could not export CSV. Please try again.",
)

// Advance well past the success auto-hide window; the error must remain.
act(() => {
jest.advanceTimersByTime(10000)
})

expect(screen.getByRole("alert")).toHaveTextContent(
"Could not export CSV. Please try again.",
)
} finally {
jest.useRealTimers()
}
})
})

describe("header stat counts refresh after mutations", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import React, { useEffect, useRef, useState } from "react"
import React, { useCallback, useEffect, useRef, useState } from "react"
import Image from "next/image"
import {
keepPreviousData,
Expand Down Expand Up @@ -153,6 +153,19 @@ const SeatAssignmentsControls = styled.div(({ theme }) => ({
}))

const ExportButtonWrapper = styled.div(({ theme }) => ({
// smoot-design Button only styles the native `:disabled` state, but the export
// button uses `aria-disabled` while exporting — mirror the bordered variant's
// disabled appearance here.
"> button[aria-disabled='true']": {
cursor: "default",
backgroundColor: theme.custom.colors.lightGray2,
border: `1px solid ${theme.custom.colors.lightGray2}`,
color: theme.custom.colors.silverGrayDark,
":hover": {
backgroundColor: theme.custom.colors.lightGray2,
color: theme.custom.colors.silverGrayDark,
},
},
[theme.breakpoints.down("md")]: {
width: "100%",
"> button": {
Expand Down Expand Up @@ -376,8 +389,8 @@ const ContractAdminPageInternal: React.FC<ContractAdminPageInternalProps> = ({
// Mirror row-action Alert text into an assertive live region — smoot-design
// Alert announces only its aria-describedby ("success/error message") via NVDA
// instead of the actual children text. Must be before early returns (Rules of Hooks).
// Reset-then-set with a 100ms delay so the live region fires after the Alert's
// role="alert" has been processed by NVDA, preventing the update from being dropped.
// Reset-then-set with a 100ms delay so it fires after the Alert's role="alert"
// has been processed by NVDA rather than being dropped.
useEffect(() => {
if (!rowActionResult?.message) {
setRowActionAnnouncement("")
Expand All @@ -390,6 +403,12 @@ const ContractAdminPageInternal: React.FC<ContractAdminPageInternalProps> = ({
return () => clearTimeout(id)
}, [rowActionResult])

// The Alert restarts its autoHideDuration timer whenever its `onClose` prop
// changes identity. Passing a new inline function each render (e.g. when a
// keystroke in the search box re-renders this page) would keep resetting that
// timer so the alert never auto-dismisses — so keep a stable reference here.
const handleAlertClose = useCallback(() => setRowActionResult(null), [])

const {
data: managerOrgs,
isLoading: isLoadingOrgs,
Expand Down Expand Up @@ -520,7 +539,7 @@ const ContractAdminPageInternal: React.FC<ContractAdminPageInternalProps> = ({
}

const handleExportCsv = async () => {
if (!hasExportableRows) return
if (isExporting || !hasExportableRows) return
setIsExporting(true)
try {
const allRows: ManagerEnrollmentCode[] = []
Expand Down Expand Up @@ -675,7 +694,13 @@ const ContractAdminPageInternal: React.FC<ContractAdminPageInternalProps> = ({
<Alert
severity={rowActionResult.severity}
closable
onClose={() => setRowActionResult(null)}
// A success message only confirms the action worked, so it
// auto-dismisses after 5s. Errors stay until dismissed so the
// user has time to read and act on them.
autoHideDuration={
rowActionResult.severity === "success" ? 5000 : undefined
}
onClose={handleAlertClose}
>
{rowActionResult.message}
</Alert>
Expand Down Expand Up @@ -705,9 +730,8 @@ const ContractAdminPageInternal: React.FC<ContractAdminPageInternalProps> = ({
<Button
variant="bordered"
onClick={handleExportCsv}
disabled={
isLoadingContractDetail || !hasExportableRows || isExporting
}
disabled={isLoadingContractDetail || !hasExportableRows}
aria-disabled={isExporting}
aria-busy={isExporting}
>
{isExporting ? "Exporting..." : "Export CSV"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@ describe("ProgramAsCourseCard", () => {
includeProgramEnrollment = false,
startDate,
endDate,
// Most tests here exercise the program-as-course presentation, which requires
// display_mode "course" — pinned because the card's labels read it.
displayMode = "course",
}: {
includeProgramEnrollment?: boolean
startDate?: string | null
endDate?: string | null
displayMode?: "course" | null
} = {}) => {
const moduleOne = mitxonline.factories.courses.course({
courseruns: [mitxonline.factories.courses.courseRun()],
Expand All @@ -59,6 +63,7 @@ describe("ProgramAsCourseCard", () => {
req_tree: reqTree.serialize(),
start_date: startDate ?? null,
end_date: endDate ?? null,
display_mode: displayMode,
})

const moduleEnrollment = mitxonline.factories.enrollment.courseEnrollment({
Expand Down Expand Up @@ -148,6 +153,81 @@ describe("ProgramAsCourseCard", () => {
expect(screen.getAllByText("Course")).toHaveLength(2)
})

test("uses 'Program' labels and program details link when display_mode is not 'course'", async () => {
const cardData = setupCardData({ displayMode: null })

renderWithProviders(
<ProgramAsCourseCard
courseProgram={cardData.courseProgram}
moduleCourses={cardData.moduleCourses}
moduleEnrollmentsByCourseId={cardData.moduleEnrollmentsByCourseId}
courseProgramEnrollment={cardData.courseProgramEnrollment}
/>,
)

await screen.findAllByRole("heading", {
name: cardData.courseProgram.title,
level: 3,
})
// Card type label, rendered once per (desktop/mobile) header copy.
expect(screen.getAllByText("Program")).toHaveLength(2)
expect(screen.queryByText("Course")).not.toBeInTheDocument()
// Children are described as courses, not modules.
expect(screen.getByText("2 Courses (0 of 2 complete)")).toBeInTheDocument()

const programCard = screen.getByTestId("program-as-course-card")
await user.click(within(programCard).getAllByLabelText("More options")[0])
const detailsLink = await screen.findByRole("menuitem", {
name: "View Program Details",
})
expect(detailsLink).toHaveAttribute(
"href",
`/programs/${cardData.courseProgram.readable_id}`,
)
})

test.each([
{ displayMode: "course" as const, label: "Module" },
{ displayMode: null, label: "Course" },
])(
"singularizes the sub-header label when there is exactly 1 $label",
async ({ displayMode, label }) => {
const moduleOne = mitxonline.factories.courses.course({
courseruns: [mitxonline.factories.courses.courseRun()],
})
const reqTree =
new mitxonline.factories.requirements.RequirementTreeBuilder()
const modules = reqTree.addOperator({
operator: "all_of",
title: "Modules",
})
modules.addCourse({ course: moduleOne.id })

const program = mitxonline.factories.programs.program({
courses: [moduleOne.id],
req_tree: reqTree.serialize(),
display_mode: displayMode,
})

setMockResponse.get(
mitxonline.urls.userMe.get(),
mitxonline.factories.user.user(),
)

renderWithProviders(
<ProgramAsCourseCard
courseProgram={program}
moduleCourses={[moduleOne]}
moduleEnrollmentsByCourseId={{}}
/>,
)

expect(
await screen.findByText(`1 ${label} (0 of 1 complete)`),
).toBeInTheDocument()
},
)

test("progress badge reflects program enrollment status ('In Progress')", async () => {
const cardData = setupCardData({ includeProgramEnrollment: true })
invariant(cardData.courseProgramEnrollment)
Expand Down
Loading
Loading