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/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 new file mode 100644 index 0000000000..22aa7ce9ab --- /dev/null +++ b/public/test-auto-advance/config.json @@ -0,0 +1,245 @@ +{ + "$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": { + "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": [ + { + "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", + "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": [ + { + "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", + "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 2a7bb7738a..e32a28837c 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,11 @@ type Props = { location?: ResponseBlockLocation; checkAnswer: JSX.Element | null; onCheckAnswer?: () => void; - onNext: () => void; + onNext: () => boolean; + autoAdvanceRequest?: AutoAdvanceSelection; + autoAdvanceEligible?: boolean; + responseAutoAdvances?: boolean; + showNextOnReturn?: boolean; }; export function NextButton({ @@ -43,6 +48,10 @@ export function NextButton({ checkAnswer, onCheckAnswer, 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(); @@ -54,13 +63,35 @@ 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 = !showNextOnReturn && (config?.nextButtonHidden ?? ( + nextButtonAutoAdvanceTime !== undefined + || responseAutoAdvances + || false + )); const [timer, setTimer] = useState(undefined); - const autoAdvanceTriggered = useRef(false); + const trialStartTime = useRef(Date.now()); + 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(); + trialStartTime.current = start; setTimer(0); const interval = setInterval(() => { setTimer(Date.now() - start); @@ -70,6 +101,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; @@ -79,15 +122,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) { @@ -100,6 +134,45 @@ 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; + } + + 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, nextButtonDisableTime, nextButtonEnableTime, readyAutoAdvanceRequest, studyConfig.uiConfig.timeoutReject]); + const autoAdvanceWarning = useMemo(() => getAutoAdvanceWarning({ timer, autoAdvanceTime: nextButtonAutoAdvanceTime, @@ -119,7 +192,7 @@ export function NextButton({ onCheckAnswer(); return; } - if (!disabled && !isNextDisabled && buttonTimerSatisfied) { + if (!disabled && !isNextDisabled && buttonTimerSatisfied && !nextButtonHidden) { onNext(); } }; @@ -130,9 +203,8 @@ 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'; const nextButtonAlignment = config?.nextButtonAlignment ?? studyConfig.uiConfig.nextButtonAlignment ?? 'right'; const componentWidth = config && getComponentContainerStyle(config.type, config.style); @@ -155,43 +227,45 @@ export function NextButton({ /> )} {checkAnswer} - + {!nextButtonHidden && ( + + )} {timer !== undefined && ( <> diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index e1234e917c..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, @@ -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; @@ -98,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(); @@ -655,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. @@ -706,17 +710,27 @@ 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( + () => 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]); + const autoAdvanceSelection = useAutoAdvanceSelection(identifier, autoAdvanceResponseIds, autoAdvanceOwner, allowTrialRequests); + let index = 0; return ( <> @@ -751,7 +765,7 @@ export function ResponseBlock({ {(isDelayedDisabled: boolean) => ( trackInputChange(response.id, value, source), @@ -837,6 +851,10 @@ 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} onCheckAnswer={!isAnalysis && hasCorrectAnswerFeedback && !disabledAttempts ? checkAnswerProvideFeedback : undefined} checkAnswer={showBtnsInLocation && hasCorrectAnswerFeedback ? ( diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index 625be0afe3..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 { useSearchParams } from 'react-router'; -import { useMemo } from 'react'; import { GetInputPropsReturnType } from '@mantine/form/lib/types'; +import { useSearchParams } from 'react-router'; +import { + useCallback, useEffect, useMemo, useState, +} from 'react'; import { CustomResponse, IndividualComponent, JsonValue, MatrixResponse, Response, SliderResponse, StoredAnswer, } from '../../parser/types'; @@ -26,13 +28,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 +44,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,22 +81,64 @@ 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 [userSelected, setUserSelected] = useState(false); + + useEffect(() => { + setUserSelected(false); + }, [identifier]); const completed = useStoreSelector((state) => state.completed); 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') { + return form; + } + + return { + ...form, + onChange: (val: unknown, source?: 'keyboard' | 'click') => { + selectAnswer(hasAnswerValue(val)); + form.onChange(val, source); + }, + }; + }, [form, selectAnswer]); // 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 || restoredResponseLocked || (Object.keys(finalStoredAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed + ? { value: finalStoredAnswer?.[response.id], readOnly: true } + : wrappedForm) || { value: undefined }, + [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 }) : { 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 +146,56 @@ 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; + } + + if (response.type === 'buttons' && response.allowResponseChange !== false) { + return false; + } + + if (response.allowResponseChange === false) { + return restoredResponseLocked || isResponseChangeLocked(userSelected, dontKnowChecked || ans.value); + } + + return false; + }, [response, restoredResponseLocked, ans.value, dontKnowChecked, userSelected]); + const isStateDisabled = useMemo(() => { // Always disable if participant is completed - if (completed) { + if (completed || responseChangeLocked) { return true; } @@ -136,40 +228,9 @@ 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(() => { - 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]); @@ -452,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); form.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 new file mode 100644 index 0000000000..91d7a22e7f --- /dev/null +++ b/src/components/response/autoAdvanceEvents.ts @@ -0,0 +1,65 @@ +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, + allowTrialRequests = false, +): AutoAdvanceSelection | undefined { + const [selection, setSelection] = useState(); + + useEffect(() => { + 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) { + return undefined; + } + + const allowedResponseIds = new Set(responseIds); + const unsubscribe = subscribeToAutoAdvanceSelections((nextSelection) => { + const allowed = nextSelection.responseId === undefined + ? allowTrialRequests + : allowedResponseIds.has(nextSelection.responseId); + if (nextSelection.identifier === identifier && allowed) { + setSelection((current) => (!nextSelection.selected && current?.responseId !== nextSelection.responseId ? current : nextSelection)); + } + }); + return () => unsubscribe(); + }, [enabled, identifier, responseIds, allowTrialRequests]); + + 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/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/tests/ResponseSwitcher.spec.tsx b/src/components/response/tests/ResponseSwitcher.spec.tsx index 7889224156..1437132f0a 100644 --- a/src/components/response/tests/ResponseSwitcher.spec.tsx +++ b/src/components/response/tests/ResponseSwitcher.spec.tsx @@ -1,15 +1,25 @@ -import { ComponentPropsWithoutRef } from 'react'; -import { render, cleanup } from '@testing-library/react'; +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'; // ── mocks ──────────────────────────────────────────────────────────────────── const { - capturedStringInputProps, capturedCustomInputProps, mockIsAnalysis, mockStoreState, mockCurrentComponent, + capturedStringInputProps, capturedCustomInputProps, mockIsAnalysis, mockStoreState, mockCurrentComponent, mockGoToNextStep, } = vi.hoisted(() => ({ capturedStringInputProps: { disabled: undefined as boolean | undefined, @@ -20,22 +30,71 @@ const { mockStoreState: { sequence: { order: 'fixed', orderPath: 'root', components: ['trial1'], skip: [], - }, + } as Sequence, completed: false, answers: {}, }, mockCurrentComponent: { value: '' }, + mockGoToNextStep: vi.fn(() => true), })); 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}
, + Alert: ({ children }: { children?: ReactNode }) =>
{children}
, + Button: ({ children, disabled, onClick }: { children?: ReactNode; disabled?: boolean; onClick?: () => void }) => ( + + ), + Group: ({ children }: { children?: ReactNode }) =>
{children}
, + Kbd: ({ children }: { children?: ReactNode }) => {children}, + Radio: Object.assign( + ({ children, value, ...props }: { children?: ReactNode; value?: string; [key: string]: unknown }) => ( +
{children}
+ ), + { + 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 ( +
+ {label} + {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 +107,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 +135,28 @@ 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', () => ({ + OptionTextTemplateContext: { Provider: ({ children }: { children?: ReactNode }) => children }, + OptionLabel: ({ label }: { label: ReactNode }) => {label}, +})); + // ── fixtures ────────────────────────────────────────────────────────────────── const response = { @@ -104,12 +186,19 @@ 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; mockIsAnalysis.value = false; mockCurrentComponent.value = ''; mockStoreState.completed = false; + mockGoToNextStep.mockReset(); + mockGoToNextStep.mockReturnValue(true); }); afterEach(() => cleanup()); @@ -167,6 +256,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', () => { @@ -297,6 +391,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( @@ -311,4 +448,246 @@ 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); + }); +}); + +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(); + }); +}); + +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/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 d012093bc3..610806daf4 100644 --- a/src/components/response/utils.ts +++ b/src/components/response/utils.ts @@ -58,6 +58,26 @@ 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 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..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'); @@ -434,6 +455,294 @@ 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(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(); + 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/controllers/IframeController.tsx b/src/controllers/IframeController.tsx index f42c2fd73b..9a780d1068 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 && typeof data.options?.autoAdvanceToNextStep === 'boolean') { + 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..17470fdf14 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,84 @@ describe('IframeController', () => { expect(iframe?.style.colorScheme).toBe(colorMode ?? 'inherit'); }); + test.each([ + { 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 }, + ])('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([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(); + 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/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index d7c5e0628b..a44fc49ce3 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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -310,6 +314,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, including when returning with Previous. 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" @@ -1497,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -1797,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -2376,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3053,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3764,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -4912,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5096,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5288,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5484,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 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": { "$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 71d526ed3e..7064b2ea94 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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -331,6 +335,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, including when returning with Previous. 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" @@ -1597,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -1897,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -2425,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3124,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -3835,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5340,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5524,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5716,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 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": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the next button. If present, will override the next button location setting in the uiConfig." @@ -5912,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 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": { "$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 f51e98da0a..c895e1d3b5 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -993,6 +993,28 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco }); }); + Object.entries(studyConfig.components).forEach(([componentName, component]) => { + const resolvedComponent = studyComponentToIndividualComponent(component, studyConfig); + + const responses = resolvedComponent.response ?? []; + const hasAutoAdvanceButton = responses.some( + (response) => response.type === 'buttons' && response.autoAdvanceToNextStep === true, + ); + 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}/`, + 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..81a921395b 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,99 @@ 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([true, false])( + 'validates buttons response with autoAdvanceToNextStep set to %s without errors', + async (autoAdvanceToNextStep) => { + 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, + }, + ], + }, + }, + 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..5d643c26b4 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, including when returning with Previous. Defaults to true. */ + allowResponseChange?: boolean; } /** @@ -1335,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 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; /** The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component. */ 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]); diff --git a/tests/demo-form-elements.spec.ts b/tests/demo-form-elements.spec.ts index cd1d5c0cd2..982e196769 100644 --- a/tests/demo-form-elements.spec.ts +++ b/tests/demo-form-elements.spec.ts @@ -327,6 +327,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 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 }); });