diff --git a/apps/web/src/__tests__/navigation-blocker.test.tsx b/apps/web/src/__tests__/navigation-blocker.test.tsx new file mode 100644 index 000000000..c3936714f --- /dev/null +++ b/apps/web/src/__tests__/navigation-blocker.test.tsx @@ -0,0 +1,80 @@ +import { + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider +} from '@tanstack/react-router'; +import type { RouterHistory } from '@tanstack/react-router'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { NavigationBlocker } from '@/components/NavigationBlocker'; + +import '@/services/i18n'; + +// Test scaffolding rather than copy, so it is not translated -- `jsx-no-literals` still applies here. +const DESTINATION_TEXT = 'Elsewhere'; + +let history: RouterHistory; + +async function renderBlocker(active: boolean) { + const rootRoute = createRootRoute({ component: Outlet }); + const routeTree = rootRoute.addChildren([ + createRoute({ + component: () => , + getParentRoute: () => rootRoute, + path: '/' + }), + createRoute({ component: () =>

{DESTINATION_TEXT}

, getParentRoute: () => rootRoute, path: '/elsewhere' }) + ]); + const router = createRouter({ history, routeTree }); + await act(() => router.load()); + render(); +} + +function dispatchBeforeUnload() { + const event = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(event); + return event; +} + +describe('NavigationBlocker', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/'); + history = createBrowserHistory(); + }); + + afterEach(() => { + cleanup(); + history.destroy(); + }); + + it('should let the page unload while inactive, so a refresh after submitting does not prompt', async () => { + await renderBlocker(false); + expect(dispatchBeforeUnload().defaultPrevented).toBe(false); + }); + + it('should hold a page unload while active, so closing the tab mid-instrument prompts first', async () => { + await renderBlocker(true); + expect(dispatchBeforeUnload().defaultPrevented).toBe(true); + }); + + it('should keep the user in place when they decline to leave', async () => { + await renderBlocker(true); + act(() => history.push('/elsewhere')); + await screen.findByTestId('blocker-dialog'); + fireEvent.click(screen.getByRole('button', { name: 'No' })); + await act(() => Promise.resolve()); + expect(window.location.pathname).toBe('/'); + }); + + it('should complete the navigation once the user confirms leaving', async () => { + await renderBlocker(true); + act(() => history.push('/elsewhere')); + await screen.findByTestId('blocker-dialog'); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + expect(await screen.findByText(DESTINATION_TEXT)).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/Navbar/Navbar.tsx b/apps/web/src/components/Navbar/Navbar.tsx index 0126b2513..77ab8489d 100644 --- a/apps/web/src/components/Navbar/Navbar.tsx +++ b/apps/web/src/components/Navbar/Navbar.tsx @@ -115,7 +115,7 @@ export const Navbar = () => { url="#" onClick={() => { endSession(); - void navigate({ to: '/session/start-session' }); + void navigate({ ignoreBlocker: true, to: '/session/start-session' }); }} /> )} diff --git a/apps/web/src/components/NavigationBlocker.tsx b/apps/web/src/components/NavigationBlocker.tsx index 6045c203c..6f317b4cd 100644 --- a/apps/web/src/components/NavigationBlocker.tsx +++ b/apps/web/src/components/NavigationBlocker.tsx @@ -6,7 +6,7 @@ import { useBlocker } from '@tanstack/react-router'; export const NavigationBlocker: React.FC = ({ active, message }) => { const blocker = useBlocker({ - enableBeforeUnload: true, + enableBeforeUnload: () => active, shouldBlockFn: () => active, withResolver: true }); diff --git a/apps/web/src/components/Sidebar/Sidebar.tsx b/apps/web/src/components/Sidebar/Sidebar.tsx index ba72d919a..2008842d7 100644 --- a/apps/web/src/components/Sidebar/Sidebar.tsx +++ b/apps/web/src/components/Sidebar/Sidebar.tsx @@ -84,7 +84,7 @@ export const Sidebar = () => { className="min-w-20" onClick={() => { endSession(); - void navigate({ to: '/session/start-session' }).then(() => { + void navigate({ ignoreBlocker: true, to: '/session/start-session' }).then(() => { setIsEndSessionModalOpen(false); }); }} diff --git a/apps/web/src/routes/_app/instruments/render/$id.tsx b/apps/web/src/routes/_app/instruments/render/$id.tsx index 850b82041..cf738f990 100644 --- a/apps/web/src/routes/_app/instruments/render/$id.tsx +++ b/apps/web/src/routes/_app/instruments/render/$id.tsx @@ -33,7 +33,7 @@ const RouteComponent = () => { useEffect(() => { if (!currentSession?.id) { - void navigate({ to: '/instruments/accessible-instruments' }); + void navigate({ ignoreBlocker: true, to: '/instruments/accessible-instruments' }); } }, [currentSession?.id]); diff --git a/packages/react-core/AGENTS.md b/packages/react-core/AGENTS.md index 84c6a8557..b00168a96 100644 --- a/packages/react-core/AGENTS.md +++ b/packages/react-core/AGENTS.md @@ -78,9 +78,11 @@ Gateway, playground and serve-instrument have no router. Nothing under `src/` im except `InstrumentRenderer.stories.tsx`. Router-dependent behaviour is **injected as a component prop**: `InstrumentRenderer` accepts -`NavigationBlocker?: NavigationBlockerComponent` and forwards it to `FileInstrumentContent`; -`apps/web/src/components/NavigationBlocker.tsx` is the implementation that calls `useBlocker`. Note -that only the scalar path takes it — `SeriesInstrumentRenderer` has no such prop. Add any new +`NavigationBlocker?: NavigationBlockerComponent`, and both the scalar and series renderers hand it to +`InstrumentRendererContainer`, which keeps it active while the content step is showing (from "Begin" +until the summary). `apps/web/src/components/NavigationBlocker.tsx` is the implementation that calls +`useBlocker`. Render it nowhere else: TanStack runs every registered blocker in turn, so a second +active one shows a second dialog after the user has already confirmed the first. Add any new host-specific behaviour the same way, as an injected component or callback. ## Interactive instruments run in an iframe diff --git a/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx b/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx index 68b2bf033..3c0cff37a 100644 --- a/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx +++ b/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { Button } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; @@ -16,7 +16,7 @@ import { UploadProgressBar } from './UploadProgressBar'; import type { FileInstrumentContentProps } from './types'; -const _FileInstrumentContent: React.FC = ({ NavigationBlocker }) => { +const _FileInstrumentContent: React.FC = () => { const actions = useFileInstrumentContentStore((store) => store.actions); const fileGroups = useFileInstrumentContentStore((store) => store.props.instrument.content.fileGroups); const onSuccess = useFileInstrumentContentStore((store) => store.props.onSuccess); @@ -31,52 +31,40 @@ const _FileInstrumentContent: React.FC = ({ Navigati }, [onSuccess, status]); return ( - -
-
- {fileGroups.map((_, index) => ( - - ))} -
-
- {match(status) - .with('PENDING', () => ) - .with('FAILED', () => ( - - )) - .otherwise(() => null)} - -
+
+
+ {fileGroups.map((_, index) => ( + + ))}
- {NavigationBlocker && ( - - )} - +
+ {match(status) + .with('PENDING', () => ) + .with('FAILED', () => ( + + )) + .otherwise(() => null)} + +
+
); }; @@ -84,7 +72,7 @@ export const FileInstrumentContent: React.FC = (prop const storeRef = useRef(createFileInstrumentContentStore(props)); return ( - <_FileInstrumentContent {...props} /> + <_FileInstrumentContent /> ); }; diff --git a/packages/react-core/src/components/FileInstrumentContent/__tests__/FileInstrumentContent.test.tsx b/packages/react-core/src/components/FileInstrumentContent/__tests__/FileInstrumentContent.test.tsx index bf32c283b..62e621d21 100644 --- a/packages/react-core/src/components/FileInstrumentContent/__tests__/FileInstrumentContent.test.tsx +++ b/packages/react-core/src/components/FileInstrumentContent/__tests__/FileInstrumentContent.test.tsx @@ -103,20 +103,4 @@ describe('FileInstrumentContent', () => { expect(screen.getByRole('button', { name: 'Submit' }).hasAttribute('disabled')).toBe(false); errorSpy.mockRestore(); }); - - it('should pass the pending status through to a given NavigationBlocker', async () => { - const NavigationBlocker = vi.fn((_props: { active: boolean; message: string }) => null); - const props = createProps({ NavigationBlocker }); - render(); - - await act(async () => { - selectFile(screen.getByTestId('dropzone'), createFile('report.pdf')); - await Promise.resolve(); - }); - fireEvent.click(screen.getByText('Submit')); - - await waitFor(() => { - expect(NavigationBlocker.mock.calls.some(([props]) => props.active)).toBe(true); - }); - }); }); diff --git a/packages/react-core/src/components/FileInstrumentContent/types.ts b/packages/react-core/src/components/FileInstrumentContent/types.ts index 4f11b680c..4e7f1d82d 100644 --- a/packages/react-core/src/components/FileInstrumentContent/types.ts +++ b/packages/react-core/src/components/FileInstrumentContent/types.ts @@ -5,7 +5,6 @@ import type { AxiosProgressEvent } from 'axios'; import type { Promisable } from 'type-fest'; import type { LocalizedText } from '../../types'; -import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; export type UploadMap = { [basename: string]: File[]; @@ -53,7 +52,6 @@ export type FileInstrumentContentSubmitResult = { export type FileInstrumentContentProps = { instrument: AnyUnilingualFileInstrument & { id: string }; - NavigationBlocker?: NavigationBlockerComponent; onSubmit: (result: FileInstrumentContentSubmitResult) => Promisable; onSuccess?: () => Promisable; }; diff --git a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx index d1ebe01ba..e67b67e53 100644 --- a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx @@ -26,9 +26,9 @@ export type InstrumentRendererProps = { target: InstrumentBundleContainer; }; -export const InstrumentRenderer = ({ NavigationBlocker, target, ...props }: InstrumentRendererProps) => { +export const InstrumentRenderer = ({ target, ...props }: InstrumentRendererProps) => { if (target.kind === 'SERIES') { return ; } - return ; + return ; }; diff --git a/packages/react-core/src/components/InstrumentRenderer/InstrumentRendererContainer.tsx b/packages/react-core/src/components/InstrumentRenderer/InstrumentRendererContainer.tsx index 45a40872b..e4e9befbb 100644 --- a/packages/react-core/src/components/InstrumentRenderer/InstrumentRendererContainer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/InstrumentRendererContainer.tsx @@ -4,13 +4,21 @@ import { useTranslation, useWindowSize } from '@douglasneuroinformatics/libui/ho import { cn } from '@douglasneuroinformatics/libui/utils'; import { FileCheckIcon, MonitorIcon, PrinterIcon } from 'lucide-react'; +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; + export type InstrumentRendererContainerProps = { children: React.ReactNode; className?: string; index: 0 | 1 | 2; + NavigationBlocker?: NavigationBlockerComponent; }; -export const InstrumentRendererContainer = ({ children, className, index }: InstrumentRendererContainerProps) => { +export const InstrumentRendererContainer = ({ + children, + className, + index, + NavigationBlocker +}: InstrumentRendererContainerProps) => { const { height, width } = useWindowSize(); const { resolvedLanguage, t } = useTranslation(); const icons = useRef([]); @@ -96,6 +104,16 @@ export const InstrumentRendererContainer = ({ children, className, index }: Inst })}
{children} + {NavigationBlocker && ( + + )} ); }; diff --git a/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx index 26ea7f44c..4b25e682c 100644 --- a/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx @@ -90,7 +90,7 @@ export const ScalarInstrumentRenderer = ({ }; return ( - + {match(interpreted) .with({ status: 'LOADING' }, () => ) .with({ status: 'ERROR' }, ({ error }) => { @@ -137,13 +137,7 @@ export const ScalarInstrumentRenderer = ({ /> )) .with({ index: 1, instrument: { kind: 'FILE' } }, ({ instrument }) => { - return ( - - ); + return ; }) .with({ index: 2 }, () => ( ; subject?: SubjectDisplayInfo; /** A localizable label for each constituent form's submit button. */ @@ -42,6 +44,7 @@ export const SeriesInstrumentRenderer = ({ className, disableBegin, initialSeriesIndex, + NavigationBlocker, onSubmit, submitButtonLabel, target @@ -127,7 +130,7 @@ export const SeriesInstrumentRenderer = ({ }, [currentItemIndex, target.items.length]); return ( - + {match(rootState) .with({ status: 'LOADING' }, () => ) .with({ status: 'ERROR' }, () => ( diff --git a/packages/react-core/src/components/InstrumentRenderer/__tests__/ScalarInstrumentRenderer.test.tsx b/packages/react-core/src/components/InstrumentRenderer/__tests__/ScalarInstrumentRenderer.test.tsx index 74d2df5db..8cd53c89a 100644 --- a/packages/react-core/src/components/InstrumentRenderer/__tests__/ScalarInstrumentRenderer.test.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/__tests__/ScalarInstrumentRenderer.test.tsx @@ -1,10 +1,12 @@ import { i18n } from '@douglasneuroinformatics/libui/i18n'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { ScalarInstrumentRenderer } from '../ScalarInstrumentRenderer'; +import type { NavigationBlockerProps } from '../../NavigationBlockerDialog'; + /** * A bundle is evaluated with `new Function`, so it can close over nothing in this file. The * validation schema is handed to it through `globalThis`, which is the one scope both share. @@ -12,9 +14,12 @@ import { ScalarInstrumentRenderer } from '../ScalarInstrumentRenderer'; declare global { // eslint-disable-next-line no-var var __testValidationSchema: z.ZodTypeAny; + // eslint-disable-next-line no-var + var __testFileValidationSchema: z.ZodTypeAny; } globalThis.__testValidationSchema = z.object({ answer: z.string().min(1) }); +globalThis.__testFileValidationSchema = z.any(); const FORM_BUNDLE = `(async () => ({ __runtimeVersion: 1, @@ -48,7 +53,8 @@ const FILE_BUNDLE = `(async () => ({ license: 'Apache-2.0', title: 'Stub File' }, - measures: null + measures: null, + validationSchema: globalThis.__testFileValidationSchema }))()`; const INTERACTIVE_BUNDLE = `(async () => ({ @@ -66,8 +72,20 @@ const INTERACTIVE_BUNDLE = `(async () => ({ measures: null }))()`; +const NavigationBlocker = vi.fn((_props: NavigationBlockerProps) => null); + +function isNavigationBlocked() { + return NavigationBlocker.mock.lastCall?.[0].active; +} + async function beginAt(bundle: string, onSubmit = vi.fn()) { - render(); + render( + + ); fireEvent.click(await screen.findByRole('button', { name: 'Begin' })); return { onSubmit }; } @@ -78,7 +96,10 @@ describe('ScalarInstrumentRenderer', () => { i18n.changeLanguage('en'); }); - afterEach(cleanup); + afterEach(() => { + cleanup(); + NavigationBlocker.mockClear(); + }); it('should show a spinner while the bundle is still being interpreted', () => { const { container } = render( @@ -128,4 +149,47 @@ describe('ScalarInstrumentRenderer', () => { await beginAt(FILE_BUNDLE); expect(await screen.findByTestId('dropzone')).toBeTruthy(); }); + + it('should not block navigation from the overview, where nothing has been entered yet', async () => { + render( + + ); + await screen.findByRole('button', { name: 'Begin' }); + expect(isNavigationBlocked()).toBe(false); + }); + + it('should block navigation once the instrument has begun, so a stray click cannot discard the responses', async () => { + await beginAt(FORM_BUNDLE); + await screen.findByLabelText('Answer'); + expect(isNavigationBlocked()).toBe(true); + }); + + it('should stop blocking navigation once the responses have been submitted', async () => { + await beginAt(FORM_BUNDLE); + fireEvent.change(screen.getByLabelText('Answer'), { target: { value: 'hello' } }); + fireEvent.submit(screen.getByTestId('form-content')); + await screen.findByText('Stub Form'); + expect(isNavigationBlocked()).toBe(false); + }); + + it('should keep blocking navigation while uploaded files are still being saved', async () => { + const onSubmit = vi.fn(() => new Promise(() => undefined)); + await beginAt(FILE_BUNDLE, onSubmit); + const dropzone = await screen.findByTestId('dropzone'); + await act(async () => { + fireEvent.change(dropzone.querySelector('input[type="file"]')!, { + target: { files: [new File(['content'], 'report.pdf', { type: 'application/pdf' })] } + }); + await Promise.resolve(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce(); + }); + expect(isNavigationBlocked()).toBe(true); + }); }); diff --git a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx index 99215540c..72ec37791 100644 --- a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx @@ -6,6 +6,8 @@ import { z } from 'zod/v4'; import { SeriesInstrumentRenderer } from '../SeriesInstrumentRenderer'; +import type { NavigationBlockerProps } from '../../NavigationBlockerDialog'; + /** * A bundle is evaluated with `new Function`, so it can close over nothing in this file. The * validation schema is handed to it through `globalThis`, which is the one scope both share. @@ -79,9 +81,21 @@ function getAnswerInput(): HTMLInputElement { return screen.getByLabelText('Answer'); } +const NavigationBlocker = vi.fn((_props: NavigationBlockerProps) => null); + +function isNavigationBlocked() { + return NavigationBlocker.mock.lastCall?.[0].active; +} + async function beginSeries({ skipProgress }: { skipProgress: boolean }) { const onSubmit = vi.fn(); - render(); + render( + + ); fireEvent.click(await screen.findByRole('button', { name: 'Begin' })); if (!skipProgress) { // Without `skipProgress`, the overview hands over to the interstitial screen rather than to the @@ -109,7 +123,10 @@ describe('SeriesInstrumentRenderer', () => { i18n.changeLanguage('en'); }); - afterEach(cleanup); + afterEach(() => { + cleanup(); + NavigationBlocker.mockClear(); + }); it('should present the next item of a skipProgress series with no answer filled in', async () => { const { onSubmit } = await beginSeries({ skipProgress: true }); @@ -159,6 +176,24 @@ describe('SeriesInstrumentRenderer', () => { }); }); + it('should keep blocking navigation between items, so leaving cannot abandon the series partway', async () => { + const { onSubmit } = await beginSeries({ skipProgress: false }); + await answerAndSubmit('first administration', onSubmit); + await screen.findByText('Series Instrument in Progress'); + expect(isNavigationBlocked()).toBe(true); + }); + + it('should stop blocking navigation once every item has been submitted', async () => { + const { onSubmit } = await beginSeries({ skipProgress: true }); + await answerAndSubmit('first administration', onSubmit); + await waitFor(() => { + expect(getAnswerInput().value).toBe(''); + }); + await answerAndSubmit('second administration', onSubmit); + await screen.findByText('Thank You!'); + expect(isNavigationBlocked()).toBe(false); + }); + it('should show a placeholder when an item bundle fails to interpret', async () => { const target: SeriesInstrumentBundleContainer = { bundle: `(async () => ({ diff --git a/testing/src/pages/_app/instruments/render/$id.page.ts b/testing/src/pages/_app/instruments/render/$id.page.ts index 4c6cd5aa0..91270d04c 100644 --- a/testing/src/pages/_app/instruments/render/$id.page.ts +++ b/testing/src/pages/_app/instruments/render/$id.page.ts @@ -12,6 +12,8 @@ export class RenderInstrumentPage extends AppPage { readonly errorMessages: Locator; /** The Happiness Questionnaire's affirmative answer to "are you satisfied with your life?". */ readonly happinessSatisfiedRadio: Locator; + /** Asks the clinician to confirm leaving an instrument whose responses have not been submitted. */ + readonly leaveDialog: Locator; /** Rendered only for an instrument declaring `resetButton: true`; libui gives it this aria-label. */ readonly resetButton: Locator; /** Shown once every item of a series has been administered, in place of a scalar summary. */ @@ -24,6 +26,7 @@ export class RenderInstrumentPage extends AppPage { this.beginButton = page.getByRole('button', { name: 'Begin' }); this.consentPreamble = page.getByTestId('consent-preamble'); this.happinessSatisfiedRadio = this.$ref.getByRole('radio', { name: 'Yes' }); + this.leaveDialog = page.getByTestId('blocker-dialog'); this.resetButton = page.getByRole('button', { name: 'Reset' }); this.seriesCompletionHeading = page.getByRole('heading', { name: /Thank You/i }); this.submitButton = page.getByRole('button', { name: 'Submit' }); @@ -90,6 +93,14 @@ export class RenderInstrumentPage extends AppPage { } } + async confirmLeave(): Promise { + await this.leaveDialog.getByRole('button', { name: 'Yes' }).click(); + } + + async declineLeave(): Promise { + await this.leaveDialog.getByRole('button', { name: 'No' }).click(); + } + async submit(): Promise { await this.submitButton.click(); } diff --git a/testing/src/specs/instrument-navigation-block.spec.ts b/testing/src/specs/instrument-navigation-block.spec.ts new file mode 100644 index 000000000..f425a09f9 --- /dev/null +++ b/testing/src/specs/instrument-navigation-block.spec.ts @@ -0,0 +1,73 @@ +import { RenderInstrumentPage } from '../pages/_app/instruments/render/$id.page'; +import { expect, test } from '../support/fixtures'; + +const INSTRUMENT_TITLE = 'Happiness Questionnaire'; + +test.describe('leaving a started instrument', () => { + test.beforeEach(async ({ getPageModel, page, uniqueId }) => { + const startSessionPage = await getPageModel('/session/start-session'); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + await startSessionPage.selectIdentificationMethod('PERSONAL_INFO'); + await startSessionPage.fillSessionForm(`Leave${uniqueId}`, `Subject${uniqueId}`, 'Female'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + + // The active session lives in memory, so navigate via the sidebar rather than a hard load. + await page.getByTestId('nav-button-/instruments/accessible-instruments').click(); + await page.waitForURL('**/instruments/accessible-instruments'); + + const card = page.locator('[data-testid^="instrument-card-"]').filter({ hasText: INSTRUMENT_TITLE }).first(); + await expect(card).toBeVisible(); + await card.click(); + + await new RenderInstrumentPage(page).begin(); + }); + + test('should keep the clinician on the instrument, answers intact, when they decline to leave', async ({ page }) => { + const instrumentPage = new RenderInstrumentPage(page); + await instrumentPage.happinessSatisfiedRadio.click(); + + await page.getByTestId('nav-button-/dashboard').click(); + await expect(instrumentPage.leaveDialog).toBeVisible(); + await instrumentPage.declineLeave(); + + await expect(instrumentPage.leaveDialog).toBeHidden(); + await expect(page).toHaveURL(/\/instruments\/render\//); + await expect(instrumentPage.happinessSatisfiedRadio).toBeChecked(); + }); + + test('should leave the instrument once the clinician confirms', async ({ page }) => { + const instrumentPage = new RenderInstrumentPage(page); + + await page.getByTestId('nav-button-/dashboard').click(); + await expect(instrumentPage.leaveDialog).toBeVisible(); + await instrumentPage.confirmLeave(); + + await page.waitForURL('**/dashboard'); + }); + + test('should not warn about leaving once the responses have been submitted', async ({ page }) => { + const instrumentPage = new RenderInstrumentPage(page); + await instrumentPage.completeHappinessQuestionnaire(); + await instrumentPage.submit(); + await expect(instrumentPage.summaryHeading).toBeVisible(); + + await page.getByTestId('nav-button-/dashboard').click(); + + await page.waitForURL('**/dashboard'); + await expect(instrumentPage.leaveDialog).toBeHidden(); + }); + + // Ending a session is confirmed by its own dialog, and an instrument cannot be submitted once the + // session is gone, so a second warning would protect nothing. + test('should end the session without also warning about leaving the instrument', async ({ page }) => { + const instrumentPage = new RenderInstrumentPage(page); + + await page.getByTestId('nav-button-#').click(); + await page.getByRole('button', { name: 'Yes' }).click(); + + await expect(page).not.toHaveURL(/\/instruments\/render\//); + await expect(instrumentPage.leaveDialog).toBeHidden(); + await expect(page.getByTestId('current-session-info')).toBeHidden(); + }); +});