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();
+ });
+});