diff --git a/apps/intake/src/components/TermsAndConditions.tsx b/apps/intake/src/components/TermsAndConditions.tsx new file mode 100644 index 0000000000..07b11f525c --- /dev/null +++ b/apps/intake/src/components/TermsAndConditions.tsx @@ -0,0 +1,46 @@ +import { Typography } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { getLegalCompositionForLocation } from 'utils'; +import { DisplayTextDef } from 'utils/lib/configuration/types'; + +interface TermsAndConditionsProps { + pageId: string; +} + +export const TermsAndConditions: React.FC = ({ pageId }) => { + const theme = useTheme(); + + const legalComposition = getLegalCompositionForLocation(pageId); + if (!legalComposition) { + return null; + } + + return ( + + {/* this rendering of link and text nodes is pretty generic and could be extracted to its own component at some point */} + {legalComposition.map((node, index) => { + if (node.nodeType === 'Link') { + return ( + + + + ); + } else { + return ; + } + })} + {'.'} + + ); +}; + +const TextNode = (node: DisplayTextDef): React.ReactElement => { + const { t } = useTranslation(); + if (node.keyPath) { + return <>{t(node.keyPath)}; + } + return <>{node.literal || ''}; +}; diff --git a/apps/intake/src/pages/Review.tsx b/apps/intake/src/pages/Review.tsx index 99ace79bad..e7e2c5200f 100644 --- a/apps/intake/src/pages/Review.tsx +++ b/apps/intake/src/pages/Review.tsx @@ -1,10 +1,11 @@ import { EditOutlined } from '@mui/icons-material'; -import { IconButton, Table, TableBody, TableCell, TableRow, Tooltip, Typography, useTheme } from '@mui/material'; +import { IconButton, Table, TableBody, TableCell, TableRow, Tooltip, Typography } from '@mui/material'; import { QuestionnaireResponseItem } from 'fhir/r4b'; import { DateTime } from 'luxon'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams } from 'react-router-dom'; +import { TermsAndConditions } from 'src/components/TermsAndConditions'; import { APIError, APPOINTMENT_CANT_BE_IN_PAST_ERROR, @@ -43,6 +44,8 @@ const makeFullName = (patient: PatientInfo | undefined): string | undefined => { return `${firstName}${middleName ? ` ${middleName}` : ''} ${lastName}`; }; +const PAGE_ID = 'REVIEW_PAGE'; + const Review = (): JSX.Element => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); @@ -60,7 +63,6 @@ const Review = (): JSX.Element => { completeBooking, } = useBookingContext(); const [errorConfig, setErrorConfig] = useState(undefined); - const theme = useTheme(); const { slotId } = useParams<{ slotId: string }>(); const patientInfo: PatientInfo | undefined = (() => { @@ -235,17 +237,7 @@ const Review = (): JSX.Element => { ))} - - {t('reviewAndSubmit.byProceeding')} - - {t('reviewAndSubmit.privacyPolicy')} - {' '} - {t('reviewAndSubmit.andPrivacyPolicy')} - - {t('reviewAndSubmit.termsAndConditions')} - - . - + ({ diff --git a/apps/intake/src/pages/ReviewPaperwork.tsx b/apps/intake/src/pages/ReviewPaperwork.tsx index a20bd8fc10..6e255914d7 100644 --- a/apps/intake/src/pages/ReviewPaperwork.tsx +++ b/apps/intake/src/pages/ReviewPaperwork.tsx @@ -1,10 +1,11 @@ import { EditOutlined } from '@mui/icons-material'; -import { IconButton, Link as MuiLink, Table, TableBody, TableCell, TableRow, Tooltip, Typography } from '@mui/material'; +import { IconButton, Table, TableBody, TableCell, TableRow, Tooltip, Typography } from '@mui/material'; import { QuestionnaireResponseItem } from 'fhir/r4b'; import { t } from 'i18next'; import { DateTime } from 'luxon'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { TermsAndConditions } from 'src/components/TermsAndConditions'; import { makeValidationSchema, pickFirstValueFromAnswerItem, ServiceMode, uuidRegex, VisitType } from 'utils'; import { ValidationError } from 'yup'; import { dataTestIds } from '../../src/helpers/data-test-ids'; @@ -26,12 +27,12 @@ import { otherColors } from '../IntakeThemeProvider'; import i18n from '../lib/i18n'; import { useAppointmentStore } from '../telemed/features/appointments/appointment.store'; import { useCreateInviteMutation } from '../telemed/features/waiting-room'; -import { useOpenExternalLink } from '../telemed/hooks/useOpenExternalLink'; import { ReviewItem } from '../types'; import { slugFromLinkId } from './PaperworkPage'; +const PAGE_ID = 'PAPERWORK_REVIEW_PAGE'; + const ReviewPaperwork = (): JSX.Element => { - const openExternalLink = useOpenExternalLink(); const navigate = useNavigate(); const { id: appointmentID } = useParams(); const { pathname } = useLocation(); @@ -377,27 +378,7 @@ const ReviewPaperwork = (): JSX.Element => { ))} - - By proceeding with a visit, you acknowledge that you have reviewed and accept our{' '} - openExternalLink('/template.pdf')} - target="_blank" - data-testid={dataTestIds.privacyPolicyReviewScreen} - > - Privacy Policy - {' '} - and{' '} - openExternalLink('/template.pdf')} - target="_blank" - data-testid={dataTestIds.termsAndConditionsReviewScreen} - > - Terms and Conditions of Service - - . - + { ); - const privacyPolicyLink = screen.getByRole('link', { name: 'Privacy Policy' }); - expect(privacyPolicyLink).toBeDefined(); - expect(privacyPolicyLink.getAttribute('href')).toBe('/template.pdf'); - expect(privacyPolicyLink.getAttribute('target')).toBe('_blank'); - - const termsLink = screen.getByRole('link', { name: 'Terms and Conditions of Service' }); - expect(termsLink).toBeDefined(); - expect(termsLink.getAttribute('href')).toBe('/template.pdf'); - expect(termsLink.getAttribute('target')).toBe('_blank'); + const privacyLinkDef = getPrivacyPolicyLinkDefForLocation('REVIEW_PAGE'); + const privacyPolicyLink = screen.queryByRole('link', { name: 'Privacy Policy' }); + if (privacyLinkDef === undefined) { + expect(privacyPolicyLink).toBeNull(); + } else { + expect(privacyPolicyLink).toBeDefined(); + expect(privacyPolicyLink?.getAttribute('href')).toBe(privacyLinkDef.url); + expect(privacyPolicyLink?.getAttribute('target')).toBe('_blank'); + } + + const termsLinkDef = getTermsAndConditionsLinkDefForLocation('REVIEW_PAGE'); + const termsLink = screen.queryByRole('link', { name: 'Terms and Conditions of Service' }); + if (termsLinkDef === undefined) { + expect(termsLink).toBeNull(); + } else { + expect(termsLink).toBeDefined(); + expect(termsLink?.getAttribute('href')).toBe(termsLinkDef.url); + expect(termsLink?.getAttribute('target')).toBe('_blank'); + } }); test('Check visit type display differences', () => { diff --git a/apps/intake/tests/specs/in-person/CheckReviewAndSubmitScreen.spec.ts b/apps/intake/tests/specs/in-person/CheckReviewAndSubmitScreen.spec.ts index 34272f2af2..68c10d6648 100644 --- a/apps/intake/tests/specs/in-person/CheckReviewAndSubmitScreen.spec.ts +++ b/apps/intake/tests/specs/in-person/CheckReviewAndSubmitScreen.spec.ts @@ -1,4 +1,5 @@ import { expect, Page, test } from '@playwright/test'; +import { getPrivacyPolicyLinkDefForLocation, getTermsAndConditionsLinkDefForLocation } from 'utils'; import { CommonLocatorsHelper } from '../../utils/CommonLocatorsHelper'; import { PrebookInPersonFlow } from '../../utils/in-person/PrebookInPersonFlow'; import { Locators } from '../../utils/locators'; @@ -9,6 +10,7 @@ let locator: Locators; let visitData: Awaited>; let commonLocators: CommonLocatorsHelper; let scheduleOwnerTypeExpected = 'Location'; +const REVIEW_PAGE_ID = 'REVIEW_PAGE'; test.beforeAll(async ({ browser }) => { page = await browser.newPage(); @@ -73,10 +75,22 @@ test('Review and Submit Screen check data is correct', async () => { }); await test.step('Check privacy policy link', async () => { - await commonLocators.checkLinkOpensPdf(locator.privacyPolicyReviewScreen); + const privacyLinkDef = getPrivacyPolicyLinkDefForLocation(REVIEW_PAGE_ID); + if (privacyLinkDef === undefined) { + await expect(locator.privacyPolicyReviewScreen).not.toBeVisible(); + return; + } + const link = page.locator(`[data-testid="${privacyLinkDef.testId}"]`); + await commonLocators.checkLinkOpensPdf(link); }); await test.step('Check terms and conditions link', async () => { - await commonLocators.checkLinkOpensPdf(locator.termsAndConditions); + const termsLinkDef = getTermsAndConditionsLinkDefForLocation(REVIEW_PAGE_ID); + if (termsLinkDef === undefined) { + await expect(locator.termsAndConditions).not.toBeVisible(); + return; + } + const link = page.locator(`[data-testid="${termsLinkDef.testId}"]`); + await commonLocators.checkLinkOpensPdf(link); }); }); diff --git a/apps/intake/tests/specs/in-person/PaperworkReviewScreen.spec.ts b/apps/intake/tests/specs/in-person/PaperworkReviewScreen.spec.ts index b6160c8ded..0094c2bfa9 100644 --- a/apps/intake/tests/specs/in-person/PaperworkReviewScreen.spec.ts +++ b/apps/intake/tests/specs/in-person/PaperworkReviewScreen.spec.ts @@ -1,6 +1,11 @@ // cSpell:ignore networkidle import { BrowserContext, expect, Page, test } from '@playwright/test'; -import { chooseJson, CreateAppointmentResponse } from 'utils'; +import { + chooseJson, + CreateAppointmentResponse, + getPrivacyPolicyLinkDefForLocation, + getTermsAndConditionsLinkDefForLocation, +} from 'utils'; import { CommonLocatorsHelper } from '../../utils/CommonLocatorsHelper'; import { PrebookInPersonFlow } from '../../utils/in-person/PrebookInPersonFlow'; import { Locators } from '../../utils/locators'; @@ -16,6 +21,7 @@ let locator: Locators; let uploadPhoto: UploadDocs; let commonLocators: CommonLocatorsHelper; const appointmentIds: string[] = []; +const REVIEW_PAGE_ID = 'PAPERWORK_REVIEW_PAGE'; test.beforeAll(async ({ browser }) => { context = await browser.newContext(); @@ -111,10 +117,22 @@ test.describe('Paperwork.Review and Submit - Check values', () => { await expect(locator.checkInTimePaperworkReviewScreen).toHaveText(`${bookingData.selectedSlot}`); }); test('PRS-9 Check privacy policy link', async () => { - await commonLocators.checkLinkOpensPdf(locator.privacyPolicyReviewScreen); + const privacyLinkDef = getPrivacyPolicyLinkDefForLocation(REVIEW_PAGE_ID); + if (privacyLinkDef === undefined) { + await expect(locator.privacyPolicyReviewScreen).not.toBeVisible(); + return; + } + const link = page.locator(`[data-testid="${privacyLinkDef.testId}"]`); + await commonLocators.checkLinkOpensPdf(link); }); test('PRS-10 Check terms and conditions link', async () => { - await commonLocators.checkLinkOpensPdf(locator.termsAndConditions); + const termsLinkDef = getTermsAndConditionsLinkDefForLocation(REVIEW_PAGE_ID); + if (termsLinkDef === undefined) { + await expect(locator.termsAndConditions).not.toBeVisible(); + return; + } + const link = page.locator(`[data-testid="${termsLinkDef.testId}"]`); + await commonLocators.checkLinkOpensPdf(link); }); }); test.describe('Paperwork.Review and Submit - Check edit icons', () => { diff --git a/apps/intake/tests/specs/telemed/PaperworkTelemedReviewScreen.spec.ts b/apps/intake/tests/specs/telemed/PaperworkTelemedReviewScreen.spec.ts index fcaa26629b..6c0ee3c8ac 100644 --- a/apps/intake/tests/specs/telemed/PaperworkTelemedReviewScreen.spec.ts +++ b/apps/intake/tests/specs/telemed/PaperworkTelemedReviewScreen.spec.ts @@ -1,6 +1,11 @@ // cSpell:ignore networkidle, PRST import { BrowserContext, expect, Page, test } from '@playwright/test'; -import { chooseJson, CreateAppointmentResponse } from 'utils'; +import { + chooseJson, + CreateAppointmentResponse, + getPrivacyPolicyLinkDefForLocation, + getTermsAndConditionsLinkDefForLocation, +} from 'utils'; import { CommonLocatorsHelper } from '../../utils/CommonLocatorsHelper'; import { Locators } from '../../utils/locators'; import { Paperwork } from '../../utils/Paperwork'; @@ -39,6 +44,7 @@ test.afterAll(async () => { await page.close(); await context.close(); }); +const REVIEW_PAGE_ID = 'REVIEW_PAGE'; test.describe('Paperwork.Review and Submit - Check Complete/Missing chips', () => { test.describe.configure({ mode: 'serial' }); @@ -127,10 +133,22 @@ test.describe('Paperwork.Review and Submit - Check values', () => { ); }); test('PRST-9 Check privacy policy link', async () => { - await commonLocatorsHelper.checkLinkOpensPdf(locator.privacyPolicyReviewScreen); + const privacyLinkDef = getPrivacyPolicyLinkDefForLocation(REVIEW_PAGE_ID); + if (privacyLinkDef === undefined) { + await expect(locator.privacyPolicyReviewScreen).not.toBeVisible(); + return; + } + const link = page.locator(`[data-testid="${privacyLinkDef.testId}"]`); + await commonLocatorsHelper.checkLinkOpensPdf(link); }); test('PRST-10 Check terms and conditions link', async () => { - await commonLocatorsHelper.checkLinkOpensPdf(locator.termsAndConditions); + const termsLinkDef = getTermsAndConditionsLinkDefForLocation(REVIEW_PAGE_ID); + if (termsLinkDef === undefined) { + await expect(locator.termsAndConditions).not.toBeVisible(); + return; + } + const link = page.locator(`[data-testid="${termsLinkDef.testId}"]`); + await commonLocatorsHelper.checkLinkOpensPdf(link); }); }); test.describe('Paperwork.Review and Submit - Check edit icons', () => { diff --git a/packages/utils/.ottehr_config/index.ts b/packages/utils/.ottehr_config/index.ts index c14f320a91..3a6f509c8e 100644 --- a/packages/utils/.ottehr_config/index.ts +++ b/packages/utils/.ottehr_config/index.ts @@ -5,3 +5,4 @@ export * from './examination'; export * from './screening-questions'; export * from './texting'; export * from './vitals'; +export * from './legal'; diff --git a/packages/utils/.ottehr_config/legal/index.ts b/packages/utils/.ottehr_config/legal/index.ts new file mode 100644 index 0000000000..ee51b4701e --- /dev/null +++ b/packages/utils/.ottehr_config/legal/index.ts @@ -0,0 +1 @@ +export const LEGAL_OVERRIDES = {}; diff --git a/packages/utils/lib/configuration/index.ts b/packages/utils/lib/configuration/index.ts index 0304c2c699..4efd9fa4af 100644 --- a/packages/utils/lib/configuration/index.ts +++ b/packages/utils/lib/configuration/index.ts @@ -5,3 +5,4 @@ export * from './examination'; export * from './questionnaire'; export * from './texting'; export * from './vitals'; +export * from './legal'; diff --git a/packages/utils/lib/configuration/legal/index.ts b/packages/utils/lib/configuration/legal/index.ts new file mode 100644 index 0000000000..535ae2b9a8 --- /dev/null +++ b/packages/utils/lib/configuration/legal/index.ts @@ -0,0 +1,96 @@ +import { z } from 'zod'; +import { LEGAL_OVERRIDES as OVERRIDES } from '../../../.ottehr_config'; +import { mergeAndFreezeConfigObjects } from '../helpers'; +import { DisplayTextSchema, LinkDef, LinkDefSchema, TextWithLinkComposition } from '../types'; + +export type LegalConfigSchemaType = Record; + +const LEGAL_DEFAULTS: LegalConfigSchemaType = { + REVIEW_PAGE: [ + { + keyPath: 'reviewAndSubmit.byProceeding', + nodeType: 'DisplayText', + }, + { + url: '/template.pdf', + textToDisplay: { keyPath: 'reviewAndSubmit.privacyPolicy', nodeType: 'DisplayText' }, + testId: 'privacy-policy-review-screen', + nodeType: 'Link', + tags: ['privacy-policy'], + }, + { + keyPath: 'reviewAndSubmit.andPrivacyPolicy', + nodeType: 'DisplayText', + }, + { + url: '/template.pdf', + textToDisplay: { keyPath: 'reviewAndSubmit.termsAndConditions', nodeType: 'DisplayText' }, + testId: 'terms-conditions-review-screen', + nodeType: 'Link', + tags: ['terms-and-conditions'], + }, + ], + PAPERWORK_REVIEW_PAGE: [ + { + literal: 'By proceeding with a visit, you acknowledge that you have reviewed and accept our ', + nodeType: 'DisplayText', + }, + { + url: '/template.pdf', + textToDisplay: { literal: 'Privacy Policy', nodeType: 'DisplayText' }, + testId: 'privacy-policy-review-screen', + nodeType: 'Link', + tags: ['privacy-policy'], + }, + { literal: ' and ', nodeType: 'DisplayText' }, + { + url: '/template.pdf', + textToDisplay: { keyPath: 'reviewAndSubmit.termsAndConditions', nodeType: 'DisplayText' }, + testId: 'terms-conditions-review-screen', + nodeType: 'Link', + tags: ['terms-and-conditions'], + }, + ], +}; + +const mergedLegalConfig = mergeAndFreezeConfigObjects(LEGAL_DEFAULTS, OVERRIDES); + +const textWithLinkCompositionSchema: z.ZodType = z.array( + z.union([DisplayTextSchema, LinkDefSchema]) +); + +const legalConfigSchema = z.record(z.string(), textWithLinkCompositionSchema); + +export const LEGAL_CONFIG = legalConfigSchema.parse(mergedLegalConfig); + +export const getLegalCompositionForLocation = (locationKey: string): TextWithLinkComposition | undefined => { + const legalComposition = LEGAL_CONFIG[locationKey]; + if (!legalComposition || legalComposition.length === 0) { + return undefined; + } + return legalComposition; +}; + +export const getPrivacyPolicyLinkDefForLocation = (locationKey: string): LinkDef | undefined => { + const legalComposition = getLegalCompositionForLocation(locationKey); + if (!legalComposition) { + return undefined; + } + return LinkDefSchema.safeParse( + legalComposition.find( + (node) => node.nodeType === 'Link' && 'tags' in node && node.tags?.includes('privacy-policy') + ) ?? {} + )?.data; +}; + +export const getTermsAndConditionsLinkDefForLocation = (locationKey: string): LinkDef | undefined => { + const legalComposition = getLegalCompositionForLocation(locationKey); + if (!legalComposition) { + return undefined; + } + return LinkDefSchema.safeParse( + legalComposition.find( + (node) => node.nodeType === 'Link' && 'tags' in node && node.tags?.includes('terms-and-conditions') + ) ?? {} + )?.data; +}; diff --git a/packages/utils/lib/configuration/types.ts b/packages/utils/lib/configuration/types.ts new file mode 100644 index 0000000000..1a76e3ff42 --- /dev/null +++ b/packages/utils/lib/configuration/types.ts @@ -0,0 +1,24 @@ +import z from 'zod'; + +export const DisplayTextSchema = z + .object({ + nodeType: z.literal('DisplayText'), + literal: z.string().optional(), + keyPath: z.string().optional(), + }) + .refine((data) => Boolean(data.literal) || Boolean(data.keyPath), { + message: 'Either literal or keyPath must be provided', + }); + +export const LinkDefSchema = z.object({ + nodeType: z.literal('Link'), + url: z.string(), + textToDisplay: DisplayTextSchema, + testId: z.string().optional(), + tags: z.array(z.string()).optional(), +}); +export type DisplayTextDef = z.infer; + +export type LinkDef = z.infer; + +export type TextWithLinkComposition = Array;