Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand All @@ -2742,18 +2744,22 @@ 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",
url: urls.b2b.dataConsent(contract.id),
body: { consented: false },
}),
)
for (const button of await startButtons()) {
expect(button).toBeDisabled()
}
})

test("Agree records true, closes the dialog, and enables the cards", async () => {
Expand Down
53 changes: 16 additions & 37 deletions frontends/main/src/app-pages/DashboardPage/ContractContent.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"
Expand All @@ -36,14 +33,16 @@ import {
contractAdminView,
contractAnalyticsView,
contractLearnersView,
DASHBOARD_HOME,
} from "@/common/urls"
import { ResourceType, getKey } from "./CoursewareDisplay/helpers"
import type { DashboardCourseEntry } from "./CoursewareDisplay/model/dashboardViewModel"
import { useContractDashboardData } from "./CoursewareDisplay/hooks/useContractDashboardData"
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",
Expand Down Expand Up @@ -621,23 +620,8 @@ const ContractContent: React.FC<ContractContentProps> = ({
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<number | null>(
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) {
Expand Down Expand Up @@ -672,20 +656,15 @@ const ContractContent: React.FC<ContractContentProps> = ({
contract={b2bContract}
cardsDisabled={consentRequired}
/>
<DataConsentDialog
<DataConsentPrompt
key={b2bContract.id}
open={consentRequired && declinedContractId !== b2bContract.id}
contractName={b2bContract.name}
onAccept={() => 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)
}}
/>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
<DashboardAnnouncer>
<OrganizationCards />
</DashboardAnnouncer>,
{ 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()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -100,25 +101,28 @@ const CardButton = styled(ButtonLink)({
})

interface OrganizationContractsProps {
org: OrganizationPage
org: UserOrganizationPage
}

const OrganizationContracts: React.FC<OrganizationContractsProps> = ({
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 (
<CardContent key={contract.id} direction="row">
<ContractTitleHeading>
<TitleLink size="medium" color="black" href={href}>
<TitleLink size="medium" color="black" href={href} {...linkProps}>
{contract.name}
</TitleLink>
</ContractTitleHeading>
<CardButton
size="small"
href={href}
{...linkProps}
aria-label={`Continue ${contract.name}`}
>
Continue
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<AnnounceContext.Provider value={setMessage}>
{children}
<VisuallyHidden aria-live="polite" aria-atomic="true">
{message}
</VisuallyHidden>
</AnnounceContext.Provider>
)
}

const useDashboardAnnounce = () => React.useContext(AnnounceContext)

export { DashboardAnnouncer, useDashboardAnnounce }
Loading
Loading