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
27 changes: 16 additions & 11 deletions public/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@
"test-device-restriction",
"test-library",
"test-likert-matrix",
"test-parser-errors",
"test-randomization",
"test-skip-logic",
"test-step-logic"
"test-parser-errors",
"test-randomization",
"test-component-timeout",
"test-skip-logic",
"test-step-logic"
],
"configs": {
"tutorial": {
Expand Down Expand Up @@ -210,13 +211,17 @@
"path": "test-parser-errors/config.json",
"test": true
},
"test-randomization": {
"path": "test-randomization/config.json",
"test": true
},
"test-skip-logic": {
"path": "test-skip-logic/config.json",
"test": true
"test-randomization": {
"path": "test-randomization/config.json",
"test": true
},
"test-component-timeout": {
"path": "test-component-timeout/config.json",
"test": true
},
"test-skip-logic": {
"path": "test-skip-logic/config.json",
"test": true
},
"test-step-logic": {
"path": "test-step-logic/config.json",
Expand Down
58 changes: 58 additions & 0 deletions public/test-component-timeout/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.4.2/src/parser/StudyConfigSchema.json",
"studyMetadata": {
"title": "Component Timeout Auto-Advance Test",
"version": "pilot",
"authors": [
"The reVISit Team"
],
"date": "2026-05-14",
"description": "A test study for component-level auto-advance timeouts.",
"organizations": [
"The reVISit Team"
]
},
"uiConfig": {
"contactEmail": "contact@revisit.dev",
"logoPath": "revisitAssets/revisitLogoSquare.svg",
"withProgressBar": true,
"autoDownloadStudy": false,
"withSidebar": true,
"studyEndMsg": "Timeout auto-advance test complete."
},
"baseComponents": {
"timed-question": {
"type": "questionnaire",
"instruction": "Do not answer this question. It should automatically advance.",
"response": [
{
"id": "timeout-response",
"prompt": "Optional answer",
"location": "belowStimulus",
"type": "shortText",
"required": false
}
],
"nextButtonAutoAdvanceTime": 2500,
"nextButtonAutoAdvanceWarningTime": 1500,
"nextButtonAutoAdvanceWarningMessage": "Custom timeout warning: advancing in {seconds} {unit} without saving this component."
}
},
"components": {
"introduction": {
"type": "questionnaire",
"instruction": "Press next to begin the timeout auto-advance test.",
"response": []
},
"timeout-question": {
"baseComponent": "timed-question"
}
},
"sequence": {
"order": "fixed",
"components": [
"introduction",
"timeout-question"
]
}
}
137 changes: 137 additions & 0 deletions src/components/NextButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/** @vitest-environment jsdom */

import { act, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import type { IndividualComponent } from '../parser/types';
import { NextButton } from './NextButton';

const mockNavigate = vi.fn();
const mockGoToNextStep = vi.fn();

let mockIdentifier = 'intro_0';

vi.mock('@mantine/core', () => ({
Alert: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Button: ({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
),
Group: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));

vi.mock('@tabler/icons-react', () => ({
IconInfoCircle: () => null,
IconAlertTriangle: () => null,
}));

vi.mock('react-router', () => ({
useNavigate: () => mockNavigate,
}));

vi.mock('../store/hooks/useNextStep', () => ({
useNextStep: () => ({
isNextDisabled: false,
goToNextStep: mockGoToNextStep,
}),
}));

vi.mock('../store/hooks/useStudyConfig', () => ({
useStudyConfig: () => ({
uiConfig: {
nextOnEnter: false,
timeoutReject: false,
},
}),
}));

vi.mock('../routes/utils', () => ({
useCurrentIdentifier: () => mockIdentifier,
}));

vi.mock('./PreviousButton', () => ({
PreviousButton: () => null,
}));

describe('NextButton', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
mockIdentifier = 'intro_0';
mockNavigate.mockReset();
mockGoToNextStep.mockReset();
vi.useFakeTimers();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.useRealTimers();
delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});

test('resets auto-advance state when the current identifier changes', () => {
const config = {
type: 'questionnaire',
response: [],
nextButtonAutoAdvanceTime: 1000,
} as unknown as IndividualComponent;

act(() => {
root.render(
<NextButton
config={config}
checkAnswer={null}
/>,
);
});

act(() => {
vi.advanceTimersByTime(1100);
});

expect(mockGoToNextStep).toHaveBeenCalledTimes(1);
expect(mockGoToNextStep).toHaveBeenLastCalledWith(false);

mockIdentifier = 'intro_0_followup_1';

act(() => {
root.render(
<NextButton
config={config}
checkAnswer={null}
/>,
);
});

act(() => {
vi.advanceTimersByTime(1100);
});

expect(mockGoToNextStep).toHaveBeenCalledTimes(2);
expect(mockGoToNextStep).toHaveBeenLastCalledWith(false);
});
});
59 changes: 45 additions & 14 deletions src/components/NextButton.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Alert, Button, Group } from '@mantine/core';
import {
JSX, useEffect, useMemo, useState,
JSX, useEffect, useMemo, useRef, useState,
} from 'react';
import { IconInfoCircle, IconAlertTriangle } from '@tabler/icons-react';
import { useNavigate } from 'react-router';
import { useNextStep } from '../store/hooks/useNextStep';
import { IndividualComponent, ResponseBlockLocation } from '../parser/types';
import type { IndividualComponent, ResponseBlockLocation } from '../parser/types';
import { useStudyConfig } from '../store/hooks/useStudyConfig';
import { useCurrentIdentifier } from '../routes/utils';
import { PreviousButton } from './PreviousButton';
import {
DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE,
DEFAULT_AUTO_ADVANCE_WARNING_TIME,
getAutoAdvanceWarning,
} from './nextButtonTimeout';

type Props = {
label?: string;
Expand All @@ -27,13 +33,19 @@ export function NextButton({
const { isNextDisabled, goToNextStep } = useNextStep();
const studyConfig = useStudyConfig();
const navigate = useNavigate();
const identifier = useCurrentIdentifier();

const nextButtonDisableTime = useMemo(() => config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime, [config, studyConfig]);
const nextButtonEnableTime = useMemo(() => config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0, [config, studyConfig]);
const nextButtonDisableTime = config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime;
const nextButtonEnableTime = config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0;
const nextButtonAutoAdvanceTime = config?.nextButtonAutoAdvanceTime;
const nextButtonAutoAdvanceWarningTime = config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME;
const nextButtonAutoAdvanceWarningMessage = config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE;

const [timer, setTimer] = useState<number | undefined>(undefined);
// Use Date.now() to keep time even if tab is hidden
const autoAdvanceTriggered = useRef(false);
// Use the current identifier so nested function-sequence items reset their timer state.
useEffect(() => {
autoAdvanceTriggered.current = false;
const start = Date.now();
setTimer(0);
const interval = setInterval(() => {
Expand All @@ -42,7 +54,7 @@ export function NextButton({
return () => {
clearInterval(interval);
};
}, []);
}, [identifier]);

useEffect(() => {
if (timer === undefined) {
Expand All @@ -53,6 +65,15 @@ export function NextButton({
}
}, [nextButtonDisableTime, timer, navigate, studyConfig.uiConfig.timeoutReject]);

useEffect(() => {
if (timer === undefined || nextButtonAutoAdvanceTime === undefined || timer < nextButtonAutoAdvanceTime || autoAdvanceTriggered.current) {
return;
}

autoAdvanceTriggered.current = true;
goToNextStep(false);
}, [goToNextStep, nextButtonAutoAdvanceTime, timer]);

const buttonTimerSatisfied = useMemo(
() => {
if (timer === undefined) {
Expand All @@ -65,7 +86,14 @@ export function NextButton({
[nextButtonDisableTime, nextButtonEnableTime, timer],
);

const nextOnEnter = useMemo(() => config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter, [config, studyConfig]);
const autoAdvanceWarning = useMemo(() => getAutoAdvanceWarning({
timer,
autoAdvanceTime: nextButtonAutoAdvanceTime,
warningTime: nextButtonAutoAdvanceWarningTime,
warningMessage: nextButtonAutoAdvanceWarningMessage,
}), [nextButtonAutoAdvanceTime, nextButtonAutoAdvanceWarningMessage, nextButtonAutoAdvanceWarningTime, timer]);

const nextOnEnter = config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter;

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
Expand All @@ -76,15 +104,14 @@ export function NextButton({

if (nextOnEnter) {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}
return () => {};
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [disabled, isNextDisabled, buttonTimerSatisfied, goToNextStep, nextOnEnter]);

const nextButtonDisabled = useMemo(() => disabled || isNextDisabled || !buttonTimerSatisfied, [disabled, isNextDisabled, buttonTimerSatisfied]);
const previousButtonText = useMemo(() => config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous', [config, studyConfig]);
const nextButtonDisabled = disabled || isNextDisabled || !buttonTimerSatisfied;
const previousButtonText = config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous';

return (
<>
Expand Down Expand Up @@ -134,8 +161,12 @@ export function NextButton({
</Group>
</Alert>
))}
{autoAdvanceWarning && (
<Alert mt="md" title="Automatically advancing soon" color="yellow" icon={<IconAlertTriangle />}>
{autoAdvanceWarning.message}
</Alert>
)}
</>

)}
</>
);
Expand Down
Loading
Loading