diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index cea1282c31..08876330da 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -106,6 +106,14 @@ describe("ContractContent", () => { }), { results: reversedCoursesA }, ) + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 200, + }), + { results: reversedCoursesA }, + ) renderWithProviders( { }), { results: [firstCourseB, firstCourseA] }, ) + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 200, + }), + { results: [...normalizedCoursesA, ...normalizedCoursesB] }, + ) renderWithProviders( { }), { results: [firstCourseA, firstCourseB] }, // API returns A's course first ) + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 200, + }), + { results: [...normalizedCoursesA, ...normalizedCoursesB] }, + ) renderWithProviders( { expect(collections.length).toBe(0) }) + test("only renders programs whose courses appear in the contract-scoped courses response", async () => { + const { orgX, programA, programB } = setupProgramsAndCourses() + + // Create a program whose courses are not in the contract-scoped courses response. + // The courses API mock from setupProgramsAndCourses only returns coursesA and coursesB, + // so getSortedStandaloneContractPrograms will exclude programC because none of its + // course IDs appear in contractCourses. + const programC = factories.programs.program({ + courses: [99991, 99992], + }) + + // Override the contract-filtered programs list to include the extra program. + setMockResponse.get( + urls.programs.programsList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 30, + }), + { results: [programA, programB, programC] }, + ) + setMockResponse.get(urls.programCollections.programCollectionsList(), { + results: [], + }) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: orgX.name }) + // A and B have contract-scoped courses, so they render. + const renderedPrograms = await screen.findAllByTestId("org-program-root") + expect(renderedPrograms.length).toBe(2) + // C has no contract-scoped courses, so it must not appear. + expect( + screen.queryByRole("heading", { name: programC.title }), + ).not.toBeInTheDocument() + }) + test("Does not render program collection if all programs have no courses", async () => { const { orgX, programA, programB } = setupProgramsAndCourses() @@ -736,6 +801,14 @@ describe("ContractContent", () => { }), { results: [programANoCourses, programB] }, ) + setMockResponse.get( + urls.programs.programsList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 30, + }), + { results: [programANoCourses, programB] }, + ) // Mock bulk course API call - only programB has courses, so only its first course should be included const firstCourseBId = programB.courses[0] @@ -748,6 +821,14 @@ describe("ContractContent", () => { }), { results: [firstCourseB] }, ) + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 200, + }), + { results: [firstCourseB] }, + ) renderWithProviders( ({ @@ -275,78 +267,10 @@ const ProgramLanguageSelect = styled(SimpleSelectField)(({ theme }) => ({ }, })) as typeof SimpleSelectField -// Custom hook to handle multiple program queries and check if any have courses -const useProgramCollectionCourses = ( - programCollection: V2ProgramCollection, - contractId: number, -) => { - const programIds = programCollection.programs - .map((program) => program.id) - .filter((id) => id !== undefined) - const programsQuery = useQuery({ - ...programsQueries.programsList({ - id: programIds, - contract_id: contractId, - page_size: programIds.length, - }), - enabled: programIds.length > 0, - }) - const isLoading = programsQuery.isLoading - - const programsWithCourses = programsQuery.data?.results.map((program) => { - return { - programId: program.id, - program: program, - hasCourses: program.courses && program.courses.length > 0, - } - }) - - const hasAnyCourses = programsQuery.data?.results.some( - (p) => p?.courses && p.courses.length > 0, - ) - - return { - isLoading, - programsWithCourses, - hasAnyCourses, - } -} - const OrgProgramCollectionDisplay: React.FC<{ collection: V2ProgramCollection - contract: ContractPage - enrollments?: CourseRunEnrollmentV3[] - selectedLanguageKey: string -}> = ({ collection, contract, enrollments, selectedLanguageKey }) => { - const { isLoading, programsWithCourses, hasAnyCourses } = - useProgramCollectionCourses(collection, contract.id) - const firstCourseIds = programsWithCourses - ?.map((p) => p?.program.courses[0]) - .filter((id): id is number => id !== undefined) - const courses = useQuery({ - ...coursesQueries.coursesList({ - id: firstCourseIds, - contract_id: contract.id, - }), - enabled: firstCourseIds !== undefined && firstCourseIds.length > 0, - }) - const rawCourses = React.useMemo(() => { - const courseIdToOrder = new Map() - programsWithCourses?.forEach((item) => { - const firstCourseId = item.program.courses[0] - const programId = item.programId - const order = - collection.programs.find((p) => p.id === programId)?.order ?? Infinity - courseIdToOrder.set(firstCourseId, order) - }) - - const results = courses.data?.results ?? [] - return [...results].sort((a, b) => { - const orderA = courseIdToOrder.get(a.id) ?? Infinity - const orderB = courseIdToOrder.get(b.id) ?? Infinity - return orderA - orderB - }) - }, [courses.data?.results, programsWithCourses, collection.programs]) + entries: DashboardCourseEntry[] +}> = ({ collection, entries }) => { const header = ( @@ -358,23 +282,7 @@ const OrgProgramCollectionDisplay: React.FC<{ ) - if (isLoading) { - return ( - - {header} - - - - - ) - } - - // Only render if at least one program has courses - if (!hasAnyCourses) { + if (entries.length === 0) { return null } @@ -382,56 +290,20 @@ const OrgProgramCollectionDisplay: React.FC<{ {header} - {courses.isLoading && - programsWithCourses?.map((item, index) => ( - - ))} - {rawCourses.map((course) => { - // Filter enrollments to only those matching this contract - const contractEnrollments = - enrollments?.filter( - (enrollment) => enrollment.b2b_contract_id === contract.id, - ) ?? [] - const { displayedEnrollment, displayedRun } = - resolveCourseEntryForLanguage( - course, - contractEnrollments, - selectedLanguageKey, - { contractId: contract.id }, - ) - - const resource = displayedEnrollment - ? { - type: DashboardType.CourseRunEnrollment, - data: displayedEnrollment, - } - : { type: DashboardType.Course, data: course } - - return ( - - ) - })} + {entries.map((entry) => ( + + ))} ) @@ -439,44 +311,10 @@ const OrgProgramCollectionDisplay: React.FC<{ const OrgProgramDisplay: React.FC<{ program: V2Program - contract?: ContractPage - courseRunEnrollments?: CourseRunEnrollmentV3[] - programEnrollments?: V3UserProgramEnrollment[] - programLoading: boolean - orgId: number - selectedLanguageKey: string -}> = ({ - program, - contract, - courseRunEnrollments, - programEnrollments, - programLoading, - orgId: _orgId, - selectedLanguageKey, -}) => { - const programEnrollment = programEnrollments?.find( - (enrollment) => enrollment.program.id === program.id, - ) + entries: DashboardCourseEntry[] + programEnrollment?: V3UserProgramEnrollment +}> = ({ program, entries, programEnrollment }) => { const hasValidCertificate = !!programEnrollment?.certificate - const coursesQuery = useQuery( - coursesQueries.coursesList({ - id: program.courses, - contract_id: contract?.id, - page_size: 30, - }), - ) - const skeleton = ( - - ) - - const courses = React.useMemo( - () => - [...(coursesQuery.data?.results ?? [])].sort((a, b) => { - return program.courses.indexOf(a.id) - program.courses.indexOf(b.id) - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [coursesQuery.data?.results, program.courses], - ) return ( @@ -484,7 +322,7 @@ const OrgProgramDisplay: React.FC<{ {program.title} - + {hasValidCertificate && ( @@ -500,49 +338,20 @@ const OrgProgramDisplay: React.FC<{ )} - {programLoading || coursesQuery.isLoading - ? skeleton - : courses.map((course) => { - // Filter enrollments to only those matching this contract - const contractEnrollments = - courseRunEnrollments?.filter( - (enrollment) => enrollment.b2b_contract_id === contract?.id, - ) ?? [] - const { displayedEnrollment, displayedRun } = - resolveCourseEntryForLanguage( - course, - contractEnrollments, - selectedLanguageKey, - { contractId: contract?.id }, - ) - - const resource = displayedEnrollment - ? { - type: DashboardType.CourseRunEnrollment, - data: displayedEnrollment, - } - : { type: DashboardType.Course, data: course } - - return ( - - ) + {entries.map((entry) => ( + + ))} ) @@ -578,109 +387,37 @@ const ContractContentInternal: React.FC = ({ org, contract, }) => { - const orgId = org.id - const courseRunEnrollmentsQuery = useQuery( - enrollmentQueries.courseRunEnrollmentsList(), - ) - const programEnrollmentsQuery = useQuery( - enrollmentQueries.programEnrollmentsList(), - ) - const programsQuery = useQuery( - programsQueries.programsList({ - org_id: orgId, - contract_id: contract.id, - page_size: 30, - }), - ) - const programCollectionsQuery = useQuery( - programCollectionQueries.programCollectionsList({}), - ) - const coursesQuery = useQuery( - coursesQueries.coursesList({ - org_id: orgId, - contract_id: contract.id, - page_size: 200, - }), - ) - const contractCourses = React.useMemo( - () => coursesQuery.data?.results ?? [], - [coursesQuery.data?.results], - ) - const languageOptions = React.useMemo( - () => - getDistinctDashboardLanguageOptions( - contractCourses, - courseRunEnrollmentsQuery.data ?? [], - { contractId: contract.id }, - ), - [contract.id, contractCourses, courseRunEnrollmentsQuery.data], - ) - const [selectedLanguageKey, setSelectedLanguageKey] = React.useState("") - - useEffect(() => { - if (languageOptions.length === 0) { - if (selectedLanguageKey) { - setSelectedLanguageKey("") - } - return - } - - const hasSelectedLanguage = languageOptions.some( - (option) => option.value === selectedLanguageKey, - ) - if (!hasSelectedLanguage) { - setSelectedLanguageKey(String(languageOptions[0].value)) - } - }, [languageOptions, selectedLanguageKey]) - - // Helper to check if a program has any courses with contract-scoped runs - const programHasContractRuns = (programId: number): boolean => { - const programData = programsQuery.data?.results.find( - (p) => p.id === programId, - ) - if (!programData?.courses || !coursesQuery.data?.results) return false - // Since courses query is already filtered by contract_id, - // we just need to check if any of the program's courses exist in the results - return programData.courses.some((courseId) => - coursesQuery.data.results.some((c) => c.id === courseId), - ) - } - - // Get IDs of all programs that are in collections - const programsInCollections = new Set( - programCollectionsQuery.data?.results.flatMap((collection) => - collection.programs.map((p) => p.id), - ) ?? [], - ) - - const sortedPrograms = programsQuery.data?.results - .filter((program) => !programsInCollections.has(program.id)) - .filter(() => { - // If contract has no programs defined, show nothing - return contract?.programs && contract.programs.length > 0 - }) - .filter((program) => { - // Only include programs that are in the contract - return contract?.programs.includes(program.id) - }) - .filter((program) => programHasContractRuns(program.id)) - .sort((a, b) => { - if (!contract?.programs) return 0 - const indexA = contract.programs.indexOf(a.id) - const indexB = contract.programs.indexOf(b.id) - return indexA - indexB - }) + const { + isLoading, + showNoPrograms, + languageOptions, + selectedLanguageKey, + setSelectedLanguageKey, + programs, + collections, + } = useContractDashboardData(org, contract) const skeleton = ( - - - + {Array.from({ length: 2 }).map((_, index) => ( + + + + + + + + + + + + + + ))} ) - // Wait for all program and collection data to load - if (programsQuery.isLoading || programCollectionsQuery.isLoading) { + if (isLoading) { return ( <> @@ -722,61 +459,30 @@ const ContractContentInternal: React.FC = ({ - {!sortedPrograms - ? skeleton - : sortedPrograms.map((program) => ( - - ))} - - {(programCollectionsQuery.data?.results ?? []) - .filter(() => { - // If contract has no programs defined, show nothing - return contract?.programs && contract.programs.length > 0 - }) - .filter((collection) => { - // Only show collections where at least one program is in the contract - const collectionProgramIds = collection.programs.map((p) => p.id) - return collectionProgramIds.some( - (id) => id !== undefined && contract?.programs.includes(id), - ) - }) - .filter((collection) => { - // Filter out collections where none of the programs have valid course runs - const collectionProgramIds = collection.programs - .map((p) => p.id) - .filter((id): id is number => id !== undefined) - return collectionProgramIds.some((id) => - programHasContractRuns(id), - ) - }) - .map((collection) => { - return ( - - ) + {programs.map(({ program, entries, programEnrollment }) => ( + + ))} + + {collections.map(({ collection, entries }) => ( + + ))} - {programsQuery.data?.results.length === 0 && ( + {showNoPrograms && ( No programs found diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md index 678a85627f..26b1e237dc 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md @@ -515,6 +515,11 @@ If any of these fails, the phase has relocated complexity rather than reduced it **Purpose:** Move contract-scoped orchestration out of `ContractContent.tsx` while preserving B2B behavior. +**Implementation decisions (2026-05-19, Phase 5 kickoff):** + +- **Language-picker state:** inline in `useContractDashboardData` for now, with a short comment explaining this is temporary while Phase 4 is developed in parallel. If Phase 4 lands first, lift both to the shared `useDashboardLanguagePicker`; if Phase 5 lands first, Phase 4 reuses/extracts from the inlined implementation. +- **Loading UI shape:** keep loading skeletons structurally aligned with the final contract UI (program blocks with course rows beneath each header), rather than generic flat skeleton lists. + **Files:** - Create: `frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.ts` @@ -535,6 +540,7 @@ If any of these fails, the phase has relocated complexity rather than reduced it - [ ] Preserve contract-scoped enrollment filtering using `b2b_contract_id`. - [ ] Preserve contract-scoped run filtering using `contract_id` query params and `contractId` run resolution. - [ ] Preserve current language picker behavior and selected-language state. +- [ ] For this phase's branch order, inline the language-picker state/effect in `useContractDashboardData` with a TODO note to extract to shared `useDashboardLanguagePicker` once Phase 4 merges. - [ ] Use `dashboardAdapters.ts` to keep rendering through `DashboardCard` in this phase. - [ ] Add or update tests proving: - only contract programs are rendered, @@ -542,7 +548,7 @@ If any of these fails, the phase has relocated complexity rather than reduced it - collections render only when at least one program has valid contract runs, - selected-language enrollment is preferred over contract next/best run, - contract A does not display contract B enrollment, - - welcome/header/skeleton behavior is unchanged. + - welcome/header behavior is unchanged and the loading skeleton preserves program + course-row structure. - [ ] Run: ```bash diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.test.tsx new file mode 100644 index 0000000000..fd1febb29c --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.test.tsx @@ -0,0 +1,82 @@ +import React from "react" +import { QueryClientProvider } from "@tanstack/react-query" +import { renderHook, waitFor, setMockResponse } from "@/test-utils" +import { makeBrowserQueryClient } from "@/app/getQueryClient" +import { urls } from "api/mitxonline-test-utils" +import { setupProgramsAndCourses } from "../test-utils" +import { useContractDashboardData } from "./useContractDashboardData" + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = makeBrowserQueryClient({ maxRetries: 0 }) + return ( + {children} + ) +} + +describe("useContractDashboardData", () => { + beforeEach(() => { + setMockResponse.get(urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), []) + }) + + test("omits standalone programs that are represented inside collections", async () => { + const { orgX, programA, programB, programCollection } = + setupProgramsAndCourses() + + programCollection.programs = [ + { id: programA.id, title: programA.title, order: 1 }, + ] + setMockResponse.get(urls.programCollections.programCollectionsList(), { + results: [programCollection], + }) + + const { result } = renderHook( + () => useContractDashboardData(orgX, orgX.contracts[0]), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.programs.map((row) => row.program.id)).toEqual([ + programB.id, + ]) + expect(result.current.collections.map((row) => row.collection.id)).toEqual([ + programCollection.id, + ]) + }) + + test("builds collection first-course entries in collection program order", async () => { + const { orgX, programA, programB, programCollection } = + setupProgramsAndCourses() + + programCollection.programs = [ + { id: programA.id, title: programA.title, order: 2 }, + { id: programB.id, title: programB.title, order: 1 }, + ] + setMockResponse.get(urls.programCollections.programCollectionsList(), { + results: [programCollection], + }) + + const { result } = renderHook( + () => useContractDashboardData(orgX, orgX.contracts[0]), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + const collection = result.current.collections.find( + (row) => row.collection.id === programCollection.id, + ) + expect(collection).toBeDefined() + expect(collection?.entries.map((entry) => entry.course.id)).toEqual([ + programB.courses[0], + programA.courses[0], + ]) + + if (result.current.languageOptions.length > 0) { + expect(result.current.selectedLanguageKey).toBe( + String(result.current.languageOptions[0].value), + ) + } + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.ts new file mode 100644 index 0000000000..702aed1846 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/hooks/useContractDashboardData.ts @@ -0,0 +1,194 @@ +import React from "react" +import { useQuery } from "@tanstack/react-query" +import type { SimpleSelectOption } from "ol-components" +import { + programsQueries, + programCollectionQueries, +} from "api/mitxonline-hooks/programs" +import { coursesQueries } from "api/mitxonline-hooks/courses" +import { enrollmentQueries } from "api/mitxonline-hooks/enrollment" +import { useDashboardLanguagePicker } from "./useDashboardLanguagePicker" +import type { + ContractPage, + CourseRunEnrollmentV3, + OrganizationPage, + V2Program, + V2ProgramCollection, + V3UserProgramEnrollment, +} from "@mitodl/mitxonline-api-axios/v2" +import { + buildCourseEntry, + getCollectionFirstCoursesInDisplayOrder, + getDistinctDashboardLanguageOptions, + getProgramCoursesInContractOrder, + getRenderableContractCollections, + getSortedStandaloneContractPrograms, + groupCourseRunEnrollmentsByCourseId, + groupProgramEnrollmentsByProgramId, + type DashboardCourseEntry, +} from "../model/dashboardViewModel" + +type ContractProgramDisplayData = { + program: V2Program + entries: DashboardCourseEntry[] + programEnrollment?: V3UserProgramEnrollment +} + +type ContractCollectionDisplayData = { + collection: V2ProgramCollection + entries: DashboardCourseEntry[] +} + +type ContractDashboardData = { + isLoading: boolean + showNoPrograms: boolean + languageOptions: SimpleSelectOption[] + selectedLanguageKey: string + setSelectedLanguageKey: (value: string) => void + programs: ContractProgramDisplayData[] + collections: ContractCollectionDisplayData[] + courseRunEnrollments: CourseRunEnrollmentV3[] + programEnrollmentsById: Record +} + +const useContractDashboardData = ( + org: OrganizationPage, + contract: ContractPage, +): ContractDashboardData => { + const courseRunEnrollmentsQuery = useQuery( + enrollmentQueries.courseRunEnrollmentsList(), + ) + const programEnrollmentsQuery = useQuery( + enrollmentQueries.programEnrollmentsList(), + ) + const programsQuery = useQuery( + programsQueries.programsList({ + org_id: org.id, + contract_id: contract.id, + page_size: 30, + }), + ) + const programCollectionsQuery = useQuery( + programCollectionQueries.programCollectionsList({}), + ) + const coursesQuery = useQuery( + coursesQueries.coursesList({ + org_id: org.id, + contract_id: contract.id, + page_size: 200, + }), + ) + + const contractCourses = React.useMemo( + () => coursesQuery.data?.results ?? [], + [coursesQuery.data?.results], + ) + const courseRunEnrollments = courseRunEnrollmentsQuery.data ?? [] + + const languageOptions = React.useMemo( + () => + getDistinctDashboardLanguageOptions( + contractCourses, + courseRunEnrollments, + { + contractId: contract.id, + }, + ), + [contract.id, contractCourses, courseRunEnrollments], + ) + + const { selectedLanguageKey, setSelectedLanguageKey } = + useDashboardLanguagePicker(languageOptions) + + const programs = programsQuery.data?.results ?? [] + const collections = programCollectionsQuery.data?.results ?? [] + const sortedPrograms = getSortedStandaloneContractPrograms( + programs, + collections, + contract, + contractCourses, + ) + + const renderableCollections = getRenderableContractCollections( + collections, + programs, + contract, + contractCourses, + ) + + const programEnrollmentsById = groupProgramEnrollmentsByProgramId( + programEnrollmentsQuery.data ?? [], + ) + + const enrollmentsByCourseId = + groupCourseRunEnrollmentsByCourseId(courseRunEnrollments) + + const programRows = sortedPrograms.map((program) => { + const courses = getProgramCoursesInContractOrder(program, contractCourses) + const programEnrollment = programEnrollmentsById[program.id] + return { + program, + entries: courses.map((course) => + buildCourseEntry( + course, + enrollmentsByCourseId[course.id] ?? [], + selectedLanguageKey, + { + availableLanguages: languageOptions, + contractId: contract.id, + ancestorContext: programEnrollment + ? { programEnrollment } + : undefined, + }, + ), + ), + programEnrollment, + } + }) + + const collectionRows = renderableCollections.map((collection) => { + const firstCourses = getCollectionFirstCoursesInDisplayOrder( + collection, + programs, + contractCourses, + ) + return { + collection, + entries: firstCourses.map((course) => + buildCourseEntry( + course, + enrollmentsByCourseId[course.id] ?? [], + selectedLanguageKey, + { + availableLanguages: languageOptions, + contractId: contract.id, + }, + ), + ), + } + }) + + return { + isLoading: + courseRunEnrollmentsQuery.isLoading || + programEnrollmentsQuery.isLoading || + programsQuery.isLoading || + programCollectionsQuery.isLoading || + coursesQuery.isLoading, + showNoPrograms: programRows.length === 0 && collectionRows.length === 0, + languageOptions, + selectedLanguageKey, + setSelectedLanguageKey, + programs: programRows, + collections: collectionRows, + courseRunEnrollments, + programEnrollmentsById, + } +} + +export { useContractDashboardData } +export type { + ContractDashboardData, + ContractProgramDisplayData, + ContractCollectionDisplayData, +} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts index 5c5d62a5b1..61dcf13019 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts @@ -6,9 +6,13 @@ import { buildCourseEntry, buildRequirementSections, enrollmentCourseIsInPrograms, + getCollectionFirstCoursesInDisplayOrder, getDistinctDashboardLanguageOptions, getModuleCourseIdsFromPrograms, getNonContractProgramEnrollments, + getProgramCoursesInContractOrder, + getRenderableContractCollections, + getSortedStandaloneContractPrograms, getTopLevelProgramEnrollments, groupCourseRunEnrollmentsByCourseId, groupModuleCoursesByProgramId, @@ -16,6 +20,7 @@ import { isNonContractEnrollment, isProgramAsCourse, pickDisplayedEnrollmentForLegacyDashboard, + programHasContractRuns, resolveCourseEntryForLanguage, } from "./dashboardViewModel" @@ -578,31 +583,13 @@ describe("dashboardViewModel", () => { ], }) - const spanishEnrollment = factories.enrollment.courseEnrollment({ - b2b_contract_id: null, - run: { - id: 999, - language: "es", - title: "Modulo Espanol", - run_tag: "ES-1", - course: { id: course.id, title: course.title }, - courseware_id: "cw-es-999", - courseware_url: "https://example.com/es-999", - is_enrollable: false, - is_upgradable: false, - is_archived: false, - is_self_paced: true, - start_date: null, - end_date: null, - upgrade_deadline: null, - certificate_available_date: null, - course_number: "", - }, + const esEnrollment = factories.enrollment.courseEnrollment({ + run: { id: 999, language: "es", course: { id: course.id } }, }) const options = getDistinctDashboardLanguageOptions( [course], - [spanishEnrollment], + [esEnrollment], ) expect(options.map((option) => option.value)).toEqual([ @@ -616,44 +603,10 @@ describe("dashboardViewModel", () => { const courseB = factories.courses.course({ id: 20, language_options: [] }) const enrollmentA = factories.enrollment.courseEnrollment({ - run: { - id: 1, - language: "fr", - title: "Run FR", - run_tag: "FR-1", - course: { id: 10, title: "Course A" }, - courseware_id: "cw-fr-1", - courseware_url: "https://example.com/fr-1", - is_enrollable: true, - is_upgradable: false, - is_archived: false, - is_self_paced: true, - start_date: null, - end_date: null, - upgrade_deadline: null, - certificate_available_date: null, - course_number: "", - }, + run: { language: "fr", course: { id: 10 } }, }) const enrollmentOtherCourse = factories.enrollment.courseEnrollment({ - run: { - id: 2, - language: "de", - title: "Run DE", - run_tag: "DE-1", - course: { id: 999, title: "Outside" }, - courseware_id: "cw-de-2", - courseware_url: "https://example.com/de-2", - is_enrollable: true, - is_upgradable: false, - is_archived: false, - is_self_paced: true, - start_date: null, - end_date: null, - upgrade_deadline: null, - certificate_available_date: null, - course_number: "", - }, + run: { language: "de", course: { id: 999 } }, }) const options = getDistinctDashboardLanguageOptions( @@ -670,46 +623,11 @@ describe("dashboardViewModel", () => { language_options: [], courseruns: [], }) - const frenchEnrollment = factories.enrollment.courseEnrollment({ - run: { - id: 500, - language: "fr", - title: "Run FR", - run_tag: "FR-1", - course: { id: 50, title: course.title }, - courseware_id: "cw-fr-500", - courseware_url: "https://example.com/fr-500", - is_enrollable: true, - is_upgradable: false, - is_archived: false, - is_self_paced: true, - start_date: null, - end_date: null, - upgrade_deadline: null, - certificate_available_date: null, - course_number: "", - }, + run: { language: "fr", course: { id: 50 } }, }) const spanishEnrollment = factories.enrollment.courseEnrollment({ - run: { - id: 501, - language: "es", - title: "Run ES", - run_tag: "ES-1", - course: { id: 50, title: course.title }, - courseware_id: "cw-es-501", - courseware_url: "https://example.com/es-501", - is_enrollable: true, - is_upgradable: false, - is_archived: false, - is_self_paced: true, - start_date: null, - end_date: null, - upgrade_deadline: null, - certificate_available_date: null, - course_number: "", - }, + run: { language: "es", course: { id: 50 } }, }) const options = getDistinctDashboardLanguageOptions( @@ -738,24 +656,7 @@ describe("dashboardViewModel", () => { courseruns: [], }) const enrollment = factories.enrollment.courseEnrollment({ - run: { - id: 400, - language: "es", - title: "Spanish Run", - run_tag: "ES-1", - course: { id: 40, title: course.title }, - courseware_id: "cw-es-400", - courseware_url: "https://example.com/es-400", - is_enrollable: true, - is_upgradable: false, - is_archived: false, - is_self_paced: true, - start_date: null, - end_date: null, - upgrade_deadline: null, - certificate_available_date: null, - course_number: "", - }, + run: { language: "es", course: { id: 40 } }, }) const options = getDistinctDashboardLanguageOptions( @@ -779,6 +680,201 @@ describe("dashboardViewModel", () => { }) }) + describe("contract dashboard helpers", () => { + test("programHasContractRuns checks course membership in contract course ids", () => { + const program = factories.programs.program({ courses: [10, 20] }) + const contractCourseIds = new Set([20, 99]) + + expect(programHasContractRuns(program, contractCourseIds)).toBe(true) + expect(programHasContractRuns(program, new Set([99]))).toBe(false) + }) + + test("getSortedStandaloneContractPrograms excludes collection programs and sorts by contract order", () => { + const programA = factories.programs.program({ id: 1, courses: [101] }) + const programB = factories.programs.program({ id: 2, courses: [102] }) + const programC = factories.programs.program({ id: 3, courses: [103] }) + const contract = factories.contracts.contract({ + programs: [3, 1, 2], + }) + const collection = factories.programs.programCollection({ + programs: [{ id: 2, title: programB.title, order: 1 }], + }) + const contractCourses = [ + factories.courses.course({ id: 101 }), + factories.courses.course({ id: 103 }), + ] + + const programs = getSortedStandaloneContractPrograms( + [programA, programB, programC], + [collection], + contract, + contractCourses, + ) + + expect(programs.map((program) => program.id)).toEqual([3, 1]) + }) + + test("getSortedStandaloneContractPrograms returns empty when contract has no programs", () => { + const program = factories.programs.program({ id: 1, courses: [101] }) + const contract = factories.contracts.contract({ programs: [] }) + + const programs = getSortedStandaloneContractPrograms( + [program], + [], + contract, + [factories.courses.course({ id: 101 })], + ) + + expect(programs).toEqual([]) + }) + + test("getSortedStandaloneContractPrograms filters by contract course availability", () => { + const programWithContracts = factories.programs.program({ + id: 1, + courses: [101, 102], + }) + const programNoContracts = factories.programs.program({ + id: 2, + courses: [201, 202], + }) + const contract = factories.contracts.contract({ + programs: [1, 2], + }) + const contractCourses = [ + factories.courses.course({ id: 101 }), + factories.courses.course({ id: 102 }), + ] + + const programs = getSortedStandaloneContractPrograms( + [programWithContracts, programNoContracts], + [], + contract, + contractCourses, + ) + + expect(programs.map((p) => p.id)).toEqual([1]) + }) + + test("getSortedStandaloneContractPrograms preserves contract-specified sort order", () => { + const programs = [ + factories.programs.program({ id: 10, courses: [1001] }), + factories.programs.program({ id: 20, courses: [2001] }), + factories.programs.program({ id: 30, courses: [3001] }), + ] + const contract = factories.contracts.contract({ + programs: [30, 10, 20], + }) + const contractCourses = [ + factories.courses.course({ id: 1001 }), + factories.courses.course({ id: 2001 }), + factories.courses.course({ id: 3001 }), + ] + + const result = getSortedStandaloneContractPrograms( + programs, + [], + contract, + contractCourses, + ) + + expect(result.map((p) => p.id)).toEqual([30, 10, 20]) + }) + + test("getSortedStandaloneContractPrograms returns empty when all programs are filtered", () => { + const programInCollection = factories.programs.program({ + id: 1, + courses: [101], + }) + const programNoContractRuns = factories.programs.program({ + id: 2, + courses: [201, 202], + }) + const contract = factories.contracts.contract({ + programs: [1, 2], + }) + const collection = factories.programs.programCollection({ + programs: [{ id: 1, title: programInCollection.title, order: 1 }], + }) + // Contract only has courses 101, so program 2 (courses 201, 202) has no contract runs + const contractCourses = [factories.courses.course({ id: 101 })] + + const programs = getSortedStandaloneContractPrograms( + [programInCollection, programNoContractRuns], + [collection], + contract, + contractCourses, + ) + + expect(programs).toEqual([]) + }) + + test("getRenderableContractCollections keeps only collections with in-contract programs that have contract runs", () => { + const programA = factories.programs.program({ id: 10, courses: [1001] }) + const programB = factories.programs.program({ id: 20, courses: [2001] }) + const programC = factories.programs.program({ id: 30, courses: [3001] }) + const contract = factories.contracts.contract({ programs: [10, 20] }) + + const noValidRuns = factories.programs.programCollection({ + id: 1, + programs: [{ id: 10, title: programA.title, order: 1 }], + }) + const validRuns = factories.programs.programCollection({ + id: 2, + programs: [{ id: 20, title: programB.title, order: 1 }], + }) + const outsideContract = factories.programs.programCollection({ + id: 3, + programs: [{ id: 30, title: programC.title, order: 1 }], + }) + + expect( + getRenderableContractCollections( + [noValidRuns, validRuns, outsideContract], + [programA, programB, programC], + contract, + [factories.courses.course({ id: 2001 })], + ).map((collection) => collection.id), + ).toEqual([2]) + }) + + test("getProgramCoursesInContractOrder preserves program course id order and skips missing", () => { + const program = factories.programs.program({ courses: [3, 1, 2] }) + const course1 = factories.courses.course({ id: 1 }) + const course2 = factories.courses.course({ id: 2 }) + + const courses = getProgramCoursesInContractOrder(program, [ + course2, + course1, + ]) + + expect(courses.map((course) => course.id)).toEqual([1, 2]) + }) + + test("getCollectionFirstCoursesInDisplayOrder follows collection order and selects first available contract course", () => { + const programA = factories.programs.program({ + id: 10, + courses: [101, 102], + }) + const programB = factories.programs.program({ id: 20, courses: [201] }) + const collection = factories.programs.programCollection({ + programs: [ + { id: 10, title: programA.title, order: 2 }, + { id: 20, title: programB.title, order: 1 }, + ], + }) + const course102 = factories.courses.course({ id: 102 }) + const course201 = factories.courses.course({ id: 201 }) + + const courses = getCollectionFirstCoursesInDisplayOrder( + collection, + [programA, programB], + [course102, course201], + ) + + expect(courses.map((course) => course.id)).toEqual([201, 102]) + }) + }) + describe("buildCourseEntry", () => { test("returns null displayedEnrollment and a displayedRun when there are no enrollments", () => { const run = factories.courses.courseRun({ id: 100 }) @@ -815,22 +911,9 @@ describe("dashboardViewModel", () => { }) const enrollment = factories.enrollment.courseEnrollment({ run: { - id: run.id, + ...run, language: "en", course: { id: course.id, title: course.title }, - title: run.title, - run_tag: run.run_tag, - courseware_id: run.courseware_id, - courseware_url: run.courseware_url, - is_enrollable: run.is_enrollable, - is_upgradable: run.is_upgradable, - is_archived: run.is_archived, - is_self_paced: run.is_self_paced, - start_date: run.start_date, - end_date: run.end_date, - upgrade_deadline: run.upgrade_deadline, - certificate_available_date: run.certificate_available_date, - course_number: run.course_number, }, }) const availableLanguages = [{ value: "language:en", label: "English" }] @@ -904,42 +987,16 @@ describe("dashboardViewModel", () => { }) const enEnrollment = factories.enrollment.courseEnrollment({ run: { - id: enRun.id, + ...enRun, language: "en", course: { id: course.id, title: course.title }, - title: enRun.title, - run_tag: enRun.run_tag, - courseware_id: enRun.courseware_id, - courseware_url: enRun.courseware_url, - is_enrollable: enRun.is_enrollable, - is_upgradable: enRun.is_upgradable, - is_archived: enRun.is_archived, - is_self_paced: enRun.is_self_paced, - start_date: enRun.start_date, - end_date: enRun.end_date, - upgrade_deadline: enRun.upgrade_deadline, - certificate_available_date: enRun.certificate_available_date, - course_number: enRun.course_number, }, }) const esEnrollment = factories.enrollment.courseEnrollment({ run: { - id: esRun.id, + ...esRun, language: "es", course: { id: course.id, title: course.title }, - title: esRun.title, - run_tag: esRun.run_tag, - courseware_id: esRun.courseware_id, - courseware_url: esRun.courseware_url, - is_enrollable: esRun.is_enrollable, - is_upgradable: esRun.is_upgradable, - is_archived: esRun.is_archived, - is_self_paced: esRun.is_self_paced, - start_date: esRun.start_date, - end_date: esRun.end_date, - upgrade_deadline: esRun.upgrade_deadline, - certificate_available_date: esRun.certificate_available_date, - course_number: esRun.course_number, }, }) const availableLanguages = [ @@ -984,21 +1041,9 @@ describe("dashboardViewModel", () => { const otherContractEnrollment = factories.enrollment.courseEnrollment({ b2b_contract_id: 99, run: { - id: run.id, + ...run, + language: "en", course: { id: course.id, title: course.title }, - title: run.title, - run_tag: run.run_tag, - courseware_id: run.courseware_id, - courseware_url: run.courseware_url, - is_enrollable: run.is_enrollable, - is_upgradable: run.is_upgradable, - is_archived: run.is_archived, - is_self_paced: run.is_self_paced, - start_date: run.start_date, - end_date: run.end_date, - upgrade_deadline: run.upgrade_deadline, - certificate_available_date: run.certificate_available_date, - course_number: run.course_number, }, }) @@ -1015,6 +1060,49 @@ describe("dashboardViewModel", () => { expect(entry.displayedEnrollment).toBeNull() }) + test("contract-scoped: picks the matching-contract enrollment when multiple are present", () => { + const run = factories.courses.courseRun({ + id: 501, + b2b_contract: 1, + courseware_id: "cw-501", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + const otherContractEnrollment = factories.enrollment.courseEnrollment({ + b2b_contract_id: 2, + run: { ...run, course: { id: course.id, title: course.title } }, + }) + const selectedContractEnrollment = factories.enrollment.courseEnrollment({ + b2b_contract_id: 1, + run: { ...run, course: { id: course.id, title: course.title } }, + }) + + const entry = buildCourseEntry( + course, + [otherContractEnrollment, selectedContractEnrollment], + "language:en", + { + availableLanguages: [{ value: "language:en", label: "English" }], + contractId: 1, + }, + ) + + expect(entry.displayedEnrollment?.b2b_contract_id).toBe(1) + }) + test("stores all input enrollments uncollapsed regardless of displayedEnrollment choice", () => { const run = factories.courses.courseRun({ id: 601 }) const course = factories.courses.course({ courseruns: [run] }) @@ -1582,14 +1670,14 @@ describe("dashboardViewModel", () => { describe("resolveCourseEntryForLanguage", () => { test("prefers selected-language enrollment", () => { - const englishRun = factories.courses.courseRun({ + const enRun = factories.courses.courseRun({ id: 11, language: "en", courseware_id: "cw-en-11", courseware_url: "https://example.com/en-11", is_enrollable: true, }) - const spanishRun = factories.courses.courseRun({ + const esRun = factories.courses.courseRun({ id: 12, language: "es", courseware_id: "cw-es-12", @@ -1598,66 +1686,40 @@ describe("dashboardViewModel", () => { }) const course = factories.courses.course({ id: 1, - courseruns: [englishRun, spanishRun], - next_run_id: englishRun.id, + courseruns: [enRun, esRun], + next_run_id: enRun.id, language_options: [ { - id: englishRun.id, - courseware_id: englishRun.courseware_id, - courseware_url: englishRun.courseware_url ?? "", + id: enRun.id, + courseware_id: enRun.courseware_id, + courseware_url: enRun.courseware_url ?? "", language: "en", - title: englishRun.title, - run_tag: englishRun.run_tag, + title: enRun.title, + run_tag: enRun.run_tag, }, { - id: spanishRun.id, - courseware_id: spanishRun.courseware_id, - courseware_url: spanishRun.courseware_url ?? "", + id: esRun.id, + courseware_id: esRun.courseware_id, + courseware_url: esRun.courseware_url ?? "", language: "es", - title: spanishRun.title, - run_tag: spanishRun.run_tag, + title: esRun.title, + run_tag: esRun.run_tag, }, ], }) const englishEnrollment = factories.enrollment.courseEnrollment({ run: { - id: englishRun.id, + ...enRun, language: "en", course: { id: course.id, title: course.title }, - title: englishRun.title, - run_tag: englishRun.run_tag, - courseware_id: englishRun.courseware_id, - courseware_url: englishRun.courseware_url, - is_enrollable: englishRun.is_enrollable, - is_upgradable: englishRun.is_upgradable, - is_archived: englishRun.is_archived, - is_self_paced: englishRun.is_self_paced, - start_date: englishRun.start_date, - end_date: englishRun.end_date, - upgrade_deadline: englishRun.upgrade_deadline, - certificate_available_date: englishRun.certificate_available_date, - course_number: englishRun.course_number, }, }) const spanishEnrollment = factories.enrollment.courseEnrollment({ run: { - id: spanishRun.id, + ...esRun, language: "es", course: { id: course.id, title: course.title }, - title: spanishRun.title, - run_tag: spanishRun.run_tag, - courseware_id: spanishRun.courseware_id, - courseware_url: spanishRun.courseware_url, - is_enrollable: spanishRun.is_enrollable, - is_upgradable: spanishRun.is_upgradable, - is_archived: spanishRun.is_archived, - is_self_paced: spanishRun.is_self_paced, - start_date: spanishRun.start_date, - end_date: spanishRun.end_date, - upgrade_deadline: spanishRun.upgrade_deadline, - certificate_available_date: spanishRun.certificate_available_date, - course_number: spanishRun.course_number, }, }) @@ -1667,19 +1729,19 @@ describe("dashboardViewModel", () => { "language:es", ) - expect(resolved.displayedEnrollment?.run.id).toBe(spanishRun.id) - expect(resolved.displayedRun?.id).toBe(spanishRun.id) + expect(resolved.displayedEnrollment?.run.id).toBe(esRun.id) + expect(resolved.displayedRun?.id).toBe(esRun.id) }) test("does not pick enrollment from another contract", () => { - const englishRun = factories.courses.courseRun({ + const enRun = factories.courses.courseRun({ id: 21, b2b_contract: 1, courseware_id: "cw-en-21", courseware_url: "https://example.com/en-21", is_enrollable: true, }) - const spanishRun = factories.courses.courseRun({ + const esRun = factories.courses.courseRun({ id: 22, b2b_contract: 2, courseware_id: "cw-es-22", @@ -1688,24 +1750,24 @@ describe("dashboardViewModel", () => { }) const course = factories.courses.course({ id: 2, - courseruns: [englishRun, spanishRun], - next_run_id: englishRun.id, + courseruns: [enRun, esRun], + next_run_id: enRun.id, language_options: [ { - id: englishRun.id, - courseware_id: englishRun.courseware_id, - courseware_url: englishRun.courseware_url ?? "", + id: enRun.id, + courseware_id: enRun.courseware_id, + courseware_url: enRun.courseware_url ?? "", language: "en", - title: englishRun.title, - run_tag: englishRun.run_tag, + title: enRun.title, + run_tag: enRun.run_tag, }, { - id: spanishRun.id, - courseware_id: spanishRun.courseware_id, - courseware_url: spanishRun.courseware_url ?? "", + id: esRun.id, + courseware_id: esRun.courseware_id, + courseware_url: esRun.courseware_url ?? "", language: "es", - title: spanishRun.title, - run_tag: spanishRun.run_tag, + title: esRun.title, + run_tag: esRun.run_tag, }, ], }) @@ -1713,22 +1775,9 @@ describe("dashboardViewModel", () => { const otherContractEnrollment = factories.enrollment.courseEnrollment({ b2b_contract_id: 2, run: { - id: spanishRun.id, + ...esRun, language: "es", course: { id: course.id, title: course.title }, - title: spanishRun.title, - run_tag: spanishRun.run_tag, - courseware_id: spanishRun.courseware_id, - courseware_url: spanishRun.courseware_url, - is_enrollable: spanishRun.is_enrollable, - is_upgradable: spanishRun.is_upgradable, - is_archived: spanishRun.is_archived, - is_self_paced: spanishRun.is_self_paced, - start_date: spanishRun.start_date, - end_date: spanishRun.end_date, - upgrade_deadline: spanishRun.upgrade_deadline, - certificate_available_date: spanishRun.certificate_available_date, - course_number: spanishRun.course_number, }, }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts index c166cceb43..ad19e565c6 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts @@ -14,6 +14,8 @@ import type { CourseRunLanguageOption, CourseRunV2, CourseWithCourseRunsSerializerV2, + V2Program, + V2ProgramCollection, V2ProgramDetail, V2ProgramRequirement, V3UserProgramEnrollment, @@ -760,6 +762,154 @@ const buildRequirementSections = ({ return { sections, completedCount, totalCount } } +const getProgramsInCollections = ( + collections: V2ProgramCollection[], +): Set => { + return new Set( + collections.flatMap((collection) => + collection.programs + .map((program) => program.id) + .filter((id): id is number => id !== undefined), + ), + ) +} + +const programHasContractRuns = ( + program: V2Program, + contractCourseIds: Set, +): boolean => { + return program.courses.some((courseId) => contractCourseIds.has(courseId)) +} + +const getSortedStandaloneContractPrograms = ( + programs: V2Program[], + collections: V2ProgramCollection[], + contract: ContractPage, + contractCourses: CourseWithCourseRunsSerializerV2[], +): V2Program[] => { + if (!contract.programs || contract.programs.length === 0) { + return [] + } + + const contractProgramIds = new Set(contract.programs) + const programsInCollections = getProgramsInCollections(collections) + const contractCourseIds = new Set(contractCourses.map((course) => course.id)) + // Precompute sort order map: O(m) once, not O(n*m) per sort + const programOrder = new Map( + contract.programs.map((id, index) => [id, index]), + ) + + return programs + .filter((program) => !programsInCollections.has(program.id)) + .filter((program) => contractProgramIds.has(program.id)) + .filter((program) => programHasContractRuns(program, contractCourseIds)) + .sort((a, b) => { + const indexA = programOrder.get(a.id) ?? Infinity + const indexB = programOrder.get(b.id) ?? Infinity + return indexA - indexB + }) +} + +/** + * Filter `collections` to those the contract dashboard should render. + * + * A collection is renderable when **both** conditions hold: + * 1. At least one of the collection's programs appears in `contract.programs` + * (i.e. the collection is relevant to this contract). + * 2. At least one of the collection's programs has at least one course that + * belongs to the contract (via `programHasContractRuns`), so the card list + * will not be empty. + * + * If the contract has no programs at all, returns `[]` immediately. + */ +const getRenderableContractCollections = ( + collections: V2ProgramCollection[], + programs: V2Program[], + contract: ContractPage, + contractCourses: CourseWithCourseRunsSerializerV2[], +): V2ProgramCollection[] => { + if (!contract.programs || contract.programs.length === 0) { + return [] + } + + const contractProgramIds = new Set(contract.programs) + const contractCourseIds = new Set(contractCourses.map((course) => course.id)) + const programsById = new Map(programs.map((program) => [program.id, program])) + + return collections.filter((collection) => { + const collectionProgramIds = collection.programs + .map((program) => program.id) + .filter((id): id is number => id !== undefined) + + const hasProgramInContract = collectionProgramIds.some((id) => + contractProgramIds.has(id), + ) + if (!hasProgramInContract) { + return false + } + + return collectionProgramIds.some((id) => { + const program = programsById.get(id) + return program + ? programHasContractRuns(program, contractCourseIds) + : false + }) + }) +} + +const getProgramCoursesInContractOrder = ( + program: V2Program, + contractCourses: CourseWithCourseRunsSerializerV2[], +): CourseWithCourseRunsSerializerV2[] => { + const contractCoursesById = new Map( + contractCourses.map((course) => [course.id, course]), + ) + return program.courses + .map((courseId) => contractCoursesById.get(courseId)) + .filter((course): course is CourseWithCourseRunsSerializerV2 => !!course) +} + +const getCollectionFirstCoursesInDisplayOrder = ( + collection: V2ProgramCollection, + programs: V2Program[], + contractCourses: CourseWithCourseRunsSerializerV2[], +): CourseWithCourseRunsSerializerV2[] => { + const programsById = new Map(programs.map((program) => [program.id, program])) + const contractCoursesById = new Map( + contractCourses.map((course) => [course.id, course]), + ) + const contractCourseIds = new Set(contractCourses.map((course) => course.id)) + + const firstCourses = collection.programs + .slice() + .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) + .map((collectionProgram) => { + if (typeof collectionProgram.id !== "number") { + return null + } + const program = programsById.get(collectionProgram.id) + if (!program) { + return null + } + const firstCourseId = program.courses.find((courseId) => + contractCourseIds.has(courseId), + ) + return typeof firstCourseId === "number" + ? (contractCoursesById.get(firstCourseId) ?? null) + : null + }) + .filter((course): course is CourseWithCourseRunsSerializerV2 => !!course) + + const seenCourseIds = new Set() + return firstCourses.filter((course) => { + if (seenCourseIds.has(course.id)) { + return false + } + seenCourseIds.add(course.id) + return true + }) +} + export { pickDisplayedEnrollmentForLegacyDashboard, groupCourseRunEnrollmentsByCourseId, @@ -777,5 +927,11 @@ export { assembleHomeCardList, buildCourseEntry, buildRequirementSections, + programHasContractRuns, + getSortedStandaloneContractPrograms, + getRenderableContractCollections, + getProgramCoursesInContractOrder, + getCollectionFirstCoursesInDisplayOrder, } + export type { RequirementSectionItem, RequirementSection }