diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 671eef5f0f..c160c08c3a 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -36,7 +36,7 @@ const b2b = { const programs = { programsList: (opts?: ProgramsApiProgramsListV2Request) => - `${API_BASE_URL}/api/v2/programs/${queryify(opts)}`, + `${API_BASE_URL}/api/v2/programs/${queryify(opts, { explode: false })}`, programDetail: (id: number) => `${API_BASE_URL}/api/v2/programs/${id}/`, } diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx similarity index 78% rename from frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx rename to frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 13c2055bca..46bdbd8690 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -6,7 +6,7 @@ import { waitFor, user, } from "@/test-utils" -import OrganizationContent from "./OrganizationContent" +import ContractContent from "./ContractContent" import { setMockResponse } from "api/test-utils" import { urls, factories } from "api/mitxonline-test-utils" import { @@ -27,7 +27,7 @@ import { faker } from "@faker-js/faker/locale/en" const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade -describe("OrganizationContent", () => { +describe("ContractContent", () => { beforeEach(() => { setMockResponse.get(urls.enrollment.enrollmentsListV2(), []) setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) @@ -39,7 +39,12 @@ describe("OrganizationContent", () => { const { orgX, programA, programB, coursesA, coursesB } = setupProgramsAndCourses() - renderWithProviders() + renderWithProviders( + , + ) await screen.findByRole("heading", { name: orgX.name, @@ -78,7 +83,12 @@ describe("OrganizationContent", () => { { results: reversedCoursesA }, ) - renderWithProviders() + renderWithProviders( + , + ) const programElements = await screen.findAllByTestId("org-program-root") // Find the program with programA's title @@ -96,7 +106,8 @@ describe("OrganizationContent", () => { }) it("displays programs in the correct order based on contract.programs, regardless of API response order", async () => { - const { orgX, programA, programB } = setupProgramsAndCourses() + const { orgX, programA, programB, coursesA, coursesB } = + setupProgramsAndCourses() // Update the contract to specify program order (B first, then A) const contract = factories.contracts.contract({ @@ -113,8 +124,34 @@ describe("OrganizationContent", () => { setMockResponse.get(urls.programs.programsList({ org_id: orgX.id }), { results: [programA, programB], }) + // Add the contract-filtered programs query + setMockResponse.get( + urls.programs.programsList({ + org_id: orgX.id, + contract_id: contract.id, + }), + { + results: [programA, programB], + }, + ) + // Add the contract-filtered courses query + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: contract.id, + page_size: 200, + }), + { + results: [...coursesA, ...coursesB], + }, + ) - renderWithProviders() + renderWithProviders( + , + ) // Debug: see what's actually rendered await screen.findByRole("heading", { name: orgX.name }) @@ -146,7 +183,12 @@ describe("OrganizationContent", () => { // Override the default empty enrollments for this test setMockResponse.get(urls.enrollment.enrollmentsListV2(), enrollments) - renderWithProviders() + renderWithProviders( + , + ) const [programElA] = await screen.findAllByTestId("org-program-root") const cards = await within(programElA).findAllByTestId( @@ -198,10 +240,13 @@ describe("OrganizationContent", () => { results: [programCollection], }) - // Mock the new bulk programs API call with array of IDs + // Mock the new bulk programs API call with array of IDs and contract_id const programIds = [programB.id, programA.id] // B first, then A to match collection order setMockResponse.get( - expect.stringContaining(`/api/v2/programs/?id=${programIds.join("%2C")}`), + urls.programs.programsList({ + id: programIds, + contract_id: orgX.contracts[0].id, + }), { results: [programB, programA] }, // Return in same order as requested ) @@ -210,16 +255,21 @@ describe("OrganizationContent", () => { 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 - // Mock the program collection courses query + // Mock the program collection courses query with contract_id setMockResponse.get( urls.courses.coursesList({ id: firstCourseIds, - org_id: orgX.id, + contract_id: orgX.contracts[0].id, }), { results: [firstCourseB, firstCourseA] }, ) - renderWithProviders() + renderWithProviders( + , + ) const collectionHeader = await screen.findByRole("heading", { name: programCollection.title, @@ -260,24 +310,32 @@ describe("OrganizationContent", () => { results: [programCollection], }) - // Mock the new bulk programs API call with array of IDs + // Mock the new bulk programs API call with array of IDs and contract_id setMockResponse.get( - expect.stringContaining(`/api/v2/programs/?id=${programA.id}`), + urls.programs.programsList({ + id: [programA.id], + contract_id: orgX.contracts[0].id, + }), { results: [programA] }, ) - // Mock bulk API call for the first course + // Mock bulk API call for the first course with contract_id const firstCourseId = programA.courses[0] const firstCourse = coursesA.find((c) => c.id === firstCourseId) setMockResponse.get( urls.courses.coursesList({ id: [firstCourseId], - org_id: orgX.id, + contract_id: orgX.contracts[0].id, }), { results: [firstCourse] }, ) - renderWithProviders() + renderWithProviders( + , + ) const collection = await screen.findByTestId("org-program-collection-root") const collectionWrapper = within(collection) @@ -314,26 +372,34 @@ describe("OrganizationContent", () => { results: [programCollection], }) - // Mock the bulk programs API call for the collection + // Mock the bulk programs API call for the collection with contract_id const programIds = [programB.id, programA.id] setMockResponse.get( - expect.stringContaining(`/api/v2/programs/?id=${programIds.join("%2C")}`), + urls.programs.programsList({ + id: programIds, + contract_id: orgX.contracts[0].id, + }), { results: [programB, programA] }, ) - // Mock course API calls for program collection (first courses) + // Mock course API calls for program collection (first courses) with contract_id const firstCourseIds = [programB.courses[0], programA.courses[0]] const firstCourseA = coursesA.find((c) => c.id === programA.courses[0]) const firstCourseB = coursesB.find((c) => c.id === programB.courses[0]) setMockResponse.get( urls.courses.coursesList({ id: firstCourseIds, - org_id: orgX.id, + contract_id: orgX.contracts[0].id, }), { results: [firstCourseB, firstCourseA] }, ) - renderWithProviders() + renderWithProviders( + , + ) const collectionItems = await screen.findAllByTestId( "org-program-collection-root", @@ -349,11 +415,35 @@ describe("OrganizationContent", () => { setMockResponse.get(urls.programs.programsList({ org_id: orgX.id }), { results: [], }) + setMockResponse.get( + urls.programs.programsList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + }), + { + results: [], + }, + ) + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 200, + }), + { + results: [], + }, + ) setMockResponse.get(urls.programCollections.programCollectionsList(), { results: [], }) - renderWithProviders() + renderWithProviders( + , + ) // Wait for the header to appear await screen.findByRole("heading", { @@ -385,7 +475,12 @@ describe("OrganizationContent", () => { results: [], }) - renderWithProviders() + renderWithProviders( + , + ) // Wait for the header to appear await screen.findByRole("heading", { @@ -428,10 +523,13 @@ describe("OrganizationContent", () => { results: [programCollection], }) - // Mock the bulk programs API call for the collection + // Mock the bulk programs API call for the collection with contract_id const programIds = [programANoCourses.id, programB.id] setMockResponse.get( - expect.stringContaining(`/api/v2/programs/?id=${programIds.join("%2C")}`), + urls.programs.programsList({ + id: programIds, + contract_id: orgX.contracts[0].id, + }), { results: [programANoCourses, programB] }, ) @@ -441,13 +539,18 @@ describe("OrganizationContent", () => { setMockResponse.get( urls.courses.coursesList({ - id: [firstCourseBId], // Only programB's first course since programA has no courses - org_id: orgX.id, + id: [firstCourseBId], + contract_id: orgX.contracts[0].id, }), { results: [firstCourseB] }, ) - renderWithProviders() + renderWithProviders( + , + ) // The collection should be rendered since programB has courses const collectionItems = await screen.findAllByTestId( @@ -465,7 +568,7 @@ describe("OrganizationContent", () => { }) test("Shows the program certificate link button if the program has a certificate", async () => { - const { orgX, programA } = setupProgramsAndCourses() + const { orgX, programA, coursesA } = setupProgramsAndCourses() // Mock the program to have a certificate const programWithCertificate = { @@ -486,6 +589,27 @@ describe("OrganizationContent", () => { setMockResponse.get(urls.programs.programsList({ org_id: orgX.id }), { results: [programWithCertificate], }) + // Add the contract-filtered programs query + setMockResponse.get( + urls.programs.programsList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + }), + { + results: [programWithCertificate], + }, + ) + // Add the contract-filtered courses query + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: orgX.contracts[0].id, + page_size: 200, + }), + { + results: coursesA, + }, + ) setMockResponse.get(urls.programEnrollments.enrollmentsList(), [ programEnrollment, ]) @@ -493,7 +617,12 @@ describe("OrganizationContent", () => { programEnrollment, ]) - renderWithProviders() + renderWithProviders( + , + ) const programRoot = await screen.findByTestId("org-program-root") const certificateButton = within(programRoot).getByRole("link", { @@ -507,11 +636,17 @@ describe("OrganizationContent", () => { test("displays only courses with contract-scoped runs", async () => { const { orgX, user, mitxOnlineUser } = setupOrgAndUser() - const contracts = createTestContracts(orgX.id, 1) - const courses = createCoursesWithContractRuns(contracts) + const baseCourses = factories.courses.courses({ count: 3 }).results const program = factories.programs.program({ - courses: courses.map((c) => c.id), + courses: baseCourses.map((c) => c.id), }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + // Update the user's b2b_organizations to include contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + const courses = createCoursesWithContractRuns(contracts) + // Update program to use the actual course IDs from createCoursesWithContractRuns + program.courses = courses.map((c) => c.id) setupOrgDashboardMocks( orgX, @@ -522,7 +657,12 @@ describe("OrganizationContent", () => { contracts, ) - renderWithProviders() + renderWithProviders( + , + ) // Wait for programs to load const programElements = await screen.findAllByTestId("org-program-root") @@ -553,13 +693,20 @@ describe("OrganizationContent", () => { jest.useFakeTimers() jest.setSystemTime(new Date("2024-01-01T00:00:00Z")) const { orgX, user, mitxOnlineUser } = setupOrgAndUser() - const contracts = createTestContracts(orgX.id, 1) // Create courses with specific, predictable dates for the contract runs // Use a date that's guaranteed to be in the future relative to mocked time const specificStartDate = "2024-12-01T00:00:00Z" const specificEndDate = "2025-01-15T00:00:00Z" + const baseCourses = factories.courses.courses({ count: 3 }).results + const program = factories.programs.program({ + courses: baseCourses.map((c) => c.id), + }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + const courses = createCoursesWithContractRuns(contracts).map((course) => ({ ...course, courseruns: course.courseruns.map((run) => { @@ -573,10 +720,8 @@ describe("OrganizationContent", () => { return run }), })) - - const program = factories.programs.program({ - courses: courses.map((c) => c.id), - }) + // Update program to use the actual course IDs + program.courses = courses.map((c) => c.id) setupOrgDashboardMocks( orgX, @@ -587,7 +732,12 @@ describe("OrganizationContent", () => { contracts, ) - renderWithProviders() + renderWithProviders( + , + ) const cards = await within( await screen.findByTestId("org-program-root"), @@ -609,9 +759,16 @@ describe("OrganizationContent", () => { jest.setSystemTime(new Date("2024-01-01T00:00:00Z")) const { orgX, user, mitxOnlineUser } = setupOrgAndUser() - const contracts = createTestContracts(orgX.id, 1) // Create courses where non-contract runs have different, easily identifiable data + const baseCourses = factories.courses.courses({ count: 3 }).results + const program = factories.programs.program({ + courses: baseCourses.map((c) => c.id), + }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + const courses = createCoursesWithContractRuns(contracts).map((course) => ({ ...course, courseruns: course.courseruns.map((run) => { @@ -631,10 +788,8 @@ describe("OrganizationContent", () => { } }), })) - - const program = factories.programs.program({ - courses: courses.map((c) => c.id), - }) + // Update program to use the actual course IDs + program.courses = courses.map((c) => c.id) setupOrgDashboardMocks( orgX, @@ -645,7 +800,12 @@ describe("OrganizationContent", () => { contracts, ) - renderWithProviders() + renderWithProviders( + , + ) const cards = await within( await screen.findByTestId("org-program-root"), @@ -671,9 +831,16 @@ describe("OrganizationContent", () => { test("displays correct pricing from contract-scoped runs", async () => { const { orgX, user, mitxOnlineUser } = setupOrgAndUser() - const contracts = createTestContracts(orgX.id, 1) // Create courses with different pricing for contract vs non-contract runs + const baseCourses = factories.courses.courses({ count: 3 }).results + const program = factories.programs.program({ + courses: baseCourses.map((c) => c.id), + }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + const courses = createCoursesWithContractRuns(contracts).map((course) => ({ ...course, courseruns: course.courseruns.map((run) => { @@ -707,10 +874,8 @@ describe("OrganizationContent", () => { } }), })) - - const program = factories.programs.program({ - courses: courses.map((c) => c.id), - }) + // Update program to use the actual course IDs + program.courses = courses.map((c) => c.id) setupOrgDashboardMocks( orgX, @@ -721,7 +886,12 @@ describe("OrganizationContent", () => { contracts, ) - renderWithProviders() + renderWithProviders( + , + ) const cards = await within( await screen.findByTestId("org-program-root"), @@ -739,9 +909,17 @@ describe("OrganizationContent", () => { test("handles mixed scenarios with enrolled and non-enrolled contract runs", async () => { const { orgX, user, mitxOnlineUser } = setupOrgAndUser() - const contracts = createTestContracts(orgX.id, 1) + const baseCourses = factories.courses.courses({ count: 3 }).results + const program = factories.programs.program({ + courses: baseCourses.map((c) => c.id), + }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts const contractIds = contracts.map((c) => c.id) const courses = createCoursesWithContractRuns(contracts) + // Update program to use the actual course IDs + program.courses = courses.map((c) => c.id) // Create enrollment for first course only const enrollments = [ @@ -772,10 +950,6 @@ describe("OrganizationContent", () => { // Override enrollments for this test setMockResponse.get(urls.enrollment.enrollmentsListV2(), enrollments) - const program = factories.programs.program({ - courses: courses.map((c) => c.id), - }) - setupOrgDashboardMocks( orgX, user, @@ -785,7 +959,12 @@ describe("OrganizationContent", () => { contracts, ) - renderWithProviders() + renderWithProviders( + , + ) const cards = await within( await screen.findByTestId("org-program-root"), @@ -808,7 +987,9 @@ describe("OrganizationContent", () => { setMockResponse.get(urls.userMe.get(), mitxOnlineUser) - renderWithProviders() + renderWithProviders( + , + ) await screen.findByRole("heading", { name: "Organization not found" }) }) @@ -820,7 +1001,12 @@ describe("OrganizationContent", () => { orgX.contracts[0].welcome_message_extra = "

This is additional information with HTML formatting.

" - renderWithProviders() + renderWithProviders( + , + ) await screen.findByText("Welcome to our program!") @@ -836,7 +1022,12 @@ describe("OrganizationContent", () => { orgX.contracts[0].welcome_message_extra = "

Extra content with emphasis

" - renderWithProviders() + renderWithProviders( + , + ) const showMoreLink = await screen.findByText("Show more") @@ -865,7 +1056,12 @@ describe("OrganizationContent", () => { contractWithoutWelcome.welcome_message_extra = "

Extra content

" orgX.contracts[0] = contractWithoutWelcome - renderWithProviders() + renderWithProviders( + , + ) expect(screen.queryByText("Show more")).toBeNull() expect(screen.queryByText("Extra content")).toBeNull() @@ -880,20 +1076,37 @@ describe("OrganizationContent", () => { .welcome_message_extra orgX.contracts[0] = contractWithoutExtra - renderWithProviders() + renderWithProviders( + , + ) expect(screen.queryByText("Welcome message")).toBeNull() expect(screen.queryByText("Show more")).toBeNull() }) - test("does not display welcome message when organization has no contracts", async () => { + test("shows the not found screen if the contract is not found by contractSlug", async () => { const { orgX } = setupProgramsAndCourses() orgX.contracts = [] - renderWithProviders() + renderWithProviders( + , + ) - expect(screen.queryByText("Show more")).toBeNull() + await screen.findByRole("heading", { name: "Contract not found" }) + }) + + test("shows the not found screen when contract slug doesn't match any contracts", async () => { + const { orgX } = setupProgramsAndCourses() + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Contract not found" }) }) test("sanitizes HTML content in welcome_message_extra", async () => { @@ -903,7 +1116,12 @@ describe("OrganizationContent", () => { orgX.contracts[0].welcome_message_extra = '

Safe content

' - renderWithProviders() + renderWithProviders( + , + ) const showMoreLink = await screen.findByText("Show more") await user.click(showMoreLink) @@ -932,7 +1150,12 @@ describe("OrganizationContent", () => { secondContract, ] - renderWithProviders() + renderWithProviders( + , + ) await screen.findByText("First welcome message") @@ -947,10 +1170,16 @@ describe("OrganizationContent", () => { test("displays correct run URL when user is enrolled in one of multiple runs", async () => { const { orgX, user, mitxOnlineUser } = setupOrgAndUser() - const contracts = createTestContracts(orgX.id, 1) // Create a course with 3 different runs with distinct URLs const course = factories.courses.course() + const program = factories.programs.program({ + courses: [course.id], + }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + const runs = [ factories.courses.courseRun({ b2b_contract: contracts[0].id, @@ -986,10 +1215,6 @@ describe("OrganizationContent", () => { grades: [], }) - const program = factories.programs.program({ - courses: [course.id], - }) - setupOrgDashboardMocks( orgX, user, @@ -1001,7 +1226,12 @@ describe("OrganizationContent", () => { setMockResponse.get(urls.enrollment.enrollmentsListV2(), [enrollment]) - renderWithProviders() + renderWithProviders( + , + ) const programElement = await screen.findByTestId("org-program-root") const card = await within(programElement).findByTestId( diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx similarity index 78% rename from frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx rename to frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 1534f1e623..27ea41584f 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -9,7 +9,6 @@ import { programCollectionQueries, } from "api/mitxonline-hooks/programs" import { coursesQueries } from "api/mitxonline-hooks/courses" -import { contractQueries } from "api/mitxonline-hooks/contracts" import * as transform from "./CoursewareDisplay/transform" import { enrollmentQueries } from "api/mitxonline-hooks/enrollment" import { DashboardCard } from "./CoursewareDisplay/DashboardCard" @@ -28,15 +27,16 @@ import { } from "./CoursewareDisplay/types" import graduateLogo from "@/public/images/dashboard/graduate.png" import { - ContractPage, CourseRunEnrollmentRequestV2, - OrganizationPage, V2UserProgramEnrollmentDetail, + ContractPage, + OrganizationPage, } from "@mitodl/mitxonline-api-axios/v2" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { ButtonLink } from "@mitodl/smoot-design" import { RiAwardFill } from "@remixicon/react" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" +import { matchOrganizationBySlug } from "@/common/utils" const HeaderRoot = styled.div({ display: "flex", @@ -59,7 +59,10 @@ const ImageContainer = styled.div(({ theme }) => ({ }, })) -const OrganizationHeader: React.FC<{ org?: OrganizationPage }> = ({ org }) => { +const ContractHeader: React.FC<{ + org?: OrganizationPage + contract?: ContractPage +}> = ({ org, contract }) => { return ( @@ -79,8 +82,7 @@ const OrganizationHeader: React.FC<{ org?: OrganizationPage }> = ({ org }) => { {org?.name} - {/* For now we will use the first contract name until we refactor this to be based on contracts / offerings */} - {org?.contracts[0]?.name} + {contract?.name} ) @@ -93,13 +95,14 @@ const WelcomeMessageExtra = styled(Typography)({ }, }) -const WelcomeMessage: React.FC<{ org?: OrganizationPage }> = ({ org }) => { +const WelcomeMessage: React.FC<{ contract?: ContractPage }> = ({ + contract, +}) => { const empty = const [showingMore, setShowingMore] = React.useState(false) - if (!org?.contracts?.length) { + if (!contract) { return empty } - const contract = org.contracts[0] const welcomeMessage = contract.welcome_message const welcomeMessageExtra = DOMPurify.sanitize(contract.welcome_message_extra) if (!welcomeMessage || !welcomeMessageExtra) { @@ -189,13 +192,16 @@ const ProgramCollectionsList = styled(PlainList)({ // Custom hook to handle multiple program queries and check if any have courses const useProgramCollectionCourses = ( programs: DashboardProgramCollectionProgram[], - orgId: number, + contractId: number, ) => { const programIds = programs .map((program) => program.id) .filter((id) => id !== undefined) const programsQuery = useQuery({ - ...programsQueries.programsList({ id: programIds, org_id: orgId }), + ...programsQueries.programsList({ + id: programIds, + contract_id: contractId, + }), enabled: programIds.length > 0, }) const isLoading = programsQuery.isLoading @@ -225,20 +231,19 @@ const useProgramCollectionCourses = ( const OrgProgramCollectionDisplay: React.FC<{ collection: DashboardProgramCollection - contracts?: ContractPage[] + contract: ContractPage enrollments?: CourseRunEnrollmentRequestV2[] - orgId: number -}> = ({ collection, contracts, enrollments, orgId }) => { +}> = ({ collection, contract, enrollments }) => { const sanitizedDescription = DOMPurify.sanitize(collection.description ?? "") const { isLoading, programsWithCourses, hasAnyCourses } = - useProgramCollectionCourses(collection.programs, orgId) + useProgramCollectionCourses(collection.programs, contract.id) 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, + contract_id: contract.id, }), enabled: firstCourseIds !== undefined && firstCourseIds.length > 0, }) @@ -251,7 +256,7 @@ const OrgProgramCollectionDisplay: React.FC<{ }) ?? [] const transformedCourses = transform.organizationCoursesWithContracts({ courses: rawCourses, - contracts: contracts ?? [], + contract: contract, enrollments: enrollments ?? [], }) @@ -320,14 +325,14 @@ const OrgProgramCollectionDisplay: React.FC<{ const OrgProgramDisplay: React.FC<{ program: DashboardProgram - contracts?: ContractPage[] + contract?: ContractPage courseRunEnrollments?: CourseRunEnrollmentRequestV2[] programEnrollments?: V2UserProgramEnrollmentDetail[] programLoading: boolean orgId: number }> = ({ program, - contracts, + contract, courseRunEnrollments, programEnrollments, programLoading, @@ -355,7 +360,7 @@ const OrgProgramDisplay: React.FC<{ }) ?? [] const transformedCourses = transform.organizationCoursesWithContracts({ courses: rawCourses, - contracts: contracts ?? [], + contract: contract, enrollments: courseRunEnrollments ?? [], }) @@ -401,35 +406,52 @@ const OrgProgramDisplay: React.FC<{ ) } -const OrganizationRoot = styled.div({ +const ContractRoot = styled.div({ display: "flex", flexDirection: "column", gap: "40px", }) -type OrganizationContentInternalProps = { +type ContractContentInternalProps = { org: OrganizationPage + contract: ContractPage } -const OrganizationContentInternal: React.FC< - OrganizationContentInternalProps -> = ({ org }) => { +const ContractContentInternal: React.FC = ({ + org, + contract, +}) => { const orgId = org.id - const contracts = useQuery(contractQueries.contractsList()) - const orgContracts = contracts.data?.filter( - (contract) => contract.organization === orgId, - ) - // For now, the relevant contract is always the first one - const orgContract = orgContracts ? orgContracts[0] : null const courseRunEnrollments = useQuery( enrollmentQueries.courseRunEnrollmentsList(), ) const programEnrollments = useQuery( enrollmentQueries.programEnrollmentsList(), ) - const programs = useQuery(programsQueries.programsList({ org_id: orgId })) + const programs = useQuery( + programsQueries.programsList({ org_id: orgId, contract_id: contract.id }), + ) const programCollections = useQuery( programCollectionQueries.programCollectionsList({}), ) + const courses = useQuery( + coursesQueries.coursesList({ + org_id: orgId, + contract_id: contract.id, + page_size: 200, + }), + ) + + // Helper to check if a program has any courses with contract-scoped runs + const programHasContractRuns = (programId: number): boolean => { + const programData = programs.data?.results.find((p) => p.id === programId) + if (!programData?.courses || !courses.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) => + courses.data.results.some((c) => c.id === courseId), + ) + } // Get IDs of all programs that are in collections const programsInCollections = new Set( @@ -440,17 +462,20 @@ const OrganizationContentInternal: React.FC< const transformedPrograms = programs.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) => { - if (!orgContract?.programs || orgContract.programs.length === 0) { - return true - } - return orgContract.programs.includes(program.id) + // Only include programs that are in the contract + return contract?.programs.includes(program.id) }) + .filter((program) => programHasContractRuns(program.id)) .map((program) => transform.mitxonlineProgram(program)) .sort((a, b) => { - if (!orgContract?.programs) return 0 - const indexA = orgContract.programs.indexOf(a.id) - const indexB = orgContract.programs.indexOf(b.id) + if (!contract?.programs) return 0 + const indexA = contract.programs.indexOf(a.id) + const indexB = contract.programs.indexOf(b.id) return indexA - indexB }) @@ -467,8 +492,8 @@ const OrganizationContentInternal: React.FC< return ( <> - - + + {skeleton} @@ -478,16 +503,16 @@ const OrganizationContentInternal: React.FC< return ( <> - - + + - + {!transformedPrograms ? skeleton : transformedPrograms.map((program) => ( {(programCollections.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) - if (!orgContract?.programs || orgContract.programs.length === 0) { - return collectionProgramIds.length > 0 - } return collectionProgramIds.some( - (id) => id !== undefined && orgContract.programs.includes(id), + (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) => { @@ -514,9 +549,8 @@ const OrganizationContentInternal: React.FC< ) })} @@ -528,33 +562,41 @@ const OrganizationContentInternal: React.FC< )} - + ) } -const matchOrganizationBySlug = - (orgSlug: string) => (organization: OrganizationPage) => { - return organization.slug.replace("org-", "") === orgSlug - } - -type OrganizationContentProps = { +type ContractContentProps = { orgSlug: string + contractSlug: string } -const OrganizationContent: React.FC = ({ +const ContractContent: React.FC = ({ orgSlug, + contractSlug, }) => { const { isLoading: isLoadingMitxOnlineUser, data: mitxOnlineUser } = useQuery( mitxUserQueries.me(), ) + const b2bOrganization = mitxOnlineUser?.b2b_organizations.find( + matchOrganizationBySlug(orgSlug), + ) + const b2bContract = b2bOrganization?.contracts.find( + (contract) => contract.slug === contractSlug, + ) + useEffect(() => { - if ( - mitxOnlineUser?.b2b_organizations.find(matchOrganizationBySlug(orgSlug)) - ) { + if (b2bOrganization) { localStorage.setItem("last-dashboard-org", orgSlug) } - }, [mitxOnlineUser, orgSlug]) + }, [b2bOrganization, orgSlug]) + + useEffect(() => { + if (b2bContract) { + localStorage.setItem("last-dashboard-contract", contractSlug) + } + }, [b2bContract, contractSlug]) if (isLoadingMitxOnlineUser) { return ( @@ -562,17 +604,19 @@ const OrganizationContent: React.FC = ({ ) } - const b2bOrganization = mitxOnlineUser?.b2b_organizations.find( - matchOrganizationBySlug(orgSlug), - ) - if (!b2bOrganization) { return } - return + if (!b2bContract) { + return + } + + return ( + + ) } -export default OrganizationContent +export default ContractContent -export type { OrganizationContentProps } +export type { ContractContentProps } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx index 5dcac8df1f..080f018351 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx @@ -138,24 +138,25 @@ describe("OrganizationCards", () => { }) it("renders Continue buttons with correct organization URLs", async () => { + const contract1 = mitxOnlineFactories.contracts.contract({ + id: 1, + name: "Contract 1", + organization: 1, + slug: "contract-1", + }) + const contract2 = mitxOnlineFactories.contracts.contract({ + id: 2, + name: "Contract 2", + organization: 1, + slug: "contract-2", + }) const organization: OrganizationPage = { id: 1, name: "Test Organization", description: "Test description", logo: "https://example.com/logo.png", slug: "org-test-org", - contracts: [ - mitxOnlineFactories.contracts.contract({ - id: 1, - name: "Contract 1", - organization: 1, - }), - mitxOnlineFactories.contracts.contract({ - id: 2, - name: "Contract 2", - organization: 1, - }), - ], + contracts: [contract1, contract2], } setup({ organizations: [organization] }) @@ -165,12 +166,19 @@ describe("OrganizationCards", () => { }) expect(continueButtons).toHaveLength(4) // 2 contracts × 2 screen sizes (mobile + desktop) - continueButtons.forEach((button) => { - expect(button).toHaveAttribute( - "href", - "/dashboard/organization/test-org", - ) - }) + // Check that we have the correct number of buttons for each contract + const contract1Buttons = continueButtons.filter( + (button) => + button.getAttribute("href") === + "/dashboard/organization/test-org/contract/contract-1", + ) + const contract2Buttons = continueButtons.filter( + (button) => + button.getAttribute("href") === + "/dashboard/organization/test-org/contract/contract-2", + ) + expect(contract1Buttons).toHaveLength(2) // mobile + desktop + expect(contract2Buttons).toHaveLength(2) // mobile + desktop }) it("renders cards for both mobile and desktop screen sizes", async () => { @@ -238,11 +246,13 @@ describe("OrganizationCards", () => { id: 1, name: "Org1 Contract 1", organization: 1, + slug: "org1-contract-1", }), mitxOnlineFactories.contracts.contract({ id: 2, name: "Org1 Contract 2", organization: 1, + slug: "org1-contract-2", }), ], } @@ -257,6 +267,7 @@ describe("OrganizationCards", () => { id: 3, name: "Org2 Contract 1", organization: 2, + slug: "org2-contract-1", }), ], } @@ -286,22 +297,28 @@ describe("OrganizationCards", () => { expect(screen.getAllByText("Org1 Contract 2")).toHaveLength(2) // mobile + desktop expect(screen.getAllByText("Org2 Contract 1")).toHaveLength(2) // mobile + desktop - // Check Continue button URLs point to correct organizations - const org1Buttons = screen - .getAllByRole("link", { name: "Continue" }) - .filter( - (button) => - button.getAttribute("href") === "/dashboard/organization/one", - ) - const org2Buttons = screen - .getAllByRole("link", { name: "Continue" }) - .filter( - (button) => - button.getAttribute("href") === "/dashboard/organization/two", - ) + // Check Continue button URLs point to correct organizations and contracts + const allButtons = screen.getAllByRole("link", { name: "Continue" }) + + const org1Contract1Buttons = allButtons.filter( + (button) => + button.getAttribute("href") === + "/dashboard/organization/one/contract/org1-contract-1", + ) + const org1Contract2Buttons = allButtons.filter( + (button) => + button.getAttribute("href") === + "/dashboard/organization/one/contract/org1-contract-2", + ) + const org2Contract1Buttons = allButtons.filter( + (button) => + button.getAttribute("href") === + "/dashboard/organization/two/contract/org2-contract-1", + ) - expect(org1Buttons).toHaveLength(4) // 2 contracts × 2 screen sizes - expect(org2Buttons).toHaveLength(2) // 1 contract × 2 screen sizes + expect(org1Contract1Buttons).toHaveLength(2) // mobile + desktop + expect(org1Contract2Buttons).toHaveLength(2) // mobile + desktop + expect(org2Contract1Buttons).toHaveLength(2) // mobile + desktop }) }) @@ -335,19 +352,19 @@ describe("OrganizationCards", () => { }) it("handles slug transformation correctly (removes 'org-' prefix)", async () => { + const contract = mitxOnlineFactories.contracts.contract({ + id: 1, + name: "Test Contract", + organization: 1, + slug: "test-contract", + }) const organization: OrganizationPage = { id: 1, name: "Test Organization", description: "Test description", logo: "https://example.com/logo.png", slug: "org-my-company", - contracts: [ - mitxOnlineFactories.contracts.contract({ - id: 1, - name: "Test Contract", - organization: 1, - }), - ], + contracts: [contract], } setup({ organizations: [organization] }) @@ -357,24 +374,24 @@ describe("OrganizationCards", () => { }) expect(continueButtons[0]).toHaveAttribute( "href", - "/dashboard/organization/my-company", + "/dashboard/organization/my-company/contract/test-contract", ) }) it("handles slug without 'org-' prefix", async () => { + const contract = mitxOnlineFactories.contracts.contract({ + id: 1, + name: "Test Contract", + organization: 1, + slug: "test-contract", + }) const organization: OrganizationPage = { id: 1, name: "Test Organization", description: "Test description", logo: "https://example.com/logo.png", slug: "my-company", // No 'org-' prefix - contracts: [ - mitxOnlineFactories.contracts.contract({ - id: 1, - name: "Test Contract", - organization: 1, - }), - ], + contracts: [contract], } setup({ organizations: [organization] }) @@ -384,7 +401,7 @@ describe("OrganizationCards", () => { }) expect(continueButtons[0]).toHaveAttribute( "href", - "/dashboard/organization/my-company", + "/dashboard/organization/my-company/contract/test-contract", ) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx index 98044121b6..d44d3a071a 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx @@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query" import { DashboardCardRoot } from "./DashboardCard" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { ButtonLink } from "@mitodl/smoot-design" -import { organizationView } from "@/common/urls" +import { contractView } from "@/common/urls" import { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" import { RiArrowRightLine } from "@remixicon/react" @@ -104,7 +104,7 @@ const OrganizationContracts: React.FC = ({ }) => { const contractContent = org.contracts?.map((contract) => { - const href = organizationView(org.slug.replace("org-", "")) + const href = contractView(org.slug.replace("org-", ""), contract.slug) return ( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index 0b7ac92779..d3543728e4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -152,25 +152,49 @@ const setupEnrollments = (includeExpired: boolean) => { const setupProgramsAndCourses = () => { const user = u.factories.user.user() const orgX = factories.organizations.organization({ name: "Org X" }) + const contract = makeContract({ organization: orgX.id, name: "Org X Contract", + programs: [], // Will be set after creating programs }) - orgX.contracts = [contract] - const mitxOnlineUser = factories.user.user({ b2b_organizations: [orgX] }) - setMockResponse.get(u.urls.userMe.get(), user) - setMockResponse.get(urls.userMe.get(), mitxOnlineUser) - setMockResponse.get(urls.organization.organizationList(""), orgX) - setMockResponse.get(urls.organization.organizationList(orgX.slug), orgX) const coursesA = makeCourses({ count: 4 }) const coursesB = makeCourses({ count: 3 }) + + // Add contract IDs to course runs + coursesA.results = coursesA.results.map((course) => ({ + ...course, + courseruns: course.courseruns.map((run) => ({ + ...run, + b2b_contract: contract.id, + })), + })) + coursesB.results = coursesB.results.map((course) => ({ + ...course, + courseruns: course.courseruns.map((run) => ({ + ...run, + b2b_contract: contract.id, + })), + })) + const programA = makeProgram({ courses: coursesA.results.map((c) => c.id), }) const programB = makeProgram({ courses: coursesB.results.map((c) => c.id), }) + + // Now set the programs on the contract + contract.programs = [programA.id, programB.id] + + orgX.contracts = [contract] + const mitxOnlineUser = factories.user.user({ b2b_organizations: [orgX] }) + setMockResponse.get(u.urls.userMe.get(), user) + setMockResponse.get(urls.userMe.get(), mitxOnlineUser) + setMockResponse.get(urls.organization.organizationList(""), orgX) + setMockResponse.get(urls.organization.organizationList(orgX.slug), orgX) + const programCollection = makeProgramCollection({ title: "Program Collection", programs: [], @@ -179,6 +203,15 @@ const setupProgramsAndCourses = () => { setMockResponse.get(urls.programs.programsList({ org_id: orgX.id }), { results: [programA, programB], }) + setMockResponse.get( + urls.programs.programsList({ + org_id: orgX.id, + contract_id: contract.id, + }), + { + results: [programA, programB], + }, + ) setMockResponse.get(urls.programCollections.programCollectionsList(), { results: [programCollection], }) @@ -190,6 +223,16 @@ const setupProgramsAndCourses = () => { urls.programs.programsList({ id: [programB.id], org_id: orgX.id }), { results: [programB] }, ) + setMockResponse.get( + urls.courses.coursesList({ + org_id: orgX.id, + contract_id: contract.id, + page_size: 200, + }), + { + results: [...coursesA.results, ...coursesB.results], + }, + ) setMockResponse.get( urls.courses.coursesList({ id: programA.courses, @@ -307,6 +350,27 @@ function setupOrgDashboardMocks( { results: programs }, ) + // Mock programs query with contract filter + if (contracts.length > 0) { + setMockResponse.get( + mitxonline.urls.programs.programsList({ + org_id: org.id, + contract_id: contracts[0].id, + }), + { results: programs }, + ) + + // Mock courses query with contract filter for program validation + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + org_id: org.id, + contract_id: contracts[0].id, + page_size: 200, + }), + { results: courses }, + ) + } + programs.forEach((program) => { setMockResponse.get( mitxonline.urls.courses.coursesList({ @@ -325,8 +389,11 @@ function setupOrgDashboardMocks( const createTestContracts = ( orgId: number, count: number = 1, + programs: number[] = [], ): ContractPage[] => - Array.from({ length: count }, () => makeContract({ organization: orgId })) + Array.from({ length: count }, () => + makeContract({ organization: orgId, programs }), + ) /** * Test utility to create courses with contract-scoped course runs diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.test.tsx index 4a5296f2dd..49de7020d6 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.test.tsx @@ -155,7 +155,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses, - contracts, + contract: contracts[0], enrollments: [], }) @@ -195,7 +195,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses, - contracts, + contract: contracts[0], enrollments: [], }) @@ -218,7 +218,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses, - contracts, + contract: contracts[0], enrollments: enrollments, }) @@ -265,7 +265,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses, - contracts, + contract: contracts[0], enrollments: allEnrollments, }) @@ -293,7 +293,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const courses = createCoursesWithContractRuns(contracts) courses.forEach((course) => { - const transformedCourse = createOrgUnenrolledCourse(course, contracts) + const transformedCourse = createOrgUnenrolledCourse(course, contracts[0]) // Should select the run with matching contract const expectedRun = course.courseruns.find( @@ -320,7 +320,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { ], }) - const transformedCourse = createOrgUnenrolledCourse(course, contracts) + const transformedCourse = createOrgUnenrolledCourse(course, contracts[0]) // Should still return a valid course object expect(transformedCourse.title).toBe(course.title) @@ -481,7 +481,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses, - contracts, + contract: contracts[0], enrollments: enrollments, }) @@ -529,7 +529,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses, - contracts, + contract: contracts[0], enrollments: enrollments, }) @@ -592,7 +592,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses: [course], - contracts, + contract: contracts[0], enrollments: [enrollmentLowGrade, enrollmentHighGrade], }) @@ -654,7 +654,7 @@ describe("Transforming mitxonline enrollment data to DashboardResource", () => { const transformedCourses = organizationCoursesWithContracts({ courses: [course], - contracts, + contract: contracts[0], enrollments: [enrollmentHighGradeNoCert, enrollmentWithCert], }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts index 4d6e1da568..ac276d5319 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts @@ -183,11 +183,11 @@ const enrollmentsToOrgDashboardEnrollments = ( const createOrgUnenrolledCourse = ( course: CourseWithCourseRunsSerializerV2, - contracts: ContractPage[] | undefined, + contract: ContractPage | undefined, ): DashboardCourse => { - const contractIds = contracts?.map((contract) => contract.id) + const contractId = contract?.id const run = course.courseruns.find((run) => { - return run.b2b_contract && contractIds?.includes(run.b2b_contract) + return run.b2b_contract && run.b2b_contract === contractId }) return { key: getKey({ @@ -240,7 +240,7 @@ const selectBestEnrollment = ( const organizationCoursesWithContracts = (raw: { courses: CourseWithCourseRunsSerializerV2[] - contracts?: ContractPage[] // Make optional + contract?: ContractPage enrollments: CourseRunEnrollmentRequestV2[] }): DashboardCourse[] => { const enrollmentsByCourseId = groupBy( @@ -248,20 +248,14 @@ const organizationCoursesWithContracts = (raw: { (enrollment) => enrollment.run.course.id, ) - // Get contract IDs for easy lookup - const contractIds = raw.contracts?.map((contract) => contract.id) || [] - const transformedCourses = raw.courses.map((course) => { const enrollments = enrollmentsByCourseId[course.id] if (enrollments?.length > 0) { - if (raw.contracts && raw.contracts.length > 0) { - // Filter enrollments to only include those with valid contracts + if (raw.contract) { + // Filter enrollments to only include those matching this contract const contractEnrollments = enrollments.filter((enrollment) => { - const courseRunContractId = enrollment.b2b_contract_id - return ( - courseRunContractId && contractIds.includes(courseRunContractId) - ) + return enrollment.b2b_contract_id === raw.contract?.id }) if (contractEnrollments.length > 0) { @@ -297,8 +291,8 @@ const organizationCoursesWithContracts = (raw: { } } - // If contracts are provided but a matching one isn't found, treat it as unenrolled - return createOrgUnenrolledCourse(course, raw.contracts) + // If contract is provided but a matching enrollment isn't found, treat it as unenrolled + return createOrgUnenrolledCourse(course, raw.contract) } else if (enrollments?.length > 0) { // If no contracts provided, just find the matching enrollment to the course const matchingEnrollment = enrollments.find( @@ -320,7 +314,7 @@ const organizationCoursesWithContracts = (raw: { } // If no enrollments or no matching enrollment found, treat it as unenrolled - return createOrgUnenrolledCourse(course, raw.contracts) + return createOrgUnenrolledCourse(course, raw.contract) }) return transformedCourses } diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx index 9b04084e3e..a87e061809 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx @@ -15,7 +15,7 @@ import React from "react" import { DASHBOARD_HOME, MY_LISTS, - organizationView, + contractView, PROFILE, SETTINGS, } from "@/common/urls" @@ -62,23 +62,25 @@ describe("DashboardLayout", () => { }) test("Renders the expected tab links and labels", async () => { + const contract = mitxOnlineFactories.contracts.contract({ + name: "Test Contract", + }) const organizations = [ mitxOnlineFactories.organizations.organization({ slug: "org-test-org", name: "Test Organization", + contracts: [contract], }), ] - const contracts = [ - mitxOnlineFactories.contracts.contract({ - organization: organizations[0].id, - name: "Test Contract", - }), - ] + const contracts = [contract] setup({ organizations, contracts }) const expectedUrls = [ DASHBOARD_HOME, - ...organizations.map((org) => - organizationView(org.slug.replace("org-", "")), + ...organizations.map((org, index) => + contractView( + org.slug.replace("org-", ""), + contracts[index]?.slug ?? "", + ), ), MY_LISTS, PROFILE, diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx index c6a5ece287..647d04b0a2 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx @@ -27,18 +27,16 @@ import { usePathname } from "next/navigation" import backgroundImage from "@/public/images/backgrounds/user_menu_background.svg" import { + contractView, DASHBOARD_HOME, MY_LISTS, - organizationView, PROFILE, SETTINGS, } from "@/common/urls" import dynamic from "next/dynamic" import { MitxOnlineUser, mitxUserQueries } from "api/mitxonline-hooks/user" import { useUserMe } from "api/hooks/user" -import { contractQueries } from "api/mitxonline-hooks/contracts" import { useQuery } from "@tanstack/react-query" -import { ContractPage } from "@mitodl/mitxonline-api-axios/v2" const LearningResourceDrawer = dynamic( () => @@ -238,38 +236,37 @@ type TabData = { desktop: React.ReactNode } } -const getTabData = ( - user?: MitxOnlineUser, - contracts?: ContractPage[], -): TabData[] => { - const orgTabs = - user && contracts - ? user?.b2b_organizations.map((org) => { - const orgContracts = contracts?.filter( - (contract) => contract.organization === org.id, - ) - const contract = - orgContracts && orgContracts.length > 0 ? orgContracts[0] : null - const label = ( - <> - - {org.name} - - {` - ${contract?.name}`} - - ) - return { - value: organizationView(org.slug.replace("org-", "")), - href: organizationView(org.slug.replace("org-", "")), - label: { - mobile: label, - desktop: ( - } text={label} /> - ), - }, - } +const getTabData = (user?: MitxOnlineUser): TabData[] => { + const orgTabs = user + ? user?.b2b_organizations + .map((org) => { + return org.contracts.map((contract) => { + const label = ( + <> + + {org.name} + + {` - ${contract.name}`} + + ) + const href = contractView( + org.slug.replace("org-", ""), + contract.slug, + ) + return { + value: href, + href: href, + label: { + mobile: label, + desktop: ( + } text={label} /> + ), + }, + } + }) }) - : [] + .flat() + : [] return [ { value: DASHBOARD_HOME, @@ -319,16 +316,10 @@ const DashboardPage: React.FC<{ ...mitxUserQueries.me(), }, ) - const { data: contracts, isLoading: isLoadingContracts } = useQuery( - contractQueries.contractsList(), - ) const tabData = useMemo( - () => - isLoadingMitxOnlineUser || isLoadingContracts - ? getTabData() - : getTabData(mitxOnlineUser, contracts), - [isLoadingMitxOnlineUser, isLoadingContracts, mitxOnlineUser, contracts], + () => (isLoadingMitxOnlineUser ? getTabData() : getTabData(mitxOnlineUser)), + [isLoadingMitxOnlineUser, mitxOnlineUser], ) const tabValue = useMemo(() => { diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx index b335835edc..cbea1946c1 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx @@ -24,13 +24,17 @@ describe("OrganizationRedirect", () => { setMockResponse.get(urls.contracts.contractsList(), []) }) - test("navigates to user's first organization", async () => { + test("navigates to user's first organization's first contract", async () => { const { mitxOnlineUser } = setupOrgAndUser() + const contract = factories.contracts.contract({}) const userWithTwoOrgs = { ...mitxOnlineUser, b2b_organizations: [ - mitxOnlineUser.b2b_organizations[0], + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [contract], + }, factories.organizations.organization({}), ], } @@ -40,20 +44,55 @@ describe("OrganizationRedirect", () => { renderWithProviders() await waitFor(() => { + const orgSlug = userWithTwoOrgs.b2b_organizations[0].slug.replace( + "org-", + "", + ) + const contractSlug = contract.slug expect(mockReplace).toHaveBeenCalledWith( - `/dashboard/organization/${mitxOnlineUser.b2b_organizations[0].slug.replace("org-", "")}`, + `/dashboard/organization/${orgSlug}/contract/${contractSlug}`, ) }) }) - test("navigates to user's last visited organization", async () => { + test("navigates to user's last visited organization and contract", async () => { const { mitxOnlineUser } = setupOrgAndUser() setMockResponse.get(urls.userMe.get(), mitxOnlineUser) localStorage.setItem("last-dashboard-org", "last-visited-org") + localStorage.setItem("last-dashboard-contract", "last-visited-contract") + renderWithProviders() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + "/dashboard/organization/last-visited-org/contract/last-visited-contract", + ) + }) + }) + + test("navigates to first contract if only org in localStorage", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const contract = factories.contracts.contract({}) + const userWithContract = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [contract], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithContract) + localStorage.setItem("last-dashboard-org", "last-visited-org") renderWithProviders() await waitFor(() => { + const orgSlug = userWithContract.b2b_organizations[0].slug.replace( + "org-", + "", + ) + const contractSlug = contract.slug expect(mockReplace).toHaveBeenCalledWith( - "/dashboard/organization/last-visited-org", + `/dashboard/organization/${orgSlug}/contract/${contractSlug}`, ) }) }) @@ -74,4 +113,36 @@ describe("OrganizationRedirect", () => { expect(mockReplace).toHaveBeenCalledWith("/dashboard") }) }) + + test("navigates to dashboard home if organization has no contracts", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const userWithOrgButNoContract = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithOrgButNoContract) + + renderWithProviders() + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("/dashboard") + }) + }) + + test("navigates to dashboard home if user is not authenticated", async () => { + setMockResponse.get(urls.userMe.get(), null) + + renderWithProviders() + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("/dashboard") + }) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.tsx index 8ac20f5b3a..d443a42b9e 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.tsx @@ -3,6 +3,7 @@ import React, { useEffect } from "react" import { useRouter } from "next-nprogress-bar" import { useQuery } from "@tanstack/react-query" +import { contractView } from "@/common/urls" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { Skeleton } from "ol-components" @@ -18,13 +19,22 @@ const OrganizationRedirect: React.FC = () => { if (mitxOnlineUser) { const b2bOrganization = mitxOnlineUser.b2b_organizations[0] if (b2bOrganization) { - const lastVisited = localStorage.getItem("last-dashboard-org") - if (lastVisited) { - router.replace(`/dashboard/organization/${lastVisited}`) + const lastOrg = localStorage.getItem("last-dashboard-org") + const lastContract = localStorage.getItem("last-dashboard-contract") + if (lastOrg && lastContract) { + const contractUrl = contractView(lastOrg, lastContract) + router.replace(contractUrl) } else { - router.replace( - `/dashboard/organization/${b2bOrganization.slug.replace("org-", "")}`, - ) + const contract = b2bOrganization.contracts[0] + if (contract) { + const contractUrl = contractView( + b2bOrganization.slug.replace("org-", ""), + contract.slug, + ) + router.replace(contractUrl) + } else { + router.replace("/dashboard") + } } } else { router.replace("/dashboard") diff --git a/frontends/main/src/app/dashboard/organization/[orgSlug]/contract/[contractSlug]/page.tsx b/frontends/main/src/app/dashboard/organization/[orgSlug]/contract/[contractSlug]/page.tsx new file mode 100644 index 0000000000..d15d94abe3 --- /dev/null +++ b/frontends/main/src/app/dashboard/organization/[orgSlug]/contract/[contractSlug]/page.tsx @@ -0,0 +1,16 @@ +import React from "react" +import ContractContent from "@/app-pages/DashboardPage/ContractContent" + +const Page: React.FC< + PageProps<"/dashboard/organization/[orgSlug]/contract/[contractSlug]"> +> = async ({ params }) => { + const resolved = await params + return ( + + ) +} + +export default Page diff --git a/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx b/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx new file mode 100644 index 0000000000..ebb8428454 --- /dev/null +++ b/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx @@ -0,0 +1,202 @@ +import React from "react" +import { renderWithProviders, waitFor } from "@/test-utils" +import Page from "./page" +import { setMockResponse } from "api/test-utils" +import { urls, factories } from "api/mitxonline-test-utils" +import { setupOrgAndUser } from "@/app-pages/DashboardPage/CoursewareDisplay/test-utils" +import { act } from "@testing-library/react" +import { contractView, DASHBOARD_HOME } from "@/common/urls" + +jest.mock("next-nprogress-bar", () => ({ + useRouter: jest.fn(), +})) + +const mockReplace = jest.fn() + +const { useRouter } = jest.requireMock("next-nprogress-bar") +useRouter.mockReturnValue({ + replace: mockReplace, +}) + +describe("Organization Page", () => { + beforeEach(() => { + mockReplace.mockClear() + setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) + setMockResponse.get(urls.contracts.contractsList(), []) + }) + + test("redirects to contract view with first contract when organization and contract exist", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const contract = factories.contracts.contract({}) + const userWithContract = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [contract], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithContract) + + const orgSlug = userWithContract.b2b_organizations[0].slug.replace( + "org-", + "", + ) + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + renderWithProviders() + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + contractView(orgSlug, contract.slug), + ) + }) + }) + + test("redirects to dashboard when organization has no contracts", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const userWithNoContracts = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithNoContracts) + + const orgSlug = userWithNoContracts.b2b_organizations[0].slug.replace( + "org-", + "", + ) + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + renderWithProviders() + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(DASHBOARD_HOME) + }) + }) + + test("redirects to dashboard when organization is not found", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + setMockResponse.get(urls.userMe.get(), mitxOnlineUser) + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + renderWithProviders( + , + ) + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(DASHBOARD_HOME) + }) + }) + + test("redirects to first contract when organization has multiple contracts", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const contract1 = factories.contracts.contract({ slug: "first-contract" }) + const contract2 = factories.contracts.contract({ slug: "second-contract" }) + + const userWithMultipleContracts = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [contract1, contract2], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithMultipleContracts) + + const orgSlug = userWithMultipleContracts.b2b_organizations[0].slug.replace( + "org-", + "", + ) + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + renderWithProviders() + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + contractView(orgSlug, contract1.slug), + ) + }) + }) + + test("redirects correctly when matching organization slug with org- prefix", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const contract = factories.contracts.contract({}) + const userWithContract = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + slug: "org-test-organization", + contracts: [contract], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithContract) + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + renderWithProviders( + , + ) + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + contractView("test-organization", contract.slug), + ) + }) + }) + + test("redirects to dashboard when no contract available", async () => { + const { mitxOnlineUser } = setupOrgAndUser() + + const userWithNoContracts = { + ...mitxOnlineUser, + b2b_organizations: [ + { + ...mitxOnlineUser.b2b_organizations[0], + contracts: [], + }, + ], + } + + setMockResponse.get(urls.userMe.get(), userWithNoContracts) + + const orgSlug = userWithNoContracts.b2b_organizations[0].slug.replace( + "org-", + "", + ) + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + renderWithProviders() + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(DASHBOARD_HOME) + }) + }) +}) diff --git a/frontends/main/src/app/dashboard/organization/[orgSlug]/page.tsx b/frontends/main/src/app/dashboard/organization/[orgSlug]/page.tsx new file mode 100644 index 0000000000..e64e8739a9 --- /dev/null +++ b/frontends/main/src/app/dashboard/organization/[orgSlug]/page.tsx @@ -0,0 +1,39 @@ +"use client" + +import React, { use, useEffect } from "react" +import { useQuery } from "@tanstack/react-query" +import { mitxUserQueries } from "api/mitxonline-hooks/user" +import { matchOrganizationBySlug } from "@/common/utils" +import { useRouter } from "next-nprogress-bar" +import { contractView, DASHBOARD_HOME } from "@/common/urls" + +const Page: React.FC<{ + params: Promise<{ orgSlug: string }> +}> = ({ params }) => { + const router = useRouter() + const { isLoading: isLoadingMitxOnlineUser, data: mitxOnlineUser } = useQuery( + mitxUserQueries.me(), + ) + + const resolved = use(params) + const orgSlug = resolved.orgSlug + + const b2bOrganization = mitxOnlineUser?.b2b_organizations.find( + matchOrganizationBySlug(orgSlug), + ) + const firstContractSlug = b2bOrganization?.contracts[0]?.slug + + useEffect(() => { + if (!isLoadingMitxOnlineUser) { + if (firstContractSlug) { + router.replace(contractView(orgSlug, firstContractSlug)) + } else { + router.replace(DASHBOARD_HOME) + } + } + }, [isLoadingMitxOnlineUser, firstContractSlug, orgSlug, router]) + + return null +} + +export default Page diff --git a/frontends/main/src/app/dashboard/organization/[slug]/page.tsx b/frontends/main/src/app/dashboard/organization/[slug]/page.tsx deleted file mode 100644 index 690c46961b..0000000000 --- a/frontends/main/src/app/dashboard/organization/[slug]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react" -import OrganizationContent from "@/app-pages/DashboardPage/OrganizationContent" -import invariant from "tiny-invariant" - -const Page: React.FC> = async ({ - params, -}) => { - const resolved = await params - invariant(resolved.slug, "slug is required") - return -} - -export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index f43d6f2dcb..4dabfcfc2b 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -69,9 +69,10 @@ export const SETTINGS = dashboardView("settings") export const USERLIST_VIEW = "/dashboard/my-lists/[id]" export const userListView = (id: number) => generatePath(USERLIST_VIEW, { id: String(id) }) -export const ORGANIZATION_VIEW = "/dashboard/organization/[slug]" -export const organizationView = (slug: string) => - generatePath(ORGANIZATION_VIEW, { slug: slug }) +export const CONTRACT_VIEW = + "/dashboard/organization/[orgSlug]/contract/[contractSlug]" +export const contractView = (orgSlug: string, contractSlug: string) => + generatePath(CONTRACT_VIEW, { orgSlug: orgSlug, contractSlug: contractSlug }) export const PROGRAM_VIEW = "/dashboard/program/[id]" export const programView = (id: number) => generatePath(PROGRAM_VIEW, { id: String(id) }) diff --git a/frontends/main/src/common/utils.ts b/frontends/main/src/common/utils.ts index dd19c3e1bb..c41091a078 100644 --- a/frontends/main/src/common/utils.ts +++ b/frontends/main/src/common/utils.ts @@ -1,3 +1,5 @@ +import { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" + const isInEnum = ( value: string, enumObject: Record, @@ -5,4 +7,9 @@ const isInEnum = ( return Object.values(enumObject).includes(value as T) } -export { isInEnum } +const matchOrganizationBySlug = + (orgSlug: string) => (organization: OrganizationPage) => { + return organization.slug.replace("org-", "") === orgSlug + } + +export { isInEnum, matchOrganizationBySlug }