From 1a5ef580d550d607ae861ccc3ddd1a9bd8e29979 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:52:30 -0600 Subject: [PATCH 01/19] Add error messages --- public/demo-form-elements/config.json | 229 ++++++++++++++++++- src/components/NextButton.tsx | 8 +- src/components/response/ButtonsInput.tsx | 7 +- src/components/response/CheckBoxInput.tsx | 11 +- src/components/response/DropdownInput.tsx | 11 +- src/components/response/LikertInput.tsx | 5 +- src/components/response/MatrixInput.tsx | 5 +- src/components/response/NumericInput.tsx | 9 +- src/components/response/RadioInput.tsx | 9 +- src/components/response/RankingInput.tsx | 9 +- src/components/response/ResponseBlock.tsx | 23 +- src/components/response/ResponseSwitcher.tsx | 76 +++++- src/components/response/SliderInput.tsx | 12 +- src/components/response/StringInput.tsx | 7 +- src/components/response/TextAreaInput.tsx | 7 +- src/components/response/utils.spec.ts | 105 ++++++++- src/components/response/utils.ts | 93 +++++++- src/store/hooks/useNextStep.spec.tsx | 57 ++++- src/store/hooks/useNextStep.ts | 9 +- 19 files changed, 605 insertions(+), 87 deletions(-) diff --git a/public/demo-form-elements/config.json b/public/demo-form-elements/config.json index 35023a8363..ca7de25206 100644 --- a/public/demo-form-elements/config.json +++ b/public/demo-form-elements/config.json @@ -393,7 +393,10 @@ ], "minSelections": 1, "maxSelections": 3, - "default": ["Line", "test"] + "default": [ + "Line", + "test" + ] }, { "id": "default-radio", @@ -778,7 +781,7 @@ "Grapes" ], "numItems": 2, - "required": false + "required": true }, { "id": "ranking-categorical", @@ -792,7 +795,7 @@ "Dancing", "Photography" ], - "required": false + "required": true }, { "id": "ranking-pairwise", @@ -807,7 +810,7 @@ "Salad", "Tacos" ], - "required": false + "required": true } ] }, @@ -988,6 +991,212 @@ "location": "sidebar" } ] + }, + "Unanswered Highlights - Mixed Layout": { + "type": "questionnaire", + "response": [ + { + "id": "mixed-layout-title", + "type": "textOnly", + "prompt": "# Unanswered Highlights - Mixed Layout\n\nThis page keeps Next enabled. Clicking it should reveal unanswered required responses across `aboveStimulus`, `belowStimulus`, and `sidebar` together.", + "restartEnumeration": true + }, + { + "id": "mixed-layout-above", + "type": "shortText", + "prompt": "Above stimulus question", + "placeholder": "Type something here", + "location": "aboveStimulus" + }, + { + "id": "mixed-layout-main", + "type": "dropdown", + "prompt": "Main-column dropdown", + "placeholder": "Choose a chart type", + "options": [ + "Bar", + "Line", + "Scatter" + ], + "location": "belowStimulus" + }, + { + "id": "mixed-layout-sidebar", + "type": "radio", + "prompt": "Sidebar radio question", + "options": [ + "Option A", + "Option B" + ], + "location": "sidebar" + } + ] + }, + "Unanswered Highlights - Grouped Inputs": { + "type": "questionnaire", + "response": [ + { + "id": "grouped-inputs-title", + "type": "textOnly", + "prompt": "# Unanswered Highlights - Grouped Inputs\n\nGrouped responses should use the same warning size and spacing while still revealing only after a blocked Next attempt.", + "restartEnumeration": true + }, + { + "id": "grouped-checkbox", + "type": "checkbox", + "prompt": "Checkbox question", + "minSelections": 2, + "options": [ + "Alpha", + "Beta", + "Gamma" + ] + }, + { + "id": "grouped-radio", + "type": "radio", + "prompt": "Radio question", + "options": [ + "First", + "Second" + ] + }, + { + "id": "grouped-buttons", + "type": "buttons", + "prompt": "Buttons question", + "options": [ + "One", + "Two", + "Three" + ] + }, + { + "id": "grouped-likert", + "type": "likert", + "prompt": "Likert question", + "numItems": 5, + "leftLabel": "Low", + "rightLabel": "High" + } + ] + }, + "Unanswered Highlights - Sidebar Only With Main Next": { + "type": "questionnaire", + "nextButtonLocation": "belowStimulus", + "response": [ + { + "id": "sidebar-only-title", + "type": "textOnly", + "prompt": "# Unanswered Highlights - Sidebar Only With Main Next\n\nThe required response is only in the sidebar, but the Next button stays in the main column.", + "restartEnumeration": true + }, + { + "id": "sidebar-only-question", + "type": "shortText", + "prompt": "Sidebar-only required question", + "placeholder": "Answer from the sidebar", + "location": "sidebar" + } + ] + }, + "Stimulus Validation - Forced Video Only": { + "type": "video", + "path": "demo-video/assets/venice.mp4", + "forceCompletion": true, + "response": [ + { + "id": "forced-video-only-title", + "type": "textOnly", + "prompt": "# Stimulus Validation - Forced Video Only\n\nNext should remain disabled until the video finishes, without showing unanswered form warnings." + } + ] + }, + "Stimulus Validation - Forced Video With Form Elements": { + "type": "video", + "path": "demo-video/assets/venice.mp4", + "forceCompletion": true, + "response": [ + { + "id": "forced-video-form-title", + "type": "textOnly", + "prompt": "# Stimulus Validation - Forced Video With Form Elements\n\nThe video keeps Next disabled at first. After completion, Next becomes enabled and reveals unanswered form fields if needed.", + "restartEnumeration": true + }, + { + "id": "forced-video-form-main", + "type": "shortText", + "prompt": "Main-column question after the stimulus", + "placeholder": "Describe what you noticed" + }, + { + "id": "forced-video-form-sidebar", + "type": "radio", + "prompt": "Sidebar follow-up", + "options": [ + "Yes", + "No" + ], + "location": "sidebar" + } + ] + }, + "Stimulus Validation - React Component Gate": { + "type": "react-component", + "path": "demo-form-elements/assets/StimulusGate.tsx", + "parameters": { + "title": "Stimulus validation with a React component", + "description": "This mock stimulus calls setAnswer and keeps Next disabled until you click Complete stimulus." + }, + "response": [ + { + "id": "react-stimulus-status", + "type": "reactive", + "prompt": "Stimulus gate status" + } + ] + }, + "Training Validation - Check Answer Required": { + "type": "questionnaire", + "provideFeedback": true, + "allowFailedTraining": false, + "trainingAttempts": 2, + "correctAnswer": [ + { + "id": "training-chart-choice", + "answer": "Bar" + } + ], + "response": [ + { + "id": "training-title", + "type": "textOnly", + "prompt": "# Training Validation - Check Answer Required\n\nThis page keeps Next disabled until the participant checks the answer and satisfies the training rule.", + "restartEnumeration": true + }, + { + "id": "training-chart-choice", + "type": "dropdown", + "prompt": "Which chart type is correct?", + "placeholder": "Choose mark", + "options": [ + "Bar", + "Line", + "Scatter" + ] + } + ] + }, + "Timer Validation - Next Button Enable Time": { + "type": "questionnaire", + "nextButtonEnableTime": 3000, + "response": [ + { + "id": "timer-title", + "type": "textOnly", + "prompt": "# Timer Validation - Next Button Enable Time\n\nNext should be truly disabled for three seconds and then enable without any required response." + } + ] } }, "sequence": { @@ -1000,7 +1209,15 @@ "Randomizing Options", "Randomizing Questions", "Ranking Widgets", - "Sidebar Form Elements" + "Sidebar Form Elements", + "Unanswered Highlights - Mixed Layout", + "Unanswered Highlights - Grouped Inputs", + "Unanswered Highlights - Sidebar Only With Main Next", + "Stimulus Validation - Forced Video Only", + "Stimulus Validation - Forced Video With Form Elements", + "Stimulus Validation - React Component Gate", + "Training Validation - Check Answer Required", + "Timer Validation - Next Button Enable Time" ] } -} +} \ No newline at end of file diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index eced74dbbe..1407f9a371 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -15,6 +15,7 @@ type Props = { config?: IndividualComponent; location?: ResponseBlockLocation; checkAnswer: JSX.Element | null; + onNext?: () => void; }; export function NextButton({ @@ -23,6 +24,7 @@ export function NextButton({ config, location, checkAnswer, + onNext, }: Props) { const { isNextDisabled, goToNextStep } = useNextStep(); const studyConfig = useStudyConfig(); @@ -70,7 +72,7 @@ export function NextButton({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' && !disabled && !isNextDisabled && buttonTimerSatisfied) { - goToNextStep(); + onNext?.(); } }; @@ -81,7 +83,7 @@ export function NextButton({ }; } return () => {}; - }, [disabled, isNextDisabled, buttonTimerSatisfied, goToNextStep, nextOnEnter]); + }, [disabled, isNextDisabled, buttonTimerSatisfied, onNext, nextOnEnter]); const nextButtonDisabled = useMemo(() => disabled || isNextDisabled || !buttonTimerSatisfied, [disabled, isNextDisabled, buttonTimerSatisfied]); const previousButtonText = useMemo(() => config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous', [config, studyConfig]); @@ -99,7 +101,7 @@ export function NextButton({ + )} )} From c413e366b5c13efbaa13831609030c47e0760673 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:28:10 -0600 Subject: [PATCH 07/19] Fix minor things Co-authored-by: Copilot --- src/components/response/DropdownInput.tsx | 6 +-- src/components/response/MatrixInput.tsx | 4 +- src/components/response/ResponseBlock.tsx | 43 +++++++++++--------- src/components/response/ResponseSwitcher.tsx | 2 +- src/components/response/responseErrors.ts | 19 ++++++++- src/components/response/utils.spec.ts | 22 ++++++---- src/controllers/ComponentController.tsx | 13 ++++-- src/controllers/ErrorBoundary.tsx | 5 +++ src/controllers/IframeController.tsx | 6 ++- src/controllers/ReactComponentController.tsx | 33 +++++++++++++-- src/controllers/VegaController.tsx | 21 +++++++++- src/controllers/VideoController.tsx | 35 +++++++++++++--- 12 files changed, 160 insertions(+), 49 deletions(-) diff --git a/src/components/response/DropdownInput.tsx b/src/components/response/DropdownInput.tsx index 5af07e4f84..45a7c2e6b0 100644 --- a/src/components/response/DropdownInput.tsx +++ b/src/components/response/DropdownInput.tsx @@ -41,12 +41,12 @@ export function DropdownInput({ disabled={disabled} label={prompt.length > 0 && } description={secondaryText} - placeholder={answer.value.length === 0 ? placeholder : undefined} + placeholder={!answer.value || answer.value.length === 0 ? placeholder : undefined} data={optionsAsStringOptions} radius="md" size="md" {...answer} - value={answer.value === '' ? [] : Array.isArray(answer.value) ? answer.value : [answer.value]} + value={Array.isArray(answer.value) ? answer.value : answer.value ? [answer.value] : []} error={error} withErrorStyles={required} errorProps={{ c: required ? 'red' : 'orange', fz: 'sm', mt: 'xs' }} @@ -66,7 +66,7 @@ export function DropdownInput({ radius="md" size="md" {...answer} - value={answer.value === '' ? null : answer.value} + value={answer.value || null} error={error} withErrorStyles={required} errorProps={{ c: required ? 'red' : 'orange', fz: 'sm', mt: 'xs' }} diff --git a/src/components/response/MatrixInput.tsx b/src/components/response/MatrixInput.tsx index 3abc4c5219..b3fafa5910 100644 --- a/src/components/response/MatrixInput.tsx +++ b/src/components/response/MatrixInput.tsx @@ -44,7 +44,7 @@ function CheckboxComponent({ onChange(event, question, checkbox)} value={checkbox.value} classNames={{ input: checkboxClasses.fixDisabled, icon: checkboxClasses.fixDisabledIcon }} @@ -81,7 +81,7 @@ function RadioGroupComponent({ flex: 1, }} onChange={(val) => onChange(val, question)} - value={answer.value[question]} + value={answer.value?.[question]} >
{ - if (stickyVisible && !stickyVisibleRef.current) { + if (stickyVisible && !stickyVisibleRef.current && !isAnalysis) { scrollToFirstUnresolvedQuestion(); } stickyVisibleRef.current = stickyVisible; - }, [stickyVisible, scrollToFirstUnresolvedQuestion]); + }, [stickyVisible, scrollToFirstUnresolvedQuestion, isAnalysis]); const answerValidator = useAnswerField( responsesWithDefaults, @@ -518,6 +518,10 @@ export function ResponseBlock({ if (hasCorrectAnswerFeedback) { allResponsesWithDefaults.forEach((response) => { + // Do not show feedback for textOnly or divider responses + if (response.type === 'textOnly' || response.type === 'divider') { + return; + } if (correctAnswers[response.id] && !alertConfig[response.id]?.message.includes('You\'ve failed to answer this question correctly')) { updateAlertConfig(response.id, true, 'Correct Answer', 'You have answered the question correctly.', 'green'); } else { @@ -580,7 +584,7 @@ export function ResponseBlock({ window.removeEventListener('keydown', handleKeyDown); }; } - return () => {}; + return () => { }; }, [checkAnswerProvideFeedback, nextOnEnter]); const nextButtonText = useMemo(() => config?.nextButtonText ?? studyConfig.uiConfig.nextButtonText ?? 'Next', [config, studyConfig]); @@ -722,6 +726,7 @@ export function ResponseBlock({ size="xs" variant="subtle" color="yellow" + disabled={isAnalysis} onClick={scrollToFirstUnresolvedQuestion} > Next question @@ -732,22 +737,22 @@ export function ResponseBlock({ )} {showBtnsInLocation && ( - = trainingAttempts && trainingAttempts >= 0)} - onClick={() => checkAnswerProvideFeedback()} - px={location === 'sidebar' ? 8 : undefined} - > - Check Answer - - ) : null} - /> + = trainingAttempts && trainingAttempts >= 0)} + onClick={() => checkAnswerProvideFeedback()} + px={location === 'sidebar' ? 8 : undefined} + > + Check Answer + + ) : null} + /> )} ); diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index f54c2460fa..641fcbf32e 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -75,7 +75,7 @@ export function ResponseSwitcher({ const usesStandaloneDontKnow = usesStandaloneDontKnowField(response); // Don't update if we're in analysis mode - const ans = useMemo(() => (isAnalysis || (Object.keys(storedAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed ? { value: storedAnswer![response.id] } : form) || { value: undefined }, [isAnalysis, storedAnswer, response.id, form, nextConfig?.previousButton, completed]); + const ans = useMemo(() => (isAnalysis || (Object.keys(storedAnswer || {}).length > 0 && !nextConfig?.previousButton) || completed ? { value: storedAnswer![response.id], readOnly: true } : form) || { value: undefined }, [isAnalysis, storedAnswer, response.id, form, nextConfig?.previousButton, completed]); const dontKnowValue = usesStandaloneDontKnow ? ((Object.keys(storedAnswer || {}).length > 0 ? { checked: storedAnswer![`${response.id}-dontKnow`] } : dontKnowCheckbox) || { checked: undefined }) : { checked: undefined }; diff --git a/src/components/response/responseErrors.ts b/src/components/response/responseErrors.ts index 0fc9c8b479..8fdeeb0285 100644 --- a/src/components/response/responseErrors.ts +++ b/src/components/response/responseErrors.ts @@ -118,7 +118,7 @@ function getRequiredValueMismatchMessage( } if (Array.isArray(requiredValue)) { - return `Please ${options ? 'select' : 'enter'} ${requiredLabel || requiredValue.toString()} to continue.`; + return `Please ${options ? 'select' : 'enter'} ${requiredLabel || requiredValue.join(', ')} to continue.`; } return `Please ${options ? 'select' : 'enter'} ${requiredLabel || (options ? options.find((opt) => opt.value === requiredValue)?.label : requiredValue.toString())} to continue.`; @@ -300,7 +300,16 @@ export function generateCustomResponseErrorMessage( return options?.showRequiredErrors ? REQUIRED_ERROR_MESSAGE : null; } - return issue.type === 'invalid' ? issue.message ?? null : null; + if (issue.type === 'invalid') { + // Keep validation styling quiet until the participant attempts to submit, + // so typing/selecting answers in order just shows the question text. + if (!options?.showRequiredErrors) { + return null; + } + return issue.message ?? null; + } + + return null; } export function generateErrorMessage( @@ -336,6 +345,12 @@ export function generateErrorMessage( } if (issue.type === 'invalid') { + // Keep validation styling quiet until the participant attempts to submit, + // so typing/selecting answers in order just shows the question text. + if (!errorOptions?.showRequiredErrors) { + return null; + } + if (issue.reason === 'requiredValueMismatch') { return getRequiredValueMismatchMessage(response, options); } diff --git a/src/components/response/utils.spec.ts b/src/components/response/utils.spec.ts index 0ebfdec48c..ed1ecafda1 100644 --- a/src/components/response/utils.spec.ts +++ b/src/components/response/utils.spec.ts @@ -325,12 +325,20 @@ describe('generateCustomResponseErrorMessage', () => { expect(generateCustomResponseErrorMessage(response, null, {}, customValidate, undefined, { showRequiredErrors: true })).toBe('Please answer this question to continue.'); }); - it('shows validation feedback once the response is partially filled', () => { + it('shows validation feedback once the response is partially filled and errors are revealed', () => { expect(generateCustomResponseErrorMessage(response, { chartType: 'Bar', confidence: null, rationale: '', - }, {}, customValidate)).toBe('Set confidence to at least 70 to continue.'); + }, {}, customValidate, undefined, { showRequiredErrors: true })).toBe('Set confidence to at least 70 to continue.'); + }); + + it('stays quiet for invalid responses until errors are revealed', () => { + expect(generateCustomResponseErrorMessage(response, { + chartType: 'Bar', + confidence: null, + rationale: '', + }, {}, customValidate)).toBeNull(); }); it('shows no feedback once the current value is valid', () => { @@ -378,7 +386,7 @@ describe('generateErrorMessage checkbox', () => { const error = generateErrorMessage(checkboxResponse, { value: ['__other'], - }, undefined, { values: { 'checkbox-response-other': '' } }); + }, undefined, { showRequiredErrors: true, values: { 'checkbox-response-other': '' } }); expect(error).toBe('Please fill in Other to continue.'); }); @@ -410,7 +418,7 @@ describe('generateErrorMessage checkbox', () => { options: ['Option 1', 'Option 2', 'Option 3'], }; - const error = generateErrorMessage(checkboxResponse, { value: ['Option 1'] }); + const error = generateErrorMessage(checkboxResponse, { value: ['Option 1'] }, undefined, { showRequiredErrors: true }); expect(error).toBe('Please select at least 2 options'); }); @@ -445,7 +453,7 @@ describe('generateErrorMessage radio', () => { const error = generateErrorMessage(radioResponse, { value: 'other', - }, undefined, { values: { 'radio-response-other': '' } }); + }, undefined, { showRequiredErrors: true, values: { 'radio-response-other': '' } }); expect(error).toBe('Please fill in Other to continue.'); }); @@ -579,10 +587,10 @@ describe('generateErrorMessage matrix', () => { expect(error).toBeNull(); }); - it('shows matrix incomplete message after at least one answer is selected', () => { + it('shows matrix incomplete message after at least one answer is selected and errors are revealed', () => { const error = generateErrorMessage(matrixResponse, { value: { q1: '0', q2: '' }, - }); + }, undefined, { showRequiredErrors: true }); expect(error).toBe('Please answer all questions in the matrix to continue.'); }); diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index c77cab0809..481b561caa 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -168,10 +168,15 @@ export function ComponentController() { [analysisStimulusProvState], ); const stimulusMessage = useMemo( - () => (currentConfig - ? generateStimulusErrorMessage(currentConfig, stimulusValidation, { showStimulusErrors }) - : null), - [currentConfig, showStimulusErrors, stimulusValidation], + () => { + if (isAnalysis) { + return null; + } + return currentConfig + ? generateStimulusErrorMessage(currentConfig, stimulusValidation, { showStimulusErrors }) + : null; + }, + [currentConfig, isAnalysis, showStimulusErrors, stimulusValidation], ); const hasStimulusIssue = useMemo( () => !!stimulusMessage, diff --git a/src/controllers/ErrorBoundary.tsx b/src/controllers/ErrorBoundary.tsx index a237444d1c..802ebcc0ad 100644 --- a/src/controllers/ErrorBoundary.tsx +++ b/src/controllers/ErrorBoundary.tsx @@ -9,6 +9,7 @@ interface ErrorBoundaryState { interface ErrorBoundaryProps { children: React.ReactNode; + onError?: (error: unknown) => void; } export class ErrorBoundary extends React.Component { @@ -22,6 +23,10 @@ export class ErrorBoundary extends React.Component(null); @@ -67,6 +69,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren case `${PREFIX}/READY`: break; case `${PREFIX}/ANSWERS`: + if (isAnalysis) return; storeDispatch(setReactiveAnswers(data.message)); storeDispatch(updateResponseBlockValidation({ location: 'stimulus', @@ -76,6 +79,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren })); break; case `${PREFIX}/PROVENANCE`: + if (isAnalysis) return; storeDispatch(updateResponseBlockValidation({ location: 'stimulus', identifier, @@ -93,7 +97,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); - }, [storeDispatch, dispatch, iframeId, currentConfig, sendMessage, setReactiveAnswers, updateResponseBlockValidation, identifier]); + }, [storeDispatch, dispatch, iframeId, currentConfig, sendMessage, setReactiveAnswers, updateResponseBlockValidation, identifier, isAnalysis]); return (