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
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is another example of something TS ^5.5 will catch automatically, no need for the id is number part.

Inferred Type Predicates

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)
}) ?? []
Comment on lines +184 to +187
Copy link
Contributor

Choose a reason for hiding this comment

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

As far as I can tell, program collections have no ordering of the associated programs.

So if the UAI vertical modules on prod look correct, great, but I don't think that's guaranteed by anything, is it?

I.e., there's (currently) no way in wagtail / mitxonline admin to set the order of these programs, is there?

Screenshot 2025-09-19 at 1 09 02 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a good point. I suppose it would preserve whatever ordering is passed back from the API though, nonetheless.

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