diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3545a83571..a27dfd116d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - 6379:6379 steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: update apt run: sudo apt-get update -y @@ -92,7 +92,7 @@ jobs: javascript-tests: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "^22.0.0" @@ -151,7 +151,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: Build the Docker image env: @@ -199,7 +199,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -223,7 +223,7 @@ jobs: GENERATOR_OUTPUT_DIR_VC: ./frontends/api/src/generated/v0 runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "^22.0.0" @@ -262,7 +262,7 @@ jobs: GENERATOR_OUTPUT_DIR_VC: ./frontends/api/src/generated/v1 runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "^22.0.0" diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index 2e963acf57..662a2f6c59 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -5,12 +5,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout HEAD - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: ref: ${{ github.head_ref }} path: head - name: Checkout BASE - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: ref: ${{ github.base_ref }} path: base diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml index 90271f976d..1de00b537f 100644 --- a/.github/workflows/publish-pages.yml +++ b/.github/workflows/publish-pages.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: diff --git a/RELEASE.rst b/RELEASE.rst index 229f67ba4c..bdb526b073 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,19 @@ Release Notes ============= +Version 0.45.3 +-------------- + +- Canvas - skip TutorProblemFile OCR if content is the same (#2557) +- Refactor price assignment for mitxonline (#2550) +- Update MITxOnline API Client (#2554) +- chore(deps): update nginx docker tag to v1.29.1 (#2549) +- chore(deps): update actions/checkout action to v5 (#2551) +- Certificate page design adjustments (#2547) +- Canvas - resolve visibility of orphaned files (#2548) +- Certificate social media / URL sharing (#2524) +- fix(deps): update django-health-check digest to 592f6a8 (#2527) + Version 0.45.1 (Released September 29, 2025) -------------- diff --git a/frontends/api/package.json b/frontends/api/package.json index 9f28feb53b..ff8b519ffe 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -25,13 +25,13 @@ "@testing-library/react": "^16.3.0", "enforce-unique": "^1.3.0", "jest": "^29.7.0", - "jest-when": "^3.6.0", + "jest-when": "^3.7.0", "lodash": "^4.17.21", "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "^2025.9.11", + "@mitodl/mitxonline-api-axios": "^2025.9.26", "@tanstack/react-query": "^5.66.0", - "axios": "^1.6.3" + "axios": "^1.12.2" } } diff --git a/frontends/api/src/mitxonline/hooks/pages/index.ts b/frontends/api/src/mitxonline/hooks/pages/index.ts index 89b697fdf0..9405edab66 100644 --- a/frontends/api/src/mitxonline/hooks/pages/index.ts +++ b/frontends/api/src/mitxonline/hooks/pages/index.ts @@ -1,3 +1,3 @@ -import { pagesQueries, getPagesDetail } from "./queries" +import { pagesQueries } from "./queries" -export { pagesQueries, getPagesDetail } +export { pagesQueries } diff --git a/frontends/api/src/mitxonline/hooks/pages/queries.ts b/frontends/api/src/mitxonline/hooks/pages/queries.ts index 4784e89044..697397bdc7 100644 --- a/frontends/api/src/mitxonline/hooks/pages/queries.ts +++ b/frontends/api/src/mitxonline/hooks/pages/queries.ts @@ -1,10 +1,6 @@ import { queryOptions } from "@tanstack/react-query" -import { - // pagesApi, - axiosInstance, - pagesApi, -} from "../../clients" -import { CoursePageList } from "@mitodl/mitxonline-api-axios/v2" +import { pagesApi } from "../../clients" + const pagesKeys = { root: ["mitxonline", "pages"], coursePageDetail: (readableId: string) => [ @@ -14,33 +10,16 @@ const pagesKeys = { ], } -const getPagesDetail = async (readableId: string) => { - // TODO: When MITxOnline is published, API client will support readable_id param. - // The API supports it now, just not the client. - // return pagesApi - // .pagesfieldstypecmsCoursePageRetrieve({ readable_id: readableId }) - // .then((res) => res.data) - const todo = ( - ..._params: Parameters - ) => {} - // @ts-expect-error See above ... This error will trigger when client is updated. - todo({ readable_id: readableId }) - - const BASE_PATH = - process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL?.replace(/\/+$/, "") ?? "" - - const url = `${BASE_PATH}/api/v2/pages/?fields=*&readable_id=${encodeURIComponent(readableId)}&type=cms.CoursePage` - return axiosInstance.get(url) -} - const pagesQueries = { courseDetail: (readableId: string) => queryOptions({ queryKey: pagesKeys.coursePageDetail(readableId), queryFn: async () => { - return getPagesDetail(readableId).then((res) => res.data) + return pagesApi + .pagesfieldstypecmsCoursePageRetrieve({ readable_id: readableId }) + .then((res) => res.data) }, }), } -export { pagesQueries, pagesKeys, getPagesDetail } +export { pagesQueries, pagesKeys } diff --git a/frontends/api/src/mitxonline/test-utils/factories/programs.ts b/frontends/api/src/mitxonline/test-utils/factories/programs.ts index 75a6f5a694..cff79ba9bc 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/programs.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/programs.ts @@ -63,6 +63,15 @@ const program: PartialFactory = (overrides = {}) => { min_weekly_hours: `${faker.number.int({ min: 1, max: 5 })} hours`, max_weekly_hours: `${faker.number.int({ min: 6, max: 10 })} hours`, start_date: faker.date.past().toISOString(), + max_price: faker.number.int({ min: 50, max: 5000 }), + min_price: faker.number.int({ min: 50, max: 5000 }), + enrollment_start: faker.helpers.maybe(() => + faker.date.past().toISOString(), + ), + enrollment_end: faker.helpers.maybe(() => + faker.date.future().toISOString(), + ), + end_date: faker.helpers.maybe(() => faker.date.future().toISOString()), } return mergeOverrides(defaults, overrides) diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 2ded9ea90c..08960ab423 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -49,9 +49,9 @@ const courses = { const pages = { courseDetail: (readableId: string) => - `${API_BASE_URL}/api/v2/pages/?fields=*&readable_id=${encodeURIComponent( + `${API_BASE_URL}/api/v2/pages/?fields=*&type=cms.CoursePage&readable_id=${encodeURIComponent( readableId, - )}&type=cms.CoursePage`, + )}`, } const organization = { diff --git a/frontends/api/src/test-utils/mockAxios.ts b/frontends/api/src/test-utils/mockAxios.ts index 6b37727fbb..2a4959bde9 100644 --- a/frontends/api/src/test-utils/mockAxios.ts +++ b/frontends/api/src/test-utils/mockAxios.ts @@ -8,16 +8,28 @@ type Method = "get" | "post" | "patch" | "delete" type PartialAxiosResponse = Pick -const alwaysError = ( - method: string, +type RequestMaker = ( + method: Method, url: string, - _body?: unknown, -): Promise => { + body?: unknown, +) => Promise + +const alwaysError: RequestMaker = (method, url, _body) => { const msg = `No response specified for ${method} ${url}` console.error(msg) throw new Error(msg) } +const standardizeUrl = (url: string) => { + if (!url.includes("?")) { + return url + } + const [path, queryString] = url.split("?") + const query = new URLSearchParams(queryString) + query.sort() + return `${path}?${query.toString()}` +} + /** * A jest mock function that makes fake network requests. * @@ -29,17 +41,23 @@ const alwaysError = ( * '/some/url/to/thing', * expect.objectContaining({ some: 'value' }) // request body * ]) + * + * NOTE: URLs called by this function are first * ``` */ const makeRequest = jest.fn(alwaysError) +const makeSortedRequest: RequestMaker = (method, url, body) => + makeRequest(method, standardizeUrl(url), body) const mockAxiosInstance = { - get: jest.fn((url: string) => makeRequest("get", url, undefined)), - post: jest.fn((url: string, body: unknown) => makeRequest("post", url, body)), + get: jest.fn((url: string) => makeSortedRequest("get", url, undefined)), + post: jest.fn((url: string, body: unknown) => + makeSortedRequest("post", url, body), + ), patch: jest.fn((url: string, body: unknown) => - makeRequest("patch", url, body), + makeSortedRequest("patch", url, body), ), - delete: jest.fn((url: string) => makeRequest("delete", url, undefined)), + delete: jest.fn((url: string) => makeSortedRequest("delete", url, undefined)), request: jest.fn( ( { @@ -57,7 +75,11 @@ const mockAxiosInstance = { // on object shape. const deserialized = typeof data === "string" ? JSON.parse(data) : undefined - return makeRequest(method.toLowerCase(), url, deserialized) + return makeSortedRequest( + method.toLowerCase() as Method, + url, + deserialized, + ) }, ), defaults: {}, // OpenAPI Generator accesses this, so it needs to exist @@ -72,15 +94,6 @@ const expectAnythingOrNil = expect.toBeOneOf([ expect.toBeNil(), ]) -const standardizeUrl = (url: T) => { - if (!(typeof url === "string")) return url - if (!url.includes("?")) return url - const [path, queryString] = url.split("?") - const query = new URLSearchParams(queryString) - query.sort() - return `${path}?${query.toString()}` -} - const mockRequest = ( method: Method, url: string, @@ -88,8 +101,9 @@ const mockRequest = ( responseBody: U | ((req: T) => U) | undefined = undefined, code: number, ) => { + const urlMatcher = typeof url === "string" ? standardizeUrl(url) : url when(makeRequest) - .calledWith(method, standardizeUrl(url), requestBody) + .calledWith(method, urlMatcher, requestBody) .mockImplementation(async () => { let data if (isFunction(responseBody)) { @@ -158,7 +172,7 @@ const setMockResponse = { { code = 200, requestBody }: MockResponseOptions = {}, ) => mockRequest("patch", url, requestBody, responseBody, code), /** - * Set mock response for a PATCH request; default response status is 204. + * Set mock response for a DELETE request; default response status is 204. * * If `responseBody` is a Promise, the request will resolve to the value of * `responseBody` when `responseBody` resolves. diff --git a/frontends/main/package.json b/frontends/main/package.json index 86155c3424..5dd7d023b6 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.9.11", + "@mitodl/mitxonline-api-axios": "^2025.9.26", "@mitodl/smoot-design": "^6.17.1", "@next/bundle-analyzer": "^14.2.15", "@react-pdf/renderer": "^4.3.0", diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx index 4015429088..37b61afb3a 100644 --- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.test.tsx @@ -1,9 +1,15 @@ import React from "react" import moment from "moment" import { factories, setMockResponse } from "api/test-utils" -import { screen, renderWithProviders } from "@/test-utils" +import { screen, renderWithProviders, user } from "@/test-utils" import CertificatePage, { CertificateType } from "./CertificatePage" +import SharePopover from "./SharePopover" import { urls } from "api/mitxonline-test-utils" +import { + FACEBOOK_SHARE_BASE_URL, + TWITTER_SHARE_BASE_URL, + LINKEDIN_SHARE_BASE_URL, +} from "@/common/urls" describe("CertificatePage", () => { it("renders a course certificate", async () => { @@ -18,6 +24,7 @@ describe("CertificatePage", () => { , ) @@ -80,6 +87,7 @@ describe("CertificatePage", () => { , ) @@ -99,3 +107,70 @@ describe("CertificatePage", () => { await screen.findAllByText(certificate.uuid) }) }) + +describe("CertificatePage - SharePopover", () => { + const mockProps = { + open: true, + title: "Test Certificate", + anchorEl: document.createElement("div"), + onClose: jest.fn(), + pageUrl: "https://example.com/certificate/123", + } + + const mockWriteText = jest.fn() + Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders the SharePopover with correct content", () => { + renderWithProviders() + + expect(screen.getByText("Share on social")).toBeInTheDocument() + expect(screen.getByText("Share a link")).toBeInTheDocument() + expect(screen.getByDisplayValue(mockProps.pageUrl)).toBeInTheDocument() + expect(screen.getByText("Copy Link")).toBeInTheDocument() + }) + + it("renders social media share links with correct URLs", () => { + renderWithProviders() + + const facebookHref = `${FACEBOOK_SHARE_BASE_URL}?u=${encodeURIComponent(mockProps.pageUrl)}` + const twitterHref = `${TWITTER_SHARE_BASE_URL}?text=${encodeURIComponent(mockProps.title)}&url=${encodeURIComponent(mockProps.pageUrl)}` + const linkedinHref = `${LINKEDIN_SHARE_BASE_URL}?url=${encodeURIComponent(mockProps.pageUrl)}` + + const facebookLink = screen.getByRole("link", { name: "Share on Facebook" }) + const twitterLink = screen.getByRole("link", { name: "Share on Twitter" }) + const linkedinLink = screen.getByRole("link", { name: "Share on LinkedIn" }) + + expect(facebookLink).toHaveAttribute("href", facebookHref) + expect(twitterLink).toHaveAttribute("href", twitterHref) + expect(linkedinLink).toHaveAttribute("href", linkedinHref) + + expect(facebookLink).toHaveAttribute("target", "_blank") + expect(twitterLink).toHaveAttribute("target", "_blank") + expect(linkedinLink).toHaveAttribute("target", "_blank") + }) + + it("copies link to clipboard when copy button is clicked", async () => { + renderWithProviders() + + const copyButton = screen.getByRole("button", { name: "Copy Link" }) + await user.click(copyButton) + + expect(mockWriteText).toHaveBeenCalledWith(mockProps.pageUrl) + screen.getByRole("button", { name: "Copied!" }) + }) + + it("does not render when open is false", () => { + renderWithProviders() + + expect(screen.queryByText("Share on social")).not.toBeInTheDocument() + expect(screen.queryByText("Share a link")).not.toBeInTheDocument() + }) +}) diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx index a9a2ae60bf..eb44373506 100644 --- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useRef, useEffect, useCallback } from "react" +import React, { useRef, useEffect, useCallback, useState } from "react" import { notFound } from "next/navigation" import Image from "next/image" import { Link, Typography, styled } from "ol-components" @@ -12,12 +12,13 @@ import OpenLearningLogo from "@/public/images/mit-open-learning-logo.svg" import CertificateBadgeDesktop from "@/public/images/certificate-badge-desktop.svg" import CertificateBadgeMobile from "@/public/images/certificate-badge-mobile.svg" import { formatDate, NoSSR } from "ol-utilities" -import { RiDownloadLine, RiPrinterLine } from "@remixicon/react" +import { RiDownloadLine, RiPrinterLine, RiShareLine } from "@remixicon/react" import type { V2ProgramCertificate, V2CourseRunCertificate, SignatoryItem, } from "@mitodl/mitxonline-api-axios/v2" +import SharePopover from "./SharePopover" const Page = styled.div(({ theme }) => ({ backgroundImage: `url(${backgroundImage.src})`, @@ -48,7 +49,6 @@ const Title = styled(Typography)(({ theme }) => ({ }, }, [theme.breakpoints.down("md")]: { - textAlign: "left", margin: "24px 0", span: { fontSize: theme.typography.pxToRem(24), @@ -57,18 +57,22 @@ const Title = styled(Typography)(({ theme }) => ({ }, })) -const Buttons = styled.div({ +const Buttons = styled.div(({ theme }) => ({ display: "flex", gap: "12px", justifyContent: "center", - marginBottom: "50px", -}) + width: "fit-content", + margin: "0 auto 50px auto", + [theme.breakpoints.down("md")]: { + margin: "0 auto 32px auto", + }, +})) const Outer = styled.div(({ theme }) => ({ maxWidth: "1306px", minWidth: "1200px", border: `4px solid ${theme.custom.colors.silverGray}`, - padding: "24px 23px", + padding: "24px", backgroundColor: theme.custom.colors.white, marginTop: "50px", margin: "0 auto", @@ -79,6 +83,12 @@ const Outer = styled.div(({ theme }) => ({ maxWidth: "unset", minWidth: "unset", }, + [theme.breakpoints.down("md")]: { + padding: 0, + border: "none", + maxWidth: "460px", + minWidth: "unset", + }, }, "@media print": { boxSizing: "border-box", @@ -89,15 +99,14 @@ const Outer = styled.div(({ theme }) => ({ const Inner = styled.div(({ theme }) => ({ border: `1px solid ${theme.custom.colors.silverGrayLight}`, - padding: "67px", + padding: "64px", display: "flex", flexDirection: "column", - gap: "56px", + gap: "64px", position: "relative", "@media screen": { [theme.breakpoints.down("lg")]: { padding: "40px", - gap: "40px", }, [theme.breakpoints.down("md")]: { border: `2px solid ${theme.custom.colors.lightGray2}`, @@ -310,7 +319,7 @@ const Spacer = styled.div(({ theme }) => ({ const Signatories = styled.div(({ theme }) => ({ display: "flex", flexDirection: "row", - gap: "16px", + gap: "40px", width: "100%", "@media screen": { [theme.breakpoints.down("md")]: { @@ -640,7 +649,8 @@ export enum CertificateType { const CertificatePage: React.FC<{ certificateType: CertificateType uuid: string -}> = ({ certificateType, uuid }) => { + pageUrl: string +}> = ({ certificateType, uuid, pageUrl }) => { const { data: courseCertificateData, isLoading: isCourseLoading, @@ -694,6 +704,9 @@ const CertificatePage: React.FC<{ } }, [print]) + const [shareOpen, setShareOpen] = useState(false) + const shareButtonRef = useRef(null) + if (isCourseLoading || isProgramLoading) { return } @@ -709,7 +722,7 @@ const CertificatePage: React.FC<{ const url = window.URL.createObjectURL(blob) const a = document.createElement("a") a.href = url - a.download = `${title} Certificate - MIT Open Learning.pdf` + a.download = `${title} Certificate issued by MIT Open Learning.pdf` document.body.appendChild(a) a.click() document.body.removeChild(a) @@ -728,12 +741,19 @@ const CertificatePage: React.FC<{ return ( + setShareOpen(false)} + pageUrl={pageUrl} + /> <Typography variant="h3"> <strong>{title}</strong> {displayType} </Typography> - + +