From 93c2a16999efd1577ea4e8f2c63eb80236f8b3d7 Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Mon, 31 Aug 2026 15:36:06 +0200 Subject: [PATCH 01/11] auto-advance, delay and block answer change --- public/global.json | 5 + public/test-auto-advance/config.json | 110 +++++++++++++ src/components/response/ResponseBlock.tsx | 37 ++++- src/components/response/ResponseSwitcher.tsx | 24 ++- .../response/tests/ResponseInput.spec.tsx | 1 + src/components/response/utils.ts | 16 ++ src/parser/LibraryConfigSchema.json | 12 ++ src/parser/StudyConfigSchema.json | 12 ++ src/parser/parser.ts | 21 +++ src/parser/tests/parser.spec.ts | 155 ++++++++++++++++++ src/parser/types.ts | 6 + src/store/hooks/tests/useNextStep.spec.tsx | 66 ++++++++ 12 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 public/test-auto-advance/config.json diff --git a/public/global.json b/public/global.json index 8d23bf9ec8..0cce5a8996 100644 --- a/public/global.json +++ b/public/global.json @@ -89,6 +89,7 @@ "library-vlat", "test-keymapping", "test-audio", + "test-auto-advance", "test-device-restriction", "test-library", "test-likert-matrix", @@ -353,6 +354,10 @@ "path": "test-audio/config.json", "test": true }, + "test-auto-advance": { + "path": "test-auto-advance/config.json", + "test": true + }, "test-device-restriction": { "tab": "Tests", "path": "test-device-restriction/config.json" diff --git a/public/test-auto-advance/config.json b/public/test-auto-advance/config.json new file mode 100644 index 0000000000..33a4323883 --- /dev/null +++ b/public/test-auto-advance/config.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.4.3/src/parser/StudyConfigSchema.json", + "studyMetadata": { + "title": "Test auto advance and block response change", + "version": "pilot", + "authors": [ + "The reVISit Team" + ], + "date": "2026-02-23", + "description": "A test of auto advance and blocking change responses.", + "organizations": [ + "University of Utah", + "WPI" + ] + }, + "uiConfig": { + "contactEmail": "contact@revisit.dev", + "logoPath": "revisitAssets/revisitLogoSquare.svg", + "withProgressBar": true, + "autoDownloadStudy": false, + "withSidebar": true, + "urlParticipantIdParam": "PROLIFIC_PID", + "studyEndMsg": "**Thank you for completing the study. You may click this link and return to Prolific**: [yourProlificLink](yourProlificLink)" + }, + "components": { + "auto-advance": { + "response": [ + { + "id": "q-buttons", + "type": "buttons", + "prompt": "Buttons example", + "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", + "infoText": "Choose the option that best represents your answer.", + "options": [ + { + "label": "Option 1", + "infoText": "This is info text for button option 1." + }, + "Option 2", + "Option 3", + "Option 4" + ], + "autoAdvanceToNextStep": true + } + ], + "type": "questionnaire" + }, + "warning": { + "response": [ + { + "id": "q1-buttons", + "type": "buttons", + "prompt": "Buttons example", + "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", + "infoText": "Choose the option that best represents your answer.", + "options": [ + "Option 1", + "Option 2", + "Option 3", + "Option 4" + ], + "autoAdvanceToNextStep": true + }, + { + "id": "q2-buttons", + "type": "buttons", + "prompt": "Buttons example", + "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", + "infoText": "Choose the option that best represents your answer.", + "options": [ + "Option 1", + "Option 2", + "Option 3", + "Option 4" + ] + } + ], + "type": "questionnaire" + }, + "block-change": { + "response": [ + { + "id": "q-buttons", + "type": "buttons", + "prompt": "Buttons example", + "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", + "infoText": "Choose the option that best represents your answer.", + "options": [ + "Option 1", + "Option 2", + "Option 3", + "Option 4" + ], + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 6000, + "allowResponseChange": false + } + ], + "type": "questionnaire" + } +}, + "sequence": { + "order": "fixed", + "components": [ + "auto-advance", + "warning", + "block-change" + ] + } +} diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index e1234e917c..ebc93a6acf 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -21,7 +21,7 @@ import { import { NextButton } from '../NextButton'; import { - generateInitFields, mergeReactiveAnswers, useAnswerField, + generateInitFields, hasAnswerValue, mergeReactiveAnswers, useAnswerField, } from './utils'; import { generateCustomResponseErrorMessage, @@ -717,6 +717,41 @@ export function ResponseBlock({ revealResponseErrors(); }, [bypassValidationForFailedTraining, goToNextStep, hasResponseIssues, hasStimulusIssue, revealResponseErrors, revealStimulusErrors]); + const autoAdvanceResponses = useMemo( + () => allResponsesWithDefaults.filter((response) => !response.hidden && response.type === 'buttons' && response.autoAdvanceToNextStep), + [allResponsesWithDefaults], + ); + const autoAdvanceOwner = showBtnsInLocation && autoAdvanceResponses.length > 0 && !isAnalysis; + const autoAdvanceTimeoutRef = useRef | undefined>(undefined); + const autoAdvanceIdentifierRef = useRef(null); + useEffect(() => { + autoAdvanceIdentifierRef.current = null; + return () => { + if (autoAdvanceTimeoutRef.current) { + clearTimeout(autoAdvanceTimeoutRef.current); + autoAdvanceTimeoutRef.current = undefined; + } + }; + }, [identifier]); + + useEffect(() => { + if (!autoAdvanceOwner || autoAdvanceIdentifierRef.current === identifier) { + return; + } + + const readyResponse = autoAdvanceResponses.find((response) => hasAnswerValue(combinedValues[response.id])); + if (!readyResponse) { + return; + } + + autoAdvanceIdentifierRef.current = identifier; + if (readyResponse.type === 'buttons') { + autoAdvanceTimeoutRef.current = setTimeout(() => { + handleNextClick(); + }, readyResponse.autoAdvanceDelay ?? 0); + } + }, [autoAdvanceOwner, autoAdvanceResponses, combinedValues, customResponseLoadErrors, customResponseValidators, handleNextClick, identifier]); + let index = 0; return ( <> diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index 625be0afe3..7f717781a0 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -32,7 +32,7 @@ import { useFetchStylesheet } from '../../utils/fetchStylesheet'; import { parseStringOptionValue, parseStringOptions } from '../../utils/stringOptions'; import { getDropdownOptions } from '../../utils/dropdownOptions'; import { - getDefaultFieldValue, getResponseWidth, normalizeCheckboxValue, + getDefaultFieldValue, getResponseWidth, hasAnswerValue, normalizeCheckboxValue, } from './utils'; import { generateErrorMessage, @@ -93,7 +93,7 @@ export function ResponseSwitcher({ : { checked: undefined }; const dontKnowChecked = !!dontKnowValue.checked; const otherValue = (Object.keys(finalStoredAnswer || {}).length > 0 ? { value: finalStoredAnswer![`${response.id}-other`] } : otherInput) || { value: undefined }; - const inputDisabled = !!(Object.keys(finalStoredAnswer || {}).length > 0 || disabled || completed); + const inputDisabled = (Object.keys(finalStoredAnswer || {}).length > 0 || disabled || completed); const [searchParams] = useSearchParams(); @@ -101,9 +101,25 @@ export function ResponseSwitcher({ useFetchStylesheet(response.stylesheetPath); + const responseChangeLocked = useMemo(() => { + if (!('allowResponseChange' in response)) { + return false; + } + + if (response.type === 'buttons' && response.allowResponseChange !== false) { + return false; + } + + if (response.allowResponseChange === false) { + return hasAnswerValue(ans.value); + } + + return hasAnswerValue(ans.value); + }, [response, ans.value]); + const isStateDisabled = useMemo(() => { // Always disable if participant is completed - if (completed) { + if (completed || responseChangeLocked) { return true; } @@ -136,7 +152,7 @@ export function ResponseSwitcher({ return inputDisabled || !!responseParam; } return inputDisabled; - }, [completed, currentStep, flatSequence, response.paramCapture, inputDisabled, sequence.components, nextConfig?.previousButton, searchParams]); + }, [completed, responseChangeLocked, currentStep, flatSequence, response.paramCapture, inputDisabled, sequence.components, nextConfig?.previousButton, searchParams]); const isDisabled = isStateDisabled || isDelayedDisabled; const fieldInitialValue = useMemo(() => { diff --git a/src/components/response/tests/ResponseInput.spec.tsx b/src/components/response/tests/ResponseInput.spec.tsx index 29d610aa8d..7b6a36b271 100644 --- a/src/components/response/tests/ResponseInput.spec.tsx +++ b/src/components/response/tests/ResponseInput.spec.tsx @@ -287,6 +287,7 @@ vi.mock('../utils', () => ({ usesStandaloneDontKnowField: vi.fn(() => false), getDefaultFieldValue: vi.fn(() => null), getResponseWidth: vi.fn(() => 'full'), + hasAnswerValue: vi.fn((val) => val !== undefined && val !== null && val !== ''), })); vi.mock('../../../utils/stringOptions', () => ({ diff --git a/src/components/response/utils.ts b/src/components/response/utils.ts index d012093bc3..b642f93a12 100644 --- a/src/components/response/utils.ts +++ b/src/components/response/utils.ts @@ -58,6 +58,22 @@ const getQueryParameters = () => { return new URLSearchParams(window.location.search); }; +export function hasAnswerValue(value: unknown): boolean { + if (value === undefined || value === null || value === '') { + return false; + } + + if (Array.isArray(value)) { + return value.length > 0 && value.some((entry) => hasAnswerValue(entry)); + } + + if (typeof value === 'object') { + return Object.values(value as Record).some((entry) => hasAnswerValue(entry)); + } + + return true; +} + export const getDefaultFieldValue = (response: Response) => { const responseDefault = (response as ResponseWithDefault).default; if (!Object.hasOwn(response, 'default') || responseDefault === undefined) { diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index d7c5e0628b..e3286b8632 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -310,6 +310,18 @@ "additionalProperties": false, "description": "The ButtonsResponse interface is used to define the properties of a buttons response. ButtonsResponses render as a list of buttons that the participant can click. When a button is clicked, the value of the button is stored in the data file. Participants can cycle through the options using the arrow keys. ```json { \"id\": \"buttonsResponse\", \"type\": \"buttons\", \"prompt\": \"Click a button\", \"location\": \"belowStimulus\", \"default\": \"Option 2\", \"options\": [ \"Option 1\", \"Option 2\", \"Option 3\" ] } ``` In this example, the participant can click one of the buttons labeled \"Option 1\", \"Option 2\", or \"Option 3\".", "properties": { + "allowResponseChange": { + "description": "Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided. Defaults to true.", + "type": "boolean" + }, + "autoAdvanceDelay": { + "description": "The delay, in milliseconds, to wait after this response is answered before automatically advancing to the next step. Only used when `autoAdvanceToNextStep` is `true`. Defaults to 0.", + "type": "number" + }, + "autoAdvanceToNextStep": { + "description": "Controls whether the participant is automatically advanced to the next step as soon as this response has been answered. Defaults to false. If a required element besides this one is present on the page a warning is shown.", + "type": "boolean" + }, "default": { "description": "The default value of the response. Specify one option value as a string.", "type": "string" diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 71d526ed3e..2b450d5d1a 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -331,6 +331,18 @@ "additionalProperties": false, "description": "The ButtonsResponse interface is used to define the properties of a buttons response. ButtonsResponses render as a list of buttons that the participant can click. When a button is clicked, the value of the button is stored in the data file. Participants can cycle through the options using the arrow keys. ```json { \"id\": \"buttonsResponse\", \"type\": \"buttons\", \"prompt\": \"Click a button\", \"location\": \"belowStimulus\", \"default\": \"Option 2\", \"options\": [ \"Option 1\", \"Option 2\", \"Option 3\" ] } ``` In this example, the participant can click one of the buttons labeled \"Option 1\", \"Option 2\", or \"Option 3\".", "properties": { + "allowResponseChange": { + "description": "Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided. Defaults to true.", + "type": "boolean" + }, + "autoAdvanceDelay": { + "description": "The delay, in milliseconds, to wait after this response is answered before automatically advancing to the next step. Only used when `autoAdvanceToNextStep` is `true`. Defaults to 0.", + "type": "number" + }, + "autoAdvanceToNextStep": { + "description": "Controls whether the participant is automatically advanced to the next step as soon as this response has been answered. Defaults to false. If a required element besides this one is present on the page a warning is shown.", + "type": "boolean" + }, "default": { "description": "The default value of the response. Specify one option value as a string.", "type": "string" diff --git a/src/parser/parser.ts b/src/parser/parser.ts index f51e98da0a..388a349bf4 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -993,6 +993,27 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco }); }); + Object.entries(studyConfig.components).forEach(([componentName, component]) => { + if (!component.response || !Array.isArray(component.response)) { + return; + } + + const hasAutoAdvance = component.response.some( + (response) => (response.type === 'buttons' && response.autoAdvanceToNextStep === true), + ); + + if (hasAutoAdvance && component.response.length > 1) { + warnings.push({ + message: `Component \`${componentName}\` has autoAdvanceToNextStep enabled but contains multiple response elements`, + instancePath: `/components/${componentName}/`, + params: { + action: 'Ensure auto-advance components only contain a single response element, or split them into separate steps', + }, + category: 'invalid-config', + }); + } + }); + // Verify skip blocks are well defined const missingSkipTargets: string[] = []; verifyStudySkip(studyConfig.sequence, missingSkipTargets, errors, warnings); diff --git a/src/parser/tests/parser.spec.ts b/src/parser/tests/parser.spec.ts index b7924ea958..96d4eed536 100644 --- a/src/parser/tests/parser.spec.ts +++ b/src/parser/tests/parser.spec.ts @@ -1121,6 +1121,65 @@ describe('Text response validation config parsing', () => { }); }); +describe('Auto-advance multiple elements warning', () => { + test('warns when autoAdvanceToNextStep is enabled on a component with multiple responses', async () => { + const studyConfig = { + $schema: '', + studyMetadata: { + title: 'Auto Advance Warning Test', + version: '1.0', + authors: ['Test'], + date: '2026-05-14', + description: 'Ensures warnings fire when auto-advance is used on multi-element components.', + organizations: ['Test Org'], + }, + uiConfig: { + contactEmail: 'test@test.com', + helpTextPath: '', + logoPath: '', + withProgressBar: true, + autoDownloadStudy: false, + withSidebar: true, + }, + components: { + question1: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + prompt: 'Select an option', + options: ['Option 1', 'Option 2'], + autoAdvanceToNextStep: true, + }, + { + id: 'btn-2', + type: 'buttons', + prompt: 'Select an option', + options: ['Option 1', 'Option 2'], + autoAdvanceToNextStep: true, + }, + ], + }, + }, + sequence: { + order: 'fixed', + components: ['question1'], + }, + }; + + const result = await parseStudyConfig(JSON.stringify(studyConfig)); + + expect(result.errors).toEqual([]); + expect(result.warnings).toContainEqual( + expect.objectContaining({ + message: 'Component `question1` has autoAdvanceToNextStep enabled but contains multiple response elements', + instancePath: '/components/question1/', + }), + ); + }); +}); + describe('Component auto-advance config parsing', () => { test('accepts component-level auto-advance timeout options on a base component', async () => { const studyConfig = { @@ -1168,6 +1227,102 @@ describe('Component auto-advance config parsing', () => { ); expect(hasAutoAdvanceFieldError).toBe(false); }); + + test('accepts response-level autoAdvanceToNextStep and autoAdvanceDelay options', async () => { + const studyConfig = { + $schema: '', + studyMetadata: { + title: 'Response Auto Advance Test', + version: '1.0', + authors: ['Test'], + date: '2026-05-14', + description: 'Ensures response-level auto-advance options are accepted without schema errors.', + organizations: ['Test Org'], + }, + uiConfig: { + contactEmail: 'test@test.com', + helpTextPath: '', + logoPath: '', + withProgressBar: true, + autoDownloadStudy: false, + withSidebar: true, + }, + components: { + question1: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + prompt: 'Select an option', + options: ['Option 1', 'Option 2'], + autoAdvanceToNextStep: true, + }, + ], + }, + }, + sequence: { + order: 'fixed', + components: ['question1'], + }, + }; + + const result = await parseStudyConfig(JSON.stringify(studyConfig)); + + const hasAutoAdvanceFieldError = result.errors.some( + (error) => error.message?.includes('autoAdvanceToNextStep') || error.message?.includes('autoAdvanceDelay'), + ); + expect(hasAutoAdvanceFieldError).toBe(false); + }); + + test.each([ + ['buttons', true], + ['buttons', false], + ])( + 'validates %s response with autoAdvanceToNextStep set to %s without errors', + async () => { + const studyConfig = { + $schema: '', + studyMetadata: { + title: 'Auto Advance Parameter Test', + version: '1.0', + authors: ['Test'], + date: '2026-05-14', + description: 'Validates autoAdvanceToNextStep parameter acceptance.', + organizations: ['Test Org'], + }, + uiConfig: { + contactEmail: 'test@test.com', + helpTextPath: '', + logoPath: '', + withProgressBar: true, + autoDownloadStudy: false, + withSidebar: true, + }, + components: { + question1: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + prompt: 'Select an option', + options: ['Option 1', 'Option 2'], + autoAdvanceToNextStep: true, + }, + ], + }, + }, + sequence: { + order: 'fixed', + components: ['question1'], + }, + }; + + const result = await parseStudyConfig(JSON.stringify(studyConfig)); + expect(result.errors).toEqual([]); + }, + ); }); describe('Next button alignment config parsing', () => { diff --git a/src/parser/types.ts b/src/parser/types.ts index 82ab92980c..1ccc4016e3 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1185,6 +1185,12 @@ export interface ButtonsResponse extends BaseResponse { optionOrder?: 'fixed' | 'random'; /** Set to true to hide keybinding indicators on buttons. Defaults to false when keymapping is active, else false. */ hideKeyVisual?: boolean; + /** Controls whether the participant is automatically advanced to the next step as soon as this response has been answered. Defaults to false. If a required element besides this one is present on the page a warning is shown. */ + autoAdvanceToNextStep?: boolean; + /** The delay, in milliseconds, to wait after this response is answered before automatically advancing to the next step. Only used when `autoAdvanceToNextStep` is `true`. Defaults to 0. */ + autoAdvanceDelay?: number; + /** Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided. Defaults to true. */ + allowResponseChange?: boolean; } /** diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index dc4a799b4b..6af654a790 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -344,6 +344,72 @@ describe('useNextStep', () => { expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); }); + test('advances to next step when auto-advance trigger executes navigation without collectData', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + mockSequence = { + id: 'root', + orderPath: 'root', + order: 'fixed', + components: ['intro', 'followup'], + skip: [], + }; + mockFlatSequence = ['intro', 'followup']; + mockStudyConfig = { + components: { + intro: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + autoAdvanceToNextStep: true, + autoAdvanceDelay: 500, + }, + ], + }, + followup: {}, + }, + }; + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(true); + await Promise.resolve(); + + expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); + }); + + test('respects auto-advance delay timing before calling next step navigation', async () => { + vi.useFakeTimers(); + mockSaveAnswers.mockResolvedValueOnce(undefined); + + renderToStaticMarkup(); + + const delay = 300; + let autoAdvanceTriggered = false; + + const timeoutId = setTimeout(() => { + capturedGoToNextStep?.(true); + autoAdvanceTriggered = true; + }, delay); + + expect(autoAdvanceTriggered).toBe(false); + + vi.advanceTimersByTime(200); + expect(autoAdvanceTriggered).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + await Promise.resolve(); + + expect(autoAdvanceTriggered).toBe(true); + expect(mockNavigate).toHaveBeenCalledTimes(1); + + clearTimeout(timeoutId); + vi.useRealTimers(); + }); + test('excludes timed out answers from block skip conditions', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); mockSequence = { From ccfef614518ac7e1e603b19236643f78b4c47ecc Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Tue, 1 Sep 2026 14:44:14 +0200 Subject: [PATCH 02/11] minor fixes --- src/components/response/ButtonsInput.tsx | 9 +++- src/components/response/ResponseBlock.tsx | 57 ++++++++++++++++++++--- src/parser/parser.ts | 14 +++--- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/components/response/ButtonsInput.tsx b/src/components/response/ButtonsInput.tsx index 6424fbb3b5..b97009756b 100644 --- a/src/components/response/ButtonsInput.tsx +++ b/src/components/response/ButtonsInput.tsx @@ -1,7 +1,7 @@ import { Flex, FocusTrap, Kbd, Radio, } from '@mantine/core'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import ClearSelectionButton from './ClearSelectionButton'; import { ButtonsResponse, ParsedStringOption } from '../../parser/types'; import classes from './css/ButtonsInput.module.css'; @@ -88,6 +88,13 @@ export function ButtonsInput({ hideKeyVisual = false, } = response; + const userInteractedRef = useRef(false); + + const handleUserSelect = (value: string) => { + userInteractedRef.current = true; + answer.onChange?.(value); + }; + const storedAnswer = useStoredAnswer(); const optionOrders: Record = useMemo(() => storedAnswer?.optionOrders ?? {}, [storedAnswer]); diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index ebc93a6acf..0f0c55b8ea 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -734,23 +734,68 @@ export function ResponseBlock({ }; }, [identifier]); + const userInteractedRef = useRef(false); + useEffect(() => { - if (!autoAdvanceOwner || autoAdvanceIdentifierRef.current === identifier) { - return; + userInteractedRef.current = false; + }, [identifier]); + + const handleNextClickRef = useRef(handleNextClick); + useEffect(() => { + handleNextClickRef.current = handleNextClick; + }, [handleNextClick]); + + const prevValuesRef = useRef(answerValidator.values); + useEffect(() => { + if (!isEqual(prevValuesRef.current, answerValidator.values)) { + if (Object.keys(answerValidator.values).length > 0) { + userInteractedRef.current = true; + } + prevValuesRef.current = answerValidator.values; + } + }, [answerValidator.values]); + + useEffect(() => { + if (!autoAdvanceOwner) { + return undefined; } const readyResponse = autoAdvanceResponses.find((response) => hasAnswerValue(combinedValues[response.id])); + if (!readyResponse) { - return; + autoAdvanceIdentifierRef.current = null; + return undefined; + } + + const hasFeedback = 'provideFeedback' in readyResponse && Boolean(readyResponse.provideFeedback); + if (hasFeedback) { + return undefined; + } + + if (!userInteractedRef.current) { + return undefined; + } + + if (autoAdvanceTimeoutRef.current) { + clearTimeout(autoAdvanceTimeoutRef.current); + autoAdvanceTimeoutRef.current = undefined; } autoAdvanceIdentifierRef.current = identifier; if (readyResponse.type === 'buttons') { + const delay = readyResponse.autoAdvanceDelay ?? 0; autoAdvanceTimeoutRef.current = setTimeout(() => { - handleNextClick(); - }, readyResponse.autoAdvanceDelay ?? 0); + handleNextClickRef.current(); + }, delay); } - }, [autoAdvanceOwner, autoAdvanceResponses, combinedValues, customResponseLoadErrors, customResponseValidators, handleNextClick, identifier]); + + return () => { + if (autoAdvanceTimeoutRef.current) { + clearTimeout(autoAdvanceTimeoutRef.current); + autoAdvanceTimeoutRef.current = undefined; + } + }; + }, [autoAdvanceOwner, autoAdvanceResponses, combinedValues, identifier]); let index = 0; return ( diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 388a349bf4..6e4916d640 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -994,15 +994,17 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco }); Object.entries(studyConfig.components).forEach(([componentName, component]) => { - if (!component.response || !Array.isArray(component.response)) { - return; - } + const resolvedComponent = studyComponentToIndividualComponent(component, studyConfig); - const hasAutoAdvance = component.response.some( - (response) => (response.type === 'buttons' && response.autoAdvanceToNextStep === true), + const responses = resolvedComponent.response ?? []; + const hasAutoAdvanceButton = responses.some( + (response) => response.type === 'buttons' && response.autoAdvanceToNextStep === true, ); - if (hasAutoAdvance && component.response.length > 1) { + const interactiveResponses = responses.filter( + (response) => response.type !== 'textOnly' && response.type !== 'divider', + ); + if (hasAutoAdvanceButton && interactiveResponses.length > 1) { warnings.push({ message: `Component \`${componentName}\` has autoAdvanceToNextStep enabled but contains multiple response elements`, instancePath: `/components/${componentName}/`, From 794445d419f6e47b64e1425f1d4dbf1fbfab9b41 Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Wed, 2 Sep 2026 11:55:13 +0200 Subject: [PATCH 03/11] autoadvance in sidebar, prevent default locks --- public/test-auto-advance/config.json | 47 ++++++- src/components/response/ResponseBlock.tsx | 12 +- src/components/response/ResponseSwitcher.tsx | 98 +++++++++------ src/store/hooks/tests/useNextStep.spec.tsx | 125 +++++++++++++++++++ 4 files changed, 238 insertions(+), 44 deletions(-) diff --git a/public/test-auto-advance/config.json b/public/test-auto-advance/config.json index 33a4323883..c9f7b6ca8f 100644 --- a/public/test-auto-advance/config.json +++ b/public/test-auto-advance/config.json @@ -97,6 +97,49 @@ } ], "type": "questionnaire" + }, + "sidebar-next": { + "response": [ + { + "id": "q-buttons", + "type": "buttons", + "prompt": "Buttons example", + "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", + "infoText": "Choose the option that best represents your answer.", + "options": [ + "Option 1", + "Option 2", + "Option 3", + "Option 4" + ], + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 6000, + "allowResponseChange": false + } + ], + "nextButtonLocation": "sidebar", + "type": "questionnaire" + }, + "sidebar-next-immediately": { + "response": [ + { + "id": "q-buttons", + "type": "buttons", + "prompt": "Buttons example", + "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", + "infoText": "Choose the option that best represents your answer.", + "options": [ + "Option 1", + "Option 2", + "Option 3", + "Option 4" + ], + "autoAdvanceToNextStep": true, + "allowResponseChange": false + } + ], + "nextButtonLocation": "sidebar", + "type": "questionnaire" } }, "sequence": { @@ -104,7 +147,9 @@ "components": [ "auto-advance", "warning", - "block-change" + "block-change", + "sidebar-next", + "sidebar-next-immediately" ] } } diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 0f0c55b8ea..f4c304fbd2 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -745,15 +745,17 @@ export function ResponseBlock({ handleNextClickRef.current = handleNextClick; }, [handleNextClick]); - const prevValuesRef = useRef(answerValidator.values); + const prevCombinedValuesRef = useRef(combinedValues); useEffect(() => { - if (!isEqual(prevValuesRef.current, answerValidator.values)) { - if (Object.keys(answerValidator.values).length > 0) { + const hasValuesChanged = !isEqual(prevCombinedValuesRef.current, combinedValues); + + if (hasValuesChanged) { + if (Object.keys(combinedValues || {}).length > 0) { userInteractedRef.current = true; } - prevValuesRef.current = answerValidator.values; + prevCombinedValuesRef.current = combinedValues; } - }, [answerValidator.values]); + }, [combinedValues]); useEffect(() => { if (!autoAdvanceOwner) { diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index 7f717781a0..4d935b39c5 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -1,6 +1,6 @@ import { Box, Checkbox, Divider } from '@mantine/core'; import { useSearchParams } from 'react-router'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { GetInputPropsReturnType } from '@mantine/form/lib/types'; import { CustomResponse, IndividualComponent, JsonValue, MatrixResponse, Response, SliderResponse, StoredAnswer, @@ -32,7 +32,7 @@ import { useFetchStylesheet } from '../../utils/fetchStylesheet'; import { parseStringOptionValue, parseStringOptions } from '../../utils/stringOptions'; import { getDropdownOptions } from '../../utils/dropdownOptions'; import { - getDefaultFieldValue, getResponseWidth, hasAnswerValue, normalizeCheckboxValue, + getDefaultFieldValue, getResponseWidth, normalizeCheckboxValue, } from './utils'; import { generateErrorMessage, @@ -80,14 +80,34 @@ export function ResponseSwitcher({ const currentStep = useCurrentStep(); const nextComponent = useMemo(() => (typeof currentStep === 'number' ? flatSequence[currentStep + 1] : undefined), [currentStep, flatSequence]); const nextConfig = useMemo(() => (nextComponent ? studyConfig.components[nextComponent] : undefined), [nextComponent, studyConfig]); + const userSelectedFieldsRef = useRef>({}); const completed = useStoreSelector((state) => state.completed); const usesStandaloneDontKnow = usesStandaloneDontKnowField(response); const finalStoredAnswer = isAnalysis || answerFinalized || completed ? storedAnswer : undefined; + const wrappedForm = useMemo(() => { + if (!form || typeof form.onChange !== 'function') { + return form; + } + + return { + ...form, + onChange: (val: unknown) => { + userSelectedFieldsRef.current[response.id] = true; + form.onChange(val); + }, + }; + }, [form, response.id]); + // Don't update if we're in analysis mode - const ans = useMemo(() => (isAnalysis || (Object.keys(finalStoredAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed ? { value: finalStoredAnswer?.[response.id], readOnly: true } : form) || { value: undefined }, [isAnalysis, finalStoredAnswer, response.id, form, nextConfig?.previousButton, completed]); + const ans = useMemo( + () => (isAnalysis || (Object.keys(finalStoredAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed + ? { value: finalStoredAnswer?.[response.id], readOnly: true } + : wrappedForm) || { value: undefined }, + [isAnalysis, finalStoredAnswer, response.id, wrappedForm, nextConfig?.previousButton, completed], + ); const dontKnowValue = usesStandaloneDontKnow ? ((Object.keys(finalStoredAnswer || {}).length > 0 ? { checked: finalStoredAnswer![`${response.id}-dontKnow`] } : dontKnowCheckbox) || { checked: undefined }) : { checked: undefined }; @@ -101,6 +121,37 @@ export function ResponseSwitcher({ useFetchStylesheet(response.stylesheetPath); + const fieldInitialValue = useMemo(() => { + if (response.paramCapture) { + const capturedValue = searchParams.get(response.paramCapture); + return response.type === 'checkbox' ? normalizeCheckboxValue(capturedValue) : capturedValue || ''; + } + + const defaultFieldValue = getDefaultFieldValue(response); + if (defaultFieldValue !== null) { + return defaultFieldValue; + } + + if (response.type === 'reactive' || response.type === 'checkbox') { + return []; + } + + if (response.type === 'matrix-radio' || response.type === 'matrix-checkbox') { + return Object.fromEntries(response.questionOptions.map((entry) => [parseStringOptionValue(entry), ''])); + } + + if (response.type === 'slider' && response.startingValue !== undefined) { + return response.startingValue.toString(); + } + + if (response.type === 'custom') { + return null; + } + + return ''; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [response.paramCapture, (response as MatrixResponse).questionOptions, (response as SliderResponse).startingValue, response.type, searchParams]); + const responseChangeLocked = useMemo(() => { if (!('allowResponseChange' in response)) { return false; @@ -111,11 +162,13 @@ export function ResponseSwitcher({ } if (response.allowResponseChange === false) { - return hasAnswerValue(ans.value); + const userInteracted = Boolean(userSelectedFieldsRef.current[response.id]); + const hasNonDefaultValue = ans.value !== undefined && ans.value !== '' && ans.value !== fieldInitialValue; + return userInteracted || hasNonDefaultValue; } - return hasAnswerValue(ans.value); - }, [response, ans.value]); + return false; + }, [response, ans.value, fieldInitialValue]); const isStateDisabled = useMemo(() => { // Always disable if participant is completed @@ -155,37 +208,6 @@ export function ResponseSwitcher({ }, [completed, responseChangeLocked, currentStep, flatSequence, response.paramCapture, inputDisabled, sequence.components, nextConfig?.previousButton, searchParams]); const isDisabled = isStateDisabled || isDelayedDisabled; - const fieldInitialValue = useMemo(() => { - if (response.paramCapture) { - const capturedValue = searchParams.get(response.paramCapture); - return response.type === 'checkbox' ? normalizeCheckboxValue(capturedValue) : capturedValue || ''; - } - - const defaultFieldValue = getDefaultFieldValue(response); - if (defaultFieldValue !== null) { - return defaultFieldValue; - } - - if (response.type === 'reactive' || response.type === 'checkbox') { - return []; - } - - if (response.type === 'matrix-radio' || response.type === 'matrix-checkbox') { - return Object.fromEntries(response.questionOptions.map((entry) => [parseStringOptionValue(entry), ''])); - } - - if (response.type === 'slider' && response.startingValue !== undefined) { - return response.startingValue.toString(); - } - - if (response.type === 'custom') { - return null; - } - - return ''; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [response.paramCapture, (response as MatrixResponse).questionOptions, (response as SliderResponse).startingValue, response.type, searchParams]); - const responseStyle = useMemo(() => response.style || {}, [response.style]); const responseDividers = useMemo(() => response.withDivider ?? config?.responseDividers ?? studyConfig.uiConfig.responseDividers, [response, config, studyConfig]); const customResponseValue = useMemo(() => (ans.value ?? null) as JsonValue | null, [ans.value]); @@ -468,7 +490,7 @@ export function ResponseSwitcher({ classNames={{ input: classes.fixDisabled, label: classes.fixDisabledLabel, icon: classes.fixDisabledIcon }} {...dontKnowCheckbox} checked={dontKnowValue.checked} - onChange={(event) => { dontKnowCheckbox?.onChange(event.currentTarget.checked); form.onChange(fieldInitialValue); }} + onChange={(event) => { dontKnowCheckbox?.onChange(event.currentTarget.checked); wrappedForm.onChange(fieldInitialValue); }} /> )} {(response.type === 'divider' || responseDividers) && } diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index 6af654a790..f2d3d69ac3 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -410,6 +410,72 @@ describe('useNextStep', () => { vi.useRealTimers(); }); + test('retains default field values without locking response change on initial render', () => { + mockStoredAnswer = { + ...mockStoredAnswer, + answer: { + 'btn-1': 'Option A', + }, + }; + + mockStudyConfig = { + components: { + intro: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + options: ['Option A', 'Option B'], + default: 'Option A', + allowResponseChange: false, + }, + ], + }, + }, + }; + + renderToStaticMarkup(); + + // Verification: isNextDisabled should remain active/accessible + // and form interactions are not disabled on mount merely because of default values + expect(capturedIsNextDisabled).toBe(false); + }); + + test('locks option selection after explicit user interaction when allowResponseChange is false', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + + mockStudyConfig = { + components: { + intro: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + options: ['Option A', 'Option B'], + allowResponseChange: false, + }, + ], + }, + }, + }; + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(true); + await Promise.resolve(); + + expect(mockSaveTrialAnswer).toHaveBeenCalledWith( + expect.objectContaining({ + identifier: 'intro_0', + answer: expect.objectContaining({ + response: 'saved-answer', + }), + }), + ); + }); + test('excludes timed out answers from block skip conditions', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); mockSequence = { @@ -456,4 +522,63 @@ describe('useNextStep', () => { })); expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); }); + + test('locks response options immediately during autoAdvanceDelay before navigation occurs', async () => { + vi.useFakeTimers(); + mockSaveAnswers.mockResolvedValueOnce(undefined); + + mockStudyConfig = { + components: { + intro: { + type: 'questionnaire', + response: [ + { + id: 'btn-1', + type: 'buttons', + options: ['Option A', 'Option B'], + default: 'Option A', + allowResponseChange: false, + autoAdvanceToNextStep: true, + autoAdvanceDelay: 500, + }, + ], + }, + followup: {}, + }, + }; + + renderToStaticMarkup(); + + // Simulating user interaction changing value from default 'Option A' to 'Option B' + mockTrialValidation = { + intro_0: { + ...(mockTrialValidation.intro_0 as Record), + belowStimulus: { valid: true, values: { 'btn-1': 'Option B' } }, + }, + }; + + // Re-render to simulate the state update during the delay + renderToStaticMarkup(); + let stepTriggered = false; + setTimeout(() => { + capturedGoToNextStep?.(true); + stepTriggered = true; + }, 500); + + // Advance time partially (250ms out of 500ms delay) + vi.advanceTimersByTime(250); + + // Verify navigation has NOT happened yet... + expect(stepTriggered).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(capturedIsNextDisabled).toBe(false); + + // Complete remaining delay + vi.advanceTimersByTime(250); + await Promise.resolve(); + + expect(stepTriggered).toBe(true); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); + vi.useRealTimers(); + }); }); From d87cbfb4ba994213d2d7e5c71cea3f52041a400c Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Fri, 4 Sep 2026 14:10:27 +0200 Subject: [PATCH 04/11] hide next button auto advance; add flag for next button visibility --- public/demo-form-elements/config.json | 42 ++++++++++++++ public/test-auto-advance/config.json | 3 +- src/components/NextButton.tsx | 81 ++++++++++++++------------- src/parser/LibraryConfigSchema.json | 40 +++++++++++++ src/parser/StudyConfigSchema.json | 40 +++++++++++++ src/parser/parser.ts | 2 + src/parser/types.ts | 2 + 7 files changed, 170 insertions(+), 40 deletions(-) diff --git a/public/demo-form-elements/config.json b/public/demo-form-elements/config.json index 1e4a25b42d..1395f32d63 100644 --- a/public/demo-form-elements/config.json +++ b/public/demo-form-elements/config.json @@ -687,6 +687,47 @@ } ] }, + "Auto advance": { + "type": "questionnaire", + "response": [ + { + "id": "math-buttons", + "type": "buttons", + "prompt": "Complex calculation that use buttons with auto-advance.", + "secondaryText": "What does 53 + 98 equal?", + "infoText": "Choose the option that is correct.", + "options": [ + { + "label": "162", + "value": "162" + }, + { + "label": "149", + "value": "149" + }, + { + "label": "151", + "value": "151" + }, + { + "label": "137", + "value": "137" + } + ], + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 5000, + "allowResponseChange": false + } + ], + "correctAnswer": [ + { + "id": "math-buttons", + "answer": "151" + } + ], + "provideFeedback": true, + "trainingAttempts": 1 + }, "Default Values": { "type": "questionnaire", "response": [ @@ -1422,6 +1463,7 @@ "Text Validation", "Default Values", "Custom Response", + "Auto advance", "Randomizing Options", "Randomizing Questions", "Ranking Widgets", diff --git a/public/test-auto-advance/config.json b/public/test-auto-advance/config.json index c9f7b6ca8f..a75136ebe1 100644 --- a/public/test-auto-advance/config.json +++ b/public/test-auto-advance/config.json @@ -43,7 +43,8 @@ "autoAdvanceToNextStep": true } ], - "type": "questionnaire" + "type": "questionnaire", + "nextButtonHidden": false }, "warning": { "response": [ diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index 2a7bb7738a..02ad8238a6 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -54,6 +54,7 @@ export function NextButton({ const nextButtonAutoAdvanceTime = config?.nextButtonAutoAdvanceTime; const nextButtonAutoAdvanceWarningTime = config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME; const nextButtonAutoAdvanceWarningMessage = config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE; + const nextButtonHidden = config?.nextButtonHidden ?? false; const [timer, setTimer] = useState(undefined); const autoAdvanceTriggered = useRef(false); @@ -119,7 +120,7 @@ export function NextButton({ onCheckAnswer(); return; } - if (!disabled && !isNextDisabled && buttonTimerSatisfied) { + if (!disabled && !isNextDisabled && buttonTimerSatisfied && !nextButtonHidden) { onNext(); } }; @@ -130,7 +131,7 @@ export function NextButton({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [disabled, isNextDisabled, buttonTimerSatisfied, onCheckAnswer, onNext, nextOnEnter]); + }, [disabled, isNextDisabled, nextButtonHidden, buttonTimerSatisfied, onCheckAnswer, onNext, nextOnEnter]); const nextButtonDisabled = disabled || isNextDisabled || !buttonTimerSatisfied; const previousButtonText = config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous'; @@ -155,43 +156,45 @@ export function NextButton({ /> )} {checkAnswer} - + {!nextButtonHidden && ( + + )} {timer !== undefined && ( <> diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index e3286b8632..f0a42d0638 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -116,6 +116,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -1509,6 +1513,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -1809,6 +1817,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -2388,6 +2400,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3065,6 +3081,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3776,6 +3796,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -4924,6 +4948,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5108,6 +5136,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5300,6 +5332,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5496,6 +5532,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 2b450d5d1a..574aea92c8 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -116,6 +116,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -1609,6 +1613,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -1909,6 +1917,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -2437,6 +2449,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3136,6 +3152,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3847,6 +3867,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5352,6 +5376,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5536,6 +5564,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5728,6 +5760,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5924,6 +5960,10 @@ "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" }, + "nextButtonHidden": { + "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "type": "boolean" + }, "nextButtonLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 6e4916d640..3f03dfab5e 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1000,6 +1000,8 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco const hasAutoAdvanceButton = responses.some( (response) => response.type === 'buttons' && response.autoAdvanceToNextStep === true, ); + const isAutoAdvance = hasAutoAdvanceButton || resolvedComponent.nextButtonAutoAdvanceTime !== undefined; + resolvedComponent.nextButtonHidden = resolvedComponent.nextButtonHidden ?? (isAutoAdvance ? true : (studyConfig.uiConfig?.nextButtonHidden ?? false)); const interactiveResponses = responses.filter( (response) => response.type !== 'textOnly' && response.type !== 'divider', diff --git a/src/parser/types.ts b/src/parser/types.ts index 1ccc4016e3..0a4d1c0031 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1341,6 +1341,8 @@ export interface BaseIndividualComponent { nextButtonAlignment?: NextButtonAlignment; /** The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig. */ nextButtonEnableTime?: number; + /** Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true. */ + nextButtonHidden?: boolean; /** The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig. */ nextButtonDisableTime?: number; /** The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component. */ From aa681cfab2cc04f9a4d4387bd99e5a17adb7c3d7 Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Fri, 4 Sep 2026 20:06:29 +0200 Subject: [PATCH 05/11] fix nextbuttonhidden error --- src/parser/parser.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 3f03dfab5e..987c2a09b6 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1001,8 +1001,7 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco (response) => response.type === 'buttons' && response.autoAdvanceToNextStep === true, ); const isAutoAdvance = hasAutoAdvanceButton || resolvedComponent.nextButtonAutoAdvanceTime !== undefined; - resolvedComponent.nextButtonHidden = resolvedComponent.nextButtonHidden ?? (isAutoAdvance ? true : (studyConfig.uiConfig?.nextButtonHidden ?? false)); - + resolvedComponent.nextButtonHidden = resolvedComponent.nextButtonHidden ?? (isAutoAdvance ? true : ((studyConfig.uiConfig as { nextButtonHidden?: boolean })?.nextButtonHidden ?? false)); const interactiveResponses = responses.filter( (response) => response.type !== 'textOnly' && response.type !== 'divider', ); From e95ac7ca043228147220eb27d83b57c47c387b4f Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Mon, 7 Sep 2026 14:37:51 +0200 Subject: [PATCH 06/11] fix playwright test --- tests/demo-form-elements.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/demo-form-elements.spec.ts b/tests/demo-form-elements.spec.ts index cd1d5c0cd2..0d6cac0d73 100644 --- a/tests/demo-form-elements.spec.ts +++ b/tests/demo-form-elements.spec.ts @@ -82,6 +82,7 @@ async function advanceToSidebarFormElements(page: Page) { } test('Test questionnaire component with responses and randomizing questions and responses', async ({ page }) => { + test.setTimeout(120000); await page.setViewportSize({ width: 1400, height: 900, @@ -327,6 +328,13 @@ test('Test questionnaire component with responses and randomizing questions and await expect(customResponseNextButton).toBeEnabled(); await nextClick(page); + // Auto-advance page + await expect(page.getByText('What does 53 + 98 equal?')).toBeVisible(); + await page.getByRole('radio', { name: '151', exact: true }).click(); + await page.getByRole('button', { name: 'Check Answer', exact: true }).click(); + await expect(page.getByRole('alert').getByText('Correct Answer', { exact: true })).toBeVisible(); + await expect(page.locator('#q-multi-satisfaction')).toBeVisible({ timeout: 10000 }); + // Fill the survey: Randomizing Options // Matrix radio From e2fe77600cf73171bbbd411dcad28164f361609d Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Wed, 9 Sep 2026 15:07:23 +0200 Subject: [PATCH 07/11] remove local test timeout --- tests/demo-form-elements.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/demo-form-elements.spec.ts b/tests/demo-form-elements.spec.ts index 0d6cac0d73..982e196769 100644 --- a/tests/demo-form-elements.spec.ts +++ b/tests/demo-form-elements.spec.ts @@ -82,7 +82,6 @@ async function advanceToSidebarFormElements(page: Page) { } test('Test questionnaire component with responses and randomizing questions and responses', async ({ page }) => { - test.setTimeout(120000); await page.setViewportSize({ width: 1400, height: 900, From c93fe17e50f584bab63b3b70a4a6bba3b7559426 Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Fri, 2 Oct 2026 13:10:04 +0200 Subject: [PATCH 08/11] fixed comments, need more testing; additional comment on issue not yet implemented --- src/components/NextButton.tsx | 88 +++++-- src/components/response/ButtonsInput.tsx | 5 +- src/components/response/ResponseBlock.tsx | 89 +------ src/components/response/ResponseSwitcher.tsx | 35 ++- src/components/response/autoAdvanceEvents.ts | 55 ++++ .../response/tests/ResponseInput.spec.tsx | 1 + .../response/tests/ResponseSwitcher.spec.tsx | 110 +++++++- .../response/tests/clearSelection.spec.tsx | 19 ++ src/components/response/utils.ts | 4 + src/components/tests/NextButton.spec.tsx | 238 ++++++++++++++++++ src/parser/parser.ts | 2 - src/store/hooks/tests/useNextStep.spec.tsx | 155 ------------ src/store/hooks/useNextStep.ts | 6 +- 13 files changed, 536 insertions(+), 271 deletions(-) create mode 100644 src/components/response/autoAdvanceEvents.ts diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index 02ad8238a6..ff5525c8c1 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -18,6 +18,7 @@ import { DEFAULT_AUTO_ADVANCE_WARNING_TIME, getAutoAdvanceWarning, } from './nextButtonTimeout'; +import type { AutoAdvanceSelection } from './response/autoAdvanceEvents'; const nextButtonJustify = { left: 'flex-start', @@ -32,7 +33,9 @@ type Props = { location?: ResponseBlockLocation; checkAnswer: JSX.Element | null; onCheckAnswer?: () => void; - onNext: () => void; + onNext: () => boolean; + autoAdvanceRequest?: AutoAdvanceSelection; + autoAdvanceEligible?: boolean; }; export function NextButton({ @@ -43,6 +46,8 @@ export function NextButton({ checkAnswer, onCheckAnswer, onNext, + autoAdvanceRequest, + autoAdvanceEligible = true, }: Props) { const { isNextDisabled, goToNextStep } = useNextStep(config?.response, config?.correctAnswer); const studyConfig = useStudyConfig(); @@ -54,13 +59,33 @@ export function NextButton({ const nextButtonAutoAdvanceTime = config?.nextButtonAutoAdvanceTime; const nextButtonAutoAdvanceWarningTime = config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME; const nextButtonAutoAdvanceWarningMessage = config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE; - const nextButtonHidden = config?.nextButtonHidden ?? false; + const responseAutoAdvances = config?.response?.some((response) => response.type === 'buttons' && response.autoAdvanceToNextStep && !response.hidden); + const nextButtonHidden = config?.nextButtonHidden ?? ( + nextButtonAutoAdvanceTime !== undefined + || responseAutoAdvances + || false + ); const [timer, setTimer] = useState(undefined); - const autoAdvanceTriggered = useRef(false); + const deadlineAutoAdvanceTriggered = useRef(false); + const navigationStarted = useRef(false); + const attemptedRequest = useRef(undefined); + const latestOnNext = useRef(onNext); + const [readyAutoAdvanceRequest, setReadyAutoAdvanceRequest] = useState(); + + useEffect(() => { + latestOnNext.current = onNext; + }, [onNext]); + + useEffect(() => { + if (!autoAdvanceEligible) { + attemptedRequest.current = undefined; + } + }, [autoAdvanceEligible]); // Use the current identifier so nested function-sequence items reset their timer state. useEffect(() => { - autoAdvanceTriggered.current = false; + deadlineAutoAdvanceTriggered.current = false; + navigationStarted.current = false; const start = Date.now(); setTimer(0); const interval = setInterval(() => { @@ -71,6 +96,18 @@ export function NextButton({ }; }, [identifier]); + useEffect(() => { + setReadyAutoAdvanceRequest(undefined); + if (!autoAdvanceRequest || !autoAdvanceRequest.selected || autoAdvanceRequest.identifier !== identifier) { + return undefined; + } + + const timeout = setTimeout(() => { + setReadyAutoAdvanceRequest(autoAdvanceRequest.eventId); + }, autoAdvanceRequest.delay); + return () => clearTimeout(timeout); + }, [autoAdvanceRequest, identifier]); + useEffect(() => { if (timer === undefined) { return; @@ -80,15 +117,6 @@ export function NextButton({ } }, [nextButtonDisableTime, timer, navigate, studyConfig.uiConfig.timeoutReject]); - useEffect(() => { - if (isNextDisabled || timer === undefined || nextButtonAutoAdvanceTime === undefined || timer < nextButtonAutoAdvanceTime || autoAdvanceTriggered.current) { - return; - } - - autoAdvanceTriggered.current = true; - goToNextStep(false); - }, [goToNextStep, isNextDisabled, nextButtonAutoAdvanceTime, timer]); - const buttonTimerSatisfied = useMemo( () => { if (timer === undefined) { @@ -101,6 +129,39 @@ export function NextButton({ [nextButtonDisableTime, nextButtonEnableTime, timer], ); + const nextButtonDisabled = disabled || isNextDisabled || !buttonTimerSatisfied; + + useEffect(() => { + if (isNextDisabled || timer === undefined || nextButtonAutoAdvanceTime === undefined || timer < nextButtonAutoAdvanceTime || deadlineAutoAdvanceTriggered.current) { + return; + } + + deadlineAutoAdvanceTriggered.current = true; + if (!navigationStarted.current && goToNextStep(false)) { + navigationStarted.current = true; + } + }, [goToNextStep, isNextDisabled, nextButtonAutoAdvanceTime, timer]); + + useEffect(() => { + if ( + readyAutoAdvanceRequest === undefined + || !autoAdvanceRequest?.selected + || autoAdvanceRequest.eventId !== readyAutoAdvanceRequest + || autoAdvanceRequest.identifier !== identifier + || nextButtonDisabled + || !autoAdvanceEligible + || navigationStarted.current + || attemptedRequest.current === readyAutoAdvanceRequest + ) { + return; + } + + attemptedRequest.current = readyAutoAdvanceRequest; + if (latestOnNext.current()) { + navigationStarted.current = true; + } + }, [autoAdvanceEligible, autoAdvanceRequest, identifier, nextButtonDisabled, readyAutoAdvanceRequest]); + const autoAdvanceWarning = useMemo(() => getAutoAdvanceWarning({ timer, autoAdvanceTime: nextButtonAutoAdvanceTime, @@ -133,7 +194,6 @@ export function NextButton({ }; }, [disabled, isNextDisabled, nextButtonHidden, buttonTimerSatisfied, onCheckAnswer, onNext, nextOnEnter]); - const nextButtonDisabled = disabled || isNextDisabled || !buttonTimerSatisfied; const previousButtonText = config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous'; const nextButtonAlignment = config?.nextButtonAlignment ?? studyConfig.uiConfig.nextButtonAlignment ?? 'right'; const componentWidth = config && getComponentContainerStyle(config.type, config.style); diff --git a/src/components/response/ButtonsInput.tsx b/src/components/response/ButtonsInput.tsx index b97009756b..b97df6b94b 100644 --- a/src/components/response/ButtonsInput.tsx +++ b/src/components/response/ButtonsInput.tsx @@ -1,7 +1,7 @@ import { Flex, FocusTrap, Kbd, Radio, } from '@mantine/core'; -import { useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import ClearSelectionButton from './ClearSelectionButton'; import { ButtonsResponse, ParsedStringOption } from '../../parser/types'; import classes from './css/ButtonsInput.module.css'; @@ -88,10 +88,7 @@ export function ButtonsInput({ hideKeyVisual = false, } = response; - const userInteractedRef = useRef(false); - const handleUserSelect = (value: string) => { - userInteractedRef.current = true; answer.onChange?.(value); }; diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index f4c304fbd2..98d101d6cd 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -21,7 +21,7 @@ import { import { NextButton } from '../NextButton'; import { - generateInitFields, hasAnswerValue, mergeReactiveAnswers, useAnswerField, + generateInitFields, mergeReactiveAnswers, useAnswerField, } from './utils'; import { generateCustomResponseErrorMessage, @@ -48,6 +48,7 @@ import { useStorageEngine } from '../../storage/storageEngineHooks'; import { showNotification } from '../../utils/notifications'; import { getAnswersFromAllLocations, getPersistedAnswersFromAllLocations } from '../../utils/getAnswersFromAllLocations'; import { DelayedResponseWrapper } from './DelayedResponseWrapper'; +import { useAutoAdvanceSelection } from './autoAdvanceEvents'; type Props = { status?: StoredAnswer; @@ -706,15 +707,15 @@ export function ResponseBlock({ const handleNextClick = useCallback(() => { if (hasStimulusIssue) { revealStimulusErrors(); - return; + return false; } if (bypassValidationForFailedTraining || !hasResponseIssues) { - goToNextStep(); - return; + return goToNextStep(); } revealResponseErrors(); + return false; }, [bypassValidationForFailedTraining, goToNextStep, hasResponseIssues, hasStimulusIssue, revealResponseErrors, revealStimulusErrors]); const autoAdvanceResponses = useMemo( @@ -722,82 +723,8 @@ export function ResponseBlock({ [allResponsesWithDefaults], ); const autoAdvanceOwner = showBtnsInLocation && autoAdvanceResponses.length > 0 && !isAnalysis; - const autoAdvanceTimeoutRef = useRef | undefined>(undefined); - const autoAdvanceIdentifierRef = useRef(null); - useEffect(() => { - autoAdvanceIdentifierRef.current = null; - return () => { - if (autoAdvanceTimeoutRef.current) { - clearTimeout(autoAdvanceTimeoutRef.current); - autoAdvanceTimeoutRef.current = undefined; - } - }; - }, [identifier]); - - const userInteractedRef = useRef(false); - - useEffect(() => { - userInteractedRef.current = false; - }, [identifier]); - - const handleNextClickRef = useRef(handleNextClick); - useEffect(() => { - handleNextClickRef.current = handleNextClick; - }, [handleNextClick]); - - const prevCombinedValuesRef = useRef(combinedValues); - useEffect(() => { - const hasValuesChanged = !isEqual(prevCombinedValuesRef.current, combinedValues); - - if (hasValuesChanged) { - if (Object.keys(combinedValues || {}).length > 0) { - userInteractedRef.current = true; - } - prevCombinedValuesRef.current = combinedValues; - } - }, [combinedValues]); - - useEffect(() => { - if (!autoAdvanceOwner) { - return undefined; - } - - const readyResponse = autoAdvanceResponses.find((response) => hasAnswerValue(combinedValues[response.id])); - - if (!readyResponse) { - autoAdvanceIdentifierRef.current = null; - return undefined; - } - - const hasFeedback = 'provideFeedback' in readyResponse && Boolean(readyResponse.provideFeedback); - if (hasFeedback) { - return undefined; - } - - if (!userInteractedRef.current) { - return undefined; - } - - if (autoAdvanceTimeoutRef.current) { - clearTimeout(autoAdvanceTimeoutRef.current); - autoAdvanceTimeoutRef.current = undefined; - } - - autoAdvanceIdentifierRef.current = identifier; - if (readyResponse.type === 'buttons') { - const delay = readyResponse.autoAdvanceDelay ?? 0; - autoAdvanceTimeoutRef.current = setTimeout(() => { - handleNextClickRef.current(); - }, delay); - } - - return () => { - if (autoAdvanceTimeoutRef.current) { - clearTimeout(autoAdvanceTimeoutRef.current); - autoAdvanceTimeoutRef.current = undefined; - } - }; - }, [autoAdvanceOwner, autoAdvanceResponses, combinedValues, identifier]); + const autoAdvanceResponseIds = useMemo(() => autoAdvanceResponses.map((response) => response.id), [autoAdvanceResponses]); + const autoAdvanceSelection = useAutoAdvanceSelection(identifier, autoAdvanceResponseIds, autoAdvanceOwner); let index = 0; return ( @@ -919,6 +846,8 @@ export function ResponseBlock({ label={nextButtonText} config={config} location={location} + autoAdvanceRequest={autoAdvanceSelection} + autoAdvanceEligible={!hasStimulusIssue && (bypassValidationForFailedTraining || !hasResponseIssues)} onNext={handleNextClick} onCheckAnswer={!isAnalysis && hasCorrectAnswerFeedback && !disabledAttempts ? checkAnswerProvideFeedback : undefined} checkAnswer={showBtnsInLocation && hasCorrectAnswerFeedback ? ( diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index 4d935b39c5..e8f6bfa98b 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -1,7 +1,7 @@ import { Box, Checkbox, Divider } from '@mantine/core'; -import { useSearchParams } from 'react-router'; -import { useMemo, useRef } from 'react'; import { GetInputPropsReturnType } from '@mantine/form/lib/types'; +import { useSearchParams } from 'react-router'; +import { useEffect, useMemo, useState } from 'react'; import { CustomResponse, IndividualComponent, JsonValue, MatrixResponse, Response, SliderResponse, StoredAnswer, } from '../../parser/types'; @@ -26,13 +26,13 @@ import './css/default-form.css'; import { useIsAnalysis } from '../../store/hooks/useIsAnalysis'; import { useStoreSelector } from '../../store/store'; import { getSequenceFlatMap } from '../../utils/getSequenceFlatMap'; -import { useCurrentStep } from '../../routes/utils'; +import { useCurrentIdentifier, useCurrentStep } from '../../routes/utils'; import { TextOnlyInput } from './TextOnlyInput'; import { useFetchStylesheet } from '../../utils/fetchStylesheet'; import { parseStringOptionValue, parseStringOptions } from '../../utils/stringOptions'; import { getDropdownOptions } from '../../utils/dropdownOptions'; import { - getDefaultFieldValue, getResponseWidth, normalizeCheckboxValue, + getDefaultFieldValue, getResponseWidth, hasAnswerValue, isResponseChangeLocked, normalizeCheckboxValue, } from './utils'; import { generateErrorMessage, @@ -42,6 +42,7 @@ import { CustomResponseField } from '../../store/types'; import { compileTemplate } from '../../utils/handlebars'; import { useTemplateAnswerContext } from '../../store/hooks/useTemplateAnswerContext'; import { OptionTextTemplateContext } from './OptionLabel'; +import { publishAutoAdvanceSelection } from './autoAdvanceEvents'; export function ResponseSwitcher({ response, @@ -78,9 +79,14 @@ export function ResponseSwitcher({ const sequence = useStoreSelector((state) => state.sequence); const flatSequence = useMemo(() => getSequenceFlatMap(sequence), [sequence]); const currentStep = useCurrentStep(); + const identifier = useCurrentIdentifier(); const nextComponent = useMemo(() => (typeof currentStep === 'number' ? flatSequence[currentStep + 1] : undefined), [currentStep, flatSequence]); const nextConfig = useMemo(() => (nextComponent ? studyConfig.components[nextComponent] : undefined), [nextComponent, studyConfig]); - const userSelectedFieldsRef = useRef>({}); + const [userSelected, setUserSelected] = useState(false); + + useEffect(() => { + setUserSelected(false); + }, [identifier]); const completed = useStoreSelector((state) => state.completed); const usesStandaloneDontKnow = usesStandaloneDontKnowField(response); @@ -95,11 +101,20 @@ export function ResponseSwitcher({ return { ...form, onChange: (val: unknown) => { - userSelectedFieldsRef.current[response.id] = true; + const hasSelectedAnswer = hasAnswerValue(val); + setUserSelected(hasSelectedAnswer); + if (response.type === 'buttons' && response.autoAdvanceToNextStep) { + publishAutoAdvanceSelection({ + identifier, + responseId: response.id, + delay: response.autoAdvanceDelay ?? 0, + selected: hasSelectedAnswer, + }); + } form.onChange(val); }, }; - }, [form, response.id]); + }, [form, identifier, response]); // Don't update if we're in analysis mode const ans = useMemo( @@ -162,13 +177,11 @@ export function ResponseSwitcher({ } if (response.allowResponseChange === false) { - const userInteracted = Boolean(userSelectedFieldsRef.current[response.id]); - const hasNonDefaultValue = ans.value !== undefined && ans.value !== '' && ans.value !== fieldInitialValue; - return userInteracted || hasNonDefaultValue; + return isResponseChangeLocked(userSelected, ans.value); } return false; - }, [response, ans.value, fieldInitialValue]); + }, [response, ans.value, userSelected]); const isStateDisabled = useMemo(() => { // Always disable if participant is completed diff --git a/src/components/response/autoAdvanceEvents.ts b/src/components/response/autoAdvanceEvents.ts new file mode 100644 index 0000000000..8ab8c6f6e9 --- /dev/null +++ b/src/components/response/autoAdvanceEvents.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; + +export type AutoAdvanceSelection = { + eventId: number; + identifier: string; + responseId: string; + delay: number; + selected: boolean; +}; + +type Listener = (selection: AutoAdvanceSelection) => void; + +const listeners = new Set(); +let nextEventId = 0; + +export function publishAutoAdvanceSelection(selection: Omit) { + const event = { ...selection, eventId: nextEventId }; + nextEventId += 1; + listeners.forEach((listener) => listener(event)); +} + +export function subscribeToAutoAdvanceSelections(listener: Listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useAutoAdvanceSelection( + identifier: string, + responseIds: string[], + enabled: boolean, +): AutoAdvanceSelection | undefined { + const [selection, setSelection] = useState(); + + useEffect(() => { + setSelection(undefined); + }, [identifier]); + + useEffect(() => { + if (!enabled) { + return undefined; + } + + const allowedResponseIds = new Set(responseIds); + const unsubscribe = subscribeToAutoAdvanceSelections((nextSelection) => { + if (nextSelection.identifier === identifier && allowedResponseIds.has(nextSelection.responseId)) { + setSelection(nextSelection); + } + }); + return () => unsubscribe(); + }, [enabled, identifier, responseIds]); + + return selection; +} diff --git a/src/components/response/tests/ResponseInput.spec.tsx b/src/components/response/tests/ResponseInput.spec.tsx index 7b6a36b271..f09ea8a55a 100644 --- a/src/components/response/tests/ResponseInput.spec.tsx +++ b/src/components/response/tests/ResponseInput.spec.tsx @@ -318,6 +318,7 @@ vi.mock('../../../store/hooks/useIsAnalysis', () => ({ vi.mock('../../../routes/utils', () => ({ useCurrentStep: vi.fn(() => 0), + useCurrentIdentifier: vi.fn(() => 'trial1_0'), useCurrentComponent: vi.fn(() => ''), useCurrentIdentifier: vi.fn(() => 'test_0'), })); diff --git a/src/components/response/tests/ResponseSwitcher.spec.tsx b/src/components/response/tests/ResponseSwitcher.spec.tsx index 7889224156..1629dc9fd8 100644 --- a/src/components/response/tests/ResponseSwitcher.spec.tsx +++ b/src/components/response/tests/ResponseSwitcher.spec.tsx @@ -1,15 +1,19 @@ -import { ComponentPropsWithoutRef } from 'react'; -import { render, cleanup } from '@testing-library/react'; +import { ComponentPropsWithoutRef, ReactNode } from 'react'; +import { + render, cleanup, fireEvent, act, +} from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; import type { IndividualComponent, JsonValue, Response } from '../../../parser/types'; import { ResponseSwitcher } from '../ResponseSwitcher'; +import { useAutoAdvanceSelection } from '../autoAdvanceEvents'; +import { NextButton } from '../../NextButton'; // ── mocks ──────────────────────────────────────────────────────────────────── const { - capturedStringInputProps, capturedCustomInputProps, mockIsAnalysis, mockStoreState, mockCurrentComponent, + capturedStringInputProps, capturedCustomInputProps, mockIsAnalysis, mockStoreState, mockCurrentComponent, mockGoToNextStep, } = vi.hoisted(() => ({ capturedStringInputProps: { disabled: undefined as boolean | undefined, @@ -25,17 +29,53 @@ const { answers: {}, }, mockCurrentComponent: { value: '' }, + mockGoToNextStep: vi.fn(() => true), })); vi.mock('@mantine/core', () => ({ Box: ({ children, ...props }: ComponentPropsWithoutRef<'div'>) =>
{children}
, Checkbox: () => , Divider: () =>
, + Flex: ({ children }: { children?: ReactNode }) =>
{children}
, + FocusTrap: ({ children }: { children?: ReactNode }) =>
{children}
, + Alert: ({ children }: { children?: ReactNode }) =>
{children}
, + Button: ({ children, disabled, onClick }: { children?: ReactNode; disabled?: boolean; onClick?: () => void }) => ( + + ), + Group: ({ children }: { children?: ReactNode }) =>
{children}
, + Radio: Object.assign( + ({ children, value, ...props }: { children?: ReactNode; value?: string; [key: string]: unknown }) => ( +
{children}
+ ), + { + Group: ({ children, onChange }: { children?: ReactNode; onChange?: (value: string) => void }) => { + const handleClick = (event: React.MouseEvent) => { + const card = (event.target as HTMLElement).closest('[data-radio-card]'); + if (card) onChange?.(card.getAttribute('data-value') || ''); + }; + + return
{children}
; + }, + Card: ({ children, value, ...props }: { children?: ReactNode; value?: string; [key: string]: unknown }) => ( + + ), + }, + ), })); vi.mock('react-router', () => ({ useSearchParams: vi.fn(() => [new URLSearchParams()]), useParams: vi.fn(() => ({})), + useNavigate: vi.fn(() => vi.fn()), +})); + +vi.mock('@tabler/icons-react', () => ({ + IconInfoCircle: () => null, + IconAlertTriangle: () => null, +})); + +vi.mock('../../../store/hooks/useNextStep', () => ({ + useNextStep: vi.fn(() => ({ isNextDisabled: false, goToNextStep: mockGoToNextStep })), })); vi.mock('../../../store/hooks/useStudyConfig', () => ({ @@ -48,6 +88,7 @@ vi.mock('../../../store/hooks/useIsAnalysis', () => ({ vi.mock('../../../routes/utils', () => ({ useCurrentStep: vi.fn(() => 0), + useCurrentIdentifier: vi.fn(() => 'trial1_0'), useCurrentComponent: vi.fn(() => mockCurrentComponent.value), })); @@ -75,6 +116,27 @@ vi.mock('../StringInput', () => ({ }, })); +vi.mock('../../PreviousButton', () => ({ + PreviousButton: () => null, +})); + +vi.mock('../../../store/hooks/useStoredAnswer', () => ({ + useStoredAnswer: vi.fn(() => undefined), +})); + +vi.mock('../InputLabel', () => ({ + InputLabel: ({ prompt, clearSelectionButton }: { prompt?: ReactNode; clearSelectionButton?: ReactNode }) => ( +
+ {prompt} + {clearSelectionButton} +
+ ), +})); + +vi.mock('../OptionLabel', () => ({ + OptionLabel: ({ label }: { label: ReactNode }) => {label}, +})); + // ── fixtures ────────────────────────────────────────────────────────────────── const response = { @@ -110,6 +172,8 @@ beforeEach(() => { mockIsAnalysis.value = false; mockCurrentComponent.value = ''; mockStoreState.completed = false; + mockGoToNextStep.mockReset(); + mockGoToNextStep.mockReturnValue(true); }); afterEach(() => cleanup()); @@ -167,6 +231,11 @@ test.each([ expect(view.container.querySelector('.response')?.classList.contains(expectedClass)).toBe(true); }); +function AutoAdvanceOwner({ onNext }: { onNext: () => boolean }) { + const selection = useAutoAdvanceSelection('trial1_0', ['choice'], true); + return ; +} + // ── ResponseSwitcher stored answer locking ──────────────────────────────────── describe('ResponseSwitcher stored answer locking', () => { @@ -311,4 +380,39 @@ describe('ResponseSwitcher dynamic loading', () => { expect(container.textContent).not.toContain('{{value}}'); expect(container.querySelector('[data-testid="string-input"]')).toBeNull(); }); + + test('a button selection in a separate response location reaches its Next owner', async () => { + vi.useFakeTimers(); + const onNext = vi.fn(() => true); + const autoAdvanceResponse = { + type: 'buttons', + id: 'choice', + prompt: 'Choose', + required: false, + options: ['A', 'B'], + location: 'sidebar', + allowResponseChange: false, + autoAdvanceToNextStep: true, + autoAdvanceDelay: 0, + } as Response; + const { container } = render( + <> + [0]['form']} + index={1} + config={{} as IndividualComponent} + /> + + , + ); + + const selectedOption = container.querySelector('[data-radio-card][data-value="A"]') as HTMLButtonElement; + expect(selectedOption.disabled).toBe(false); + fireEvent.click(selectedOption); + await act(async () => { vi.advanceTimersByTime(0); }); + + expect(selectedOption.disabled).toBe(true); + expect(onNext).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/response/tests/clearSelection.spec.tsx b/src/components/response/tests/clearSelection.spec.tsx index 40eea20f32..c1080848bd 100644 --- a/src/components/response/tests/clearSelection.spec.tsx +++ b/src/components/response/tests/clearSelection.spec.tsx @@ -9,6 +9,7 @@ import ClearSelectionButton from '../ClearSelectionButton'; import { RadioInput } from '../RadioInput'; import { ButtonsInput } from '../ButtonsInput'; import { MatrixInput } from '../MatrixInput'; +import { hasAnswerValue, isResponseChangeLocked } from '../utils'; interface DivProps { children?: React.ReactNode; @@ -25,6 +26,7 @@ interface RadioProps { interface RadioGroupProps extends DivProps { label?: React.ReactNode; description?: React.ReactNode; + value?: string; onChange?: (value: string) => void; } @@ -171,6 +173,23 @@ describe('ClearSelectionButton', () => { }); }); +describe('response change locking', () => { + test('recognizes whether an answer has a selected value', () => { + expect(hasAnswerValue('')).toBe(false); + expect(hasAnswerValue(undefined)).toBe(false); + expect(hasAnswerValue([''])).toBe(false); + expect(hasAnswerValue({ nested: '' })).toBe(false); + expect(hasAnswerValue(0)).toBe(true); + expect(hasAnswerValue(false)).toBe(true); + }); + + test('locks only after a user interaction leaves an answer selected', () => { + expect(isResponseChangeLocked(true, 'B')).toBe(true); + expect(isResponseChangeLocked(true, '')).toBe(false); + expect(isResponseChangeLocked(false, 'B')).toBe(false); + }); +}); + describe('RadioInput / ButtonsInput clear & toggle behaviour', () => { test('RadioInput toggles selection and Clear selection calls onChange("")', () => { const onChange = vi.fn(); diff --git a/src/components/response/utils.ts b/src/components/response/utils.ts index b642f93a12..610806daf4 100644 --- a/src/components/response/utils.ts +++ b/src/components/response/utils.ts @@ -74,6 +74,10 @@ export function hasAnswerValue(value: unknown): boolean { return true; } +export function isResponseChangeLocked(userInteracted: boolean, value: unknown): boolean { + return userInteracted && hasAnswerValue(value); +} + export const getDefaultFieldValue = (response: Response) => { const responseDefault = (response as ResponseWithDefault).default; if (!Object.hasOwn(response, 'default') || responseDefault === undefined) { diff --git a/src/components/tests/NextButton.spec.tsx b/src/components/tests/NextButton.spec.tsx index 827c355c20..97dea4ea01 100644 --- a/src/components/tests/NextButton.spec.tsx +++ b/src/components/tests/NextButton.spec.tsx @@ -434,6 +434,244 @@ describe('NextButton', () => { expect(onNext).not.toHaveBeenCalled(); }); + test('waits for the response auto-advance delay before calling onNext', async () => { + vi.useFakeTimers(); + const onNext = vi.fn(); + await act(async () => { + render( + , + ); + }); + + await act(async () => { vi.advanceTimersByTime(249); }); + expect(onNext).not.toHaveBeenCalled(); + await act(async () => { vi.advanceTimersByTime(1); }); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + test('waits for nextButtonEnableTime before response auto-advance', async () => { + vi.useFakeTimers(); + mockStudyConfig = { + uiConfig: { ...mockStudyConfig.uiConfig, nextButtonEnableTime: 300 }, + }; + const onNext = vi.fn(); + await act(async () => { + render( + , + ); + }); + + await act(async () => { vi.advanceTimersByTime(0); }); + expect(onNext).not.toHaveBeenCalled(); + await act(async () => { vi.advanceTimersByTime(300); }); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + test('does not response-auto-advance after nextButtonDisableTime', async () => { + vi.useFakeTimers(); + mockStudyConfig = { + uiConfig: { ...mockStudyConfig.uiConfig, nextButtonDisableTime: 200 }, + }; + const onNext = vi.fn(); + await act(async () => { + render( + , + ); + }); + + await act(async () => { vi.advanceTimersByTime(500); }); + expect(onNext).not.toHaveBeenCalled(); + }); + + test('cancels a pending response auto-advance when the answer is cleared', async () => { + vi.useFakeTimers(); + const onNext = vi.fn(); + let rerender!: ReturnType['rerender']; + await act(async () => { + ({ rerender } = render( + , + )); + }); + + await act(async () => { + rerender( + , + ); + }); + await act(async () => { vi.advanceTimersByTime(500); }); + expect(onNext).not.toHaveBeenCalled(); + }); + + test('preserves the configured auto-advance deadline despite Next timing and disabled gates', async () => { + vi.useFakeTimers(); + mockStudyConfig = { + uiConfig: { + ...mockStudyConfig.uiConfig, + nextButtonEnableTime: 300, + nextButtonDisableTime: 200, + }, + }; + const config = { + type: 'questionnaire', response: [], nextButtonAutoAdvanceTime: 100, + } as unknown as IndividualComponent; + + await act(async () => { + render(); + }); + await act(async () => { vi.advanceTimersByTime(100); }); + expect(mockGoToNextStep).toHaveBeenCalledTimes(1); + expect(mockGoToNextStep).toHaveBeenLastCalledWith(false); + }); + + test('waits for validation eligibility and does not retry on callback identity changes', async () => { + vi.useFakeTimers(); + const failedValidation = vi.fn(() => false); + let rerender!: ReturnType['rerender']; + const request = { + eventId: 26, identifier: 'intro_0', responseId: 'choice', delay: 0, selected: true, + }; + await act(async () => { + ({ rerender } = render( + , + )); + }); + await act(async () => { vi.advanceTimersByTime(0); }); + expect(failedValidation).not.toHaveBeenCalled(); + + await act(async () => { + rerender( + , + ); + }); + expect(failedValidation).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender( + false} + autoAdvanceRequest={request} + autoAdvanceEligible + />, + ); + }); + expect(failedValidation).toHaveBeenCalledTimes(1); + }); + + test('ignores a ready request after the current identifier changes', async () => { + vi.useFakeTimers(); + const onNext = vi.fn(() => true); + const request = { + eventId: 27, identifier: 'intro_0', responseId: 'choice', delay: 0, selected: true, + }; + let rerender!: ReturnType['rerender']; + await act(async () => { + ({ rerender } = render()); + }); + mockIdentifier = 'intro_0_dynamic_1'; + await act(async () => { + rerender(); + }); + await act(async () => { vi.advanceTimersByTime(500); }); + expect(onNext).not.toHaveBeenCalled(); + }); + + test('does not navigate twice when deadline and response advancement become ready together', async () => { + vi.useFakeTimers(); + mockGoToNextStep.mockReturnValue(true); + const onNext = vi.fn(() => true); + const config = { + type: 'questionnaire', response: [], nextButtonAutoAdvanceTime: 100, + } as unknown as IndividualComponent; + await act(async () => { + render( + , + ); + }); + await act(async () => { vi.advanceTimersByTime(100); }); + expect(mockGoToNextStep.mock.calls.length + onNext.mock.calls.length).toBe(1); + }); + + test('holds a ready response auto-advance while training keeps Next disabled', async () => { + vi.useFakeTimers(); + const onNext = vi.fn(); + let rerender!: ReturnType['rerender']; + await act(async () => { + ({ rerender } = render( + , + )); + }); + await act(async () => { vi.advanceTimersByTime(0); }); + expect(onNext).not.toHaveBeenCalled(); + + await act(async () => { + rerender( + , + ); + }); + expect(onNext).toHaveBeenCalledTimes(1); + }); + test('resets auto-advance state when the current identifier changes', async () => { const config = { type: 'questionnaire', diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 987c2a09b6..c895e1d3b5 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1000,8 +1000,6 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco const hasAutoAdvanceButton = responses.some( (response) => response.type === 'buttons' && response.autoAdvanceToNextStep === true, ); - const isAutoAdvance = hasAutoAdvanceButton || resolvedComponent.nextButtonAutoAdvanceTime !== undefined; - resolvedComponent.nextButtonHidden = resolvedComponent.nextButtonHidden ?? (isAutoAdvance ? true : ((studyConfig.uiConfig as { nextButtonHidden?: boolean })?.nextButtonHidden ?? false)); const interactiveResponses = responses.filter( (response) => response.type !== 'textOnly' && response.type !== 'divider', ); diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index f2d3d69ac3..9459e59141 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -380,102 +380,6 @@ describe('useNextStep', () => { expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); }); - test('respects auto-advance delay timing before calling next step navigation', async () => { - vi.useFakeTimers(); - mockSaveAnswers.mockResolvedValueOnce(undefined); - - renderToStaticMarkup(); - - const delay = 300; - let autoAdvanceTriggered = false; - - const timeoutId = setTimeout(() => { - capturedGoToNextStep?.(true); - autoAdvanceTriggered = true; - }, delay); - - expect(autoAdvanceTriggered).toBe(false); - - vi.advanceTimersByTime(200); - expect(autoAdvanceTriggered).toBe(false); - expect(mockNavigate).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(100); - await Promise.resolve(); - - expect(autoAdvanceTriggered).toBe(true); - expect(mockNavigate).toHaveBeenCalledTimes(1); - - clearTimeout(timeoutId); - vi.useRealTimers(); - }); - - test('retains default field values without locking response change on initial render', () => { - mockStoredAnswer = { - ...mockStoredAnswer, - answer: { - 'btn-1': 'Option A', - }, - }; - - mockStudyConfig = { - components: { - intro: { - type: 'questionnaire', - response: [ - { - id: 'btn-1', - type: 'buttons', - options: ['Option A', 'Option B'], - default: 'Option A', - allowResponseChange: false, - }, - ], - }, - }, - }; - - renderToStaticMarkup(); - - // Verification: isNextDisabled should remain active/accessible - // and form interactions are not disabled on mount merely because of default values - expect(capturedIsNextDisabled).toBe(false); - }); - - test('locks option selection after explicit user interaction when allowResponseChange is false', async () => { - mockSaveAnswers.mockResolvedValueOnce(undefined); - - mockStudyConfig = { - components: { - intro: { - type: 'questionnaire', - response: [ - { - id: 'btn-1', - type: 'buttons', - options: ['Option A', 'Option B'], - allowResponseChange: false, - }, - ], - }, - }, - }; - - renderToStaticMarkup(); - - await capturedGoToNextStep?.(true); - await Promise.resolve(); - - expect(mockSaveTrialAnswer).toHaveBeenCalledWith( - expect.objectContaining({ - identifier: 'intro_0', - answer: expect.objectContaining({ - response: 'saved-answer', - }), - }), - ); - }); - test('excludes timed out answers from block skip conditions', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); mockSequence = { @@ -522,63 +426,4 @@ describe('useNextStep', () => { })); expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); }); - - test('locks response options immediately during autoAdvanceDelay before navigation occurs', async () => { - vi.useFakeTimers(); - mockSaveAnswers.mockResolvedValueOnce(undefined); - - mockStudyConfig = { - components: { - intro: { - type: 'questionnaire', - response: [ - { - id: 'btn-1', - type: 'buttons', - options: ['Option A', 'Option B'], - default: 'Option A', - allowResponseChange: false, - autoAdvanceToNextStep: true, - autoAdvanceDelay: 500, - }, - ], - }, - followup: {}, - }, - }; - - renderToStaticMarkup(); - - // Simulating user interaction changing value from default 'Option A' to 'Option B' - mockTrialValidation = { - intro_0: { - ...(mockTrialValidation.intro_0 as Record), - belowStimulus: { valid: true, values: { 'btn-1': 'Option B' } }, - }, - }; - - // Re-render to simulate the state update during the delay - renderToStaticMarkup(); - let stepTriggered = false; - setTimeout(() => { - capturedGoToNextStep?.(true); - stepTriggered = true; - }, 500); - - // Advance time partially (250ms out of 500ms delay) - vi.advanceTimersByTime(250); - - // Verify navigation has NOT happened yet... - expect(stepTriggered).toBe(false); - expect(mockNavigate).not.toHaveBeenCalled(); - expect(capturedIsNextDisabled).toBe(false); - - // Complete remaining delay - vi.advanceTimersByTime(250); - await Promise.resolve(); - - expect(stepTriggered).toBe(true); - expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); - vi.useRealTimers(); - }); }); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index 5af08c774e..d5fbf5ba65 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -67,8 +67,8 @@ export function useNextStep(responses: Response[] = [], correctAnswers: Answer[] const windowEvents = useWindowEvents(); const goToNextStep = useCallback((collectData = true) => { try { - if (isNextDisabled) { - return; + if (isNextDisabled || typeof currentStep !== 'number') { + return false; } // Get answer from across the 3 response blocks and the provenance graph const validation = trialValidation[identifier]; @@ -163,6 +163,7 @@ export function useNextStep(responses: Response[] = [], correctAnswers: Answer[] } else { navigate(`/${studyId}/${encryptIndex(nextStep)}${window.location.search}`); } + return true; } catch (error) { console.error('Failed to advance to next step', error); showNotification({ @@ -170,6 +171,7 @@ export function useNextStep(responses: Response[] = [], correctAnswers: Answer[] message: 'Something went wrong while processing your response. Please try again.', color: 'red', }); + return false; } }, [isNextDisabled, currentStep, trialValidation, identifier, storedAnswer, windowEvents, dataCollectionEnabled, clickedPrevious, sequence, answers, startTime, funcIndex, storeDispatch, saveTrialAnswer, storageEngine, setReactiveAnswers, setMatrixAnswersCheckbox, setMatrixAnswersRadio, setRankingAnswers, setAlertModal, studyConfig, participantSequence, navigate, studyId, responseSubmitAttempted, checkAnswerState, responses, correctAnswers]); From 70ad9c71307c68d273ba909a52acba7f08bcf580 Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Mon, 5 Oct 2026 11:42:35 +0200 Subject: [PATCH 09/11] implement autoadvance for postAnswers to use it in html charts --- .../assets/bar-chart-interaction.html | 11 +- public/demo-html-input/assets/introduction.md | 3 + public/demo-html-input/config.json | 14 +- .../revisitUtilities/revisit-communicate.js | 168 +++++++++--------- public/test-auto-advance/assets/choice.html | 74 ++++++++ public/test-auto-advance/config.json | 64 ++----- src/components/response/ButtonsInput.tsx | 4 - src/components/response/ResponseBlock.tsx | 5 +- src/components/response/autoAdvanceEvents.ts | 10 +- .../response/tests/ResponseInput.spec.tsx | 1 - .../response/tests/ResponseSwitcher.spec.tsx | 42 ++++- src/controllers/IframeController.tsx | 11 +- .../tests/IframeController.spec.tsx | 57 ++++++ tests/demo-html-input.spec.ts | 9 +- 14 files changed, 325 insertions(+), 148 deletions(-) create mode 100644 public/test-auto-advance/assets/choice.html diff --git a/public/demo-html-input/assets/bar-chart-interaction.html b/public/demo-html-input/assets/bar-chart-interaction.html index 2584c69fa1..f8b58fdeda 100644 --- a/public/demo-html-input/assets/bar-chart-interaction.html +++ b/public/demo-html-input/assets/bar-chart-interaction.html @@ -20,7 +20,7 @@ - + + + + +

HTML bar chart

+

Click a bar to record its value and automatically continue after 300 milliseconds. + You can also focus a bar with Tab and select it with Enter or Space.

+ + + + + 20 + A + + + + 45 + B + + + + 30 + C + + + + 60 + D + + +

No bar selected.

+ + + diff --git a/public/test-auto-advance/config.json b/public/test-auto-advance/config.json index a75136ebe1..9e29b6b1b8 100644 --- a/public/test-auto-advance/config.json +++ b/public/test-auto-advance/config.json @@ -23,6 +23,20 @@ "studyEndMsg": "**Thank you for completing the study. You may click this link and return to Prolific**: [yourProlificLink](yourProlificLink)" }, "components": { + "html-auto-advance": { + "type": "website", + "path": "test-auto-advance/assets/choice.html", + "nextButtonHidden": true, + "response": [ + { + "id": "selectedBar", + "type": "reactive", + "prompt": "Your selected bar value:", + "location": "belowStimulus", + "required": true + } + ] + }, "auto-advance": { "response": [ { @@ -98,59 +112,15 @@ } ], "type": "questionnaire" - }, - "sidebar-next": { - "response": [ - { - "id": "q-buttons", - "type": "buttons", - "prompt": "Buttons example", - "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", - "infoText": "Choose the option that best represents your answer.", - "options": [ - "Option 1", - "Option 2", - "Option 3", - "Option 4" - ], - "autoAdvanceToNextStep": true, - "autoAdvanceDelay": 6000, - "allowResponseChange": false - } - ], - "nextButtonLocation": "sidebar", - "type": "questionnaire" - }, - "sidebar-next-immediately": { - "response": [ - { - "id": "q-buttons", - "type": "buttons", - "prompt": "Buttons example", - "secondaryText": "Buttons that function as radio buttons, but look like buttons. Control with the arrow keys.", - "infoText": "Choose the option that best represents your answer.", - "options": [ - "Option 1", - "Option 2", - "Option 3", - "Option 4" - ], - "autoAdvanceToNextStep": true, - "allowResponseChange": false - } - ], - "nextButtonLocation": "sidebar", - "type": "questionnaire" } -}, - "sequence": { + }, + "sequence": { "order": "fixed", "components": [ "auto-advance", "warning", "block-change", - "sidebar-next", - "sidebar-next-immediately" + "html-auto-advance" ] } } diff --git a/src/components/response/ButtonsInput.tsx b/src/components/response/ButtonsInput.tsx index b97df6b94b..6424fbb3b5 100644 --- a/src/components/response/ButtonsInput.tsx +++ b/src/components/response/ButtonsInput.tsx @@ -88,10 +88,6 @@ export function ButtonsInput({ hideKeyVisual = false, } = response; - const handleUserSelect = (value: string) => { - answer.onChange?.(value); - }; - const storedAnswer = useStoredAnswer(); const optionOrders: Record = useMemo(() => storedAnswer?.optionOrders ?? {}, [storedAnswer]); diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 98d101d6cd..87277afba4 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -722,9 +722,10 @@ export function ResponseBlock({ () => allResponsesWithDefaults.filter((response) => !response.hidden && response.type === 'buttons' && response.autoAdvanceToNextStep), [allResponsesWithDefaults], ); - const autoAdvanceOwner = showBtnsInLocation && autoAdvanceResponses.length > 0 && !isAnalysis; + const allowTrialRequests = config.type === 'website'; + const autoAdvanceOwner = showBtnsInLocation && (autoAdvanceResponses.length > 0 || allowTrialRequests) && !isAnalysis; const autoAdvanceResponseIds = useMemo(() => autoAdvanceResponses.map((response) => response.id), [autoAdvanceResponses]); - const autoAdvanceSelection = useAutoAdvanceSelection(identifier, autoAdvanceResponseIds, autoAdvanceOwner); + const autoAdvanceSelection = useAutoAdvanceSelection(identifier, autoAdvanceResponseIds, autoAdvanceOwner, allowTrialRequests); let index = 0; return ( diff --git a/src/components/response/autoAdvanceEvents.ts b/src/components/response/autoAdvanceEvents.ts index 8ab8c6f6e9..c7d4d274a9 100644 --- a/src/components/response/autoAdvanceEvents.ts +++ b/src/components/response/autoAdvanceEvents.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; export type AutoAdvanceSelection = { eventId: number; identifier: string; - responseId: string; + responseId?: string; delay: number; selected: boolean; }; @@ -30,6 +30,7 @@ export function useAutoAdvanceSelection( identifier: string, responseIds: string[], enabled: boolean, + allowTrialRequests = false, ): AutoAdvanceSelection | undefined { const [selection, setSelection] = useState(); @@ -44,12 +45,15 @@ export function useAutoAdvanceSelection( const allowedResponseIds = new Set(responseIds); const unsubscribe = subscribeToAutoAdvanceSelections((nextSelection) => { - if (nextSelection.identifier === identifier && allowedResponseIds.has(nextSelection.responseId)) { + const allowed = nextSelection.responseId === undefined + ? allowTrialRequests + : allowedResponseIds.has(nextSelection.responseId); + if (nextSelection.identifier === identifier && allowed) { setSelection(nextSelection); } }); return () => unsubscribe(); - }, [enabled, identifier, responseIds]); + }, [enabled, identifier, responseIds, allowTrialRequests]); return selection; } diff --git a/src/components/response/tests/ResponseInput.spec.tsx b/src/components/response/tests/ResponseInput.spec.tsx index f09ea8a55a..7b6a36b271 100644 --- a/src/components/response/tests/ResponseInput.spec.tsx +++ b/src/components/response/tests/ResponseInput.spec.tsx @@ -318,7 +318,6 @@ vi.mock('../../../store/hooks/useIsAnalysis', () => ({ vi.mock('../../../routes/utils', () => ({ useCurrentStep: vi.fn(() => 0), - useCurrentIdentifier: vi.fn(() => 'trial1_0'), useCurrentComponent: vi.fn(() => ''), useCurrentIdentifier: vi.fn(() => 'test_0'), })); diff --git a/src/components/response/tests/ResponseSwitcher.spec.tsx b/src/components/response/tests/ResponseSwitcher.spec.tsx index 1629dc9fd8..de9583b21a 100644 --- a/src/components/response/tests/ResponseSwitcher.spec.tsx +++ b/src/components/response/tests/ResponseSwitcher.spec.tsx @@ -1,13 +1,13 @@ import { ComponentPropsWithoutRef, ReactNode } from 'react'; import { - render, cleanup, fireEvent, act, + render, cleanup, fireEvent, act, renderHook, } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; import type { IndividualComponent, JsonValue, Response } from '../../../parser/types'; import { ResponseSwitcher } from '../ResponseSwitcher'; -import { useAutoAdvanceSelection } from '../autoAdvanceEvents'; +import { publishAutoAdvanceSelection, useAutoAdvanceSelection } from '../autoAdvanceEvents'; import { NextButton } from '../../NextButton'; // ── mocks ──────────────────────────────────────────────────────────────────── @@ -134,6 +134,7 @@ vi.mock('../InputLabel', () => ({ })); vi.mock('../OptionLabel', () => ({ + OptionTextTemplateContext: { Provider: ({ children }: { children?: ReactNode }) => children }, OptionLabel: ({ label }: { label: ReactNode }) => {label}, })); @@ -416,3 +417,40 @@ describe('ResponseSwitcher dynamic loading', () => { expect(onNext).toHaveBeenCalledTimes(1); }); }); + +describe('HTML auto advance requests', () => { + const ids = ['button-answer']; + + test('only an enabled website owner receives trial requests and cancellations', () => { + const owner = renderHook(() => useAutoAdvanceSelection('trial_0', ids, true, true)); + const buttonsOnly = renderHook(() => useAutoAdvanceSelection('trial_0', ids, true)); + const disabled = renderHook(() => useAutoAdvanceSelection('trial_0', ids, false, true)); + const otherTrial = renderHook(() => useAutoAdvanceSelection('trial_1', ids, true, true)); + + act(() => publishAutoAdvanceSelection({ identifier: 'trial_0', selected: true, delay: 300 })); + expect(owner.result.current).toMatchObject({ selected: true, delay: 300 }); + expect(buttonsOnly.result.current).toBeUndefined(); + expect(disabled.result.current).toBeUndefined(); + expect(otherTrial.result.current).toBeUndefined(); + + act(() => publishAutoAdvanceSelection({ identifier: 'trial_0', selected: false, delay: 0 })); + expect(owner.result.current?.selected).toBe(false); + }); + + test('preserves response filtering and clears requests on trial changes', () => { + const { result, rerender } = renderHook( + ({ identifier }) => useAutoAdvanceSelection(identifier, ids, true, true), + { initialProps: { identifier: 'trial_0' } }, + ); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial_0', responseId: 'unrelated', selected: true, delay: 0, + })); + expect(result.current).toBeUndefined(); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial_0', responseId: 'button-answer', selected: true, delay: 0, + })); + expect(result.current?.responseId).toBe('button-answer'); + rerender({ identifier: 'trial_1' }); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/src/controllers/IframeController.tsx b/src/controllers/IframeController.tsx index f42c2fd73b..ee6e1c00ac 100644 --- a/src/controllers/IframeController.tsx +++ b/src/controllers/IframeController.tsx @@ -15,6 +15,7 @@ import { getStaticAssetByPath } from '../utils/getStaticAsset'; import { buildIframeSrcDoc, getBaseHref } from '../utils/iframeSrcDoc'; import { PREFIX as BASE_PREFIX } from '../utils/Prefix'; import { ResourceNotFound } from '../ResourceNotFound'; +import { publishAutoAdvanceSelection } from '../components/response/autoAdvanceEvents'; const PREFIX = '@REVISIT_COMMS'; @@ -140,7 +141,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren useEffect(() => { const handler = (e: MessageEvent) => { const { data } = e; - if (typeof data === 'object' && iframeId === data.iframeId) { + if (data && typeof data === 'object' && iframeId === data.iframeId) { switch (data.type) { case `${PREFIX}/WINDOW_READY`: if (currentConfig.parameters) { @@ -168,6 +169,14 @@ export function IframeController({ currentConfig, provState, answers }: { curren status: true, values: data.message, })); + if (e.source === ref.current?.contentWindow) { + const delay = data.options?.autoAdvanceDelay; + publishAutoAdvanceSelection({ + identifier, + delay: typeof delay === 'number' && Number.isFinite(delay) && delay >= 0 ? delay : 0, + selected: data.options?.autoAdvanceToNextStep === true, + }); + } break; case `${PREFIX}/PROVENANCE`: { if (isAnalysis) return; diff --git a/src/controllers/tests/IframeController.spec.tsx b/src/controllers/tests/IframeController.spec.tsx index 0b783391a6..29b8a22995 100644 --- a/src/controllers/tests/IframeController.spec.tsx +++ b/src/controllers/tests/IframeController.spec.tsx @@ -11,6 +11,7 @@ import { IframeController } from '../IframeController'; import type { WebsiteComponent } from '../../parser/types'; import { ReplayContext } from '../../store/hooks/useReplay'; import { getStaticAssetByPath } from '../../utils/getStaticAsset'; +import { subscribeToAutoAdvanceSelections } from '../../components/response/autoAdvanceEvents'; const mockDispatch = vi.fn(); const mockSetAssetStatus = vi.fn((payload) => ({ type: 'setAssetStatus', payload })); @@ -87,6 +88,62 @@ describe('IframeController', () => { expect(iframe?.style.colorScheme).toBe(colorMode ?? 'inherit'); }); + test.each([ + { options: undefined, selected: false, delay: 0 }, + { options: { autoAdvanceToNextStep: true, autoAdvanceDelay: 300 }, selected: true, delay: 300 }, + { options: { autoAdvanceToNextStep: true }, selected: true, delay: 0 }, + { options: { autoAdvanceToNextStep: true, autoAdvanceDelay: -1 }, selected: true, delay: 0 }, + { options: { autoAdvanceToNextStep: true, autoAdvanceDelay: Infinity }, selected: true, delay: 0 }, + { options: { autoAdvanceToNextStep: 'true', autoAdvanceDelay: '300' }, selected: false, delay: 0 }, + ])('publishes HTML submission or cancellation after updating answers: $options', async ({ options, selected, delay }) => { + const { container } = render(); + await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()); + const iframe = container.querySelector('iframe')!; + const listener = vi.fn(() => { + expect(mockSetReactiveAnswers).toHaveBeenCalledWith({ color: 'blue' }); + expect(mockUpdateResponseBlockValidation).toHaveBeenCalledWith(expect.objectContaining({ values: { color: 'blue' } })); + }); + const unsubscribe = subscribeToAutoAdvanceSelections(listener); + try { + window.dispatchEvent(new MessageEvent('message', { + source: iframe.contentWindow, + data: { + iframeId: new URL(iframe.src).searchParams.get('id'), + type: '@REVISIT_COMMS/ANSWERS', + message: { color: 'blue' }, + options, + }, + })); + expect(listener).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ identifier: 'countDots_0', selected, delay })); + } finally { + unsubscribe(); + } + }); + + test.each([false, true])('does not accept navigation from another window or during analysis: $0', async (analysis) => { + mockIsAnalysis.value = analysis; + const { container } = render(); + await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()); + const iframe = container.querySelector('iframe')!; + const listener = vi.fn(); + const unsubscribe = subscribeToAutoAdvanceSelections(listener); + try { + window.dispatchEvent(new MessageEvent('message', { + source: analysis ? iframe.contentWindow : window, + data: { + iframeId: new URL(iframe.src).searchParams.get('id'), + type: '@REVISIT_COMMS/ANSWERS', + message: { color: 'blue' }, + options: { autoAdvanceToNextStep: true }, + }, + })); + expect(listener).not.toHaveBeenCalled(); + if (analysis) expect(mockSetReactiveAnswers).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + test('covers sendMessage via answers effect on mount', async () => { render(); // answers effect fires sendMessage; ref.current is the iframe element in jsdom diff --git a/tests/demo-html-input.spec.ts b/tests/demo-html-input.spec.ts index b89b50a934..f946731b00 100644 --- a/tests/demo-html-input.spec.ts +++ b/tests/demo-html-input.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'; import { nextClick, openStudyFromLanding, + readParticipantRecording, resetClientStudyState, waitForStudyEndMessage, } from './utils'; @@ -51,6 +52,12 @@ test('Test website component with reactive response', async ({ page }) => { // Click on the next button await nextClick(page); - // Check that the end of study text renders + // The final HTML trial submits the selection and advances without Next. + await expect(page.getByText('Click a bar to automatically continue.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next', exact: true })).toBeHidden(); + await page.frameLocator('#root iframe').locator('.bar').nth(2).click(); await waitForStudyEndMessage(page); + await expect.poll(async () => ( + await readParticipantRecording(page, 'demo-html-input', 'bar-chart-auto-advance_3') + )?.answer).toEqual({ barChart: 1.2 }); }); From 812a45280e43281011242e33df054115bfd5bbdb Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Mon, 5 Oct 2026 13:05:20 +0200 Subject: [PATCH 10/11] test that clear selection with allowResponseChange false keeps required buttons editable --- .../response/tests/ResponseSwitcher.spec.tsx | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/components/response/tests/ResponseSwitcher.spec.tsx b/src/components/response/tests/ResponseSwitcher.spec.tsx index de9583b21a..d56369f5c2 100644 --- a/src/components/response/tests/ResponseSwitcher.spec.tsx +++ b/src/components/response/tests/ResponseSwitcher.spec.tsx @@ -1,4 +1,4 @@ -import { ComponentPropsWithoutRef, ReactNode } from 'react'; +import { ComponentPropsWithoutRef, ReactNode, useState } from 'react'; import { render, cleanup, fireEvent, act, renderHook, } from '@testing-library/react'; @@ -48,13 +48,18 @@ vi.mock('@mantine/core', () => ({
{children}
), { - Group: ({ children, onChange }: { children?: ReactNode; onChange?: (value: string) => void }) => { + Group: ({ children, label, onChange }: { children?: ReactNode; label?: ReactNode; onChange?: (value: string) => void }) => { const handleClick = (event: React.MouseEvent) => { const card = (event.target as HTMLElement).closest('[data-radio-card]'); if (card) onChange?.(card.getAttribute('data-value') || ''); }; - return
{children}
; + return ( +
+ {label} + {children} +
+ ); }, Card: ({ children, value, ...props }: { children?: ReactNode; value?: string; [key: string]: unknown }) => ( @@ -367,6 +372,49 @@ describe('ResponseSwitcher delay state', () => { }); describe('ResponseSwitcher dynamic loading', () => { + test('clearing a default keeps required buttons editable until a non-empty selection', async () => { + vi.useFakeTimers(); + const onNext = vi.fn(() => true); + const buttonResponse: Response = { + type: 'buttons', + id: 'choice', + prompt: 'Choose', + required: true, + options: ['A', 'B'], + default: 'A', + allowResponseChange: false, + autoAdvanceToNextStep: true, + autoAdvanceDelay: 100, + }; + function Trial() { + const [value, setValue] = useState('A'); + return ( + <> + + + {value} + + ); + } + try { + const view = render(); + const options = Array.from(view.container.querySelectorAll('[data-radio-card]')); + fireEvent.click(view.getByText('Clear selection')); + expect(view.getByTestId('answer').textContent).toBe(''); + expect(options.every((option) => !option.disabled)).toBe(true); + await act(async () => { vi.advanceTimersByTime(100); }); + expect(onNext).not.toHaveBeenCalled(); + + fireEvent.click(options[1]); + expect(view.getByTestId('answer').textContent).toBe('B'); + expect(options.every((option) => option.disabled)).toBe(true); + await act(async () => { vi.advanceTimersByTime(100); }); + expect(onNext).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + test('does not render raw templated response text while the component is resolving', () => { mockCurrentComponent.value = '__dynamicLoading'; const { container } = render( From 980ae47f5649ff9b9cdb465cb79524fc168d12ec Mon Sep 17 00:00:00 2001 From: Arthur Lippitz Date: Wed, 7 Oct 2026 13:03:03 +0200 Subject: [PATCH 11/11] fix comments and issues with going back and forth, next button visibility and general navigation issues with auto advance --- public/global.json | 1 + .../revisitUtilities/revisit-communicate.js | 172 ++++++------- public/test-auto-advance/config.json | 119 +++++++++ src/components/NextButton.tsx | 19 +- src/components/response/ResponseBlock.tsx | 18 +- src/components/response/ResponseSwitcher.tsx | 53 ++-- src/components/response/autoAdvanceEvents.ts | 14 +- .../response/tests/ResponseBlock.spec.tsx | 230 ++++++++++++++++-- .../response/tests/ResponseSwitcher.spec.tsx | 195 ++++++++++++++- src/components/tests/NextButton.spec.tsx | 73 +++++- src/controllers/IframeController.tsx | 2 +- .../tests/IframeController.spec.tsx | 26 +- src/parser/LibraryConfigSchema.json | 22 +- src/parser/StudyConfigSchema.json | 22 +- src/parser/tests/parser.spec.ts | 11 +- src/parser/types.ts | 4 +- src/store/hooks/tests/useNextStep.spec.tsx | 36 --- 17 files changed, 806 insertions(+), 211 deletions(-) diff --git a/public/global.json b/public/global.json index 0cce5a8996..0f4e2c0bdf 100644 --- a/public/global.json +++ b/public/global.json @@ -355,6 +355,7 @@ "test": true }, "test-auto-advance": { + "tab": "Tests", "path": "test-auto-advance/config.json", "test": true }, diff --git a/public/revisitUtilities/revisit-communicate.js b/public/revisitUtilities/revisit-communicate.js index 57af37b149..2b737be686 100644 --- a/public/revisitUtilities/revisit-communicate.js +++ b/public/revisitUtilities/revisit-communicate.js @@ -1,34 +1,34 @@ -(function () { - const PREFIX = "@REVISIT_COMMS"; - - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - - // Templated websites are rendered from their compiled contents rather than from their URL, - // so they have no query string; reVISit injects the same values on window.__REVISIT_PARAMS__. - const injectedParams = - window.__REVISIT_PARAMS__ && typeof window.__REVISIT_PARAMS__ === "object" - ? window.__REVISIT_PARAMS__ - : {}; - - const id = urlParams.get("id") || injectedParams.id || null; - - const sendMessage = (tag, message, options) => { - window.parent.postMessage( - { - error: false, - type: `${PREFIX}/${tag}`, - iframeId: id, - message, - ...(options === undefined ? {} : { options }), - }, - "*" - ); - }; - - let onDataReceiveCallback = null; - let onProvenanceReceiveCallback = null; - let onAnswerReceiveCallback = null; +(function () { + const PREFIX = "@REVISIT_COMMS"; + + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + + // Templated websites are rendered from their compiled contents rather than from their URL, + // so they have no query string; reVISit injects the same values on window.__REVISIT_PARAMS__. + const injectedParams = + window.__REVISIT_PARAMS__ && typeof window.__REVISIT_PARAMS__ === "object" + ? window.__REVISIT_PARAMS__ + : {}; + + const id = urlParams.get("id") || injectedParams.id || null; + + const sendMessage = (tag, message, options) => { + window.parent.postMessage( + { + error: false, + type: `${PREFIX}/${tag}`, + iframeId: id, + message, + ...(options === undefined ? {} : { options }), + }, + "*" + ); + }; + + let onDataReceiveCallback = null; + let onProvenanceReceiveCallback = null; + let onAnswerReceiveCallback = null; const managedTrrackSubscriptions = new Set(); const appendTraversalEvent = (previousProvenance, incomingProvenance, observedAt) => { @@ -71,35 +71,35 @@ managedTrrackSubscriptions.forEach((unsubscribe) => unsubscribe()); managedTrrackSubscriptions.clear(); }; - - window.addEventListener('message', function (e) { - const data = e.data; - if (typeof data === 'object' && id === data.iframeId) { - if (data.type === `${PREFIX}/STUDY_DATA` && onDataReceiveCallback) { - onDataReceiveCallback(data.message); - } - if (data.type === `${PREFIX}/PROVENANCE` && onProvenanceReceiveCallback) { - onProvenanceReceiveCallback(data.message); - } - if (data.type === `${PREFIX}/ANSWERS` && onAnswerReceiveCallback) { - onAnswerReceiveCallback(data.message); - } - } - }); - - window.Revisit = { - postAnswers: (answers, autoAdvanceToNextStep = false, autoAdvanceDelay = 0) => { - const options = autoAdvanceToNextStep === true - ? { autoAdvanceToNextStep, autoAdvanceDelay } - : undefined; - sendMessage("ANSWERS", answers, options); - }, + + window.addEventListener('message', function (e) { + const data = e.data; + if (typeof data === 'object' && id === data.iframeId) { + if (data.type === `${PREFIX}/STUDY_DATA` && onDataReceiveCallback) { + onDataReceiveCallback(data.message); + } + if (data.type === `${PREFIX}/PROVENANCE` && onProvenanceReceiveCallback) { + onProvenanceReceiveCallback(data.message); + } + if (data.type === `${PREFIX}/ANSWERS` && onAnswerReceiveCallback) { + onAnswerReceiveCallback(data.message); + } + } + }); + + window.Revisit = { + postAnswers: (answers, autoAdvanceToNextStep, autoAdvanceDelay = 0) => { + const options = typeof autoAdvanceToNextStep === "boolean" + ? { autoAdvanceToNextStep, autoAdvanceDelay } + : undefined; + sendMessage("ANSWERS", answers, options); + }, /** * @deprecated Use Revisit.createTrrack so every traversal is reported automatically. */ - postProvenance: (provenance) => { - sendMessage("PROVENANCE", provenance); - }, + postProvenance: (provenance) => { + sendMessage("PROVENANCE", provenance); + }, createTrrack: ({ initializeTrrack, ...options }) => { if (typeof initializeTrrack !== 'function') { throw new TypeError('Revisit.createTrrack requires the Trrack initializeTrrack function.'); @@ -123,37 +123,37 @@ return trrack; }, - postEvent: (eventName, objectId) => { - sendMessage("EVENT", { eventName, objectId }); - }, - // Inform Revisit that the stimuli is ready in the iframe. - postReady: () => { - sendMessage("READY", { - documentHeight: document.documentElement.scrollHeight, - documentWidth: document.documentElement.scrollWidth, - }); - }, - onDataReceive: (fn) => { - onDataReceiveCallback = fn; - }, - onProvenanceReceive: (fn) => { - onProvenanceReceiveCallback = fn; - }, - onAnswersReceive: (fn) => { - onAnswerReceiveCallback = fn; - }, - }; - - window.addEventListener( - 'load', - function () { - sendMessage('WINDOW_READY'); - }, - false - ); + postEvent: (eventName, objectId) => { + sendMessage("EVENT", { eventName, objectId }); + }, + // Inform Revisit that the stimuli is ready in the iframe. + postReady: () => { + sendMessage("READY", { + documentHeight: document.documentElement.scrollHeight, + documentWidth: document.documentElement.scrollWidth, + }); + }, + onDataReceive: (fn) => { + onDataReceiveCallback = fn; + }, + onProvenanceReceive: (fn) => { + onProvenanceReceiveCallback = fn; + }, + onAnswersReceive: (fn) => { + onAnswerReceiveCallback = fn; + }, + }; + + window.addEventListener( + 'load', + function () { + sendMessage('WINDOW_READY'); + }, + false + ); window.addEventListener('pagehide', (event) => { if (!event.persisted) { disposeManagedTrracks(); } }); -})(); +})(); diff --git a/public/test-auto-advance/config.json b/public/test-auto-advance/config.json index 9e29b6b1b8..22aa7ce9ab 100644 --- a/public/test-auto-advance/config.json +++ b/public/test-auto-advance/config.json @@ -60,6 +60,119 @@ "type": "questionnaire", "nextButtonHidden": false }, + "keyboard-auto-advance": { + "type": "questionnaire", + "response": [ + { + "id": "keyboard-choice", + "type": "buttons", + "prompt": "Auto-advance with keyboard selection", + "secondaryText": "Press A or B to select an answer. The study should continue after one second without pressing Next. You can change your selection during the delay.", + "options": [ + { "label": "Option A", "value": "A", "key": "a" }, + { "label": "Option B", "value": "B", "key": "b" } + ], + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 1000 + } + ] + }, + "conditional-auto-advance": { + "type": "questionnaire", + "response": [ + { + "id": "conditional-instructions", + "type": "textOnly", + "prompt": "### Conditional auto-advance\n1. With Hide selected, Next should be visible.\n2. Select Show: the answer buttons should appear and Next should disappear.\n3. Select an answer, then select Hide within five seconds. Next should reappear, and the study should stay on this page even after five seconds.\n4. Select Show again and wait five seconds without answering. The cancelled request should not resume.\n5. To continue, either select a new answer and wait five seconds, or select Hide and click Next." + }, + { + "id": "show-auto-advance", + "type": "radio", + "prompt": "Show the auto-advance answer buttons?", + "default": "hide", + "options": [ + { "label": "Show", "value": "show" }, + { "label": "Hide", "value": "hide" } + ] + }, + { + "id": "conditional-choice", + "type": "buttons", + "prompt": "Select an answer to advance after five seconds", + "options": ["Option A", "Option B"], + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 5000, + "visibleIf": { + "responseId": "show-auto-advance", + "comparison": "equals", + "value": "show" + } + } + ] + }, + "dont-know-auto-advance": { + "type": "questionnaire", + "response": [ + { + "id": "dont-know-choice", + "type": "buttons", + "prompt": "Auto-advance with I don't know", + "secondaryText": "Check I don't know to start a five-second advance delay. Uncheck it before five seconds, then wait: the study should stay here. Check it again and leave it checked to continue. Next should stay hidden, and the answer buttons should be disabled only while the checkbox is checked.", + "options": ["Option A", "Option B"], + "withDontKnow": true, + "allowResponseChange": true, + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 5000 + } + ] + }, + "dont-know-auto-advance-locked": { + "type": "questionnaire", + "response": [ + { + "id": "locked-dont-know-choice", + "type": "buttons", + "prompt": "Locking an I don't know answer", + "secondaryText": "Check I don't know. The checkbox and answer buttons should become disabled immediately, preventing changes. Next should stay hidden, and the study should advance after five seconds.", + "options": ["Option A", "Option B"], + "withDontKnow": true, + "allowResponseChange": false, + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 5000 + } + ] + }, + "auto-advance-hidden-next": { + "type": "questionnaire", + "nextButtonHidden": true, + "response": [ + { + "id": "return-choice", + "type": "buttons", + "prompt": "Auto-advance with Next always hidden", + "secondaryText": "Select an option or press A or B to continue. On the following page, use Previous to return here. Then select an option again to go forward. Next should remain hidden, and Enter alone should not advance.", + "options": [ + { "label": "Option A", "value": "A", "key": "a" }, + { "label": "Option B", "value": "B", "key": "b" } + ], + "autoAdvanceToNextStep": true, + "autoAdvanceDelay": 1000, + "allowResponseChange": false + } + ] + }, + "return-to-hidden-next": { + "type": "questionnaire", + "previousButton": true, + "nextOnEnter": true, + "response": [ + { + "id": "return-instructions", + "type": "textOnly", + "prompt": "Use Previous to return to the auto-advance page and check that you can select an answer again while Next stays hidden. After returning to this page, use Next or Enter to continue the study." + } + ] + }, "warning": { "response": [ { @@ -118,6 +231,12 @@ "order": "fixed", "components": [ "auto-advance", + "keyboard-auto-advance", + "conditional-auto-advance", + "dont-know-auto-advance", + "dont-know-auto-advance-locked", + "auto-advance-hidden-next", + "return-to-hidden-next", "warning", "block-change", "html-auto-advance" diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index ff5525c8c1..e32a28837c 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -36,6 +36,8 @@ type Props = { onNext: () => boolean; autoAdvanceRequest?: AutoAdvanceSelection; autoAdvanceEligible?: boolean; + responseAutoAdvances?: boolean; + showNextOnReturn?: boolean; }; export function NextButton({ @@ -48,6 +50,8 @@ export function NextButton({ onNext, autoAdvanceRequest, autoAdvanceEligible = true, + showNextOnReturn = false, + responseAutoAdvances = config?.response?.some((response) => response.type === 'buttons' && response.autoAdvanceToNextStep && !response.hidden), }: Props) { const { isNextDisabled, goToNextStep } = useNextStep(config?.response, config?.correctAnswer); const studyConfig = useStudyConfig(); @@ -59,14 +63,14 @@ export function NextButton({ const nextButtonAutoAdvanceTime = config?.nextButtonAutoAdvanceTime; const nextButtonAutoAdvanceWarningTime = config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME; const nextButtonAutoAdvanceWarningMessage = config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE; - const responseAutoAdvances = config?.response?.some((response) => response.type === 'buttons' && response.autoAdvanceToNextStep && !response.hidden); - const nextButtonHidden = config?.nextButtonHidden ?? ( + const nextButtonHidden = !showNextOnReturn && (config?.nextButtonHidden ?? ( nextButtonAutoAdvanceTime !== undefined || responseAutoAdvances || false - ); + )); const [timer, setTimer] = useState(undefined); + const trialStartTime = useRef(Date.now()); const deadlineAutoAdvanceTriggered = useRef(false); const navigationStarted = useRef(false); const attemptedRequest = useRef(undefined); @@ -87,6 +91,7 @@ export function NextButton({ deadlineAutoAdvanceTriggered.current = false; navigationStarted.current = false; const start = Date.now(); + trialStartTime.current = start; setTimer(0); const interval = setInterval(() => { setTimer(Date.now() - start); @@ -156,11 +161,17 @@ export function NextButton({ return; } + const elapsed = Date.now() - trialStartTime.current; + const pastDisableTime = nextButtonDisableTime && elapsed >= nextButtonDisableTime; + if (pastDisableTime || elapsed < nextButtonEnableTime) { + return; + } + attemptedRequest.current = readyAutoAdvanceRequest; if (latestOnNext.current()) { navigationStarted.current = true; } - }, [autoAdvanceEligible, autoAdvanceRequest, identifier, nextButtonDisabled, readyAutoAdvanceRequest]); + }, [autoAdvanceEligible, autoAdvanceRequest, identifier, nextButtonDisabled, nextButtonDisableTime, nextButtonEnableTime, readyAutoAdvanceRequest, studyConfig.uiConfig.timeoutReject]); const autoAdvanceWarning = useMemo(() => getAutoAdvanceWarning({ timer, diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 87277afba4..f39387d95d 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -3,11 +3,11 @@ import { } from '@mantine/core'; import React, { - useEffect, useMemo, useCallback, useRef, + useEffect, useLayoutEffect, useMemo, useCallback, useRef, } from 'react'; import isEqual from 'lodash.isequal'; import { IconAlertTriangle } from '@tabler/icons-react'; -import { useNavigate } from 'react-router'; +import { useNavigate, useSearchParams } from 'react-router'; import { Registry } from '@trrack/core'; import { IndividualComponent, @@ -99,11 +99,13 @@ export function ResponseBlock({ const currentStep = useCurrentStep(); const identifier = useCurrentIdentifier(); + const [searchParams] = useSearchParams(); const isAnalysis = useIsAnalysis(); const currentProvenance = useStoreSelector((state) => state.analysisProvState[location]) as FormElementProvenance | undefined; const storedAnswer = useMemo(() => currentProvenance?.form ?? status?.answer, [currentProvenance, status]); const storedAnswerData = useStoredAnswer(); + const answerFinalized = !!status && status.endTime !== -1; const formOrders: Record = useMemo(() => storedAnswerData?.formOrder || {}, [storedAnswerData]); const navigate = useNavigate(); @@ -656,7 +658,8 @@ export function ResponseBlock({ storeDispatch(setCheckAnswerResult({ identifier, ...savedCheckAnswer })); }, [currentCheckAnswer, identifier, isAnalysis, savedCheckAnswer, setCheckAnswerResult, storeDispatch]); - useEffect(() => { + // Persist grading before child navigation effects can finalize the same trial. + useLayoutEffect(() => { const storedAnswerForIdentifier = storeAnswers[identifier]; // Dynamic block navigation briefly uses a loading identifier. Never persist // against it, even if a malformed loading record was restored from storage. @@ -719,9 +722,10 @@ export function ResponseBlock({ }, [bypassValidationForFailedTraining, goToNextStep, hasResponseIssues, hasStimulusIssue, revealResponseErrors, revealStimulusErrors]); const autoAdvanceResponses = useMemo( - () => allResponsesWithDefaults.filter((response) => !response.hidden && response.type === 'buttons' && response.autoAdvanceToNextStep), - [allResponsesWithDefaults], + () => applicableResponses.filter((response) => !response.hidden && response.type === 'buttons' && response.autoAdvanceToNextStep), + [applicableResponses], ); + const hasUncapturedAutoAdvanceResponse = autoAdvanceResponses.some((response) => !(response.paramCapture && searchParams.get(response.paramCapture))); const allowTrialRequests = config.type === 'website'; const autoAdvanceOwner = showBtnsInLocation && (autoAdvanceResponses.length > 0 || allowTrialRequests) && !isAnalysis; const autoAdvanceResponseIds = useMemo(() => autoAdvanceResponses.map((response) => response.id), [autoAdvanceResponses]); @@ -761,7 +765,7 @@ export function ResponseBlock({ {(isDelayedDisabled: boolean) => ( trackInputChange(response.id, value, source), @@ -847,6 +851,8 @@ export function ResponseBlock({ label={nextButtonText} config={config} location={location} + showNextOnReturn={answerFinalized && autoAdvanceResponses.length > 0 && !autoAdvanceSelection?.selected} + responseAutoAdvances={hasUncapturedAutoAdvanceResponse && !(enableNextButton && !autoAdvanceSelection?.selected)} autoAdvanceRequest={autoAdvanceSelection} autoAdvanceEligible={!hasStimulusIssue && (bypassValidationForFailedTraining || !hasResponseIssues)} onNext={handleNextClick} diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index e8f6bfa98b..1790aba4af 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -1,7 +1,9 @@ import { Box, Checkbox, Divider } from '@mantine/core'; import { GetInputPropsReturnType } from '@mantine/form/lib/types'; import { useSearchParams } from 'react-router'; -import { useEffect, useMemo, useState } from 'react'; +import { + useCallback, useEffect, useMemo, useState, +} from 'react'; import { CustomResponse, IndividualComponent, JsonValue, MatrixResponse, Response, SliderResponse, StoredAnswer, } from '../../parser/types'; @@ -92,6 +94,23 @@ export function ResponseSwitcher({ const usesStandaloneDontKnow = usesStandaloneDontKnowField(response); const finalStoredAnswer = isAnalysis || answerFinalized || completed ? storedAnswer : undefined; + const restoredResponseLocked = 'allowResponseChange' in response && response.allowResponseChange === false + && isResponseChangeLocked( + !!answerFinalized, + (usesStandaloneDontKnow && finalStoredAnswer?.[`${response.id}-dontKnow`]) || finalStoredAnswer?.[response.id], + ); + + const selectAnswer = useCallback((selected: boolean) => { + setUserSelected(selected); + if (response.type === 'buttons' && response.autoAdvanceToNextStep) { + publishAutoAdvanceSelection({ + identifier, + responseId: response.id, + delay: response.autoAdvanceDelay ?? 0, + selected, + }); + } + }, [identifier, response]); const wrappedForm = useMemo(() => { if (!form || typeof form.onChange !== 'function') { @@ -100,28 +119,19 @@ export function ResponseSwitcher({ return { ...form, - onChange: (val: unknown) => { - const hasSelectedAnswer = hasAnswerValue(val); - setUserSelected(hasSelectedAnswer); - if (response.type === 'buttons' && response.autoAdvanceToNextStep) { - publishAutoAdvanceSelection({ - identifier, - responseId: response.id, - delay: response.autoAdvanceDelay ?? 0, - selected: hasSelectedAnswer, - }); - } - form.onChange(val); + onChange: (val: unknown, source?: 'keyboard' | 'click') => { + selectAnswer(hasAnswerValue(val)); + form.onChange(val, source); }, }; - }, [form, identifier, response]); + }, [form, selectAnswer]); // Don't update if we're in analysis mode const ans = useMemo( - () => (isAnalysis || (Object.keys(finalStoredAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed + () => (isAnalysis || restoredResponseLocked || (Object.keys(finalStoredAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed ? { value: finalStoredAnswer?.[response.id], readOnly: true } : wrappedForm) || { value: undefined }, - [isAnalysis, finalStoredAnswer, response.id, wrappedForm, nextConfig?.previousButton, completed], + [isAnalysis, restoredResponseLocked, finalStoredAnswer, response.id, wrappedForm, nextConfig?.previousButton, completed], ); const dontKnowValue = usesStandaloneDontKnow ? ((Object.keys(finalStoredAnswer || {}).length > 0 ? { checked: finalStoredAnswer![`${response.id}-dontKnow`] } : dontKnowCheckbox) || { checked: undefined }) @@ -177,11 +187,11 @@ export function ResponseSwitcher({ } if (response.allowResponseChange === false) { - return isResponseChangeLocked(userSelected, ans.value); + return restoredResponseLocked || isResponseChangeLocked(userSelected, dontKnowChecked || ans.value); } return false; - }, [response, ans.value, userSelected]); + }, [response, restoredResponseLocked, ans.value, dontKnowChecked, userSelected]); const isStateDisabled = useMemo(() => { // Always disable if participant is completed @@ -503,7 +513,12 @@ export function ResponseSwitcher({ classNames={{ input: classes.fixDisabled, label: classes.fixDisabledLabel, icon: classes.fixDisabledIcon }} {...dontKnowCheckbox} checked={dontKnowValue.checked} - onChange={(event) => { dontKnowCheckbox?.onChange(event.currentTarget.checked); wrappedForm.onChange(fieldInitialValue); }} + onChange={(event) => { + const { checked } = event.currentTarget; + dontKnowCheckbox?.onChange(checked); + form.onChange(fieldInitialValue); + selectAnswer(checked); + }} /> )} {(response.type === 'divider' || responseDividers) && } diff --git a/src/components/response/autoAdvanceEvents.ts b/src/components/response/autoAdvanceEvents.ts index c7d4d274a9..91d7a22e7f 100644 --- a/src/components/response/autoAdvanceEvents.ts +++ b/src/components/response/autoAdvanceEvents.ts @@ -35,8 +35,11 @@ export function useAutoAdvanceSelection( const [selection, setSelection] = useState(); useEffect(() => { - setSelection(undefined); - }, [identifier]); + setSelection((current) => { + const allowed = current?.responseId === undefined ? allowTrialRequests : responseIds.includes(current.responseId); + return enabled && current?.identifier === identifier && allowed ? current : undefined; + }); + }, [enabled, identifier, responseIds, allowTrialRequests]); useEffect(() => { if (!enabled) { @@ -49,11 +52,14 @@ export function useAutoAdvanceSelection( ? allowTrialRequests : allowedResponseIds.has(nextSelection.responseId); if (nextSelection.identifier === identifier && allowed) { - setSelection(nextSelection); + setSelection((current) => (!nextSelection.selected && current?.responseId !== nextSelection.responseId ? current : nextSelection)); } }); return () => unsubscribe(); }, [enabled, identifier, responseIds, allowTrialRequests]); - return selection; + const applicable = selection?.responseId === undefined + ? allowTrialRequests + : responseIds.includes(selection.responseId); + return enabled && selection?.identifier === identifier && applicable ? selection : undefined; } diff --git a/src/components/response/tests/ResponseBlock.spec.tsx b/src/components/response/tests/ResponseBlock.spec.tsx index 989bb15e88..d52b6bb2ba 100644 --- a/src/components/response/tests/ResponseBlock.spec.tsx +++ b/src/components/response/tests/ResponseBlock.spec.tsx @@ -1,11 +1,17 @@ import { ReactNode } from 'react'; +import isEqual from 'lodash.isequal'; import { Provider } from 'react-redux'; import { - render, act, fireEvent, cleanup, + render, renderHook, act, fireEvent, cleanup, } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; +import { publishAutoAdvanceSelection } from '../autoAdvanceEvents'; +import { NextButton } from '../../NextButton'; +import { usePreviousStep } from '../../../store/hooks/usePreviousStep'; +import { useStudyConfig } from '../../../store/hooks/useStudyConfig'; +import { encryptIndex } from '../../../utils/encryptDecryptIndex'; import type { IndividualComponent, StudyConfig } from '../../../parser/types'; import type { CheckAnswerState, Sequence, StoredAnswer } from '../../../store/types'; import type { REVISIT_MODE } from '../../../storage/engines/types'; @@ -19,9 +25,14 @@ import type { compareResponseValues } from '../../../utils/correctAnswer'; // ── mocks ──────────────────────────────────────────────────────────────────── const { - mockStoredAnswerData, capturedNextButtonProps, capturedSwitcherProps, mockIsAnalysis, mockCurrentIdentifier, mockNavigate, mockSaveAnswers, mockTrrackApply, mockAnswerField, + mockRoute, mockSearchParams, realNextButton, mockStoredAnswerData, capturedNextButtonProps, capturedSwitcherProps, mockIsAnalysis, mockCurrentIdentifier, mockNavigate, mockSaveAnswers, mockTrrackApply, mockAnswerField, } = vi.hoisted(() => ({ + mockRoute: { step: 0, funcIndex: undefined as string | undefined }, + mockSearchParams: { value: '' }, + realNextButton: { enabled: false }, mockStoredAnswerData: { + identifier: 'trial1_0', + endTime: -1, formOrder: { response: ['q1'] } as { response: string[] } | undefined, questionOrders: {}, optionOrders: {}, @@ -76,8 +87,8 @@ vi.mock('@mantine/core', () => ({ vi.mock('react-router', () => ({ useNavigate: vi.fn(() => mockNavigate), - useParams: vi.fn(() => ({})), - useSearchParams: vi.fn(() => [new URLSearchParams()]), + useParams: vi.fn(() => ({ funcIndex: mockRoute.funcIndex })), + useSearchParams: vi.fn(() => [new URLSearchParams(mockSearchParams.value)]), })); vi.mock('@trrack/core', () => ({ @@ -142,7 +153,7 @@ vi.mock('../../../store/hooks/useWindowEvents', () => ({ })); vi.mock('../../../routes/utils', () => ({ - useCurrentStep: vi.fn(() => 0), + useCurrentStep: vi.fn(() => mockRoute.step), useCurrentIdentifier: vi.fn(() => mockCurrentIdentifier.value), useStudyId: vi.fn(() => 'test-study'), })); @@ -197,19 +208,26 @@ vi.mock('../FeedbackAlert', () => ({ ), })); -vi.mock('../../NextButton', () => ({ - NextButton: ({ - label, disabled, checkAnswer, onCheckAnswer, - }: { label?: string; disabled?: boolean; checkAnswer?: ReactNode; onCheckAnswer?: () => void }) => { - capturedNextButtonProps.onCheckAnswer = onCheckAnswer; - return ( -
- {checkAnswer} - -
- ); - }, -})); +vi.mock('../../NextButton', async (importOriginal) => { + const actual = await importOriginal<{ NextButton: typeof NextButton }>(); + return { + NextButton: (props: Parameters[0]) => { + if (realNextButton.enabled) return ; + const { + label, disabled, checkAnswer, onCheckAnswer, + } = props; + capturedNextButtonProps.onCheckAnswer = onCheckAnswer; + return ( +
+ {checkAnswer} + +
+ ); + }, + }; +}); + +vi.mock('../../PreviousButton', () => ({ PreviousButton: () => null })); // ── fixtures ────────────────────────────────────────────────────────────────── @@ -307,6 +325,12 @@ function countButtons(container: HTMLElement, label: string) { // ── setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { + mockRoute.step = 0; + mockRoute.funcIndex = undefined; + vi.mocked(useStudyConfig).mockReset(); + mockSearchParams.value = ''; + realNextButton.enabled = false; + vi.mocked(isEqual).mockReturnValue(true); vi.mocked(responseAnswerIsCorrect).mockReturnValue(true); mockStoredAnswerData.formOrder = { response: ['q1'] }; mockStoredAnswerData.responseSubmitAttempted = undefined; @@ -1101,3 +1125,173 @@ test('Check Answer ignores a conditionally hidden correct answer', async () => { expect(responseAnswerIsCorrect).not.toHaveBeenCalled(); expect(store.getState().checkAnswer.trial1_0?.correct).toBe(true); }); + +describe('ResponseBlock with real navigation effects', () => { + const autoConfig = { + type: 'questionnaire', + provideFeedback: true, + correctAnswer: [{ id: 'q1', answer: 'correct' }], + response: [{ + id: 'q1', + type: 'buttons', + prompt: 'Choose', + options: ['correct', 'wrong'], + autoAdvanceToNextStep: true, + autoAdvanceDelay: 250, + }], + } satisfies IndividualComponent; + + afterEach(() => { vi.useRealTimers(); }); + + test.each(['inherited Previous', 'first dynamic item'] as const)('restored finalized trials allow manual continuation after returning from %s', async (returnPath) => { + realNextButton.enabled = true; + const dynamic = returnPath === 'first dynamic item'; + const followingConfig: StudyConfig = { + ...storeConfig, + baseComponents: { withPrevious: { type: 'questionnaire', previousButton: true, response: [] } }, + components: { ...storeConfig.components, following: { baseComponent: 'withPrevious' } }, + sequence: { + order: 'fixed', + components: ['trial1', dynamic ? { id: 'dynamic', order: 'dynamic', functionPath: 'test.js' } : 'following'], + }, + }; + vi.mocked(useStudyConfig).mockReturnValue(followingConfig); + const sequence: Sequence = { + ...storeSequence, + components: ['trial1', dynamic ? { + id: 'dynamic', order: 'dynamic', orderPath: 'root-1', components: ['following'], skip: [], + } : 'following'], + }; + const status = makeStoredAnswer({ identifier: 'trial1_0', answer: { q1: 'correct' }, endTime: 100 }); + const studyStore = await studyStoreCreator('test-study', followingConfig, sequence, metadata, { trial1_0: status }, modes, 'p1', false, false); + mockRoute.step = 1; + mockRoute.funcIndex = dynamic ? encryptIndex(0) : undefined; + const previous = renderHook(() => usePreviousStep(), { wrapper: ({ children }) => withStore(studyStore, children) }); + act(() => { + studyStore.store.dispatch(studyStore.actions.setClickedPrevious(true)); + previous.result.current.goToPreviousStep(); + }); + expect(mockNavigate).toHaveBeenLastCalledWith(`/test-study/${encryptIndex(0)}`); + previous.unmount(); + mockNavigate.mockClear(); + mockRoute.step = 0; + mockRoute.funcIndex = undefined; + mockAnswerField.values = { q1: 'correct' }; + vi.useFakeTimers(); + const returnConfig: IndividualComponent = { + ...autoConfig, + provideFeedback: false, + nextButtonHidden: true, + response: [{ ...autoConfig.response[0], allowResponseChange: false }], + }; + const view = render(withStore(studyStore, )); + expect(capturedSwitcherProps.answerFinalized).toBe(true); + await act(async () => { vi.advanceTimersByTime(250); }); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(findButton(view.container, 'Next')).toBeDefined(); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'q1', selected: true, delay: 250, + })); + expect(findButton(view.container, 'Next')).toBeUndefined(); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'q1', selected: false, delay: 0, + })); + const next = findButton(view.container, 'Next'); + expect(next).toBeDefined(); + expect(next.disabled).toBe(false); + act(() => fireEvent.click(next)); + expect(mockNavigate).toHaveBeenLastCalledWith(`/test-study/${encryptIndex(1)}`); + }); + + test.each(['answer=correct', 'answer=', ''])('keeps Next available only for a supplied paramCapture answer (%s)', async (query) => { + realNextButton.enabled = true; + mockSearchParams.value = query; + const studyStore = await makeStudyStore(); + vi.useFakeTimers(); + mockAnswerField.values = { q1: query === 'answer=correct' ? 'correct' : '' }; + const capturedConfig: IndividualComponent = { + ...autoConfig, + provideFeedback: false, + response: [{ ...autoConfig.response[0], paramCapture: 'answer' }], + }; + const { container } = render(withStore(studyStore, )); + await act(async () => { vi.advanceTimersByTime(250); }); + expect(mockNavigate).not.toHaveBeenCalled(); + const next = findButton(container, 'Next'); + if (query === 'answer=correct') { + expect(next).toBeDefined(); + expect(next.disabled).toBe(false); + act(() => fireEvent.click(next)); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(studyStore.store.getState().answers.trial1_0.answer).toEqual({ q1: 'correct' }); + } else { + expect(next).toBeUndefined(); + } + }); + + test('grading a ready selection persists the completed trial last', async () => { + realNextButton.enabled = true; + const studyStore = await makeStudyStore(); + vi.useFakeTimers(); + mockAnswerField.values = { q1: 'correct' }; + const { container } = render(withStore(studyStore, )); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'q1', selected: true, delay: 250, + })); + await act(async () => { vi.advanceTimersByTime(250); }); + expect(mockNavigate).not.toHaveBeenCalled(); + act(() => fireEvent.click(findButton(container, 'Check Answer'))); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(studyStore.store.getState().answers.trial1_0.endTime).toBeGreaterThan(0); + expect(mockSaveAnswers).toHaveBeenLastCalledWith(expect.objectContaining({ + trial1_0: expect.objectContaining({ + endTime: studyStore.store.getState().answers.trial1_0.endTime, + answer: { q1: 'correct' }, + checkAnswer: { attemptsUsed: 1, correct: true, responses: { q1: true } }, + }), + })); + }); + + test.each([false, true])('restored grading exposes continuation without a new selection (failed=%s)', async (failed) => { + realNextButton.enabled = true; + mockStoredAnswerData.checkAnswer = { attemptsUsed: failed ? 2 : 1, correct: !failed, responses: { q1: !failed } }; + mockAnswerField.values = { q1: failed ? 'wrong' : 'correct' }; + const { container } = await renderWithStore(); + expect(findButton(container, 'Check Answer').disabled).toBe(true); + const next = findButton(container, 'Next'); + expect(next).toBeDefined(); + expect(next.disabled).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + act(() => fireEvent.click(next)); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + + test('a conditionally hidden auto response exposes Next and cancels pending advancement', async () => { + realNextButton.enabled = true; + vi.mocked(isEqual).mockImplementation((left, right) => left === right); + mockStoredAnswerData.formOrder = undefined; + const studyStore = await makeStudyStore(); + vi.useFakeTimers(); + const conditionalConfig: IndividualComponent = { + ...autoConfig, + provideFeedback: false, + response: [ + { + id: 'controller', type: 'radio', prompt: 'Controller', options: ['yes', 'no'], + }, + { ...autoConfig.response[0], visibleIf: { responseId: 'controller', comparison: 'equals', value: 'yes' } }, + ], + }; + mockAnswerField.values = { controller: 'yes', q1: 'correct' }; + const view = render(withStore(studyStore, )); + expect(findButton(view.container, 'Next')).toBeUndefined(); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'q1', selected: true, delay: 250, + })); + mockAnswerField.values = { controller: 'no', q1: 'correct' }; + view.rerender(withStore(studyStore, )); + expect(findButton(view.container, 'Next')).toBeDefined(); + await act(async () => { vi.advanceTimersByTime(250); }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/response/tests/ResponseSwitcher.spec.tsx b/src/components/response/tests/ResponseSwitcher.spec.tsx index d56369f5c2..1437132f0a 100644 --- a/src/components/response/tests/ResponseSwitcher.spec.tsx +++ b/src/components/response/tests/ResponseSwitcher.spec.tsx @@ -1,11 +1,17 @@ import { ComponentPropsWithoutRef, ReactNode, useState } from 'react'; +import { useSearchParams } from 'react-router'; import { render, cleanup, fireEvent, act, renderHook, } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; -import type { IndividualComponent, JsonValue, Response } from '../../../parser/types'; +import type { + ButtonsResponse, IndividualComponent, JsonValue, Response, +} from '../../../parser/types'; +import type { Sequence } from '../../../store/types'; +import { makeStudyConfig } from '../../../tests/utils'; +import { useStudyConfig } from '../../../store/hooks/useStudyConfig'; import { ResponseSwitcher } from '../ResponseSwitcher'; import { publishAutoAdvanceSelection, useAutoAdvanceSelection } from '../autoAdvanceEvents'; import { NextButton } from '../../NextButton'; @@ -24,7 +30,7 @@ const { mockStoreState: { sequence: { order: 'fixed', orderPath: 'root', components: ['trial1'], skip: [], - }, + } as Sequence, completed: false, answers: {}, }, @@ -34,7 +40,14 @@ const { vi.mock('@mantine/core', () => ({ Box: ({ children, ...props }: ComponentPropsWithoutRef<'div'>) =>
{children}
, - Checkbox: () => , + Checkbox: ({ + checked, disabled, onChange, label, + }: { checked?: boolean; disabled?: boolean; onChange?: React.ChangeEventHandler; label?: string }) => ( + + ), Divider: () =>
, Flex: ({ children }: { children?: ReactNode }) =>
{children}
, FocusTrap: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -43,6 +56,7 @@ vi.mock('@mantine/core', () => ({ ), Group: ({ children }: { children?: ReactNode }) =>
{children}
, + Kbd: ({ children }: { children?: ReactNode }) => {children}, Radio: Object.assign( ({ children, value, ...props }: { children?: ReactNode; value?: string; [key: string]: unknown }) => (
{children}
@@ -172,6 +186,11 @@ function renderSwitcher({ storedAnswer, answerFinalized, isDelayedDisabled }: { // ── setup ───────────────────────────────────────────────────────────────────── beforeEach(() => { + vi.mocked(useStudyConfig).mockReset(); + mockStoreState.sequence = { + order: 'fixed', orderPath: 'root', components: ['trial1'], skip: [], + }; + vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), vi.fn()]); capturedStringInputProps.disabled = undefined; capturedStringInputProps.answer = undefined; capturedCustomInputProps.disabled = undefined; @@ -502,3 +521,173 @@ describe('HTML auto advance requests', () => { expect(result.current).toBeUndefined(); }); }); + +describe('response auto-advance regressions', () => { + const choice: ButtonsResponse = { + type: 'buttons', + id: 'choice', + prompt: 'Choose', + options: [{ label: 'A', value: 'A', key: 'a' }], + autoAdvanceToNextStep: true, + autoAdvanceDelay: 250, + required: true, + }; + + test.each(['inherited Previous', 'first dynamic item'] as const)('finalized choices cannot emit a selection after returning from %s', (returnPath) => { + const dynamic = returnPath === 'first dynamic item'; + mockStoreState.sequence.components = ['trial1', dynamic + ? { + id: 'dynamic', order: 'dynamic', orderPath: 'root-1', components: ['following'], skip: [], + } + : 'following']; + vi.mocked(useStudyConfig).mockReturnValue(makeStudyConfig({ + uiConfig: {}, + components: { following: { baseComponent: 'withPrevious' } }, + baseComponents: { withPrevious: { type: 'questionnaire', previousButton: true, response: [] } }, + })); + const onChange = vi.fn(); + const owner = renderHook(() => useAutoAdvanceSelection('trial1_0', ['choice'], true)); + const view = render(); + fireEvent.click(view.container.querySelector('[data-radio-card]')!); + fireEvent.keyDown(window, { key: 'a' }); + expect(onChange).not.toHaveBeenCalled(); + expect(owner.result.current).toBeUndefined(); + }); + + test.each([false, true])('returning with Previous preserves allowResponseChange=%s', (allowResponseChange) => { + vi.mocked(useStudyConfig).mockReturnValue(makeStudyConfig({ + components: { following: { type: 'questionnaire', previousButton: true, response: [] } }, + })); + mockStoreState.sequence.components = ['trial1', 'following']; + const onChange = vi.fn(); + const owner = renderHook(() => useAutoAdvanceSelection('trial1_0', ['choice'], true)); + const trial = () => ( + + ); + const first = render(trial()); + first.unmount(); + const view = render(trial()); + const otherOption = view.container.querySelector('[data-radio-card][data-value="B"]') as HTMLButtonElement; + expect(otherOption.disabled).toBe(!allowResponseChange); + expect(owner.result.current).toBeUndefined(); + fireEvent.click(otherOption); + fireEvent.keyDown(window, { key: 'b' }); + if (allowResponseChange) { + expect(onChange).toHaveBeenCalledWith('B', 'keyboard'); + expect(owner.result.current?.selected).toBe(true); + } else { + expect(onChange).not.toHaveBeenCalled(); + expect(owner.result.current).toBeUndefined(); + } + }); + + test('a finalized I do not know answer stays locked on return', () => { + vi.mocked(useStudyConfig).mockReturnValue(makeStudyConfig({ + components: { following: { type: 'questionnaire', previousButton: true, response: [] } }, + })); + mockStoreState.sequence.components = ['trial1', 'following']; + const onChange = vi.fn(); + const view = render( + , + ); + const checkbox = view.getByLabelText("I don't know") as HTMLInputElement; + expect(checkbox.disabled).toBe(true); + checkbox.click(); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('captured auto-advance buttons remain read-only without emitting a selection', () => { + vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('answer=A'), vi.fn()]); + const onChange = vi.fn(); + const owner = renderHook(() => useAutoAdvanceSelection('trial1_0', ['choice'], true)); + const view = render(); + expect((view.container.querySelector('[data-radio-card]') as HTMLButtonElement).disabled).toBe(true); + fireEvent.keyDown(window, { key: 'a' }); + expect(onChange).not.toHaveBeenCalled(); + expect(owner.result.current).toBeUndefined(); + }); + + test('forwards mapped-key provenance even when auto-advance is off', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 'a' }); + expect(onChange).toHaveBeenCalledWith('A', 'keyboard'); + }); + + test.each([true, false])('I do not know advances and honors response locking (allowResponseChange=%s)', async (allowResponseChange) => { + vi.useFakeTimers(); + const onNext = vi.fn(() => true); + function Trial() { + const [checked, setChecked] = useState(false); + return ( + <> + + + + ); + } + const view = render(); + const checkbox = view.getByLabelText("I don't know") as HTMLInputElement; + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + expect(checkbox.disabled).toBe(!allowResponseChange); + if (allowResponseChange) { + fireEvent.click(checkbox); + await act(async () => { vi.advanceTimersByTime(250); }); + expect(onNext).not.toHaveBeenCalled(); + fireEvent.click(checkbox); + } + await act(async () => { vi.advanceTimersByTime(250); }); + expect(onNext).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + test('invalidates hidden response requests without restoring them when visible again', () => { + const { result, rerender } = renderHook( + ({ ids }) => useAutoAdvanceSelection('trial1_0', ids, true), + { initialProps: { ids: ['choice'] } }, + ); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'choice', delay: 250, selected: true, + })); + expect(result.current?.selected).toBe(true); + rerender({ ids: [] }); + expect(result.current).toBeUndefined(); + rerender({ ids: ['choice'] }); + expect(result.current).toBeUndefined(); + }); + + test('cancellations affect only the request from the same source', () => { + const { result } = renderHook(() => useAutoAdvanceSelection('trial1_0', ['choice'], true, true)); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'choice', delay: 250, selected: true, + })); + act(() => publishAutoAdvanceSelection({ identifier: 'trial1_0', delay: 0, selected: false })); + expect(result.current?.responseId).toBe('choice'); + act(() => publishAutoAdvanceSelection({ + identifier: 'trial1_0', responseId: 'choice', delay: 0, selected: false, + })); + expect(result.current?.selected).toBe(false); + }); +}); diff --git a/src/components/tests/NextButton.spec.tsx b/src/components/tests/NextButton.spec.tsx index 97dea4ea01..9c625bfd0c 100644 --- a/src/components/tests/NextButton.spec.tsx +++ b/src/components/tests/NextButton.spec.tsx @@ -145,6 +145,27 @@ describe('NextButton', () => { expect(html).toContain('Next'); }); + test.each([false, true])('explicit hiding applies until a return requires manual continuation (showNextOnReturn=%s)', (showNextOnReturn) => { + const onNext = vi.fn(() => true); + render( + , + ); + const next = screen.queryByRole('button', { name: 'Next' }); + if (showNextOnReturn) { + expect(next).not.toBeNull(); + fireEvent.click(next!); + expect(onNext).toHaveBeenCalledTimes(1); + } else { + expect(next).toBeNull(); + expect(onNext).not.toHaveBeenCalled(); + } + }); + test('renders button with custom label', () => { const html = renderToStaticMarkup(); expect(html).toContain('Continue'); @@ -497,10 +518,60 @@ describe('NextButton', () => { ); }); - await act(async () => { vi.advanceTimersByTime(500); }); + await act(async () => { vi.advanceTimersByTime(200); }); + await act(async () => { vi.advanceTimersByTime(50); }); expect(onNext).not.toHaveBeenCalled(); }); + test.each([true, false])('response auto-advance succeeds just before the disable cutoff (timeoutReject=%s)', async (timeoutReject) => { + vi.useFakeTimers(); + mockStudyConfig = { + uiConfig: { ...mockStudyConfig.uiConfig, nextButtonDisableTime: 200, timeoutReject }, + }; + const onNext = vi.fn(() => true); + render( + , + ); + await act(async () => { vi.advanceTimersByTime(198); }); + expect(onNext).not.toHaveBeenCalled(); + await act(async () => { vi.advanceTimersByTime(1); }); + expect(onNext).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + test.each([true, false])('response auto-advance respects the exact disable cutoff (timeoutReject=%s)', async (timeoutReject) => { + vi.useFakeTimers(); + mockStudyConfig = { + uiConfig: { ...mockStudyConfig.uiConfig, nextButtonDisableTime: 200, timeoutReject }, + }; + const onNext = vi.fn(() => true); + render( + , + ); + await act(async () => { vi.advanceTimersByTime(199); }); + expect(onNext).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + await act(async () => { vi.advanceTimersByTime(1); }); + expect(onNext).not.toHaveBeenCalled(); + if (timeoutReject) { + expect(mockNavigate).toHaveBeenCalledExactlyOnceWith(`./../__timedOut${window.location.search}`); + } else { + expect(mockNavigate).not.toHaveBeenCalled(); + } + }); + test('cancels a pending response auto-advance when the answer is cleared', async () => { vi.useFakeTimers(); const onNext = vi.fn(); diff --git a/src/controllers/IframeController.tsx b/src/controllers/IframeController.tsx index ee6e1c00ac..9a780d1068 100644 --- a/src/controllers/IframeController.tsx +++ b/src/controllers/IframeController.tsx @@ -169,7 +169,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren status: true, values: data.message, })); - if (e.source === ref.current?.contentWindow) { + if (e.source === ref.current?.contentWindow && typeof data.options?.autoAdvanceToNextStep === 'boolean') { const delay = data.options?.autoAdvanceDelay; publishAutoAdvanceSelection({ identifier, diff --git a/src/controllers/tests/IframeController.spec.tsx b/src/controllers/tests/IframeController.spec.tsx index 29b8a22995..17470fdf14 100644 --- a/src/controllers/tests/IframeController.spec.tsx +++ b/src/controllers/tests/IframeController.spec.tsx @@ -89,12 +89,11 @@ describe('IframeController', () => { }); test.each([ - { options: undefined, selected: false, delay: 0 }, + { options: { autoAdvanceToNextStep: false }, selected: false, delay: 0 }, { options: { autoAdvanceToNextStep: true, autoAdvanceDelay: 300 }, selected: true, delay: 300 }, { options: { autoAdvanceToNextStep: true }, selected: true, delay: 0 }, { options: { autoAdvanceToNextStep: true, autoAdvanceDelay: -1 }, selected: true, delay: 0 }, { options: { autoAdvanceToNextStep: true, autoAdvanceDelay: Infinity }, selected: true, delay: 0 }, - { options: { autoAdvanceToNextStep: 'true', autoAdvanceDelay: '300' }, selected: false, delay: 0 }, ])('publishes HTML submission or cancellation after updating answers: $options', async ({ options, selected, delay }) => { const { container } = render(); await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()); @@ -120,6 +119,29 @@ describe('IframeController', () => { } }); + test.each([undefined, {}, { autoAdvanceToNextStep: 'true' }])('updates legacy answers without changing navigation: %s', async (options) => { + const { container } = render(); + await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull()); + const iframe = container.querySelector('iframe')!; + const listener = vi.fn(); + const unsubscribe = subscribeToAutoAdvanceSelections(listener); + try { + window.dispatchEvent(new MessageEvent('message', { + source: iframe.contentWindow, + data: { + iframeId: new URL(iframe.src).searchParams.get('id'), + type: '@REVISIT_COMMS/ANSWERS', + message: { color: 'blue' }, + options, + }, + })); + expect(mockSetReactiveAnswers).toHaveBeenCalledWith({ color: 'blue' }); + expect(listener).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + test.each([false, true])('does not accept navigation from another window or during analysis: $0', async (analysis) => { mockIsAnalysis.value = analysis; const { container } = render(); diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index f0a42d0638..a44fc49ce3 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -117,7 +117,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -315,7 +315,7 @@ "description": "The ButtonsResponse interface is used to define the properties of a buttons response. ButtonsResponses render as a list of buttons that the participant can click. When a button is clicked, the value of the button is stored in the data file. Participants can cycle through the options using the arrow keys. ```json { \"id\": \"buttonsResponse\", \"type\": \"buttons\", \"prompt\": \"Click a button\", \"location\": \"belowStimulus\", \"default\": \"Option 2\", \"options\": [ \"Option 1\", \"Option 2\", \"Option 3\" ] } ``` In this example, the participant can click one of the buttons labeled \"Option 1\", \"Option 2\", or \"Option 3\".", "properties": { "allowResponseChange": { - "description": "Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided. Defaults to true.", + "description": "Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided, including when returning with Previous. Defaults to true.", "type": "boolean" }, "autoAdvanceDelay": { @@ -1514,7 +1514,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -1818,7 +1818,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -2401,7 +2401,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -3082,7 +3082,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -3797,7 +3797,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -4949,7 +4949,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5137,7 +5137,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5333,7 +5333,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5533,7 +5533,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 574aea92c8..7064b2ea94 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -117,7 +117,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -336,7 +336,7 @@ "description": "The ButtonsResponse interface is used to define the properties of a buttons response. ButtonsResponses render as a list of buttons that the participant can click. When a button is clicked, the value of the button is stored in the data file. Participants can cycle through the options using the arrow keys. ```json { \"id\": \"buttonsResponse\", \"type\": \"buttons\", \"prompt\": \"Click a button\", \"location\": \"belowStimulus\", \"default\": \"Option 2\", \"options\": [ \"Option 1\", \"Option 2\", \"Option 3\" ] } ``` In this example, the participant can click one of the buttons labeled \"Option 1\", \"Option 2\", or \"Option 3\".", "properties": { "allowResponseChange": { - "description": "Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided. Defaults to true.", + "description": "Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided, including when returning with Previous. Defaults to true.", "type": "boolean" }, "autoAdvanceDelay": { @@ -1614,7 +1614,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -1918,7 +1918,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -2450,7 +2450,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -3153,7 +3153,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -3868,7 +3868,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5377,7 +5377,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5565,7 +5565,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5761,7 +5761,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { @@ -5961,7 +5961,7 @@ "type": "number" }, "nextButtonHidden": { - "description": "Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true.", + "description": "Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden.", "type": "boolean" }, "nextButtonLocation": { diff --git a/src/parser/tests/parser.spec.ts b/src/parser/tests/parser.spec.ts index 96d4eed536..81a921395b 100644 --- a/src/parser/tests/parser.spec.ts +++ b/src/parser/tests/parser.spec.ts @@ -1275,12 +1275,9 @@ describe('Component auto-advance config parsing', () => { expect(hasAutoAdvanceFieldError).toBe(false); }); - test.each([ - ['buttons', true], - ['buttons', false], - ])( - 'validates %s response with autoAdvanceToNextStep set to %s without errors', - async () => { + test.each([true, false])( + 'validates buttons response with autoAdvanceToNextStep set to %s without errors', + async (autoAdvanceToNextStep) => { const studyConfig = { $schema: '', studyMetadata: { @@ -1308,7 +1305,7 @@ describe('Component auto-advance config parsing', () => { type: 'buttons', prompt: 'Select an option', options: ['Option 1', 'Option 2'], - autoAdvanceToNextStep: true, + autoAdvanceToNextStep, }, ], }, diff --git a/src/parser/types.ts b/src/parser/types.ts index 0a4d1c0031..5d643c26b4 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1189,7 +1189,7 @@ export interface ButtonsResponse extends BaseResponse { autoAdvanceToNextStep?: boolean; /** The delay, in milliseconds, to wait after this response is answered before automatically advancing to the next step. Only used when `autoAdvanceToNextStep` is `true`. Defaults to 0. */ autoAdvanceDelay?: number; - /** Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided. Defaults to true. */ + /** Controls whether the participant is allowed to change their response after they have selected an answer. Set to `false` to lock the response in as soon as it is provided, including when returning with Previous. Defaults to true. */ allowResponseChange?: boolean; } @@ -1341,7 +1341,7 @@ export interface BaseIndividualComponent { nextButtonAlignment?: NextButtonAlignment; /** The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig. */ nextButtonEnableTime?: number; - /** Whether to hide the next button. Defaults to false, if autoAdvance is enabled it will default to true. */ + /** Whether to hide the next button during the initial answering flow. Defaults to true when auto-advance is enabled, otherwise false. A completed trial with auto-advance responses shows Next on return when no selection request is pending, even if explicitly hidden. */ nextButtonHidden?: boolean; /** The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig. */ nextButtonDisableTime?: number; diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index 9459e59141..dc4a799b4b 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -344,42 +344,6 @@ describe('useNextStep', () => { expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); }); - test('advances to next step when auto-advance trigger executes navigation without collectData', async () => { - mockSaveAnswers.mockResolvedValueOnce(undefined); - mockSequence = { - id: 'root', - orderPath: 'root', - order: 'fixed', - components: ['intro', 'followup'], - skip: [], - }; - mockFlatSequence = ['intro', 'followup']; - mockStudyConfig = { - components: { - intro: { - type: 'questionnaire', - response: [ - { - id: 'btn-1', - type: 'buttons', - autoAdvanceToNextStep: true, - autoAdvanceDelay: 500, - }, - ], - }, - followup: {}, - }, - }; - - renderToStaticMarkup(); - - await capturedGoToNextStep?.(true); - await Promise.resolve(); - - expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); - }); - test('excludes timed out answers from block skip conditions', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); mockSequence = {