diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 48c6428ad3..7cd057b54c 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,8 +2744,15 @@ 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(screen.getByRole("button", { name: "Decline" })).toHaveAttribute( + "aria-disabled", + "true", + ) + expect( + screen.getByRole("button", { name: "Agree and continue" }), + ).toHaveAttribute("aria-disabled", "true") expect(makeRequest).toHaveBeenCalledWith( expect.objectContaining({ method: "post", @@ -2751,9 +2760,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..f2842c50b7 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 { @@ -21,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" @@ -36,6 +33,7 @@ import { contractAdminView, contractAnalyticsView, contractLearnersView, + DASHBOARD_HOME, } from "@/common/urls" import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import type { DashboardCourseEntry } from "./CoursewareDisplay/model/dashboardViewModel" @@ -43,7 +41,8 @@ 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" +import { useDashboardAnnounce } from "./DashboardAnnouncer" const HeaderRoot = styled.div(({ theme }) => ({ display: "flex", @@ -621,23 +620,8 @@ 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 consentMutation = useDataConsentMutation({ meta: SILENCE_ERROR_TOAST }) - const submitConsent = (consented: boolean) => { - if (!b2bContract) return - const contractId = b2bContract.id - consentMutation.mutate( - { contract_id: contractId, DataConsentRequest: { consented } }, - { - onSuccess: () => { - if (!consented) setDeclinedContractId(contractId) - }, - }, - ) - } + const router = useRouter() + const announce = useDashboardAnnounce() useEffect(() => { if (b2bOrganization) { @@ -672,20 +656,15 @@ const ContractContent: React.FC = ({ contract={b2bContract} cardsDisabled={consentRequired} /> - submitConsent(true)} - onDecline={() => submitConsent(false)} - submitting={ - consentMutation.isPending - ? consentMutation.variables?.DataConsentRequest.consented - ? "accept" - : "decline" - : null - } - isError={consentMutation.isError} + open={consentRequired} + contract={b2bContract} + 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 ad51ce8eb4..4935c69d88 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,15 @@ 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 { DashboardAnnouncer } from "../DashboardAnnouncer" import { factories as mitxOnlineFactories, urls as mitxOnlineUrls, @@ -455,4 +464,122 @@ 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( + screen.getByText("Consent recorded. Opening Consent Contract."), + ).toBeInTheDocument() + 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/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 5cbfeb8820..321e956bb2 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,49 @@ 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)) + 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 44882d967c..b0b4f0e2ca 100644 --- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx @@ -36,8 +36,13 @@ 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 { + useConsentGatedNavigation, + type GatedLinkProps, +} from "./useConsentGatedNavigation" const LearningResourceDrawer = dynamic( () => @@ -232,12 +237,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 +266,7 @@ const getTabData = (user?: MitxOnlineUser): TabData[] => { return { value: href, href: href, + linkProps: gateContractLink?.(contract, href), label: { mobile: label, desktop: ( @@ -307,7 +317,7 @@ const getTabData = (user?: MitxOnlineUser): TabData[] => { ] } -const DashboardPage: React.FC<{ +const DashboardPageContent: React.FC<{ children: React.ReactNode }> = ({ children }) => { const pathname = usePathname() @@ -318,9 +328,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 +374,7 @@ const DashboardPage: React.FC<{ label={tab.label.desktop} component={Link} href={tab.href} + {...tab.linkProps} /> ))} @@ -374,6 +389,7 @@ const DashboardPage: React.FC<{ key={tab.value} value={tab.value} href={tab.href} + {...tab.linkProps} label={tab.label.mobile} /> ))} @@ -404,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 439000f061..86c33b4466 100644 --- a/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx +++ b/frontends/main/src/app-pages/DashboardPage/DataConsentDialog.tsx @@ -15,10 +15,29 @@ 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, + }, +})) + +// 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", @@ -46,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/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 } 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..0f2526423c --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/useConsentGatedNavigation.ts @@ -0,0 +1,61 @@ +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" +import { useDashboardAnnounce } from "./DashboardAnnouncer" + +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() + const announce = useDashboardAnnounce() + + 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) { + announce(`Consent recorded. Opening ${contract.name}.`) + router.push(href) + } else { + announce("Response recorded.") + if (pathname !== DASHBOARD_HOME) router.push(DASHBOARD_HOME) + } + }) + }, + } + }, + [consentFlag, router, pathname, announce], + ) +} + +export { useConsentGatedNavigation } +export type { GatedLinkProps }