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 (