diff --git a/Dockerfile b/Dockerfile index b7caa4da66..3ea19f46de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,8 +47,16 @@ EXPOSE 8061 ENV PORT=8061 CMD ["sh", "-c", "exec granian --interface asginl --reload --host 0.0.0.0 --port ${PORT:-8061} --workers ${GRANIAN_WORKERS:-3} --blocking-threads 1 main.asgi:application"] -# ─── Development target ─────────────────────────────────────────────────────── +# ─── Development target (docker compose) ───────────────────────────────────── FROM final AS development RUN --mount=type=cache,target=/opt/uv-cache,uid=1000,gid=1000 \ uv sync --frozen --no-install-project + +# ─── Local-dev target (ol-infrastructure local-dev k8s/Tilt stack) ─────────── +# Runtime user owns /src (live-synced source); dev deps come from `development`. +FROM development AS local-dev + +USER root +RUN chown -R mitodl:mitodl /src +USER mitodl diff --git a/RELEASE.rst b/RELEASE.rst index 83f7d4928e..4f0a93a134 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,17 @@ Release Notes ============= +Version 0.73.2 +-------------- + +- fix: handle no-max-learners contracts in B2B seat assignment UI (#3564) +- feat: local-dev Docker targets and granian kill timeout (#3579) +- fix: changing the positions of share feature on videos and podcasts episode (#3540) +- Bound program marketing-page embedding input to avoid token overflow (#3562) +- fix(vector-search): relax qdrant-client pin to allow 1.18.x patches (#3546) +- feat: Add "Send Test Email to Me" to the seat assignment confirmation modal (#3558) +- Upgrade django-health-check from pinned git commit to PyPI 3.24.0 (#3556) + Version 0.73.1 (Released July 07, 2026) -------------- diff --git a/frontends/api/package.json b/frontends/api/package.json index 9986a8c1ef..87960b84a6 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -31,7 +31,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2026.6.30-1", + "@mitodl/mitxonline-api-axios": "2026.7.7", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index 4b051a2bac..8eb8c68c91 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -6,6 +6,7 @@ import { B2bApiB2bManagerOrganizationsContractsCodesReassignUpdateRequest, B2bApiB2bManagerOrganizationsContractsCodesRemindCreateRequest, B2bApiB2bManagerOrganizationsContractsCodesRevokeDestroyRequest, + B2bApiB2bManagerOrganizationsContractsCodesSendTestEmailCreateRequest, } from "@mitodl/mitxonline-api-axios/v2" import { organizationQueries, @@ -106,6 +107,14 @@ const useReassignCode = () => { }) } +/** Send a test enrollment code to the given email address. */ +const useSendTestEmail = () => + useMutation({ + mutationFn: ( + opts: B2bApiB2bManagerOrganizationsContractsCodesSendTestEmailCreateRequest, + ) => b2bApi.b2bManagerOrganizationsContractsCodesSendTestEmailCreate(opts), + }) + export { organizationQueries, managerOrganizationQueries, @@ -114,6 +123,7 @@ export { useReassignCode, useRemindCode, useRevokeCode, + useSendTestEmail, } export type { ManagerEnrollmentCode, diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 9fb471e41f..3983dc05b6 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -128,6 +128,8 @@ const contracts = { code: string, ) => `${getApiBaseUrl()}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/codes/${code}/reassign/`, + managerContractSendTestEmail: (orgId: number, contractId: number) => + `${getApiBaseUrl()}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/codes/send_test_email/`, } const certificates = { diff --git a/frontends/main/Dockerfile.web b/frontends/main/Dockerfile.web index 1371f0e2b8..5efbe72444 100644 --- a/frontends/main/Dockerfile.web +++ b/frontends/main/Dockerfile.web @@ -91,6 +91,13 @@ ENV NEXT_BUILD_CI=1 RUN yarn build +# STAGE: local-dev +# Local-dev target (ol-infrastructure local-dev k8s/Tilt stack): runs `next dev` +FROM base AS local-dev + +ENV NODE_ENV=development +CMD ["yarn", "dev"] + # STAGE: runner (default) # Copies only the standalone server, static assets, and public directory from # the build stage into a clean image. No workspace install is needed; the diff --git a/frontends/main/package.json b/frontends/main/package.json index 616b656ba3..8b65d5fb23 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -18,7 +18,7 @@ "@mitodl/arithmix": "^0.2.2", "@mitodl/course-search-utils": "^3.5.2", "@mitodl/hacksnack": "^0.1.0", - "@mitodl/mitxonline-api-axios": "2026.6.30-1", + "@mitodl/mitxonline-api-axios": "2026.7.7", "@mitodl/smoot-design": "^6.27.0", "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.test.tsx index 68059f64ac..5678ad1357 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.test.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.test.tsx @@ -1,5 +1,5 @@ import React, { act } from "react" -import { renderWithTheme, screen, user } from "@/test-utils" +import { renderWithTheme, screen, user, waitFor } from "@/test-utils" import { AssignSeatsConfirmModal } from "./AssignSeatsConfirmModal" const baseProps = { @@ -297,6 +297,222 @@ describe("AssignSeatsConfirmModal — over-capacity state (CSV only)", () => { }) }) +describe("AssignSeatsConfirmModal — unlimited contract (availableSeats null)", () => { + beforeEach(() => jest.clearAllMocks()) + + const unlimitedProps = { ...baseProps, availableSeats: null } + + test("shows unlimited messaging in the seats stat instead of a number", () => { + renderWithTheme( + , + ) + + // Confirm step still renders normally. + expect( + screen.getByRole("heading", { name: /ready to send invitations/i }), + ).toBeInTheDocument() + + // Stat value is a dash and the label / group name convey no seat limit. + expect(screen.getByText("—")).toBeInTheDocument() + expect(screen.getByText("No seat limit")).toBeInTheDocument() + expect( + screen.getByRole("group", { name: /no seat limit on this contract/i }), + ).toBeInTheDocument() + }) + + test("does not display a numeric or negative 'seats remaining after sending'", () => { + // validCount far exceeds any real cap; with no cap there must be no negative. + renderWithTheme( + , + ) + + expect( + screen.queryByText(/seats remaining after sending/i), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("group", { name: /seats remaining after sending/i }), + ).not.toBeInTheDocument() + // A cap of null run through `availableSeats - validCount` would render -100. + expect(screen.queryByText("-100")).not.toBeInTheDocument() + }) + + test("never enters the over-capacity 'Not enough seats available' state", () => { + renderWithTheme( + , + ) + + expect( + screen.getByRole("heading", { name: /ready to send invitations/i }), + ).toBeInTheDocument() + expect( + screen.queryByRole("heading", { name: /not enough seats available/i }), + ).not.toBeInTheDocument() + }) + + test("dialog accessible description conveys no seat limit", () => { + renderWithTheme( + , + ) + + expect(screen.getByRole("dialog")).toHaveAccessibleDescription( + /no seat limit on this contract/i, + ) + }) +}) + +describe("AssignSeatsConfirmModal — email preview (send test email)", () => { + const sendTestEmailProps = { + ...baseProps, + userEmail: "manager@test.com", + onSendTestEmail: jest.fn(), + } + + beforeEach(() => jest.clearAllMocks()) + + test("does not show Email Preview section when onSendTestEmail is not provided", () => { + renderWithTheme() + + expect(screen.queryByText("Email Preview")).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: /send test email to me/i }), + ).not.toBeInTheDocument() + }) + + test("shows Email Preview section with button when onSendTestEmail is provided", () => { + renderWithTheme() + + expect(screen.getByText("Email Preview")).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /send test email to me/i }), + ).toBeInTheDocument() + }) + + test("button is disabled when userEmail is not provided", () => { + renderWithTheme( + , + ) + + expect( + screen.getByRole("button", { name: /send test email to me/i }), + ).toBeDisabled() + }) + + test("shows 'Sending…' while test email is being sent", async () => { + const { promise, resolve } = Promise.withResolvers() + renderWithTheme( + promise} + />, + ) + + await user.click( + screen.getByRole("button", { name: /send test email to me/i }), + ) + + expect( + screen.getByRole("button", { name: /sending…/i }), + ).toBeInTheDocument() + + await act(async () => resolve()) + }) + + test("shows success alert after test email is sent successfully", async () => { + renderWithTheme( + , + ) + + await user.click( + screen.getByRole("button", { name: /send test email to me/i }), + ) + + expect(await screen.findByRole("alert")).toHaveTextContent( + /test email successfully sent to manager@test\.com/i, + ) + }) + + test("shows error alert when test email fails", async () => { + renderWithTheme( + , + ) + + await user.click( + screen.getByRole("button", { name: /send test email to me/i }), + ) + + expect(await screen.findByRole("alert")).toHaveTextContent( + /something went wrong sending the test email/i, + ) + }) + + test("Email Preview section is not shown in review step", () => { + renderWithTheme( + , + ) + + expect(screen.queryByText("Email Preview")).not.toBeInTheDocument() + }) + + test("Email Preview section is not shown in over-capacity state", () => { + renderWithTheme( + , + ) + + expect(screen.queryByText("Email Preview")).not.toBeInTheDocument() + }) + + test("resets to idle state when modal is reopened", async () => { + const { rerender } = renderWithTheme( + , + ) + + await user.click( + screen.getByRole("button", { name: /send test email to me/i }), + ) + await screen.findByRole("alert") + + // Close then reopen + rerender( + , + ) + rerender( + , + ) + + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ) + expect( + screen.getByRole("button", { name: /send test email to me/i }), + ).not.toBeDisabled() + }) +}) + describe("AssignSeatsConfirmModal — copy buttons", () => { afterEach(() => { Object.defineProperty(navigator, "clipboard", { diff --git a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.tsx b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.tsx index 5c79f65837..f9aed5fa61 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/AssignSeatsConfirmModal.tsx @@ -9,7 +9,7 @@ import { alpha, styled, } from "ol-components" -import { Button, VisuallyHidden } from "@mitodl/smoot-design" +import { Alert, Button, VisuallyHidden } from "@mitodl/smoot-design" import { pluralize } from "ol-utilities" import { RiAlertFill, @@ -157,6 +157,26 @@ const StatLabel = styled(Typography)(({ theme }) => ({ width: "100%", })) as typeof Typography +const EmailPreviewLabel = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, +})) as typeof Typography + +// ─── Confirm-step email preview block ────────────────────────────────────────── + +const EmailPreviewSection = styled("div")({ + display: "flex", + flexDirection: "column", + gap: "8px", +}) + +const EmailPreviewRow = styled("div")({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "16px", +}) + // ─── Small helpers ──────────────────────────────────────────────────────────── const SHOW_MORE_THRESHOLD = 3 @@ -214,15 +234,25 @@ const copyToClipboard = async (text: string): Promise => { // ─── Main component ─────────────────────────────────────────────────────────── +type TestEmailStatus = "idle" | "sending" | "success" | "error" + type AssignSeatsConfirmModalProps = { open: boolean onClose: () => void onConfirm: () => void | Promise validCount: number - availableSeats: number + /** Null means the contract has no max_learners cap — never over capacity. */ + availableSeats: number | null invalidEmails: string[] duplicateEmails: string[] skippedCount: number + /** Email of the logged-in user, shown in the test-email success message. */ + userEmail?: string | null + /** + * When provided, renders the "Email Preview" section with a "Send Test Email + * to Me" button that calls this handler. Omit to hide the section entirely. + */ + onSendTestEmail?: () => Promise } type Step = "review" | "confirm" @@ -236,6 +266,8 @@ const AssignSeatsConfirmModal: React.FC = ({ invalidEmails, duplicateEmails, skippedCount, + userEmail, + onSendTestEmail, }) => { const descriptionId = useId() const overCapacitySummaryId = `${descriptionId}-summary` @@ -243,7 +275,7 @@ const AssignSeatsConfirmModal: React.FC = ({ const hasInvalid = invalidEmails.length > 0 const hasDuplicates = duplicateEmails.length > 0 const hasIssues = hasInvalid || hasDuplicates || skippedCount > 0 - const overCapacity = validCount > availableSeats + const overCapacity = availableSeats !== null && validCount > availableSeats const [step, setStep] = useState(() => hasIssues && !overCapacity ? "review" : "confirm", @@ -252,6 +284,8 @@ const AssignSeatsConfirmModal: React.FC = ({ const [copiedDuplicate, setCopiedDuplicate] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [sendError, setSendError] = useState(null) + const [testEmailStatus, setTestEmailStatus] = + useState("idle") // Assertive live region text for step transitions. Using reset-then-set (100ms // delay) so NVDA picks up the content change cleanly after focus settles on the // new step's first focusable element inside the same persistent dialog. @@ -268,6 +302,10 @@ const AssignSeatsConfirmModal: React.FC = ({ const sendErrorAnnouncementTimerRef = useRef | null>(null) + const testEmailAnnouncementTimerRef = useRef | null>(null) + const [testEmailAnnouncement, setTestEmailAnnouncement] = useState("") // Only reset step and copy state when the dialog transitions from closed to // open. Without prevOpenRef the effect would also fire when hasIssues or @@ -283,6 +321,8 @@ const AssignSeatsConfirmModal: React.FC = ({ setSendError(null) setSendErrorAnnouncement("") setStepAnnouncement("") + setTestEmailStatus("idle") + setTestEmailAnnouncement("") } }, [open, hasIssues, overCapacity]) @@ -295,6 +335,8 @@ const AssignSeatsConfirmModal: React.FC = ({ clearTimeout(stepAnnouncementTimerRef.current) if (sendErrorAnnouncementTimerRef.current) clearTimeout(sendErrorAnnouncementTimerRef.current) + if (testEmailAnnouncementTimerRef.current) + clearTimeout(testEmailAnnouncementTimerRef.current) } }, []) @@ -321,6 +363,34 @@ const AssignSeatsConfirmModal: React.FC = ({ ) } + const handleSendTestEmail = async () => { + if (!onSendTestEmail) return + setTestEmailStatus("sending") + try { + await onSendTestEmail() + setTestEmailStatus("success") + const msg = `Test email successfully sent to ${userEmail}.` + setTestEmailAnnouncement("") + if (testEmailAnnouncementTimerRef.current) + clearTimeout(testEmailAnnouncementTimerRef.current) + testEmailAnnouncementTimerRef.current = setTimeout( + () => setTestEmailAnnouncement(msg), + 100, + ) + } catch { + setTestEmailStatus("error") + const msg = + "Something went wrong sending the test email. Please try again." + setTestEmailAnnouncement("") + if (testEmailAnnouncementTimerRef.current) + clearTimeout(testEmailAnnouncementTimerRef.current) + testEmailAnnouncementTimerRef.current = setTimeout( + () => setTestEmailAnnouncement(msg), + 100, + ) + } + } + const handleSend = async () => { setIsSubmitting(true) setSendError(null) @@ -345,8 +415,15 @@ const AssignSeatsConfirmModal: React.FC = ({ } } - const seatsAfterSending = availableSeats - validCount - const overLimit = validCount - availableSeats + const seatsAfterSending = + availableSeats !== null ? availableSeats - validCount : null + const seatsAfterSendingText = + seatsAfterSending !== null + ? `${seatsAfterSending} seats remaining after sending.` + : "No seat limit on this contract." + // Only ever read from the overCapacity branches below, and overCapacity is + // false whenever availableSeats is null, so this fallback is never shown. + const overLimit = availableSeats !== null ? validCount - availableSeats : 0 const reviewTitle = hasInvalid ? "Some learners could not be added" @@ -360,7 +437,7 @@ const AssignSeatsConfirmModal: React.FC = ({ // to re-discover a new role="dialog" element. const handleReviewAndConfirm = () => { setStep("confirm") - const confirmText = `Ready to send invitations. You are about to send ${validCount} invitation ${pluralize("email", validCount)} from MIT Learn. Learners will receive an email with a secure link to claim their seat and access the materials. ${seatsAfterSending} seats remaining after sending. Emails will be sent immediately and cannot be recalled.` + const confirmText = `Ready to send invitations. You are about to send ${validCount} invitation ${pluralize("email", validCount)} from MIT Learn. Learners will receive an email with a secure link to claim their seat and access the materials. ${seatsAfterSendingText} Emails will be sent immediately and cannot be recalled.` setStepAnnouncement("") if (stepAnnouncementTimerRef.current) clearTimeout(stepAnnouncementTimerRef.current) @@ -434,7 +511,7 @@ const AssignSeatsConfirmModal: React.FC = ({ : "", " Only valid, unique emails will be assigned.", ].join("") - : `You are about to send ${validCount} invitation ${pluralize("email", validCount)} from MIT Learn. Learners will receive an email with secure link to claim their seat and access the materials. ${seatsAfterSending} seats remaining after sending. Emails will be sent immediately and cannot be recalled.` + : `You are about to send ${validCount} invitation ${pluralize("email", validCount)} from MIT Learn. Learners will receive an email with secure link to claim their seat and access the materials. ${seatsAfterSendingText} Emails will be sent immediately and cannot be recalled.` // For overCapacity we point aria-describedby at the visible paragraph so // role="alertdialog" reads it exactly once on open. We do NOT programmatically @@ -470,6 +547,10 @@ const AssignSeatsConfirmModal: React.FC = ({ {sendErrorAnnouncement} + {/* Test email announcement — same reset-then-set pattern for NVDA */} + + {testEmailAnnouncement} + {/* Copy-success announcement — polite so it doesn't interrupt ongoing speech */} {copiedInvalid @@ -603,6 +684,35 @@ const AssignSeatsConfirmModal: React.FC = ({ receive an email with secure link to claim their seat and access the materials. + {onSendTestEmail && ( + + Email Preview + + + This is the email learners will receive. + + + + + )} + {onSendTestEmail && testEmailStatus === "success" && ( + + Test email successfully sent to {userEmail}. + + )} + {onSendTestEmail && testEmailStatus === "error" && ( + + Something went wrong sending the test email. Please try again. + + )} = ({