From 0bda74205ca463082b3cc66dcf2159ebeff2c617 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 30 Sep 2026 16:22:54 -0400 Subject: [PATCH 1/6] feat(dashboard): send learners to dashboard home when they decline data consent Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DashboardPage/ContractContent.test.tsx | 9 ++++----- .../app-pages/DashboardPage/ContractContent.tsx | 16 +++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 48c6428ad3..473ecac752 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -29,7 +29,9 @@ import { contractAdminView, contractAnalyticsView, contractLearnersView, + DASHBOARD_HOME, } from "@/common/urls" +import mockRouter from "next-router-mock" // Verified cards look up their order; default to none, tests override. beforeEach(() => { @@ -2721,7 +2723,7 @@ describe("ContractContent data consent", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) - test("Decline records false and closes the dialog, but cards stay disabled", async () => { + test("Decline records false and redirects to dashboard home", async () => { const { org, contract, mitxOnlineUser } = setupConsent(null) setMockResponse.post(urls.b2b.dataConsent(contract.id), undefined, { code: 204, @@ -2742,7 +2744,7 @@ describe("ContractContent data consent", () => { await user.click(screen.getByRole("button", { name: "Decline" })) await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(mockRouter.asPath).toBe(DASHBOARD_HOME) }) expect(makeRequest).toHaveBeenCalledWith( expect.objectContaining({ @@ -2751,9 +2753,6 @@ describe("ContractContent data consent", () => { body: { consented: false }, }), ) - for (const button of await startButtons()) { - expect(button).toBeDisabled() - } }) test("Agree records true, closes the dialog, and enables the cards", async () => { diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index fa73b0bcec..b25113579e 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -1,6 +1,7 @@ "use client" -import React, { useEffect, useState } from "react" +import React, { useEffect } from "react" +import { useRouter } from "next-nprogress-bar" import Image from "next/image" import { useQuery } from "@tanstack/react-query" import { @@ -36,6 +37,7 @@ import { contractAdminView, contractAnalyticsView, contractLearnersView, + DASHBOARD_HOME, } from "@/common/urls" import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import type { DashboardCourseEntry } from "./CoursewareDisplay/model/dashboardViewModel" @@ -621,19 +623,15 @@ const ContractContent: React.FC = ({ consentFlag === true && !!b2bContract && b2bContract.consented_to_data_sharing !== true - // Declining closes the dialog for this contract until the next page load. - const [declinedContractId, setDeclinedContractId] = useState( - null, - ) + const router = useRouter() const consentMutation = useDataConsentMutation({ meta: SILENCE_ERROR_TOAST }) const submitConsent = (consented: boolean) => { if (!b2bContract) return - const contractId = b2bContract.id consentMutation.mutate( - { contract_id: contractId, DataConsentRequest: { consented } }, + { contract_id: b2bContract.id, DataConsentRequest: { consented } }, { onSuccess: () => { - if (!consented) setDeclinedContractId(contractId) + if (!consented) router.push(DASHBOARD_HOME) }, }, ) @@ -674,7 +672,7 @@ const ContractContent: React.FC = ({ /> submitConsent(true)} onDecline={() => submitConsent(false)} From b168615ba7055614b85fc075fc568213c56b664f Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 30 Sep 2026 16:22:57 -0400 Subject: [PATCH 2/6] fix(dashboard): keep the consent checkbox label at its dark color Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/app-pages/DashboardPage/DataConsentDialog.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx index 439000f061..3d6f2ab86a 100644 --- a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx @@ -15,10 +15,14 @@ const Body = styled.div({ gap: "28px", }) -// smoot-design's Checkbox has a fixed 24px height; this label wraps. -const ConsentCheckbox = styled(Checkbox)({ +// smoot-design's Checkbox has a fixed 24px height (this label wraps), and it +// only darkens the label on hover or when checked; keep it dark throughout. +const ConsentCheckbox = styled(Checkbox)(({ theme }) => ({ "&&": { height: "auto" }, -}) + '&& input[type="checkbox"] + .checkbox-label': { + color: theme.custom.colors.darkGray2, + }, +})) const Actions = styled(DialogActions)({ gap: "12px", From 953a5945e3b65eb224bbf8ded99b603422181bd6 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 30 Sep 2026 16:27:28 -0400 Subject: [PATCH 3/6] fix(dashboard): keep the consent dialog locked while redirecting after decline Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DashboardPage/ContractContent.test.tsx | 4 ++++ .../DashboardPage/ContractContent.tsx | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 473ecac752..a5dbdab415 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -2746,6 +2746,10 @@ describe("ContractContent data consent", () => { await waitFor(() => { expect(mockRouter.asPath).toBe(DASHBOARD_HOME) }) + expect(screen.getByRole("button", { name: "Decline" })).toBeDisabled() + expect( + screen.getByRole("button", { name: "Agree and continue" }), + ).toBeDisabled() expect(makeRequest).toHaveBeenCalledWith( expect.objectContaining({ method: "post", diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index b25113579e..a4b565e555 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useEffect } from "react" +import React, { useEffect, useState } from "react" import { useRouter } from "next-nprogress-bar" import Image from "next/image" import { useQuery } from "@tanstack/react-query" @@ -624,6 +624,8 @@ const ContractContent: React.FC = ({ !!b2bContract && b2bContract.consented_to_data_sharing !== true const router = useRouter() + // Keeps the dialog locked between a successful decline and the redirect. + const [redirecting, setRedirecting] = useState(false) const consentMutation = useDataConsentMutation({ meta: SILENCE_ERROR_TOAST }) const submitConsent = (consented: boolean) => { if (!b2bContract) return @@ -631,7 +633,10 @@ const ContractContent: React.FC = ({ { contract_id: b2bContract.id, DataConsentRequest: { consented } }, { onSuccess: () => { - if (!consented) router.push(DASHBOARD_HOME) + if (!consented) { + setRedirecting(true) + router.push(DASHBOARD_HOME) + } }, }, ) @@ -677,11 +682,13 @@ const ContractContent: React.FC = ({ onAccept={() => submitConsent(true)} onDecline={() => submitConsent(false)} submitting={ - consentMutation.isPending - ? consentMutation.variables?.DataConsentRequest.consented - ? "accept" - : "decline" - : null + redirecting + ? "decline" + : consentMutation.isPending + ? consentMutation.variables?.DataConsentRequest.consented + ? "accept" + : "decline" + : null } isError={consentMutation.isError} /> From f4d5c8b57b30af1a050c78c7f223b0ad470e73c6 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Thu, 1 Oct 2026 18:07:13 -0400 Subject: [PATCH 4/6] refactor(dashboard): move the consent mutation into a shared DataConsentPrompt Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DashboardPage/ContractContent.tsx | 44 ++-------- .../DashboardPage/DataConsentPrompt.tsx | 81 +++++++++++++++++++ 2 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 frontends/main/src/app-pages/DashboardPage/DataConsentPrompt.tsx diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index a4b565e555..ef69fcab66 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useEffect, useState } from "react" +import React, { useEffect } from "react" import { useRouter } from "next-nprogress-bar" import Image from "next/image" import { useQuery } from "@tanstack/react-query" @@ -22,11 +22,7 @@ import type { V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { mitxUserQueries } from "api/mitxonline-hooks/user" -import { - managerOrganizationQueries, - useDataConsentMutation, -} from "api/mitxonline-hooks/organizations" -import { SILENCE_ERROR_TOAST } from "api/mutation-meta" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" import { ButtonLink } from "@mitodl/smoot-design" import { RiAwardFill } from "@remixicon/react" import { useFeatureFlagEnabled } from "posthog-js/react" @@ -45,7 +41,7 @@ import { useContractDashboardData } from "./CoursewareDisplay/hooks/useContractD import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" import { VariantPicker } from "./CoursewareDisplay/VariantPicker" import { CoursewareCard } from "./CoursewareDisplay/CoursewareCard" -import { DataConsentDialog } from "./DataConsentDialog" +import { DataConsentPrompt } from "./DataConsentPrompt" const HeaderRoot = styled.div(({ theme }) => ({ display: "flex", @@ -624,23 +620,6 @@ const ContractContent: React.FC = ({ !!b2bContract && b2bContract.consented_to_data_sharing !== true const router = useRouter() - // Keeps the dialog locked between a successful decline and the redirect. - const [redirecting, setRedirecting] = useState(false) - const consentMutation = useDataConsentMutation({ meta: SILENCE_ERROR_TOAST }) - const submitConsent = (consented: boolean) => { - if (!b2bContract) return - consentMutation.mutate( - { contract_id: b2bContract.id, DataConsentRequest: { consented } }, - { - onSuccess: () => { - if (!consented) { - setRedirecting(true) - router.push(DASHBOARD_HOME) - } - }, - }, - ) - } useEffect(() => { if (b2bOrganization) { @@ -675,22 +654,11 @@ const ContractContent: React.FC = ({ contract={b2bContract} cardsDisabled={consentRequired} /> - submitConsent(true)} - onDecline={() => submitConsent(false)} - submitting={ - redirecting - ? "decline" - : consentMutation.isPending - ? consentMutation.variables?.DataConsentRequest.consented - ? "accept" - : "decline" - : null - } - isError={consentMutation.isError} + contract={b2bContract} + onDeclined={() => router.push(DASHBOARD_HOME)} /> ) diff --git a/frontends/main/src/app-pages/DashboardPage/DataConsentPrompt.tsx b/frontends/main/src/app-pages/DashboardPage/DataConsentPrompt.tsx new file mode 100644 index 0000000000..b8a2f44272 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/DataConsentPrompt.tsx @@ -0,0 +1,81 @@ +import React, { useState } from "react" +import NiceModal, { useModal } from "@ebay/nice-modal-react" +import type { UserContractPage } from "@mitodl/mitxonline-api-axios/v2" +import { useDataConsentMutation } from "api/mitxonline-hooks/organizations" +import { SILENCE_ERROR_TOAST } from "api/mutation-meta" +import { DataConsentDialog } from "./DataConsentDialog" + +type DataConsentPromptProps = { + open: boolean + contract: Pick + onAccepted?: () => void + onDeclined: () => void +} + +const DataConsentPrompt: React.FC = ({ + open, + contract, + onAccepted, + onDeclined, +}) => { + const consentMutation = useDataConsentMutation({ meta: SILENCE_ERROR_TOAST }) + // Keeps the dialog locked between a successful decline and whatever the + // caller does next (a redirect or closing the modal). + const [declined, setDeclined] = useState(false) + + const submit = (consented: boolean) => { + consentMutation.mutate( + { contract_id: contract.id, DataConsentRequest: { consented } }, + { + onSuccess: () => { + if (consented) { + onAccepted?.() + } else { + setDeclined(true) + onDeclined() + } + }, + }, + ) + } + + return ( + submit(true)} + onDecline={() => submit(false)} + submitting={ + declined + ? "decline" + : consentMutation.isPending + ? consentMutation.variables?.DataConsentRequest.consented + ? "accept" + : "decline" + : null + } + isError={consentMutation.isError} + /> + ) +} + +/** Resolves `true` if the learner agrees and `false` if they decline. */ +const DataConsentModal = NiceModal.create( + ({ contract }: { contract: Pick }) => { + const modal = useModal() + const finish = (consented: boolean) => { + modal.resolve(consented) + modal.remove() + } + return ( + finish(true)} + onDeclined={() => finish(false)} + /> + ) + }, +) + +export { DataConsentPrompt, DataConsentModal } From e47f3406a65aefa7ee026b9caa3318abe15a525a Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Thu, 1 Oct 2026 18:07:15 -0400 Subject: [PATCH 5/6] feat(dashboard): ask for data consent before navigating to a contract from the dashboard Co-Authored-By: Claude Opus 5.5 (1M context) --- .../OrganizationCards.test.tsx | 120 +++++++++++++++++- .../CoursewareDisplay/OrganizationCards.tsx | 10 +- .../DashboardPage/DashboardLayout.test.tsx | 50 ++++++++ .../DashboardPage/DashboardLayout.tsx | 21 ++- .../useConsentGatedNavigation.ts | 54 ++++++++ 5 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts 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 ad51ce8eb4..b37fcc72d4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx @@ -1,6 +1,14 @@ import React from "react" import { screen } from "@testing-library/react" -import { renderWithProviders, setMockResponse } from "@/test-utils" +import { + renderWithProviders, + setMockResponse, + user, + waitFor, +} from "@/test-utils" +import { makeRequest } from "api/test-utils" +import mockRouter from "next-router-mock" +import { contractView, DASHBOARD_HOME } from "@/common/urls" import { factories as mitxOnlineFactories, urls as mitxOnlineUrls, @@ -455,4 +463,114 @@ describe("OrganizationCards", () => { ).not.toBeInTheDocument() }) }) + + describe("data consent before navigating to a contract", () => { + const setupContract = (consented: boolean | null) => { + const contract = mitxOnlineFactories.contracts.contract({ + name: "Consent Contract", + consented_to_data_sharing: consented, + }) + const org = mitxOnlineFactories.organizations.organization({ + slug: "org-consent-org", + contracts: [contract], + }) + const mitxOnlineUser = mitxOnlineFactories.user.user({ + b2b_organizations: [org], + }) + setMockResponse.get(mitxOnlineUrls.userMe.get(), mitxOnlineUser) + setMockResponse.post( + mitxOnlineUrls.b2b.dataConsent(contract.id), + undefined, + { code: 204 }, + ) + renderWithProviders(, { url: DASHBOARD_HOME }) + return { contract, href: contractView("consent-org", contract.slug) } + } + + const continueButton = async () => + ( + await screen.findAllByRole("link", { + name: "Continue Consent Contract", + }) + )[0] + + // Reports whether the app blocked the link, then stops jsdom from trying + // to navigate (it can't). + const clickIsBlocked = async (link: HTMLElement) => { + let blocked = false + const listener = (event: Event) => { + blocked = event.defaultPrevented + event.preventDefault() + } + window.addEventListener("click", listener) + await user.click(link) + window.removeEventListener("click", listener) + return blocked + } + + it("follows the link without asking once consent is true", async () => { + setupContract(true) + const link = await continueButton() + expect(link).not.toHaveAttribute("data-disable-nprogress") + expect(await clickIsBlocked(link)).toBe(false) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("asks first, and navigates to the contract after agreeing", async () => { + const { contract, href } = setupContract(null) + const links = await screen.findAllByRole("link", { + name: /Consent Contract/, + }) + for (const link of links) { + expect(link).toHaveAttribute("data-disable-nprogress", "true") + } + expect(await clickIsBlocked(await continueButton())).toBe(true) + + await screen.findByRole("dialog") + expect(mockRouter.asPath).toBe(DASHBOARD_HOME) + + await user.click( + screen.getByRole("checkbox", { + name: "I have read and consent to the data sharing described above.", + }), + ) + await user.click( + screen.getByRole("button", { name: "Agree and continue" }), + ) + + await waitFor(() => expect(mockRouter.asPath).toBe(href)) + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + url: mitxOnlineUrls.b2b.dataConsent(contract.id), + body: { consented: true }, + }), + ) + }) + + it("stays on dashboard home after declining", async () => { + const { contract } = setupContract(false) + await user.click(await continueButton()) + await user.click(await screen.findByRole("button", { name: "Decline" })) + + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ) + expect(mockRouter.asPath).toBe(DASHBOARD_HOME) + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + url: mitxOnlineUrls.b2b.dataConsent(contract.id), + body: { consented: false }, + }), + ) + }) + + it("follows the link without asking when the flag is off", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + setupContract(null) + expect(await clickIsBlocked(await continueButton())).toBe(false) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx index 18da6b85e5..70c6ebe68d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.tsx @@ -7,7 +7,8 @@ import { CardRoot } from "./CardShared" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { ButtonLink } from "@mitodl/smoot-design" import { contractView } from "@/common/urls" -import { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" +import { UserOrganizationPage } from "@mitodl/mitxonline-api-axios/v2" +import { useConsentGatedNavigation } from "../useConsentGatedNavigation" const Wrapper = styled.div(({ theme }) => ({ display: "flex", @@ -100,25 +101,28 @@ const CardButton = styled(ButtonLink)({ }) interface OrganizationContractsProps { - org: OrganizationPage + org: UserOrganizationPage } const OrganizationContracts: React.FC = ({ org, }) => { + const gateContractLink = useConsentGatedNavigation() const contractContent = org.contracts?.map((contract) => { const href = contractView(org.slug.replace("org-", ""), contract.slug) + const linkProps = gateContractLink(contract, href) return ( - + {contract.name} Continue diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx index 5cbfeb8820..347d4eead7 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx @@ -3,6 +3,8 @@ import { setMockResponse, within, renderWithProviders, + user as userEvent, + waitFor, } from "@/test-utils" import { factories, urls } from "api/test-utils" import { @@ -22,8 +24,12 @@ import { import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" import { UserOrganizationPage } from "@mitodl/mitxonline-api-axios/v2" +import { useFeatureFlagEnabled } from "posthog-js/react" +import mockRouter from "next-router-mock" +import { FeatureFlags } from "@/common/feature_flags" jest.mock("posthog-js/react") +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) describe("DashboardLayout", () => { type SetupOptions = { @@ -140,4 +146,48 @@ describe("DashboardLayout", () => { within(mobileNav).getByRole("tab", { selected: true }), ).toHaveAttribute("href", initialUrl) }) + + test("A contract tab asks for data consent first, and declining goes to dashboard home", async () => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.B2BDataConsent, + ) + const contract = mitxOnlineFactories.contracts.contract({ + name: "Consent Contract", + consented_to_data_sharing: null, + }) + setup({ + initialUrl: PROFILE, + organizations: [ + mitxOnlineFactories.organizations.organization({ + slug: "org-consent-org", + name: "Consent Org", + contracts: [contract], + }), + ], + }) + setMockResponse.post( + mitxOnlineUrls.b2b.dataConsent(contract.id), + undefined, + { + code: 204, + }, + ) + + const desktopNav = await screen.findByTestId("desktop-nav") + const tab = await within(desktopNav).findByRole("tab", { + name: /Consent Contract/, + }) + const mobileTab = within(screen.getByTestId("mobile-nav")).getByRole( + "tab", + { name: /Consent Contract/ }, + ) + expect(tab).toHaveAttribute("data-disable-nprogress", "true") + expect(mobileTab).toHaveAttribute("data-disable-nprogress", "true") + await userEvent.click(tab) + await userEvent.click( + await screen.findByRole("button", { name: "Decline" }), + ) + + await waitFor(() => expect(mockRouter.asPath).toBe(DASHBOARD_HOME)) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx index 44882d967c..9ad122b588 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx @@ -38,6 +38,10 @@ import { MitxOnlineUser, mitxUserQueries } from "api/mitxonline-hooks/user" import { GRID_GAP, SIDEBAR_WIDTH } from "./layoutMetrics" import { useUserMe } from "api/hooks/user" import { useQuery } from "@tanstack/react-query" +import { + useConsentGatedNavigation, + type GatedLinkProps, +} from "./useConsentGatedNavigation" const LearningResourceDrawer = dynamic( () => @@ -232,12 +236,16 @@ const DesktopTabLabel: React.FC<{ type TabData = { value: string href: string + linkProps?: GatedLinkProps label: { mobile: string | React.ReactNode desktop: React.ReactNode } } -const getTabData = (user?: MitxOnlineUser): TabData[] => { +const getTabData = ( + user?: MitxOnlineUser, + gateContractLink?: ReturnType, +): TabData[] => { const orgTabs = user ? user?.b2b_organizations .map((org) => { @@ -257,6 +265,7 @@ const getTabData = (user?: MitxOnlineUser): TabData[] => { return { value: href, href: href, + linkProps: gateContractLink?.(contract, href), label: { mobile: label, desktop: ( @@ -318,9 +327,13 @@ const DashboardPage: React.FC<{ }, ) + const gateContractLink = useConsentGatedNavigation() const tabData = useMemo( - () => (isLoadingMitxOnlineUser ? getTabData() : getTabData(mitxOnlineUser)), - [isLoadingMitxOnlineUser, mitxOnlineUser], + () => + isLoadingMitxOnlineUser + ? getTabData() + : getTabData(mitxOnlineUser, gateContractLink), + [isLoadingMitxOnlineUser, mitxOnlineUser, gateContractLink], ) const tabValue = useMemo(() => { @@ -360,6 +373,7 @@ const DashboardPage: React.FC<{ label={tab.label.desktop} component={Link} href={tab.href} + {...tab.linkProps} /> ))} @@ -374,6 +388,7 @@ const DashboardPage: React.FC<{ key={tab.value} value={tab.value} href={tab.href} + {...tab.linkProps} label={tab.label.mobile} /> ))} diff --git a/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts b/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts new file mode 100644 index 0000000000..16f8c88a4f --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts @@ -0,0 +1,54 @@ +import React from "react" +import NiceModal from "@ebay/nice-modal-react" +import { usePathname } from "next/navigation" +import { useRouter } from "next-nprogress-bar" +import { useFeatureFlagEnabled } from "posthog-js/react" +import type { UserContractPage } from "@mitodl/mitxonline-api-axios/v2" +import { FeatureFlags } from "@/common/feature_flags" +import { DASHBOARD_HOME } from "@/common/urls" +import { DataConsentModal } from "./DataConsentPrompt" + +type GatedLinkProps = { + onClick?: React.MouseEventHandler + "data-disable-nprogress"?: "true" +} + +/** + * Asks for data consent before following an in-app link to a contract + * dashboard. Declining goes to dashboard home instead. + */ +const useConsentGatedNavigation = () => { + const consentFlag = useFeatureFlagEnabled(FeatureFlags.B2BDataConsent) + const router = useRouter() + const pathname = usePathname() + + return React.useCallback( + ( + contract: Pick< + UserContractPage, + "id" | "name" | "consented_to_data_sharing" + >, + href: string, + ): GatedLinkProps => { + if (consentFlag !== true || contract.consented_to_data_sharing === true) { + return {} + } + return { + // next-nprogress-bar starts its bar on anchor clicks before React + // handlers run, so it would start even though the click is cancelled. + "data-disable-nprogress": "true", + onClick: (event) => { + event.preventDefault() + NiceModal.show(DataConsentModal, { contract }).then((result) => { + if (result === true) router.push(href) + else if (pathname !== DASHBOARD_HOME) router.push(DASHBOARD_HOME) + }) + }, + } + }, + [consentFlag, router, pathname], + ) +} + +export { useConsentGatedNavigation } +export type { GatedLinkProps } From 4abefe1cd722f66f70814b80a0f7ad9aa84d3d3e Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Fri, 2 Oct 2026 14:08:59 -0400 Subject: [PATCH 6/6] fix(dashboard): announce consent outcomes and keep focus while the consent dialog submits Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DashboardPage/ContractContent.test.tsx | 7 +++- .../DashboardPage/ContractContent.tsx | 8 +++- .../OrganizationCards.test.tsx | 11 +++++- .../DashboardPage/DashboardAnnouncer.tsx | 27 +++++++++++++ .../DashboardPage/DashboardLayout.test.tsx | 1 + .../DashboardPage/DashboardLayout.tsx | 11 +++++- .../DashboardPage/DataConsentDialog.test.tsx | 33 ++++++++++++---- .../DashboardPage/DataConsentDialog.tsx | 38 +++++++++++++++---- .../useConsentGatedNavigation.ts | 13 +++++-- 9 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 frontends/main/src/app-pages/DashboardPage/DashboardAnnouncer.tsx diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index a5dbdab415..7cd057b54c 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -2746,10 +2746,13 @@ describe("ContractContent data consent", () => { await waitFor(() => { expect(mockRouter.asPath).toBe(DASHBOARD_HOME) }) - expect(screen.getByRole("button", { name: "Decline" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Decline" })).toHaveAttribute( + "aria-disabled", + "true", + ) expect( screen.getByRole("button", { name: "Agree and continue" }), - ).toBeDisabled() + ).toHaveAttribute("aria-disabled", "true") expect(makeRequest).toHaveBeenCalledWith( expect.objectContaining({ method: "post", diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index ef69fcab66..f2842c50b7 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -42,6 +42,7 @@ import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" import { VariantPicker } from "./CoursewareDisplay/VariantPicker" import { CoursewareCard } from "./CoursewareDisplay/CoursewareCard" import { DataConsentPrompt } from "./DataConsentPrompt" +import { useDashboardAnnounce } from "./DashboardAnnouncer" const HeaderRoot = styled.div(({ theme }) => ({ display: "flex", @@ -620,6 +621,7 @@ const ContractContent: React.FC = ({ !!b2bContract && b2bContract.consented_to_data_sharing !== true const router = useRouter() + const announce = useDashboardAnnounce() useEffect(() => { if (b2bOrganization) { @@ -658,7 +660,11 @@ const ContractContent: React.FC = ({ key={b2bContract.id} open={consentRequired} contract={b2bContract} - onDeclined={() => router.push(DASHBOARD_HOME)} + onAccepted={() => announce("Consent recorded.")} + onDeclined={() => { + announce("Response recorded.") + router.push(DASHBOARD_HOME) + }} /> ) 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 b37fcc72d4..4935c69d88 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/OrganizationCards.test.tsx @@ -9,6 +9,7 @@ import { import { makeRequest } from "api/test-utils" import mockRouter from "next-router-mock" import { contractView, DASHBOARD_HOME } from "@/common/urls" +import { DashboardAnnouncer } from "../DashboardAnnouncer" import { factories as mitxOnlineFactories, urls as mitxOnlineUrls, @@ -483,7 +484,12 @@ describe("OrganizationCards", () => { undefined, { code: 204 }, ) - renderWithProviders(, { url: DASHBOARD_HOME }) + renderWithProviders( + + + , + { url: DASHBOARD_HOME }, + ) return { contract, href: contractView("consent-org", contract.slug) } } @@ -539,6 +545,9 @@ describe("OrganizationCards", () => { ) await waitFor(() => expect(mockRouter.asPath).toBe(href)) + expect( + screen.getByText("Consent recorded. Opening Consent Contract."), + ).toBeInTheDocument() expect(makeRequest).toHaveBeenCalledWith( expect.objectContaining({ method: "post", diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardAnnouncer.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardAnnouncer.tsx new file mode 100644 index 0000000000..48bd017666 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/DashboardAnnouncer.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { VisuallyHidden } from "@mitodl/smoot-design" + +const AnnounceContext = React.createContext<(message: string) => void>(() => {}) + +/** + * A polite live region that outlives dialogs and navigations within the + * dashboard, so outcomes can still be announced after the UI that caused them + * is gone. + */ +const DashboardAnnouncer: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [message, setMessage] = React.useState("") + return ( + + {children} + + {message} + + + ) +} + +const useDashboardAnnounce = () => React.useContext(AnnounceContext) + +export { DashboardAnnouncer, useDashboardAnnounce } diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx index 347d4eead7..321e956bb2 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx @@ -189,5 +189,6 @@ describe("DashboardLayout", () => { ) await waitFor(() => expect(mockRouter.asPath).toBe(DASHBOARD_HOME)) + expect(screen.getByText("Response recorded.")).toBeInTheDocument() }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx index 9ad122b588..b0b4f0e2ca 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx @@ -36,6 +36,7 @@ import { import dynamic from "next/dynamic" import { MitxOnlineUser, mitxUserQueries } from "api/mitxonline-hooks/user" import { GRID_GAP, SIDEBAR_WIDTH } from "./layoutMetrics" +import { DashboardAnnouncer } from "./DashboardAnnouncer" import { useUserMe } from "api/hooks/user" import { useQuery } from "@tanstack/react-query" import { @@ -316,7 +317,7 @@ const getTabData = ( ] } -const DashboardPage: React.FC<{ +const DashboardPageContent: React.FC<{ children: React.ReactNode }> = ({ children }) => { const pathname = usePathname() @@ -419,6 +420,14 @@ const DashboardPage: React.FC<{ ) } +const DashboardPage: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + {children} + +) + export default DashboardPage export { TabKeys as DashboardTabKeys, TabLabels as DashboardTabLabels } diff --git a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.test.tsx b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.test.tsx index 184f0f3fb4..3c26365d62 100644 --- a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.test.tsx @@ -65,14 +65,31 @@ describe("DataConsentDialog", () => { expect(onAccept).not.toHaveBeenCalled() }) - test.each(["accept", "decline"] as const)( - "disables both actions while submitting %s", - (submitting) => { - setup({ submitting }) - expect(screen.getByRole("button", { name: "Decline" })).toBeDisabled() - expect( - screen.getByRole("button", { name: "Agree and continue" }), - ).toBeDisabled() + test.each([ + { submitting: "accept" as const, busyName: "Agree and continue" }, + { submitting: "decline" as const, busyName: "Decline" }, + ])( + "while submitting $submitting, both actions are aria-disabled and ignore clicks, and only that one is busy", + async ({ submitting, busyName }) => { + const { onAccept, onDecline } = setup({ submitting }) + const decline = screen.getByRole("button", { name: "Decline" }) + const agree = screen.getByRole("button", { name: "Agree and continue" }) + + for (const button of [decline, agree]) { + expect(button).toHaveAttribute("aria-disabled", "true") + expect(button).toHaveAttribute( + "aria-busy", + button === screen.getByRole("button", { name: busyName }) + ? "true" + : "false", + ) + } + // Still focusable, so focus isn't lost when a submit starts. + expect(decline).not.toBeDisabled() + + await user.click(decline) + expect(onDecline).not.toHaveBeenCalled() + expect(onAccept).not.toHaveBeenCalled() }, ) diff --git a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx index 3d6f2ab86a..86c33b4466 100644 --- a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx @@ -24,6 +24,21 @@ const ConsentCheckbox = styled(Checkbox)(({ theme }) => ({ }, })) +// While submitting, the buttons use aria-disabled instead of disabled so the +// pressed button keeps focus; smoot-design only styles :disabled. +const ActionButton = styled(Button)(({ theme, variant }) => ({ + '&&&[aria-disabled="true"], &&&[aria-disabled="true"]:hover': { + cursor: "default", + boxShadow: "none", + ...(variant === "primary" + ? { backgroundColor: theme.custom.colors.silverGray } + : { + backgroundColor: "transparent", + color: theme.custom.colors.silverGray, + }), + }, +})) + const Actions = styled(DialogActions)({ gap: "12px", "> *": { @@ -50,6 +65,10 @@ const DataConsentDialog: React.FC = ({ }) => { const [agreed, setAgreed] = React.useState(false) const spinner = + const busy = submitting !== null + const unlessBusy = (action: () => void) => () => { + if (!busy) action() + } return ( = ({ fullWidth actions={ - - + } > diff --git a/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts b/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts index 16f8c88a4f..0f2526423c 100644 --- a/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts +++ b/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts @@ -7,6 +7,7 @@ import type { UserContractPage } from "@mitodl/mitxonline-api-axios/v2" import { FeatureFlags } from "@/common/feature_flags" import { DASHBOARD_HOME } from "@/common/urls" import { DataConsentModal } from "./DataConsentPrompt" +import { useDashboardAnnounce } from "./DashboardAnnouncer" type GatedLinkProps = { onClick?: React.MouseEventHandler @@ -21,6 +22,7 @@ const useConsentGatedNavigation = () => { const consentFlag = useFeatureFlagEnabled(FeatureFlags.B2BDataConsent) const router = useRouter() const pathname = usePathname() + const announce = useDashboardAnnounce() return React.useCallback( ( @@ -40,13 +42,18 @@ const useConsentGatedNavigation = () => { onClick: (event) => { event.preventDefault() NiceModal.show(DataConsentModal, { contract }).then((result) => { - if (result === true) router.push(href) - else if (pathname !== DASHBOARD_HOME) router.push(DASHBOARD_HOME) + if (result === true) { + announce(`Consent recorded. Opening ${contract.name}.`) + router.push(href) + } else { + announce("Response recorded.") + if (pathname !== DASHBOARD_HOME) router.push(DASHBOARD_HOME) + } }) }, } }, - [consentFlag, router, pathname], + [consentFlag, router, pathname, announce], ) }