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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- 5432:5432

redis:
image: redis:7.4.5
image: redis:8.2.1
ports:
- 6379:6379

Expand Down
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 0.44.0
--------------

- fix non-lexicographical ordering in org dashboard programs / program collections (#2523)
- canvas: citation urls for html content (#2521)
- chore(deps): update redis docker tag to v8 (#2397)
- Add browser header for podcast extraction (#2514)
- Fix flaky test (#2519)

Version 0.43.1 (Released September 18, 2025)
--------------

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ services:
redis:
profiles:
- backend
image: redis:7.4.5
image: redis:8.2.1
healthcheck:
test: ["CMD", "redis-cli", "ping", "|", "grep", "PONG"]
interval: 3s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ describe("OrganizationContent", () => {
})
})

it("displays courses in the correct order based on program.courseIds, regardless of API response order", async () => {
const { orgX, programA, coursesA } = setupProgramsAndCourses()

// Mock API to return courses in reverse order from program.courseIds
const reversedCoursesA = [...coursesA].reverse()
setMockResponse.get(
expect.stringContaining(
`/api/v2/courses/?id=${programA.courses.join("%2C")}`,
),
{ results: reversedCoursesA },
)

renderWithProviders(<OrganizationContent orgSlug={orgX.slug} />)

const programElement = await screen.findByTestId("org-program-root")
const cards = await within(programElement).findAllByTestId(
"enrollment-card-desktop",
)

// Verify courses appear in program.courseIds order, not API response order
coursesA.forEach((course, i) => {
expect(cards[i]).toHaveTextContent(course.title)
})
})

test("Shows correct enrollment status", async () => {
const { orgX, programA, coursesA } = setupProgramsAndCourses()
const enrollments = [
Expand Down Expand Up @@ -132,19 +157,16 @@ describe("OrganizationContent", () => {
{ results: [programB] },
)

// Mock the courses API calls for programs in the collection
// Use dynamic matching since course IDs are randomly generated
setMockResponse.get(
expect.stringContaining(
`/api/v2/courses/?id=${programA.courses.join("%2C")}`,
),
{ results: coursesA },
)
// Mock the bulk course API call with first course from each program
const firstCourseA = coursesA.find((c) => c.id === programA.courses[0])
const firstCourseB = coursesB.find((c) => c.id === programB.courses[0])
const firstCourseIds = [programB.courses[0], programA.courses[0]] // B first, then A to match collection order

setMockResponse.get(
expect.stringContaining(
`/api/v2/courses/?id=${programB.courses.join("%2C")}`,
`/api/v2/courses/?id=${firstCourseIds.join("%2C")}`,
),
{ results: coursesB },
{ results: [firstCourseB, firstCourseA] }, // Response order should match request order
)

renderWithProviders(<OrganizationContent orgSlug={orgX.slug} />)
Expand All @@ -168,8 +190,47 @@ describe("OrganizationContent", () => {

// Verify the order matches the programCollection.programs array [programB.id, programA.id]
const programCards = collection.getAllByTestId("enrollment-card-desktop")
expect(programCards[0]).toHaveTextContent(coursesB[0].title)
expect(programCards[1]).toHaveTextContent(coursesA[0].title)
expect(programCards[0]).toHaveTextContent(firstCourseB!.title)
expect(programCards[1]).toHaveTextContent(firstCourseA!.title)
})

test("Program collection displays the first course from each program", async () => {
const { orgX, programA, programCollection, coursesA } =
setupProgramsAndCourses()

programCollection.programs = [programA.id]
setMockResponse.get(urls.programCollections.programCollectionsList(), {
results: [programCollection],
})

setMockResponse.get(
expect.stringContaining(`/api/v2/programs/?id=${programA.id}`),
{ results: [programA] },
)

// Mock bulk API call for the first course
const firstCourseId = programA.courses[0]
const firstCourse = coursesA.find((c) => c.id === firstCourseId)
setMockResponse.get(
expect.stringContaining(`/api/v2/courses/?id=${firstCourseId}`),
{ results: [firstCourse] },
)

renderWithProviders(<OrganizationContent orgSlug={orgX.slug} />)

const collection = await screen.findByTestId("org-program-collection-root")

// Wait for program cards to be rendered
const programCards = await waitFor(() => {
const programCards = within(collection).getAllByTestId(
"enrollment-card-desktop",
)
expect(programCards.length).toBeGreaterThan(0)
return programCards
})

// Should display the first course by program.courseIds order
expect(programCards[0]).toHaveTextContent(firstCourse!.title)
})

test("Does not render a program separately if it is part of a collection", async () => {
Expand Down Expand Up @@ -265,34 +326,32 @@ describe("OrganizationContent", () => {
const { orgX, programA, programB, programCollection, coursesB } =
setupProgramsAndCourses()

// Modify programA to have no courses to test "at least one program has courses"
const programANoCourses = { ...programA, courses: [] }

// Set up the collection to include both programs
programCollection.programs = [programA.id, programB.id]
programCollection.programs = [programANoCourses.id, programB.id]
setMockResponse.get(urls.programCollections.programCollectionsList(), {
results: [programCollection],
})

// Mock individual program API calls for the collection
setMockResponse.get(
expect.stringContaining(`/api/v2/programs/?id=${programA.id}`),
{ results: [programA] },
expect.stringContaining(`/api/v2/programs/?id=${programANoCourses.id}`),
{ results: [programANoCourses] },
)
setMockResponse.get(
expect.stringContaining(`/api/v2/programs/?id=${programB.id}`),
{ results: [programB] },
)

// Mock programA to have no courses, programB to have courses
setMockResponse.get(
expect.stringContaining(
`/api/v2/courses/?id=${programA.courses.join("%2C")}`,
),
{ results: [] },
)
// Mock bulk course API call - only programB has courses, so only its first course should be included
const firstCourseBId = programB.courses[0]
const firstCourseB = coursesB.find((c) => c.id === firstCourseBId)

setMockResponse.get(
expect.stringContaining(
`/api/v2/courses/?id=${programB.courses.join("%2C")}`,
),
{ results: coursesB },
expect.stringContaining(`/api/v2/courses/?id=${firstCourseBId}`),
{ results: [firstCourseB] },
)

renderWithProviders(<OrganizationContent orgSlug={orgX.slug} />)
Expand All @@ -307,11 +366,11 @@ describe("OrganizationContent", () => {
// Should see the collection header
expect(collection.getByText(programCollection.title)).toBeInTheDocument()

// Should see programB's courses
// Should see programB's course
await waitFor(() => {
expect(collection.getAllByText(coursesB[0].title).length).toBeGreaterThan(
0,
)
expect(
collection.getAllByText(firstCourseB!.title).length,
).toBeGreaterThan(0)
})
})

Expand Down
117 changes: 46 additions & 71 deletions frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,9 @@ const ProgramDescription = styled(Typography)({
// Custom hook to handle multiple program queries and check if any have courses
const useProgramCollectionCourses = (programIds: number[], orgId: number) => {
const programQueries = useQueries({
queries: programIds.map((programId) => ({
...programsQueries.programsList({ id: programId, org_id: orgId }),
queryKey: [
...programsQueries.programsList({ id: programId, org_id: orgId })
.queryKey,
],
})),
queries: programIds.map((programId) =>
programsQueries.programsList({ id: programId, org_id: orgId }),
),
})

const isLoading = programQueries.some((query) => query.isLoading)
Expand Down Expand Up @@ -175,6 +171,25 @@ const OrgProgramCollectionDisplay: React.FC<{
const sanitizedDescription = DOMPurify.sanitize(collection.description ?? "")
const { isLoading, programsWithCourses, hasAnyCourses } =
useProgramCollectionCourses(collection.programIds, orgId)
const firstCourseIds = programsWithCourses
.map((p) => p?.program.courseIds[0])
.filter((id): id is number => id !== undefined)
const courses = useQuery({
...coursesQueries.coursesList({
id: firstCourseIds,
org_id: orgId,
}),
enabled: firstCourseIds.length > 0,
})
const rawCourses =
courses.data?.results.sort((a, b) => {
return firstCourseIds.indexOf(a.id) - firstCourseIds.indexOf(b.id)
}) ?? []
const transformedCourses = transform.organizationCoursesWithContracts({
courses: rawCourses,
contracts: contracts ?? [],
enrollments: enrollments ?? [],
})

const header = (
<ProgramHeader>
Expand Down Expand Up @@ -214,17 +229,26 @@ const OrgProgramCollectionDisplay: React.FC<{
<ProgramRoot data-testid="org-program-collection-root">
{header}
<PlainList>
{programsWithCourses.map((item) =>
item ? (
<ProgramCollectionItem
key={item.programId}
program={item.program}
contracts={contracts}
enrollments={enrollments}
orgId={orgId}
{courses.isLoading &&
programsWithCourses.map((item) => (
<Skeleton
key={item?.programId}
width="100%"
height="65px"
style={{ marginBottom: "16px" }}
/>
) : null,
)}
))}
{transformedCourses.map((course) => (
<DashboardCardStyled
Component="li"
key={course.key}
dashboardResource={course}
courseNoun="Module"
offerUpgrade={false}
titleHref={course.run?.coursewareUrl}
buttonHref={course.run?.coursewareUrl}
/>
))}
</PlainList>
</ProgramRoot>
)
Expand Down Expand Up @@ -260,8 +284,12 @@ const OrgProgramDisplay: React.FC<{
<Skeleton width="100%" height="65px" style={{ marginBottom: "16px" }} />
)
if (programLoading || courses.isLoading) return skeleton
const rawCourses =
courses.data?.results.sort((a, b) => {
return program.courseIds.indexOf(a.id) - program.courseIds.indexOf(b.id)
}) ?? []
const transformedCourses = transform.organizationCoursesWithContracts({
courses: courses.data?.results ?? [],
courses: rawCourses,
contracts: contracts ?? [],
enrollments: courseRunEnrollments ?? [],
})
Expand Down Expand Up @@ -307,59 +335,6 @@ const OrgProgramDisplay: React.FC<{
)
}

const ProgramCollectionItem: React.FC<{
program: DashboardProgram
contracts?: ContractPage[]
enrollments?: CourseRunEnrollment[]
orgId: number
}> = ({ program, contracts, enrollments, orgId }) => {
return (
<ProgramCard
program={program}
contracts={contracts}
enrollments={enrollments}
orgId={orgId}
/>
)
}

const ProgramCard: React.FC<{
program: DashboardProgram
contracts?: ContractPage[]
enrollments?: CourseRunEnrollment[]
orgId: number
}> = ({ program, contracts, enrollments, orgId }) => {
const courses = useQuery(
coursesQueries.coursesList({
id: program.courseIds,
org_id: orgId,
}),
)
const skeleton = (
<Skeleton width="100%" height="65px" style={{ marginBottom: "16px" }} />
)
if (courses.isLoading) return skeleton
const transformedCourses = transform.organizationCoursesWithContracts({
courses: courses.data?.results ?? [],
contracts: contracts ?? [],
enrollments: enrollments ?? [],
})
if (courses.isLoading || !transformedCourses.length) return skeleton
// For now we assume the first course is the main one for the program.
const course = transformedCourses[0]
return (
<DashboardCard
Component="li"
key={program.key}
dashboardResource={course}
courseNoun={"Module"}
offerUpgrade={false}
titleHref={course.run.coursewareUrl ?? ""}
buttonHref={course.run.coursewareUrl ?? ""}
/>
)
}

const OrganizationRoot = styled.div({
display: "flex",
flexDirection: "column",
Expand Down
Loading
Loading