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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/web/src/__tests__/navigation-blocker.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <NavigationBlocker active={active} message="Leave this instrument?" />,
getParentRoute: () => rootRoute,
path: '/'
}),
createRoute({ component: () => <p>{DESTINATION_TEXT}</p>, getParentRoute: () => rootRoute, path: '/elsewhere' })
]);
const router = createRouter({ history, routeTree });
await act(() => router.load());
render(<RouterProvider router={router} />);
}

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();
});
});
2 changes: 1 addition & 1 deletion apps/web/src/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const Navbar = () => {
url="#"
onClick={() => {
endSession();
void navigate({ to: '/session/start-session' });
void navigate({ ignoreBlocker: true, to: '/session/start-session' });
}}
/>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/NavigationBlocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useBlocker } from '@tanstack/react-router';

export const NavigationBlocker: React.FC<NavigationBlockerProps> = ({ active, message }) => {
const blocker = useBlocker({
enableBeforeUnload: true,
enableBeforeUnload: () => active,
shouldBlockFn: () => active,
withResolver: true
});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/_app/instruments/render/$id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
8 changes: 5 additions & 3 deletions packages/react-core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +16,7 @@ import { UploadProgressBar } from './UploadProgressBar';

import type { FileInstrumentContentProps } from './types';

const _FileInstrumentContent: React.FC<FileInstrumentContentProps> = ({ 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);
Expand All @@ -31,60 +31,48 @@ const _FileInstrumentContent: React.FC<FileInstrumentContentProps> = ({ Navigati
}, [onSuccess, status]);

return (
<Fragment>
<div
className="mx-auto mt-6 flex w-full flex-col gap-12"
style={{ pointerEvents: status === 'SUBMITTED' || status === 'PENDING' ? 'none' : undefined }}
>
<div className="flex flex-col gap-12">
{fileGroups.map((_, index) => (
<Dropzone index={index} key={index} />
))}
</div>
<div className="flex flex-col gap-6">
{match(status)
.with('PENDING', () => <UploadProgressBar />)
.with('FAILED', () => (
<ErrorBox
title={t({
en: 'Something went wrong',
es: 'Algo salió mal',
fr: "Une erreur s'est produite"
})}
/>
))
.otherwise(() => null)}
<Button
className="flex items-center gap-2"
disabled={status === 'PENDING' || status === 'SUBMITTED'}
type="button"
variant="primary"
onClick={() => void actions.submit()}
>
{t('libui.form.submit')}
{status === 'PENDING' && <RefreshCwIcon className="animate-spin" />}
</Button>
</div>
<div
className="mx-auto mt-6 flex w-full flex-col gap-12"
style={{ pointerEvents: status === 'SUBMITTED' || status === 'PENDING' ? 'none' : undefined }}
>
<div className="flex flex-col gap-12">
{fileGroups.map((_, index) => (
<Dropzone index={index} key={index} />
))}
</div>
{NavigationBlocker && (
<NavigationBlocker
active={status === 'PENDING'}
message={t({
en: 'Are you sure you want to leave this page? Data is currently uploading and will be lost if you leave this page now.',
es: '¿Seguro que desea salir de esta página? Se están cargando datos que se perderán si sale ahora.',
fr: 'Êtes-vous sûr de vouloir quitter cette page ? Des données sont en cours de téléversement et seront perdues si vous quittez la page maintenant.'
})}
/>
)}
</Fragment>
<div className="flex flex-col gap-6">
{match(status)
.with('PENDING', () => <UploadProgressBar />)
.with('FAILED', () => (
<ErrorBox
title={t({
en: 'Something went wrong',
es: 'Algo salió mal',
fr: "Une erreur s'est produite"
})}
/>
))
.otherwise(() => null)}
<Button
className="flex items-center gap-2"
disabled={status === 'PENDING' || status === 'SUBMITTED'}
type="button"
variant="primary"
onClick={() => void actions.submit()}
>
{t('libui.form.submit')}
{status === 'PENDING' && <RefreshCwIcon className="animate-spin" />}
</Button>
</div>
</div>
);
};

export const FileInstrumentContent: React.FC<FileInstrumentContentProps> = (props) => {
const storeRef = useRef(createFileInstrumentContentStore(props));
return (
<FileInstrumentContentStoreContext.Provider value={{ store: storeRef.current }}>
<_FileInstrumentContent {...props} />
<_FileInstrumentContent />
</FileInstrumentContentStoreContext.Provider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FileInstrumentContent {...props} />);

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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -53,7 +52,6 @@ export type FileInstrumentContentSubmitResult = {

export type FileInstrumentContentProps = {
instrument: AnyUnilingualFileInstrument & { id: string };
NavigationBlocker?: NavigationBlockerComponent;
onSubmit: (result: FileInstrumentContentSubmitResult) => Promisable<void>;
onSuccess?: () => Promisable<void>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SeriesInstrumentRenderer target={target} {...props} />;
}
return <ScalarInstrumentRenderer NavigationBlocker={NavigationBlocker} target={target} {...props} />;
return <ScalarInstrumentRenderer target={target} {...props} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement[]>([]);
Expand Down Expand Up @@ -96,6 +104,16 @@ export const InstrumentRendererContainer = ({ children, className, index }: Inst
})}
</div>
{children}
{NavigationBlocker && (
<NavigationBlocker
active={index === 1}
message={t({
en: 'Are you sure you want to leave this page? Your responses to this instrument have not been saved and will be lost.',
es: '¿Seguro que desea salir de esta página? Sus respuestas a este instrumento no se han guardado y se perderán.',
fr: "Êtes-vous sûr de vouloir quitter cette page ? Vos réponses à cet instrument n'ont pas été enregistrées et seront perdues."
})}
/>
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const ScalarInstrumentRenderer = ({
};

return (
<InstrumentRendererContainer className={className} index={index}>
<InstrumentRendererContainer className={className} index={index} NavigationBlocker={NavigationBlocker}>
{match(interpreted)
.with({ status: 'LOADING' }, () => <Spinner />)
.with({ status: 'ERROR' }, ({ error }) => {
Expand Down Expand Up @@ -137,13 +137,7 @@ export const ScalarInstrumentRenderer = ({
/>
))
.with({ index: 1, instrument: { kind: 'FILE' } }, ({ instrument }) => {
return (
<FileInstrumentContent
instrument={{ ...instrument, id: target.id }}
NavigationBlocker={NavigationBlocker}
onSubmit={handleSubmit}
/>
);
return <FileInstrumentContent instrument={{ ...instrument, id: target.id }} onSubmit={handleSubmit} />;
})
.with({ index: 2 }, () => (
<InstrumentSummary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { validateSubmission } from './validateSubmission';
import type { LocalizedText, SubjectDisplayInfo } from '../../types';
import type { FormContentSubmitResult } from '../FormContent';
import type { InteractiveContentSubmitResult } from '../InteractiveContent';
import type { NavigationBlockerComponent } from '../NavigationBlockerDialog';
import type { InstrumentSubmitHandler } from './types';

export type SeriesInstrumentRendererProps = {
Expand All @@ -30,6 +31,7 @@ export type SeriesInstrumentRendererProps = {
/** When true, the "Begin" button on the overview screen is disabled. */
disableBegin?: boolean;
initialSeriesIndex?: number;
NavigationBlocker?: NavigationBlockerComponent;
onSubmit: InstrumentSubmitHandler<'SERIES'>;
subject?: SubjectDisplayInfo;
/** A localizable label for each constituent form's submit button. */
Expand All @@ -42,6 +44,7 @@ export const SeriesInstrumentRenderer = ({
className,
disableBegin,
initialSeriesIndex,
NavigationBlocker,
onSubmit,
submitButtonLabel,
target
Expand Down Expand Up @@ -127,7 +130,7 @@ export const SeriesInstrumentRenderer = ({
}, [currentItemIndex, target.items.length]);

return (
<InstrumentRendererContainer className={className} index={index}>
<InstrumentRendererContainer className={className} index={index} NavigationBlocker={NavigationBlocker}>
{match(rootState)
.with({ status: 'LOADING' }, () => <Spinner />)
.with({ status: 'ERROR' }, () => (
Expand Down
Loading
Loading