diff --git a/public/demo-form-elements/config.json b/public/demo-form-elements/config.json index dccb2ac6e9..076840eb55 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", @@ -1003,4 +1006,4 @@ "Sidebar Form Elements" ] } -} +} \ No newline at end of file diff --git a/src/components/NextButton.spec.tsx b/src/components/NextButton.spec.tsx index cac7258d16..c336d375ff 100644 --- a/src/components/NextButton.spec.tsx +++ b/src/components/NextButton.spec.tsx @@ -15,6 +15,7 @@ import { NextButton } from './NextButton'; const mockNavigate = vi.fn(); const mockGoToNextStep = vi.fn(); +const mockOnNext = vi.fn(); let mockIdentifier = 'intro_0'; @@ -77,6 +78,7 @@ describe('NextButton', () => { mockIdentifier = 'intro_0'; mockNavigate.mockReset(); mockGoToNextStep.mockReset(); + mockOnNext.mockReset(); vi.useFakeTimers(); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); @@ -105,6 +107,7 @@ describe('NextButton', () => { , ); }); @@ -123,6 +126,7 @@ describe('NextButton', () => { , ); }); diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index 53a4829bcb..ff6c4c2be0 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -21,6 +21,7 @@ type Props = { config?: IndividualComponent; location?: ResponseBlockLocation; checkAnswer: JSX.Element | null; + onNext: () => void; }; export function NextButton({ @@ -29,6 +30,7 @@ export function NextButton({ config, location, checkAnswer, + onNext, }: Props) { const { isNextDisabled, goToNextStep } = useNextStep(); const studyConfig = useStudyConfig(); @@ -98,7 +100,7 @@ export function NextButton({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' && !disabled && !isNextDisabled && buttonTimerSatisfied) { - goToNextStep(); + onNext(); } }; @@ -108,7 +110,7 @@ export function NextButton({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [disabled, isNextDisabled, buttonTimerSatisfied, goToNextStep, nextOnEnter]); + }, [disabled, isNextDisabled, buttonTimerSatisfied, onNext, nextOnEnter]); const nextButtonDisabled = disabled || isNextDisabled || !buttonTimerSatisfied; const previousButtonText = config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous'; @@ -126,7 +128,7 @@ export function NextButton({ - ) : 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 84df410e8f..641fcbf32e 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -26,8 +26,15 @@ import { getSequenceFlatMap } from '../../utils/getSequenceFlatMap'; import { useCurrentStep } from '../../routes/utils'; import { TextOnlyInput } from './TextOnlyInput'; import { useFetchStylesheet } from '../../utils/fetchStylesheet'; -import { parseStringOptionValue } from '../../utils/stringOptions'; -import { getDefaultFieldValue, usesStandaloneDontKnowField } from './utils'; +import { parseStringOptionValue, parseStringOptions } from '../../utils/stringOptions'; +import { + getDefaultFieldValue, +} from './utils'; +import { + generateErrorMessage, + REQUIRED_ERROR_MESSAGE, + usesStandaloneDontKnowField, +} from './responseErrors'; import { CustomResponseField } from '../../store/types'; export function ResponseSwitcher({ @@ -41,6 +48,7 @@ export function ResponseSwitcher({ disabled, field, customError, + errors, }: { response: Response; form: GetInputPropsReturnType; @@ -52,6 +60,7 @@ export function ResponseSwitcher({ disabled?: boolean; field?: CustomResponseField; customError?: string | null; + errors?: boolean; }) { const studyConfig = useStudyConfig(); const isAnalysis = useIsAnalysis(); @@ -66,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 }; @@ -147,17 +156,97 @@ export function ResponseSwitcher({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [response.paramCapture, (response as MatrixResponse).questionOptions, (response as SliderResponse).startingValue, response.type, searchParams]); - const responseStyle = response.style || {}; + 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]); + const validationValues = useMemo(() => ({ + [response.id]: ans.value, + [`${response.id}-dontKnow`]: dontKnowChecked, + [`${response.id}-other`]: otherValue.value, + }), [response.id, ans.value, dontKnowChecked, otherValue.value]); + const errorOptions = useMemo(() => { + if (response.type === 'radio' || response.type === 'checkbox' || response.type === 'buttons' || response.type === 'dropdown') { + return parseStringOptions(response.options); + } + + if (response.type === 'likert') { + const startValue = response.start ?? 1; + const spacingValue = response.spacing ?? 1; + return Array.from({ length: Number(response.numItems) }, (_, idx) => { + const value = startValue + (idx * spacingValue); + return { label: `${value}`, value: `${value}` }; + }); + } + + return undefined; + }, [response]); + const responseError = useMemo(() => { + if ( + response.type === 'reactive' + || response.type === 'custom' + || response.type === 'textOnly' + || response.type === 'divider' + || response.type === 'ranking-sublist' + || response.type === 'ranking-categorical' + || response.type === 'ranking-pairwise' + ) { + return null; + } + + return generateErrorMessage( + response, + ans as { value?: number | string | string[] | Record; checked?: string[] }, + errorOptions, + { showRequiredErrors: errors, values: validationValues }, + ); + }, [response, ans, errorOptions, errors, validationValues]); + const rankingError = useMemo(() => { + if ( + response.type !== 'ranking-sublist' + && response.type !== 'ranking-categorical' + && response.type !== 'ranking-pairwise' + ) { + return null; + } + + return errors + && response.required + && !dontKnowChecked + && Object.keys((ans.value as Record) || {}).length === 0 + ? REQUIRED_ERROR_MESSAGE + : null; + }, [response, errors, dontKnowChecked, ans.value]); + const displayError = response.type === 'custom' ? customError : ( + response.type === 'ranking-sublist' + || response.type === 'ranking-categorical' + || response.type === 'ranking-pairwise' + ? rankingError + : responseError + ); + const responseWrapperStyle = useMemo(() => { + if (!displayError) { + return responseStyle; + } + + const errorColor = response.required === false ? 'orange' : 'red'; + + return { + ...responseStyle, + border: `1px solid var(--mantine-color-${errorColor}-3)`, + backgroundColor: `var(--mantine-color-${errorColor}-0)`, + borderRadius: 'var(--mantine-radius-md)', + padding: 'var(--mantine-spacing-sm)', + }; + }, [displayError, response.required, responseStyle]); return ( - + {response.type === 'numerical' && ( @@ -167,6 +256,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -176,6 +266,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -185,6 +276,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -194,6 +286,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -203,6 +296,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: number }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -212,6 +306,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} otherValue={otherValue} @@ -222,6 +317,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string[] }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} otherValue={otherValue} @@ -233,6 +329,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: Record }} + error={rankingError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -250,6 +347,7 @@ export function ResponseSwitcher({ disabled={isDisabled} response={response} answer={ans as { value: Record }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> @@ -259,6 +357,7 @@ export function ResponseSwitcher({ response={response} disabled={isDisabled || dontKnowChecked} answer={ans as { value: string }} + error={responseError} index={index} enumerateQuestions={enumerateQuestions} /> diff --git a/src/components/response/SliderInput.tsx b/src/components/response/SliderInput.tsx index ba523cc06d..e1e3edcb11 100644 --- a/src/components/response/SliderInput.tsx +++ b/src/components/response/SliderInput.tsx @@ -4,7 +4,6 @@ import { import { useMemo, useState } from 'react'; import { useMove } from '@mantine/hooks'; import { SliderResponse } from '../../parser/types'; -import { generateErrorMessage } from './utils'; import classes from './css/SliderInput.module.css'; import { InputLabel } from './InputLabel'; import { generateSliderBreakValues } from './sliderBreaks'; @@ -13,12 +12,14 @@ export function SliderInput({ response, disabled, answer, + error, index, enumerateQuestions, }: { response: SliderResponse; disabled: boolean; - answer: object; + answer: { value?: number; onChange?: (value: number) => void }; + error?: string | null; index: number; enumerateQuestions: boolean; }) { @@ -38,7 +39,6 @@ export function SliderInput({ const [min, max] = useMemo(() => [Math.min(...options.map((opt) => opt.value)), Math.max(...options.map((opt) => opt.value))], [options]); const hasLabels = options.some((opt) => opt.label !== ''); - const errorMessage = generateErrorMessage(response, answer); // Numeric label const labelValues = useMemo(() => generateSliderBreakValues(min, max, spacing), [min, max, spacing]); @@ -55,16 +55,16 @@ export function SliderInput({ // Round to nearest step const snappedValue = Math.round((rawValue - min) / stepSize) * stepSize + min; setVal(snappedValue); - (answer as { onChange?: (value: number) => void })?.onChange?.(snappedValue); + answer.onChange?.(snappedValue); }); return ( 0 && } description={secondaryText} - error={errorMessage} + error={error} style={{ '--input-description-size': 'calc(var(--mantine-font-size-md) - calc(0.125rem * var(--mantine-scale)))' }} - errorProps={{ c: required ? 'red' : 'orange' }} + errorProps={{ c: required ? 'red' : 'orange', fz: 'sm', mt: 'xs' }} > {/* Vertical slider for SMEQ style */} {smeqStyle ? ( diff --git a/src/components/response/StringInput.tsx b/src/components/response/StringInput.tsx index d7e9f56243..35833dbdca 100644 --- a/src/components/response/StringInput.tsx +++ b/src/components/response/StringInput.tsx @@ -1,6 +1,5 @@ import { TextInput } from '@mantine/core'; import { ShortTextResponse } from '../../parser/types'; -import { generateErrorMessage } from './utils'; import classes from './css/Input.module.css'; import { InputLabel } from './InputLabel'; @@ -8,12 +7,14 @@ export function StringInput({ response, disabled, answer, + error, index, enumerateQuestions, }: { response: ShortTextResponse; disabled: boolean; answer: { value?: string }; + error?: string | null; index: number; enumerateQuestions: boolean; }) { @@ -36,9 +37,9 @@ export function StringInput({ {...answer} // This is necessary so the component doesnt switch from uncontrolled to controlled, which can cause issues. value={answer.value || ''} - error={generateErrorMessage(response, answer)} + error={error} withErrorStyles={required} - errorProps={{ c: required ? 'red' : 'orange' }} + errorProps={{ c: required ? 'red' : 'orange', fz: 'sm', mt: 'xs' }} classNames={{ input: classes.fixDisabled }} /> ); diff --git a/src/components/response/TextAreaInput.tsx b/src/components/response/TextAreaInput.tsx index 510728cec9..d76b267db1 100644 --- a/src/components/response/TextAreaInput.tsx +++ b/src/components/response/TextAreaInput.tsx @@ -1,6 +1,5 @@ import { Textarea } from '@mantine/core'; import { LongTextResponse } from '../../parser/types'; -import { generateErrorMessage } from './utils'; import classes from './css/Input.module.css'; import { InputLabel } from './InputLabel'; @@ -8,12 +7,14 @@ export function TextAreaInput({ response, disabled, answer, + error, index, enumerateQuestions, }: { response: LongTextResponse; disabled: boolean; answer: { value?: string }; + error?: string | null; index: number; enumerateQuestions: boolean; }) { @@ -36,9 +37,9 @@ export function TextAreaInput({ {...answer} // This is necessary so the component doesnt switch from uncontrolled to controlled, which can cause issues. value={answer.value || ''} - error={generateErrorMessage(response, answer)} + error={error} withErrorStyles={required} - errorProps={{ c: required ? 'red' : 'orange' }} + errorProps={{ c: required ? 'red' : 'orange', fz: 'sm', mt: 'xs' }} classNames={{ input: classes.fixDisabled }} /> ); diff --git a/src/components/response/responseErrors.ts b/src/components/response/responseErrors.ts new file mode 100644 index 0000000000..eee74a0004 --- /dev/null +++ b/src/components/response/responseErrors.ts @@ -0,0 +1,409 @@ +import isEqual from 'lodash.isequal'; +import { + CheckboxResponse, CustomResponse, DropdownResponse, MatrixResponse, NumberOption, NumericalResponse, Response, StringOption, +} from '../../parser/types'; +import { CustomResponseValidate, StoredAnswer } from '../../store/types'; +import { parseStringOptionValue } from '../../utils/stringOptions'; + +export const REQUIRED_ERROR_MESSAGE = 'Please answer this question to continue.'; +export type ResponseIssueType = 'unanswered' | 'invalid'; +export type ResponseIssueSummary = { unansweredCount: number; invalidCount: number }; +export type ResponseValidationIssue = { + type: 'none' | 'unanswered' | 'invalid'; + message?: string; + reason?: 'requiredValueMismatch'; +}; + +export function isEmptyCustomResponseValue(value: StoredAnswer['answer'][string] | undefined): boolean { + if (value === null || value === undefined || value === '') { + return true; + } + + if (Array.isArray(value)) { + return value.length === 0 || value.every((entry) => isEmptyCustomResponseValue(entry)); + } + + if (typeof value === 'object') { + const objectValues = Object.values(value); + return objectValues.length === 0 || objectValues.every((entry) => isEmptyCustomResponseValue(entry)); + } + + return false; +} + +export function checkDropdownResponse(dropdownResponse: DropdownResponse, value: string[]) { + const minNotSelected = dropdownResponse.minSelections && value.length < dropdownResponse.minSelections; + const maxNotSelected = dropdownResponse.maxSelections && value.length > dropdownResponse.maxSelections; + + if (minNotSelected) { + return `Please select at least ${dropdownResponse.minSelections} options`; + } + if (maxNotSelected) { + return `Please select at most ${dropdownResponse.maxSelections} options`; + } + return null; +} + +function checkCheckboxResponse(response: CheckboxResponse, value: string[]) { + const minNotSelected = response.minSelections && value.length < response.minSelections; + const maxNotSelected = response.maxSelections && value.length > response.maxSelections; + + if (minNotSelected && maxNotSelected) { + return `Please select between ${response.minSelections} and ${response.maxSelections} options`; + } + if (minNotSelected) { + return `Please select at least ${response.minSelections} options`; + } + if (maxNotSelected) { + return `Please select at most ${response.maxSelections} options`; + } + return null; +} + +export function checkCheckboxResponseForValidation( + response: CheckboxResponse, + value: string[], + dontKnowChecked = false, +) { + if (response.withDontKnow && dontKnowChecked) { + return null; + } + + return checkCheckboxResponse(response, value); +} + +export function checkNumericalResponse(response: NumericalResponse, value: number) { + const numValue = typeof value === 'string' ? parseFloat(value) : value; + + const { min, max } = response; + + if (min !== undefined && max !== undefined && (numValue < min || numValue > max)) { + return `Please enter a value between ${min} and ${max}`; + } + if (min !== undefined && numValue < min) { + return `Please enter a value of ${min} or greater`; + } + if (max !== undefined && numValue > max) { + return `Please enter a value of ${max} or less`; + } + return null; +} + +export function checkMatrixResponse(response: MatrixResponse, value: Record) { + const expectedQuestionKeys = response.questionOptions.map((entry) => parseStringOptionValue(entry)); + const unanswered = expectedQuestionKeys.some((questionKey) => { + const rowValue = value[questionKey]; + return rowValue === undefined || rowValue === ''; + }); + + if (unanswered) { + return 'Please answer all questions in the matrix to continue.'; + } + + return null; +} + +function hasOtherText(value: StoredAnswer['answer'][string] | undefined) { + return typeof value === 'string' && value.trim().length > 0; +} + +function getRequiredValueMismatchMessage( + response: Response, + options?: (StringOption | NumberOption)[], +) { + const { requiredValue, requiredLabel } = response; + + if (requiredValue == null) { + return null; + } + + if (Array.isArray(requiredValue)) { + 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.`; +} + +export function isOtherSelectionIncomplete( + response: Response, + value: StoredAnswer['answer'][string] | undefined, + values: StoredAnswer['answer'], +) { + if (!('withOther' in response) || !response.withOther) { + return false; + } + + const otherInputValue = values[`${response.id}-other`]; + if (response.type === 'radio') { + return value === 'other' && !hasOtherText(otherInputValue); + } + + if (response.type === 'checkbox') { + return Array.isArray(value) && value.includes('__other') && !hasOtherText(otherInputValue); + } + + return false; +} + +export const usesStandaloneDontKnowField = (response: Response) => !!response.withDontKnow + && response.type !== 'matrix-radio' + && response.type !== 'matrix-checkbox'; + +export const shouldBypassValidationForStandaloneDontKnow = (response: Response, dontKnowChecked: boolean) => ( + usesStandaloneDontKnowField(response) && dontKnowChecked +); + +export function evaluateResponseIssue( + response: Response, + value: StoredAnswer['answer'][string] | undefined, + values: StoredAnswer['answer'], + customValidate?: CustomResponseValidate, + loadError?: string, +): ResponseValidationIssue { + const dontKnowChecked = !!values[`${response.id}-dontKnow`]; + + if (response.type === 'textOnly' || response.type === 'divider' || response.type === 'reactive') { + return { type: 'none' }; + } + + if (response.type === 'custom') { + if (loadError) { + return { type: 'invalid', message: loadError }; + } + + if (shouldBypassValidationForStandaloneDontKnow(response, dontKnowChecked)) { + return { type: 'none' }; + } + + if (response.required === false && isEmptyCustomResponseValue(value)) { + return { type: 'none' }; + } + + if (isEmptyCustomResponseValue(value)) { + return response.required === false ? { type: 'none' } : { type: 'unanswered' }; + } + + const customValue = value as StoredAnswer['answer'][string]; + + if (response.requiredValue !== undefined && !isEqual(customValue, response.requiredValue)) { + return { type: 'invalid', message: 'Incorrect input' }; + } + + if (!customValidate) { + return { type: 'none' }; + } + + const customValidationMessage = customValidate(customValue, values, response); + return customValidationMessage + ? { type: 'invalid', message: customValidationMessage } + : { type: 'none' }; + } + + if (shouldBypassValidationForStandaloneDontKnow(response, dontKnowChecked)) { + return { type: 'none' }; + } + + // Selecting "Other" without filling its companion input is invalid + if (isOtherSelectionIncomplete(response, value, values)) { + return { type: 'invalid', message: 'Please fill in Other to continue.' }; + } + + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + if (response.type === 'matrix-radio' || response.type === 'matrix-checkbox') { + const matrixValue = value as Record; + const hasAnsweredAtLeastOne = Object.values(matrixValue).some((entry) => entry !== ''); + + if (!hasAnsweredAtLeastOne) { + return response.required ? { type: 'unanswered' } : { type: 'none' }; + } + + // A partially answered matrix is invalid + const matrixError = checkMatrixResponse(response, matrixValue); + return matrixError ? { type: 'invalid', message: matrixError } : { type: 'none' }; + } + + if (response.type === 'ranking-sublist' || response.type === 'ranking-categorical' || response.type === 'ranking-pairwise') { + return Object.keys(value).length === 0 && response.required ? { type: 'unanswered' } : { type: 'none' }; + } + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return response.required ? { type: 'unanswered' } : { type: 'none' }; + } + + if (Array.isArray(response.requiredValue)) { + const sortedRequired = [...response.requiredValue].sort(); + const sortedValue = [...value].sort(); + const matches = sortedRequired.length === sortedValue.length + && sortedRequired.every((entry, idx) => entry === sortedValue[idx]); + + // Array inputs are invalid when they do not exactly match the configured requiredValue + if (!matches) { + return { type: 'invalid', reason: 'requiredValueMismatch' }; + } + } + + // Checkbox answers can be present but still invalid if they miss selection-count rule + if (response.type === 'checkbox') { + const checkboxError = checkCheckboxResponseForValidation(response, value as string[], dontKnowChecked); + return checkboxError ? { type: 'invalid', message: checkboxError } : { type: 'none' }; + } + + // Dropdown answers can be present but still invalid if they miss selection-count rules + if (response.type === 'dropdown') { + const dropdownError = checkDropdownResponse(response, value as string[]); + return dropdownError ? { type: 'invalid', message: dropdownError } : { type: 'none' }; + } + + return { type: 'none' }; + } + + if (value === null || value === undefined || value === '') { + return response.required ? { type: 'unanswered' } : { type: 'none' }; + } + + // Single-value inputs (e.g. shortText, longText, numerical, radio, buttons, likert and single-select dropdown) are invalid when they do not match the configured requiredValue. + if (response.requiredValue != null && value.toString() !== response.requiredValue.toString()) { + return { type: 'invalid', reason: 'requiredValueMismatch' }; + } + + // Numerical answers can be present but still invalid when they fall outside the allowed range + if (response.type === 'numerical') { + const numericalError = checkNumericalResponse(response, value as unknown as number); + return numericalError ? { type: 'invalid', message: numericalError } : { type: 'none' }; + } + + return { type: 'none' }; +} + +export function generateCustomResponseErrorMessage( + response: CustomResponse, + value: StoredAnswer['answer'][string], + values: StoredAnswer['answer'], + customValidate?: CustomResponseValidate, + loadError?: string, + options?: { showRequiredErrors?: boolean }, +) { + const issue = evaluateResponseIssue( + response, + value, + { + ...values, + [response.id]: value, + }, + customValidate, + loadError, + ); + + if (issue.type === 'unanswered') { + return options?.showRequiredErrors ? REQUIRED_ERROR_MESSAGE : 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( + response: Response, + answer: { + value?: number | string | string[] | Record; + checked?: string[]; + }, + options?: (StringOption | NumberOption)[], + errorOptions?: { + showRequiredErrors?: boolean; + values?: StoredAnswer['answer']; + }, +) { + const responseValue = Array.isArray(answer.checked) + ? answer.checked + : answer.value; + const values = errorOptions?.values || {}; + const issueValues = responseValue === undefined + ? values + : { + ...values, + [response.id]: responseValue, + }; + const issue = evaluateResponseIssue( + response, + responseValue, + issueValues, + ); + + if (issue.type === 'unanswered') { + return errorOptions?.showRequiredErrors ? REQUIRED_ERROR_MESSAGE : 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 (!errorOptions?.showRequiredErrors) { + return null; + } + + if (issue.reason === 'requiredValueMismatch') { + return getRequiredValueMismatchMessage(response, options); + } + + return issue.message ?? null; + } + + return null; +} + +// Checks whether a response has an issue that should block progression, and if so, what type of issue it is (unanswered vs. invalid) +export function getResponseIssueType( + response: Response, + values: StoredAnswer['answer'], + customValidate?: CustomResponseValidate, + loadError?: string, +): ResponseIssueType | null { + const issue = evaluateResponseIssue( + response, + values[response.id], + values, + customValidate, + loadError, + ); + + if (issue.type === 'none') return null; + // Optional responses with invalid values still display an orange warning but they should not block progression — only required issues gate the Next button + if (issue.type === 'invalid' && response.required === false) return null; + return issue.type; +} + +export function summarizeResponseIssues( + responses: Response[], + values: StoredAnswer['answer'], + customResponseValidators: Record = {}, + customResponseLoadErrors: Record = {}, +): ResponseIssueSummary { + return responses.reduce((summary, response) => { + const issueType = getResponseIssueType( + response, + values, + customResponseValidators[response.id], + customResponseLoadErrors[response.id], + ); + + if (issueType === 'unanswered') { + return { ...summary, unansweredCount: summary.unansweredCount + 1 }; + } + + if (issueType === 'invalid') { + return { ...summary, invalidCount: summary.invalidCount + 1 }; + } + + return summary; + }, { unansweredCount: 0, invalidCount: 0 }); +} diff --git a/src/components/response/stimulusErrors.spec.ts b/src/components/response/stimulusErrors.spec.ts new file mode 100644 index 0000000000..caa5e82a2e --- /dev/null +++ b/src/components/response/stimulusErrors.spec.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { IndividualComponent } from '../../parser/types'; +import { + evaluateStimulusIssue, + generateStimulusErrorMessage, + getInitialStimulusValidation, + getStimulusIssueType, + shouldUseStimulusValidation, +} from './stimulusErrors'; + +describe('getInitialStimulusValidation', () => { + it('marks forced-completion videos as invalid initially', () => { + const component: IndividualComponent = { + type: 'video', + path: 'demo-video/assets/venice.mp4', + forceCompletion: true, + response: [], + }; + + expect(getInitialStimulusValidation(component)).toEqual({ + valid: false, + values: {}, + reason: 'forceCompletion', + }); + }); + + it('marks website components with required reactive responses as invalid initially', () => { + const component: IndividualComponent = { + type: 'website', + path: 'https://example.com', + response: [ + { + id: 'reactive-status', + type: 'reactive', + prompt: 'Status', + }, + ], + }; + + expect(getInitialStimulusValidation(component)).toEqual({ + valid: false, + values: {}, + reason: 'iframePending', + }); + }); + + it('leaves regular questionnaires valid initially', () => { + const component: IndividualComponent = { + type: 'questionnaire', + response: [], + }; + + expect(getInitialStimulusValidation(component)).toEqual({ + valid: true, + values: {}, + }); + }); + + it('does not use stimulus validation for questionnaires', () => { + const component: IndividualComponent = { + type: 'questionnaire', + response: [], + }; + + expect(shouldUseStimulusValidation(component)).toBe(false); + }); +}); + +describe('evaluateStimulusIssue', () => { + it('returns none for components that do not use stimulus validation', () => { + const component: IndividualComponent = { + type: 'questionnaire', + response: [], + }; + + expect(evaluateStimulusIssue(component, { valid: false, values: {}, reason: 'customPending' })).toEqual({ type: 'none' }); + }); + + it('returns none when the validation status is valid', () => { + const component: IndividualComponent = { + type: 'video', + path: 'demo-video/assets/venice.mp4', + forceCompletion: true, + response: [], + }; + + expect(evaluateStimulusIssue(component, { valid: true, values: {} })).toEqual({ type: 'none' }); + }); + + it('returns an unanswered issue with the reason when invalid', () => { + const component: IndividualComponent = { + type: 'video', + path: 'demo-video/assets/venice.mp4', + forceCompletion: true, + response: [], + }; + + expect(evaluateStimulusIssue(component, { valid: false, values: {}, reason: 'forceCompletion' })).toEqual({ + type: 'unanswered', + reason: 'forceCompletion', + message: undefined, + }); + }); +}); + +describe('getStimulusIssueType', () => { + it('returns null when there is no issue', () => { + const component: IndividualComponent = { + type: 'video', + path: 'demo-video/assets/venice.mp4', + forceCompletion: true, + response: [], + }; + + expect(getStimulusIssueType(component, { valid: true, values: {} })).toBeNull(); + }); + + it('returns unanswered when the stimulus is incomplete', () => { + const component: IndividualComponent = { + type: 'react-component', + path: 'demo-form-elements/assets/StimulusGate.tsx', + response: [], + }; + + expect(getStimulusIssueType(component, { valid: false, values: {}, reason: 'customPending' })).toBe('unanswered'); + }); +}); + +describe('generateStimulusErrorMessage', () => { + it('returns null when showStimulusErrors is not set', () => { + const component: IndividualComponent = { + type: 'video', + path: 'demo-video/assets/venice.mp4', + forceCompletion: true, + response: [], + }; + + expect(generateStimulusErrorMessage(component, { + valid: false, + values: {}, + reason: 'forceCompletion', + })).toBeNull(); + }); + + it('returns the forced video message', () => { + const component: IndividualComponent = { + type: 'video', + path: 'demo-video/assets/venice.mp4', + forceCompletion: true, + response: [], + }; + + expect(generateStimulusErrorMessage(component, { + valid: false, + values: {}, + reason: 'forceCompletion', + }, { showStimulusErrors: true })).toBe('Please finish the video to continue.'); + }); + + it('returns the embedded activity message for websites', () => { + const component: IndividualComponent = { + type: 'website', + path: 'https://example.com', + response: [], + }; + + expect(generateStimulusErrorMessage(component, { + valid: false, + values: {}, + reason: 'iframePending', + }, { showStimulusErrors: true })).toBe('Please finish the embedded activity to continue.'); + }); + + it('prefers an explicit controller-provided message', () => { + const component: IndividualComponent = { + type: 'react-component', + path: 'demo-form-elements/assets/StimulusGate.tsx', + response: [], + }; + + expect(generateStimulusErrorMessage(component, { + valid: false, + values: {}, + reason: 'customPending', + message: 'Please click Complete stimulus to continue.', + }, { showStimulusErrors: true })).toBe('Please click Complete stimulus to continue.'); + }); + + it('does not return a stimulus message for questionnaires', () => { + const component: IndividualComponent = { + type: 'questionnaire', + response: [], + }; + + expect(generateStimulusErrorMessage(component, { + valid: false, + values: {}, + reason: 'customPending', + }, { showStimulusErrors: true })).toBeNull(); + }); +}); diff --git a/src/components/response/stimulusErrors.ts b/src/components/response/stimulusErrors.ts new file mode 100644 index 0000000000..76fc76d314 --- /dev/null +++ b/src/components/response/stimulusErrors.ts @@ -0,0 +1,147 @@ +import { IndividualComponent, Response } from '../../parser/types'; +import type { ValidationStatus } from '../../store/types'; + +export type StimulusIssueType = 'unanswered' | 'invalid'; +export type StimulusIssueReason = + | 'forceCompletion' + | 'interactiveRequired' + | 'iframePending' + | 'customPending'; +export type StimulusValidationIssue = { + type: 'none' | StimulusIssueType; + message?: string; + reason?: StimulusIssueReason; +}; + +export function shouldUseStimulusValidation(componentConfig: IndividualComponent) { + return componentConfig.type === 'video' + || componentConfig.type === 'website' + || componentConfig.type === 'react-component' + || componentConfig.type === 'vega'; +} + +export function hasRequiredReactiveResponse(response: Response[] = []) { + return response.some((entry) => entry.type === 'reactive' && entry.required !== false); +} + +export function getInitialStimulusValidation(componentConfig: IndividualComponent): ValidationStatus { + if (!shouldUseStimulusValidation(componentConfig)) { + return { + valid: true, + values: {}, + }; + } + + // Forced-completion videos are invalid until playback reaches the end + if (componentConfig.type === 'video' && componentConfig.forceCompletion) { + return { + valid: false, + values: {}, + reason: 'forceCompletion', + }; + } + + // Interactive stimuli with required reactive answers are invalid until they report completion + if (hasRequiredReactiveResponse(componentConfig.response)) { + return { + valid: false, + values: {}, + reason: componentConfig.type === 'website' ? 'iframePending' : 'interactiveRequired', + }; + } + + return { + valid: true, + values: {}, + }; +} + +export function evaluateStimulusIssue( + componentConfig: IndividualComponent, + validationStatus?: ValidationStatus, +): StimulusValidationIssue { + if (!shouldUseStimulusValidation(componentConfig)) { + return { type: 'none' }; + } + + if (!validationStatus || validationStatus.valid) { + return { type: 'none' }; + } + + return { + type: 'unanswered', + message: validationStatus.message, + reason: validationStatus.reason, + }; +} + +function getDefaultStimulusMessage( + componentConfig: IndividualComponent, + reason?: StimulusIssueReason, +) { + switch (reason) { + // Forced-completion videos stay invalid until the participant finishes playback + case 'forceCompletion': + return 'Please finish the video to continue.'; + // Embedded website/iframe stimuli stay invalid until the embedded activity reports completion + case 'iframePending': + return 'Please finish the embedded activity to continue.'; + // React/Vega-style interactive stimuli stay invalid until they emit their required completion state + case 'interactiveRequired': + return 'Please complete the stimulus interaction to continue.'; + // Custom stimuli can explicitly keep themselves invalid until their own completion logic is met + case 'customPending': + return 'Please complete the stimulus to continue.'; + default: + break; + } + + if (componentConfig.type === 'video' && componentConfig.forceCompletion) { + return 'Please finish the video to continue.'; + } + + if (componentConfig.type === 'website') { + return 'Please finish the embedded activity to continue.'; + } + + if ( + componentConfig.type === 'react-component' + || componentConfig.type === 'vega' + || hasRequiredReactiveResponse(componentConfig.response) + ) { + return 'Please complete the stimulus interaction to continue.'; + } + + return 'Please complete the stimulus to continue.'; +} + +export function generateStimulusErrorMessage( + componentConfig: IndividualComponent, + validationStatus?: ValidationStatus, + options?: { showStimulusErrors?: boolean }, +) { + const issue = evaluateStimulusIssue(componentConfig, validationStatus); + + if (issue.type === 'none') { + return null; + } + + if (!options?.showStimulusErrors) { + return null; + } + + if (issue.message) { + return issue.message; + } + + return getDefaultStimulusMessage(componentConfig, issue.reason); +} + +// Checks whether a stimulus has an issue that should block progression, and if so, what type of issue it is (unanswered vs invalid) +export function getStimulusIssueType( + componentConfig: IndividualComponent, + validationStatus?: ValidationStatus, +): StimulusIssueType | null { + const issue = evaluateStimulusIssue(componentConfig, validationStatus); + return issue.type === 'none' ? null : issue.type; +} diff --git a/src/components/response/stimulusProvenance.ts b/src/components/response/stimulusProvenance.ts new file mode 100644 index 0000000000..1270f3ee20 --- /dev/null +++ b/src/components/response/stimulusProvenance.ts @@ -0,0 +1,48 @@ +import { initializeTrrack, Registry } from '@trrack/core'; +import type { TrrackedProvenance } from '../../store/types'; + +type StimulusReplayState = { + showStimulusErrors?: boolean; + [key: string]: unknown; +}; + +export function getStimulusShowErrorsFromState(state: unknown) { + return typeof state === 'object' + && state !== null + && 'showStimulusErrors' in state + && (state as { showStimulusErrors?: unknown }).showStimulusErrors === true; +} + +export function getStimulusProvenanceState(state: unknown) { + if (typeof state !== 'object' || state === null || !('showStimulusErrors' in state)) { + return state; + } + + const { showStimulusErrors: _showStimulusErrors, ...provenanceState } = state as StimulusReplayState; + return Object.keys(provenanceState).length > 0 ? provenanceState : undefined; +} + +export function appendStimulusShowErrorsToGraph( + provenanceGraph?: TrrackedProvenance, + showStimulusErrors: boolean = true, +) { + const reg = Registry.create(); + const setShowStimulusErrorsAction = reg.register('show-stimulus-errors', ((state: StimulusReplayState, payload: boolean) => { + state.showStimulusErrors = payload; + return state; + }) as never) as unknown as (payload: boolean) => unknown; + + const trrack = initializeTrrack({ + registry: reg, + initialState: { + showStimulusErrors: false, + } as StimulusReplayState, + }); + + if (provenanceGraph) { + trrack.importObject(structuredClone(provenanceGraph)); + } + + trrack.apply('Show stimulus errors', setShowStimulusErrorsAction(showStimulusErrors) as never); + return trrack.graph.backend; +} diff --git a/src/components/response/utils.spec.ts b/src/components/response/utils.spec.ts index 8b63467795..5a2204202e 100644 --- a/src/components/response/utils.spec.ts +++ b/src/components/response/utils.spec.ts @@ -6,15 +6,19 @@ import type { } from '../../parser/types'; import type { CustomResponseValidate } from '../../store/types'; import { - checkCheckboxResponseForValidation, - generateCustomResponseErrorMessage, - generateErrorMessage, generateInitFields, generateValidation, mergeReactiveAnswers, normalizeCheckboxDontKnowValue, - shouldBypassValidationForStandaloneDontKnow, } from './utils'; +import { + REQUIRED_ERROR_MESSAGE, + checkCheckboxResponseForValidation, + generateCustomResponseErrorMessage, + generateErrorMessage, + shouldBypassValidationForStandaloneDontKnow, + summarizeResponseIssues, +} from './responseErrors'; describe('generateInitFields', () => { const originalWindow = globalThis.window; @@ -229,7 +233,7 @@ describe('generateValidation custom', () => { const validation = generateValidation([response], { [response.id]: customValidate }); const error = validation[response.id]({}, {}); - expect(error).toBe('Empty input'); + expect(error).toBe(REQUIRED_ERROR_MESSAGE); }); it('treats nested empty string structures as missing required input', () => { @@ -243,7 +247,7 @@ describe('generateValidation custom', () => { tags: ['', ''], }, {}); - expect(error).toBe('Empty input'); + expect(error).toBe(REQUIRED_ERROR_MESSAGE); }); it('does not treat 0 or false as empty custom values', () => { @@ -324,12 +328,24 @@ describe('generateCustomResponseErrorMessage', () => { expect(generateCustomResponseErrorMessage(response, null, {}, customValidate)).toBeNull(); }); - it('shows validation feedback once the response is partially filled', () => { + it('shows the required message for untouched required custom responses after submit', () => { + 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 and errors are revealed', () => { + expect(generateCustomResponseErrorMessage(response, { + chartType: 'Bar', + confidence: null, + rationale: '', + }, {}, 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)).toBe('Set confidence to at least 70 to continue.'); + }, {}, customValidate)).toBeNull(); }); it('shows no feedback once the current value is valid', () => { @@ -365,6 +381,40 @@ describe('mergeReactiveAnswers', () => { }); describe('generateErrorMessage checkbox', () => { + it('treats checkbox other without text as invalid', () => { + const checkboxResponse: Response = { + id: 'checkbox-response', + prompt: 'Checkbox response', + type: 'checkbox', + required: true, + options: ['Option 1', 'Option 2'], + withOther: true, + }; + + const error = generateErrorMessage(checkboxResponse, { + value: ['__other'], + }, undefined, { showRequiredErrors: true, values: { 'checkbox-response-other': '' } }); + + expect(error).toBe('Please fill in Other to continue.'); + }); + + it('removes required error when dont-know is checked', () => { + const checkboxResponse: Response = { + id: 'checkbox-response', + prompt: 'Checkbox response', + type: 'checkbox', + required: true, + options: ['Option 1', 'Option 2', 'Option 3'], + withDontKnow: true, + }; + + const error = generateErrorMessage(checkboxResponse, { + value: [], + }, undefined, { showRequiredErrors: true, values: { 'checkbox-response-dontKnow': true } }); + + expect(error).toBeNull(); + }); + it('validates checkbox selections when checkbox group value is an array', () => { const checkboxResponse: Response = { id: 'checkbox-response', @@ -375,7 +425,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'); }); @@ -391,12 +441,69 @@ describe('generateErrorMessage checkbox', () => { withDontKnow: true, }; - const error = generateErrorMessage(checkboxResponse, { value: [], dontKnowChecked: true }); + const error = generateErrorMessage(checkboxResponse, { value: [] }, undefined, { values: { 'checkbox-response-dontKnow': true } }); expect(error).toBeNull(); }); }); +describe('generateErrorMessage radio', () => { + it('treats radio other without text as invalid', () => { + const radioResponse: Response = { + id: 'radio-response', + prompt: 'Radio response', + type: 'radio', + required: true, + options: ['Option 1', 'Option 2'], + withOther: true, + }; + + const error = generateErrorMessage(radioResponse, { + value: 'other', + }, undefined, { showRequiredErrors: true, values: { 'radio-response-other': '' } }); + + expect(error).toBe('Please fill in Other to continue.'); + }); +}); + +describe('generateValidation other inputs', () => { + it('treats radio other without text as empty input', () => { + const response: Response = { + id: 'radio-response', + prompt: 'Radio response', + type: 'radio', + required: true, + options: ['Option 1', 'Option 2'], + withOther: true, + }; + + const validation = generateValidation([response]); + + expect(validation[response.id]('other', { + [response.id]: 'other', + [`${response.id}-other`]: '', + })).toBe(REQUIRED_ERROR_MESSAGE); + }); + + it('treats checkbox other without text as empty input', () => { + const response: Response = { + id: 'checkbox-response', + prompt: 'Checkbox response', + type: 'checkbox', + required: true, + options: ['Option 1', 'Option 2'], + withOther: true, + }; + + const validation = generateValidation([response]); + + expect(validation[response.id](['__other'], { + [response.id]: ['__other'], + [`${response.id}-other`]: '', + })).toBe(REQUIRED_ERROR_MESSAGE); + }); +}); + describe('checkCheckboxResponseForValidation', () => { it('bypasses checkbox selection-count validation when dont-know is checked', () => { const checkboxResponse: CheckboxResponse = { @@ -463,8 +570,7 @@ describe('generateErrorMessage requiredValue with dont-know', () => { const error = generateErrorMessage(numericalResponse, { value: '', - dontKnowChecked: true, - }); + }, undefined, { values: { 'required-value-response-dontKnow': true } }); expect(error).toBeNull(); }); @@ -496,10 +602,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.'); }); @@ -512,3 +618,64 @@ describe('generateErrorMessage matrix', () => { expect(error).toBeNull(); }); }); + +describe('summarizeResponseIssues', () => { + it('counts unanswered and invalid responses separately', () => { + const responses: Response[] = [ + { + id: 'missing-text', + prompt: 'Missing text', + type: 'shortText', + required: true, + }, + { + id: 'invalid-number', + prompt: 'Invalid number', + type: 'numerical', + required: true, + min: 0, + max: 10, + }, + { + id: 'invalid-radio-other', + prompt: 'Invalid other', + type: 'radio', + required: true, + options: ['A', 'B'], + withOther: true, + }, + ]; + + const summary = summarizeResponseIssues(responses, { + 'missing-text': '', + 'invalid-number': 99, + 'invalid-radio-other': 'other', + 'invalid-radio-other-other': '', + }); + + expect(summary).toEqual({ + unansweredCount: 1, + invalidCount: 2, + }); + }); + + it('treats untouched required matrix responses as unanswered', () => { + const responses: Response[] = [{ + id: 'matrix-response', + prompt: 'Matrix', + type: 'matrix-radio', + required: true, + answerOptions: ['A', 'B'], + questionOptions: ['Q1', 'Q2'], + }]; + + const summary = summarizeResponseIssues(responses, { + 'matrix-response': { Q1: '', Q2: '' }, + }); + + expect(summary).toEqual({ + unansweredCount: 1, + invalidCount: 0, + }); + }); +}); diff --git a/src/components/response/utils.ts b/src/components/response/utils.ts index ecd90c7801..15a08eb93e 100644 --- a/src/components/response/utils.ts +++ b/src/components/response/utils.ts @@ -2,119 +2,31 @@ import { useForm } from '@mantine/form'; import { useEffect, useState } from 'react'; import isEqual from 'lodash.isequal'; import { - CheckboxResponse, CustomResponse, DropdownResponse, JsonValue, MatrixResponse, NumberOption, NumericalResponse, RadioResponse, Response, StringOption, + CheckboxResponse, CustomResponse, JsonValue, RadioResponse, Response, } from '../../parser/types'; import { CustomResponseValidate, StoredAnswer } from '../../store/types'; import { parseStringOptionValue } from '../../utils/stringOptions'; +import { + checkCheckboxResponseForValidation, + checkDropdownResponse, + checkMatrixResponse, + checkNumericalResponse, + isEmptyCustomResponseValue, + isOtherSelectionIncomplete, + REQUIRED_ERROR_MESSAGE, + shouldBypassValidationForStandaloneDontKnow, + usesStandaloneDontKnowField, +} from './responseErrors'; type ResponseDefault = JsonValue; type ResponseWithDefault = Response & { default?: ResponseDefault }; export const DONT_KNOW_DEFAULT_VALUE = "I don't know"; -function isEmptyCustomResponseValue(value: JsonValue | undefined): boolean { - if (value === null || value === undefined || value === '') { - return true; - } - - if (Array.isArray(value)) { - return value.length === 0 || value.every((entry) => isEmptyCustomResponseValue(entry)); - } - - if (typeof value === 'object') { - const objectValues = Object.values(value); - return objectValues.length === 0 || objectValues.every((entry) => isEmptyCustomResponseValue(entry)); - } - - return false; -} - export function normalizeCheckboxDontKnowValue(value: string[]) { return value.includes(DONT_KNOW_DEFAULT_VALUE) ? [] : value; } -function checkDropdownResponse(dropdownResponse: DropdownResponse, value: string[]) { - // Check max and min selections - const minNotSelected = dropdownResponse.minSelections && value.length < dropdownResponse.minSelections; - const maxNotSelected = dropdownResponse.maxSelections && value.length > dropdownResponse.maxSelections; - - if (minNotSelected) { - return `Please select at least ${dropdownResponse.minSelections} options`; - } - if (maxNotSelected) { - return `Please select at most ${dropdownResponse.maxSelections} options`; - } - return null; -} - -function checkCheckboxResponse(response: CheckboxResponse, value: string[]) { - const minNotSelected = response.minSelections && value.length < response.minSelections; - const maxNotSelected = response.maxSelections && value.length > response.maxSelections; - - if (minNotSelected && maxNotSelected) { - return `Please select between ${response.minSelections} and ${response.maxSelections} options`; - } - if (minNotSelected) { - return `Please select at least ${response.minSelections} options`; - } - if (maxNotSelected) { - return `Please select at most ${response.maxSelections} options`; - } - return null; -} - -export function checkCheckboxResponseForValidation( - response: CheckboxResponse, - value: string[], - dontKnowChecked = false, -) { - if (response.withDontKnow && dontKnowChecked) { - return null; - } - - return checkCheckboxResponse(response, value); -} - -function checkNumericalResponse(response: NumericalResponse, value: number) { - const numValue = typeof value === 'string' ? parseFloat(value) : value; - - const { min, max } = response; - - if (min !== undefined && max !== undefined && (numValue < min || numValue > max)) { - return `Please enter a value between ${min} and ${max}`; - } - if (min !== undefined && numValue < min) { - return `Please enter a value of ${min} or greater`; - } - if (max !== undefined && numValue > max) { - return `Please enter a value of ${max} or less`; - } - return null; -} - -function checkMatrixResponse(response: MatrixResponse, value: Record) { - const expectedQuestionKeys = response.questionOptions.map((entry) => parseStringOptionValue(entry)); - const unanswered = expectedQuestionKeys.some((questionKey) => { - const rowValue = value[questionKey]; - return rowValue === undefined || rowValue === ''; - }); - - if (unanswered) { - return 'Please answer all questions in the matrix to continue.'; - } - - return null; -} - -function checkMatrixResponseForMessage(response: MatrixResponse, value: Record) { - const hasAnsweredAtLeastOne = Object.values(value).some((val) => val !== ''); - if (!hasAnsweredAtLeastOne) { - return null; - } - - return checkMatrixResponse(response, value); -} - const getQueryParameters = () => { if (typeof window === 'undefined') { return new URLSearchParams(''); @@ -123,15 +35,6 @@ const getQueryParameters = () => { return new URLSearchParams(window.location.search); }; -// Matrix questions with "Don't know" option require a separate field to properly handle the "Don't know" state -export const usesStandaloneDontKnowField = (response: Response) => !!response.withDontKnow - && response.type !== 'matrix-radio' - && response.type !== 'matrix-checkbox'; - -export const shouldBypassValidationForStandaloneDontKnow = (response: Response, dontKnowChecked: boolean) => ( - usesStandaloneDontKnowField(response) && dontKnowChecked -); - export const getDefaultFieldValue = (response: Response) => { const responseDefault = (response as ResponseWithDefault).default; if (!Object.hasOwn(response, 'default') || responseDefault === undefined) { @@ -270,7 +173,7 @@ function validateCustomResponse( } if (response.required !== false && isEmptyCustomResponseValue(value)) { - return 'Empty input'; + return REQUIRED_ERROR_MESSAGE; } if (response.requiredValue !== undefined && !isEmptyCustomResponseValue(value) && !isEqual(value, response.requiredValue)) { @@ -288,40 +191,6 @@ function validateCustomResponse( return customValidate(value, values, response); } -export function generateCustomResponseErrorMessage( - response: CustomResponse, - value: StoredAnswer['answer'][string], - values: StoredAnswer['answer'], - customValidate?: CustomResponseValidate, - loadError?: string, -) { - if (loadError) { - return loadError; - } - - if (shouldBypassValidationForStandaloneDontKnow(response, !!values[`${response.id}-dontKnow`])) { - return null; - } - - if (response.required === false && isEmptyCustomResponseValue(value)) { - return null; - } - - if (isEmptyCustomResponseValue(value)) { - return null; - } - - if (response.requiredValue !== undefined && !isEqual(value, response.requiredValue)) { - return 'Incorrect input'; - } - - if (!customValidate) { - return null; - } - - return customValidate(value, values, response); -} - export const generateValidation = ( responses: Response[], customResponseValidators: Record = {}, @@ -347,14 +216,18 @@ export const generateValidation = ( return null; } + if (isOtherSelectionIncomplete(response, value, values)) { + return REQUIRED_ERROR_MESSAGE; + } + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { if (response.type === 'matrix-checkbox' || response.type === 'matrix-radio') { return checkMatrixResponse(response, value as Record); } if (response.type === 'ranking-sublist' || response.type === 'ranking-categorical' || response.type === 'ranking-pairwise') { - return Object.keys(value).length > 0 ? null : 'Empty Input'; + return Object.keys(value).length > 0 ? null : REQUIRED_ERROR_MESSAGE; } - return Object.values(value).every((val) => val !== '') ? null : 'Empty Input'; + return Object.values(value).every((val) => val !== '') ? null : REQUIRED_ERROR_MESSAGE; } if (Array.isArray(value)) { if (response.requiredValue != null && !Array.isArray(response.requiredValue)) { @@ -375,7 +248,7 @@ export const generateValidation = ( if (response.type === 'dropdown') { return checkDropdownResponse(response, value as string[]); } - return value.length === 0 ? 'Empty input' : null; + return value.length === 0 ? REQUIRED_ERROR_MESSAGE : null; } if (response.required && response.requiredValue != null && value != null) { @@ -383,15 +256,14 @@ export const generateValidation = ( } if (response.required) { if ((value === null || value === undefined || value === '') && !values[`${response.id}-dontKnow`]) { - return 'Empty input'; + return REQUIRED_ERROR_MESSAGE; } if (response.type === 'numerical') { return checkNumericalResponse(response, value as unknown as number); - // return 'Empty input'; } } - return value === null ? 'Empty input' : null; + return value === null ? REQUIRED_ERROR_MESSAGE : null; }, }; } @@ -422,36 +294,3 @@ export function useAnswerField( return answerField; } - -export function generateErrorMessage( - response: Response, - answer: { value?: number | string | string[] | Record; checked?: string[]; dontKnowChecked?: boolean }, - options?: (StringOption | NumberOption)[], -) { - const { requiredValue, requiredLabel } = response; - - if (shouldBypassValidationForStandaloneDontKnow(response, !!answer.dontKnowChecked)) { - return null; - } - - let error: string | null = ''; - const checkboxValues = Array.isArray(answer.checked) - ? answer.checked - : (Array.isArray(answer.value) ? answer.value : undefined); - - if (checkboxValues && Array.isArray(requiredValue)) { - error = requiredValue && [...requiredValue].sort().toString() !== [...checkboxValues].sort().toString() ? `Please ${options ? 'select' : 'enter'} ${requiredLabel || requiredValue.toString()} to continue.` : null; - } else if (checkboxValues && response.required && response.type === 'checkbox') { - error = checkCheckboxResponseForValidation(response, checkboxValues, !!answer.dontKnowChecked); - } else if (answer.value && response.type === 'dropdown') { - error = checkDropdownResponse(response, answer.value as string[]); - } else if (answer.value && typeof answer.value === 'number' && response.type === 'numerical' && checkNumericalResponse(response, answer.value)) { - error = checkNumericalResponse(response, answer.value); - } else if (answer.value && typeof answer.value === 'object' && !Array.isArray(answer.value) && (response.type === 'matrix-radio' || response.type === 'matrix-checkbox')) { - return checkMatrixResponseForMessage(response, answer.value); - } else { - error = answer.value && requiredValue && requiredValue.toString() !== answer.value.toString() ? `Please ${options ? 'select' : 'enter'} ${requiredLabel || (options ? options.find((opt) => opt.value === requiredValue)?.label : requiredValue.toString())} to continue.` : null; - } - - return error; -} diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index 148e4f79cd..daa93e5985 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -37,6 +37,8 @@ import { ScreenRecordingReplay } from '../components/screenRecording/ScreenRecor import { decryptIndex, encryptIndex } from '../utils/encryptDecryptIndex'; import { useRecordingConfig } from '../store/hooks/useRecordingConfig'; import { getComponentContainerStyle } from '../utils/componentStyle'; +import { generateStimulusErrorMessage } from '../components/response/stimulusErrors'; +import { getStimulusProvenanceState, getStimulusShowErrorsFromState } from '../components/response/stimulusProvenance'; // current active stimuli presented to the user export function ComponentController() { @@ -44,6 +46,7 @@ export function ComponentController() { const studyConfig = useStudyConfig(); const currentStep = useCurrentStep(); const currentComponent = useCurrentComponent(); + const currentIdentifier = useCurrentIdentifier(); const studyId = useStudyId(); const stepConfig = studyConfig.components[currentComponent]; @@ -54,7 +57,8 @@ export function ComponentController() { const { setAnalysisCanPlayScreenRecording } = useStoreActions(); - const analysisProvState = useStoreSelector((state) => state.analysisProvState.stimulus); + const analysisStimulusProvState = useStoreSelector((state) => state.analysisProvState.stimulus); + const stimulusValidation = useStoreSelector((state) => state.trialValidation[currentIdentifier]?.stimulus); const navigate = useNavigate(); @@ -64,6 +68,7 @@ export function ComponentController() { // If we have a trial, use that config to render the right component else use the step const status = useStoredAnswer(); + const currentStimulusSubmitAttempted = useStoreSelector((state) => state.stimulusSubmitAttempted[currentIdentifier]); const sequence = useStoreSelector((state) => state.sequence); const modes = useStoreSelector((state) => state.modes); @@ -121,7 +126,6 @@ export function ComponentController() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStep, storageEngine, sequence]); - const currentIdentifier = useCurrentIdentifier(); const currentConfig = useMemo(() => { const toReturn = currentComponent && currentComponent !== 'end' && !currentComponent.startsWith('__') && studyComponentToIndividualComponent(stepConfig, studyConfig) as IndividualComponent; if (typeof toReturn === 'object') { @@ -138,6 +142,61 @@ export function ComponentController() { } return toReturn as unknown as IndividualComponent; }, [answers, currentComponent, currentIdentifier, stepConfig, studyConfig]); + const hasAnalysisStimulusProvenance = useMemo( + () => analysisStimulusProvState !== undefined, + [analysisStimulusProvState], + ); + const analysisStimulusErrors = useMemo( + () => getStimulusShowErrorsFromState(analysisStimulusProvState), + [analysisStimulusProvState], + ); + const showStimulusErrors = useMemo( + () => (isAnalysis + ? (hasAnalysisStimulusProvenance ? analysisStimulusErrors : false) + : !!currentStimulusSubmitAttempted), + [ + analysisStimulusErrors, + currentStimulusSubmitAttempted, + hasAnalysisStimulusProvenance, + isAnalysis, + ], + ); + const stimulusProvState = useMemo( + () => getStimulusProvenanceState(analysisStimulusProvState), + [analysisStimulusProvState], + ); + const stimulusMessage = useMemo( + () => { + if (isAnalysis) { + return null; + } + return currentConfig + ? generateStimulusErrorMessage(currentConfig, stimulusValidation, { showStimulusErrors }) + : null; + }, + [currentConfig, isAnalysis, showStimulusErrors, stimulusValidation], + ); + const hasStimulusIssue = useMemo( + () => !!stimulusMessage, + [stimulusMessage], + ); + const componentContainerStyle = useMemo( + () => (currentConfig ? getComponentContainerStyle(currentConfig.type, currentConfig.style) : {}), + [currentConfig], + ); + const stimulusContainerStyle = useMemo(() => { + if (!hasStimulusIssue) { + return componentContainerStyle; + } + + return { + ...componentContainerStyle, + border: '1px solid var(--mantine-color-red-3)', + backgroundColor: 'var(--mantine-color-red-0)', + borderRadius: 'var(--mantine-radius-md)', + padding: 'var(--mantine-spacing-sm)', + }; + }, [componentContainerStyle, hasStimulusIssue]); useEffect(() => { // Assume that screen recording video exists. @@ -212,7 +271,6 @@ export function ComponentController() { const instruction = currentConfig?.instruction || ''; const instructionLocation = currentConfig.instructionLocation ?? studyConfig.uiConfig.instructionLocation ?? 'sidebar'; const instructionInSideBar = instructionLocation === 'sidebar'; - const componentContainerStyle = getComponentContainerStyle(currentConfig.type, currentConfig.style); if (studyHasScreenRecording && isAnalysis && analysisCanPlayScreenRecording) return ; @@ -228,18 +286,23 @@ export function ComponentController() { Loading...}> <> {currentConfig.type === 'markdown' && } - {currentConfig.type === 'website' && } + {currentConfig.type === 'website' && } {currentConfig.type === 'image' && } - {currentConfig.type === 'react-component' && } - {currentConfig.type === 'vega' && } + {currentConfig.type === 'react-component' && } + {currentConfig.type === 'vega' && } {currentConfig.type === 'video' && } + {hasStimulusIssue && ( + + {stimulusMessage} + + )} {(instructionLocation === 'belowStimulus' || (instructionLocation === undefined && !instructionInSideBar)) && } 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 state.trialValidation[identifier]?.stimulus); const ref = useRef(null); + const stimulusValidationRef = useRef(stimulusValidation); + + useEffect(() => { + stimulusValidationRef.current = stimulusValidation; + }, [stimulusValidation]); const iframeId = useMemo( () => (crypto.randomUUID ? crypto.randomUUID() : `testID-${Date.now()}`), @@ -67,6 +75,11 @@ export function IframeController({ currentConfig, provState, answers }: { curren case `${PREFIX}/READY`: break; case `${PREFIX}/ANSWERS`: + if (isAnalysis) return; + stimulusValidationRef.current = { + valid: true, + values: data.message, + }; storeDispatch(setReactiveAnswers(data.message)); storeDispatch(updateResponseBlockValidation({ location: 'stimulus', @@ -75,15 +88,20 @@ export function IframeController({ currentConfig, provState, answers }: { curren values: data.message, })); break; - case `${PREFIX}/PROVENANCE`: + case `${PREFIX}/PROVENANCE`: { + if (isAnalysis) return; + const currentStimulusValidation = stimulusValidationRef.current; storeDispatch(updateResponseBlockValidation({ location: 'stimulus', identifier, values: {}, - status: true, + status: currentStimulusValidation?.valid ?? true, provenanceGraph: data.message, + reason: currentStimulusValidation?.reason, + message: currentStimulusValidation?.message, })); break; + } default: break; } @@ -93,7 +111,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 (