From 82ed5d400b2f7b9914022d5ed47b7029389bfeb8 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Sun, 4 Oct 2026 19:39:16 -0400 Subject: [PATCH] feat(web): warn before leaving an instrument whose responses are unsaved Instrument state is not saved until it is submitted, so a stray click on the sidebar, a refresh or a closed tab silently discarded a clinician's answers. The navigation blocker now stays active from "Begin" until the summary, for scalar and series instruments alike, instead of only while a file upload is pending. - InstrumentRendererContainer owns the single blocker. TanStack runs every registered blocker in turn, so the upload-only one in FileInstrumentContent is removed rather than kept alongside it. - The web NavigationBlocker now holds beforeunload only while active. Before, it prompted on refresh for as long as it was mounted. - Ending a session bypasses the blocker. The sidebar already asks for confirmation, and once the session is gone the instrument cannot be submitted, so blocking would only strand the clinician on it. Closes #717 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/navigation-blocker.test.tsx | 80 ++++++++++++++++++ apps/web/src/components/Navbar/Navbar.tsx | 2 +- apps/web/src/components/NavigationBlocker.tsx | 2 +- apps/web/src/components/Sidebar/Sidebar.tsx | 2 +- .../routes/_app/instruments/render/$id.tsx | 2 +- packages/react-core/AGENTS.md | 8 +- .../FileInstrumentContent.tsx | 84 ++++++++----------- .../__tests__/FileInstrumentContent.test.tsx | 16 ---- .../components/FileInstrumentContent/types.ts | 2 - .../InstrumentRenderer/InstrumentRenderer.tsx | 4 +- .../InstrumentRendererContainer.tsx | 20 ++++- .../ScalarInstrumentRenderer.tsx | 10 +-- .../SeriesInstrumentRenderer.tsx | 5 +- .../ScalarInstrumentRenderer.test.tsx | 72 +++++++++++++++- .../SeriesInstrumentRenderer.test.tsx | 39 ++++++++- .../pages/_app/instruments/render/$id.page.ts | 11 +++ .../specs/instrument-navigation-block.spec.ts | 73 ++++++++++++++++ 17 files changed, 341 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/__tests__/navigation-blocker.test.tsx create mode 100644 testing/src/specs/instrument-navigation-block.spec.ts 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(); + }); +});