diff --git a/frontends/api/package.json b/frontends/api/package.json index 63276ce840..9a85853dbc 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -30,7 +30,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2025.8.6", + "@mitodl/mitxonline-api-axios": "2025.8.12", "@tanstack/react-query": "^5.66.0", "axios": "^1.6.3" } diff --git a/frontends/api/src/mitxonline/hooks/courses/queries.ts b/frontends/api/src/mitxonline/hooks/courses/queries.ts index 7dd9dfa36e..33c6056f79 100644 --- a/frontends/api/src/mitxonline/hooks/courses/queries.ts +++ b/frontends/api/src/mitxonline/hooks/courses/queries.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query" import type { CoursesApiApiV2CoursesListRequest, - PaginatedV2CourseWithCourseRunsList, + PaginatedCourseWithCourseRunsSerializerV2List, } from "@mitodl/mitxonline-api-axios/v2" import { coursesApi } from "../../clients" @@ -18,9 +18,10 @@ const coursesQueries = { coursesList: (opts?: CoursesApiApiV2CoursesListRequest) => queryOptions({ queryKey: coursesKeys.coursesList(opts), - queryFn: async (): Promise => { - return coursesApi.apiV2CoursesList(opts).then((res) => res.data) - }, + queryFn: + async (): Promise => { + return coursesApi.apiV2CoursesList(opts).then((res) => res.data) + }, }), } diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index 61db519596..e042776f5e 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -1,3 +1,3 @@ -import { organizationQueries } from "./queries" +import { organizationQueries, useB2BAttachMutation } from "./queries" -export { organizationQueries } +export { organizationQueries, useB2BAttachMutation } diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index c1bcc0d49d..e7ef845ec9 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -1,7 +1,14 @@ -import { B2bApiB2bOrganizationsRetrieveRequest } from "@mitodl/mitxonline-api-axios/v0" -import { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" -import { queryOptions } from "@tanstack/react-query" +import { + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query" import { b2bApi } from "../../clients" +import { + OrganizationPage, + B2bApiB2bAttachCreateRequest, + B2bApiB2bOrganizationsRetrieveRequest, +} from "@mitodl/mitxonline-api-axios/v2" const organizationKeys = { root: ["mitxonline", "organizations"], @@ -22,4 +29,16 @@ const organizationQueries = { }), } -export { organizationQueries, organizationKeys } +const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => b2bApi.b2bAttachCreate(opts), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.organizationsRetrieve(), + }) + }, + }) +} + +export { organizationQueries, organizationKeys, useB2BAttachMutation } diff --git a/frontends/api/src/mitxonline/test-utils/factories/courses.ts b/frontends/api/src/mitxonline/test-utils/factories/courses.ts index 6e858175f9..adc81659c8 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/courses.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/courses.ts @@ -1,14 +1,16 @@ import { mergeOverrides, makePaginatedFactory } from "ol-test-utilities" import type { PartialFactory } from "ol-test-utilities" -import type { V2CourseWithCourseRuns } from "@mitodl/mitxonline-api-axios/v2" +import type { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import { UniqueEnforcer } from "enforce-unique" const uniqueCourseId = new UniqueEnforcer() const uniqueCourseRunId = new UniqueEnforcer() -const course: PartialFactory = (overrides = {}) => { - const defaults: V2CourseWithCourseRuns = { +const course: PartialFactory = ( + overrides = {}, +) => { + const defaults: CourseWithCourseRunsSerializerV2 = { id: uniqueCourseId.enforce(() => faker.number.int()), title: faker.lorem.words(3), readable_id: faker.lorem.slug(), @@ -86,7 +88,7 @@ const course: PartialFactory = (overrides = {}) => { ingest_content_files_for_ai: faker.datatype.boolean(), } - return mergeOverrides(defaults, overrides) + return mergeOverrides(defaults, overrides) } const courses = makePaginatedFactory(course) diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index a31f94742c..129e030080 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -46,8 +46,13 @@ const organization = { `${API_BASE_URL}/api/v0/b2b/organizations/${organizationSlug}/`, } +const b2bAttach = { + b2bAttachView: (code: string) => `${API_BASE_URL}/api/v0/b2b/attach/${code}/`, +} + export { b2b, + b2bAttach, currentUser, enrollment, programs, diff --git a/frontends/main/package.json b/frontends/main/package.json index 6c0733ab08..a46ae1ccfd 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/cache": "^11.13.1", "@emotion/styled": "^11.11.0", "@mitodl/course-search-utils": "3.3.2", - "@mitodl/mitxonline-api-axios": "2025.8.6", + "@mitodl/mitxonline-api-axios": "2025.8.12", "@mitodl/smoot-design": "^6.10.0", "@next/bundle-analyzer": "^14.2.15", "@remixicon/react": "^4.2.0", diff --git a/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx new file mode 100644 index 0000000000..de25cbd0bb --- /dev/null +++ b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx @@ -0,0 +1,50 @@ +import React from "react" +import { renderWithProviders, setMockResponse, waitFor } from "@/test-utils" +import { urls } from "api/test-utils" +import { urls as b2bUrls } from "api/mitxonline-test-utils" +import * as commonUrls from "@/common/urls" +import { Permission } from "api/hooks/user" +import B2BAttachPage from "./B2BAttachPage" +import { redirect } from "next/navigation" + +// Mock Next.js redirect function +jest.mock("next/navigation", () => ({ + redirect: jest.fn(), +})) + +const mockRedirect = jest.mocked(redirect) + +describe("B2BAttachPage", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test("Renders when logged in", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permission.Authenticated]: true, + }) + + setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), []) + + renderWithProviders(, { + url: commonUrls.B2B_ATTACH_VIEW, + }) + }) + + test("Redirects to dashboard on successful attachment", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permission.Authenticated]: true, + }) + + setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), []) + + renderWithProviders(, { + url: commonUrls.B2B_ATTACH_VIEW, + }) + + // Wait for the mutation to complete and verify redirect was called + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME) + }) + }) +}) diff --git a/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx new file mode 100644 index 0000000000..1ac8b19ab7 --- /dev/null +++ b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx @@ -0,0 +1,50 @@ +"use client" +import React from "react" +import { redirect } from "next/navigation" +import { styled, Breadcrumbs, Container, Typography } from "ol-components" +import * as urls from "@/common/urls" +import { useB2BAttachMutation } from "api/mitxonline-hooks/organizations" + +type B2BAttachPageProps = { + code: string +} + +const InterstitialMessage = styled(Typography)(({ theme }) => ({ + ...theme.typography.body1, + textAlign: "center", +})) + +const B2BAttachPage: React.FC = ({ code }) => { + const { + mutate: attach, + isSuccess, + isPending, + } = useB2BAttachMutation({ + enrollment_code: code, + }) + + React.useEffect(() => { + attach?.() + }, [attach]) + + React.useEffect(() => { + if (isSuccess) { + redirect(urls.DASHBOARD_HOME) + } + }, [isSuccess]) + + return ( + + + {isPending && ( + Validating code "{code}"... + )} + + ) +} + +export default B2BAttachPage diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts index 225f6c0a4d..0065d5eda5 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts @@ -6,7 +6,7 @@ import { CourseRunEnrollment, - V2CourseWithCourseRuns, + CourseWithCourseRunsSerializerV2, V2Program, V2ProgramCollection, } from "@mitodl/mitxonline-api-axios/v2" @@ -72,7 +72,7 @@ const mitxonlineEnrollments = (data: CourseRunEnrollment[]) => data.map((course) => mitxonlineEnrollment(course)) const mitxonlineUnenrolledCourse = ( - course: V2CourseWithCourseRuns, + course: CourseWithCourseRunsSerializerV2, ): DashboardCourse => { const run = course.courseruns.find((run) => run.id === course.next_run_id) return { @@ -98,7 +98,7 @@ const mitxonlineUnenrolledCourse = ( } const mitxonlineCourses = (raw: { - courses: V2CourseWithCourseRuns[] + courses: CourseWithCourseRunsSerializerV2[] enrollments: CourseRunEnrollment[] }) => { const enrollmentsByCourseId = groupBy( diff --git a/frontends/main/src/app/attach/[code]/page.tsx b/frontends/main/src/app/attach/[code]/page.tsx new file mode 100644 index 0000000000..dec90dcea2 --- /dev/null +++ b/frontends/main/src/app/attach/[code]/page.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import invariant from "tiny-invariant" +import { Permission } from "api/hooks/user" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import type { PageParams } from "@/app/types" +import B2BAttachPage from "@/app-pages/B2BAttachPage/B2BAttachPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "Use Enrollment Code", +}) + +const Page: React.FC> = async ({ + params, +}) => { + const resolved = await params + invariant(resolved?.code, "code is required") + return ( + + + + ) +} + +export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 1937457001..56f8b9092f 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -167,3 +167,7 @@ export const SEARCH_LEARNING_MATERIAL = querifiedSearchUrl({ }) export const ECOMMERCE_CART = "/cart/" as const + +export const B2B_ATTACH_VIEW = "/attach/[code]" +export const b2bAttachView = (code: string) => + generatePath(B2B_ATTACH_VIEW, { code: code }) diff --git a/yarn.lock b/yarn.lock index cfedc860c3..d080767835 100755 --- a/yarn.lock +++ b/yarn.lock @@ -3018,13 +3018,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:2025.8.6": - version: 2025.8.6 - resolution: "@mitodl/mitxonline-api-axios@npm:2025.8.6" +"@mitodl/mitxonline-api-axios@npm:2025.8.12": + version: 2025.8.12 + resolution: "@mitodl/mitxonline-api-axios@npm:2025.8.12" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/6e20c0ff75a3700a39e16b6b88bf6fba1d682a8abf1d7dbbd369d5072530457ec535c4bc501933a0fb0602ea489cf2f8549d8806180040edf2ba1f0a4360ae84 + checksum: 10/5d2a0874c0fc251fe32218d0aeadc2b1134e61209ffeb2fb6c767676bb402a1cbcc7b7d00371419b8e4577a0119cac50d7f6799a64f8a4cf8a9c2da92b00fc39 languageName: node linkType: hard @@ -7363,7 +7363,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^9.9.0" - "@mitodl/mitxonline-api-axios": "npm:2025.8.6" + "@mitodl/mitxonline-api-axios": "npm:2025.8.12" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.1.0" axios: "npm:^1.6.3" @@ -13950,7 +13950,7 @@ __metadata: "@emotion/styled": "npm:^11.11.0" "@faker-js/faker": "npm:^9.9.0" "@mitodl/course-search-utils": "npm:3.3.2" - "@mitodl/mitxonline-api-axios": "npm:2025.8.6" + "@mitodl/mitxonline-api-axios": "npm:2025.8.12" "@mitodl/smoot-design": "npm:^6.10.0" "@next/bundle-analyzer": "npm:^14.2.15" "@remixicon/react": "npm:^4.2.0"