diff --git a/.github/workflows/lint_build.yaml b/.github/workflows/lint_build.yaml index d336dc8b0d..4ce7b79600 100644 --- a/.github/workflows/lint_build.yaml +++ b/.github/workflows/lint_build.yaml @@ -11,4 +11,20 @@ on: jobs: lint: - uses: revisit-studies/.github/.github/workflows/lint.yaml@main + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'yarn' + + - name: Install yarn packages (from cache if available) + run: yarn install --frozen-lockfile + + - name: Run linter + run: yarn lint + + - name: Build application + run: yarn build diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 0fe5c2a02c..2783d36dad 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -60,6 +60,34 @@ function findMatchingStrings(arr1: string[], arr2: string[]): string[] { return matches; } +function collectResponseValuesFromTrialValidation( + validationForStep?: Partial>, +): StoredAnswer['answer'] { + if (!validationForStep) { + return {}; + } + + return Object.values(validationForStep).reduce((acc, curr) => { + if (curr && Object.hasOwn(curr, 'values')) { + return { ...acc, ...curr.values }; + } + return acc; + }, {} as StoredAnswer['answer']); +} + +function collectResponseValuesFromAnalysisState( + analysisProvState: Partial>, + status?: StoredAnswer, +): StoredAnswer['answer'] { + return (['aboveStimulus', 'belowStimulus', 'sidebar'] as ResponseBlockLocation[]).reduce((acc, responseLocation) => { + const locationProv = analysisProvState[responseLocation]; + return { + ...acc, + ...(locationProv?.form || {}), + }; + }, { ...(status?.answer || {}) } as StoredAnswer['answer']); +} + export function ResponseBlock({ config, location, @@ -231,26 +259,14 @@ export function ResponseBlock({ [customResponseModules], ); const combinedLiveValues = useMemo(() => { - const validationForStep = trialValidation[identifier]; - if (!validationForStep) { - return {}; - } - - return Object.values(validationForStep).reduce((acc, curr) => { - if (Object.hasOwn(curr, 'values')) { - return { ...acc, ...(curr as ValidationStatus).values }; - } - return acc; - }, {}) as StoredAnswer['answer']; + const validationForStep = trialValidation[identifier] as Partial> | undefined; + return collectResponseValuesFromTrialValidation(validationForStep); }, [identifier, trialValidation]); const combinedAnalysisValues = useMemo( - () => ['aboveStimulus', 'belowStimulus', 'sidebar'].reduce((acc, responseLocation) => { - const locationProv = analysisProvState[responseLocation as ResponseBlockLocation] as FormElementProvenance | undefined; - return { - ...acc, - ...(locationProv?.form || {}), - }; - }, { ...(status?.answer || {}) } as StoredAnswer['answer']), + () => collectResponseValuesFromAnalysisState( + analysisProvState as Partial>, + status, + ), [analysisProvState, status], ); const combinedValues = useMemo( @@ -498,13 +514,8 @@ export function ResponseBlock({ const newAttemptsUsed = attemptsUsed + 1; setAttemptsUsed(newAttemptsUsed); - const trialValidationCopy = structuredClone(trialValidation[identifier]); - const allAnswers = (trialValidationCopy ? Object.values(trialValidationCopy).reduce((acc, curr) => { - if (Object.hasOwn(curr, 'values')) { - return { ...acc, ...(curr as ValidationStatus).values }; - } - return acc; - }, {}) : {}) as StoredAnswer['answer']; + const trialValidationCopy = structuredClone(trialValidation[identifier]) as Partial> | undefined; + const allAnswers = collectResponseValuesFromTrialValidation(trialValidationCopy); const correctAnswers = Object.fromEntries( (config?.correctAnswer ?? []).map((configCorrectAnswer) => { diff --git a/src/components/response/responseErrors.ts b/src/components/response/responseErrors.ts index eee74a0004..b9360c78cb 100644 --- a/src/components/response/responseErrors.ts +++ b/src/components/response/responseErrors.ts @@ -1,112 +1,42 @@ -import isEqual from 'lodash.isequal'; import { - CheckboxResponse, CustomResponse, DropdownResponse, MatrixResponse, NumberOption, NumericalResponse, Response, StringOption, + CustomResponse, NumberOption, Response, StringOption, } from '../../parser/types'; import { CustomResponseValidate, StoredAnswer } from '../../store/types'; -import { parseStringOptionValue } from '../../utils/stringOptions'; +import { + isOtherSelectionIncomplete, + REQUIRED_ERROR_MESSAGE, + validateResponse, +} from './responseValidation'; +import type { + ResponseIssueSummary, + ResponseIssueType, + ResponseValidationOptions, +} from './responseValidation'; + +export { + checkCheckboxResponseForValidation, + checkDropdownResponse, + checkMatrixResponse, + checkNumericalResponse, + isEmptyCustomResponseValue, + isOtherSelectionIncomplete, + REQUIRED_ERROR_MESSAGE, + shouldBypassValidationForStandaloneDontKnow, + usesStandaloneDontKnowField, +} from './responseValidation'; + +export type { + ResponseIssueSummary, + ResponseIssueType, + ResponseValidationResult, +} from './responseValidation'; -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)[], @@ -124,35 +54,6 @@ function getRequiredValueMismatchMessage( 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, @@ -160,121 +61,12 @@ export function evaluateResponseIssue( 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' }; + const result = validateResponse(response, value, values, { customValidate, loadError }); + return { + type: result.issueType, + message: result.message, + reason: result.reason, + }; } export function generateCustomResponseErrorMessage( @@ -285,33 +77,57 @@ export function generateCustomResponseErrorMessage( loadError?: string, options?: { showRequiredErrors?: boolean }, ) { - const issue = evaluateResponseIssue( + const result = validateResponse( response, value, { ...values, [response.id]: value, }, - customValidate, - loadError, + { customValidate, loadError }, ); - if (issue.type === 'unanswered') { + if (result.issueType === '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 (result.issueType === 'invalid') { if (!options?.showRequiredErrors) { return null; } - return issue.message ?? null; + return result.message ?? null; } return null; } +export function generateInvalidResponseErrorMessage( + response: Response, + value: StoredAnswer['answer'][string], + values: StoredAnswer['answer'], + options: ResponseValidationOptions = {}, +): string | null { + const result = validateResponse(response, value, values, options); + + if (result.issueType === 'none') { + return null; + } + + if (result.issueType === 'unanswered') { + return REQUIRED_ERROR_MESSAGE; + } + + if (response.type === 'custom' && options.loadError) { + return result.message ?? null; + } + + if (isOtherSelectionIncomplete(response, value, values)) { + return REQUIRED_ERROR_MESSAGE; + } + + return result.blocksProgression ? result.message ?? 'Incorrect input' : null; +} + export function generateErrorMessage( response: Response, answer: { @@ -334,52 +150,41 @@ export function generateErrorMessage( ...values, [response.id]: responseValue, }; - const issue = evaluateResponseIssue( - response, - responseValue, - issueValues, - ); + const result = validateResponse(response, responseValue, issueValues); - if (issue.type === 'unanswered') { + if (result.issueType === '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 (result.issueType === 'invalid') { if (!errorOptions?.showRequiredErrors) { return null; } - if (issue.reason === 'requiredValueMismatch') { + if (result.reason === 'requiredValueMismatch') { return getRequiredValueMismatchMessage(response, options); } - return issue.message ?? null; + return result.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( + const result = validateResponse( response, values[response.id], values, - customValidate, - loadError, + { 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; + return result.blocksProgression ? result.issueType as ResponseIssueType : null; } export function summarizeResponseIssues( diff --git a/src/components/response/responseValidation.ts b/src/components/response/responseValidation.ts new file mode 100644 index 0000000000..c31936c65c --- /dev/null +++ b/src/components/response/responseValidation.ts @@ -0,0 +1,293 @@ +import isEqual from 'lodash.isequal'; +import { + CheckboxResponse, + DropdownResponse, + MatrixResponse, + NumericalResponse, + Response, +} 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 ResponseValidationIssueType = 'none' | ResponseIssueType; +export type ResponseValidationResult = { + valid: boolean; + issueType: ResponseValidationIssueType; + message?: string; + reason?: 'requiredValueMismatch'; + blocksProgression: boolean; +}; + +export type ResponseValidationOptions = { + customValidate?: CustomResponseValidate; + loadError?: string; +}; + +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; +} + +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 +); + +function createValidationResult( + response: Response, + issueType: ResponseValidationIssueType, + options: Pick = {}, +): ResponseValidationResult { + return { + valid: issueType === 'none', + issueType, + ...options, + blocksProgression: issueType !== 'none' && response.required !== false, + }; +} + +export function validateResponse( + response: Response, + value: StoredAnswer['answer'][string] | undefined, + values: StoredAnswer['answer'], + options: ResponseValidationOptions = {}, +): ResponseValidationResult { + const dontKnowChecked = !!values[`${response.id}-dontKnow`]; + + if (response.type === 'textOnly' || response.type === 'divider' || response.type === 'reactive') { + return createValidationResult(response, 'none'); + } + + if (response.type === 'custom') { + const { customValidate, loadError } = options; + + if (loadError) { + return createValidationResult(response, 'invalid', { message: loadError }); + } + + if (shouldBypassValidationForStandaloneDontKnow(response, dontKnowChecked)) { + return createValidationResult(response, 'none'); + } + + if (response.required === false && isEmptyCustomResponseValue(value)) { + return createValidationResult(response, 'none'); + } + + if (isEmptyCustomResponseValue(value)) { + return createValidationResult(response, response.required === false ? 'none' : 'unanswered'); + } + + const customValue = value as StoredAnswer['answer'][string]; + + if (response.requiredValue !== undefined && !isEqual(customValue, response.requiredValue)) { + return createValidationResult(response, 'invalid', { message: 'Incorrect input' }); + } + + if (!customValidate) { + return createValidationResult(response, 'none'); + } + + const customValidationMessage = customValidate(customValue, values, response); + return customValidationMessage + ? createValidationResult(response, 'invalid', { message: customValidationMessage }) + : createValidationResult(response, 'none'); + } + + if (shouldBypassValidationForStandaloneDontKnow(response, dontKnowChecked)) { + return createValidationResult(response, 'none'); + } + + if (isOtherSelectionIncomplete(response, value, values)) { + return createValidationResult(response, '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 createValidationResult(response, response.required ? 'unanswered' : 'none'); + } + + const matrixError = checkMatrixResponse(response, matrixValue); + return matrixError + ? createValidationResult(response, 'invalid', { message: matrixError }) + : createValidationResult(response, 'none'); + } + + if (response.type === 'ranking-sublist' || response.type === 'ranking-categorical' || response.type === 'ranking-pairwise') { + return createValidationResult(response, Object.keys(value).length === 0 && response.required ? 'unanswered' : 'none'); + } + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return createValidationResult(response, response.required ? 'unanswered' : 'none'); + } + + if (response.requiredValue != null && !Array.isArray(response.requiredValue)) { + return createValidationResult(response, 'invalid', { message: 'Incorrect required value. Contact study administrator.' }); + } + + 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]); + + if (!matches) { + return createValidationResult(response, 'invalid', { reason: 'requiredValueMismatch' }); + } + } + + if (response.type === 'checkbox') { + const checkboxError = checkCheckboxResponseForValidation(response, value as string[], dontKnowChecked); + return checkboxError + ? createValidationResult(response, 'invalid', { message: checkboxError }) + : createValidationResult(response, 'none'); + } + + if (response.type === 'dropdown') { + const dropdownError = checkDropdownResponse(response, value as string[]); + return dropdownError + ? createValidationResult(response, 'invalid', { message: dropdownError }) + : createValidationResult(response, 'none'); + } + + return createValidationResult(response, 'none'); + } + + if (value === null || value === undefined || value === '') { + return createValidationResult(response, response.required ? 'unanswered' : 'none'); + } + + if (response.requiredValue != null && value.toString() !== response.requiredValue.toString()) { + return createValidationResult(response, 'invalid', { reason: 'requiredValueMismatch' }); + } + + if (response.type === 'numerical') { + const numericalError = checkNumericalResponse(response, value as unknown as number); + return numericalError + ? createValidationResult(response, 'invalid', { message: numericalError }) + : createValidationResult(response, 'none'); + } + + return createValidationResult(response, 'none'); +} diff --git a/src/components/response/tests/ResponseBlock.spec.tsx b/src/components/response/tests/ResponseBlock.spec.tsx index 1cfd8ad470..3681def2c0 100644 --- a/src/components/response/tests/ResponseBlock.spec.tsx +++ b/src/components/response/tests/ResponseBlock.spec.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { render, act, fireEvent } from '@testing-library/react'; import { renderToStaticMarkup } from 'react-dom/server'; import { - describe, expect, test, vi, + beforeEach, describe, expect, test, vi, } from 'vitest'; import type { IndividualComponent } from '../../../parser/types'; import { ResponseBlock } from '../ResponseBlock'; @@ -10,6 +10,26 @@ import { makeStoredAnswer } from '../../../tests/utils'; // ── mocks ──────────────────────────────────────────────────────────────────── +const baseStoreState = vi.hoisted(() => ({ + answers: {}, + analysisProvState: {}, + clickedPrevious: false, + modes: { dataCollectionEnabled: true }, + reactiveAnswers: {}, + matrixAnswers: {}, + rankingAnswers: {}, + responseSubmitAttempted: { trial1_0: false }, + sequence: { + order: 'fixed', orderPath: '', components: [], skip: [], + }, + trialValidation: { trial1_0: {} }, + completed: false, +})); + +const mockStoreState = vi.hoisted(() => ({ + current: structuredClone(baseStoreState), +})); + vi.mock('@mantine/core', () => ({ Box: ({ children }: { children?: ReactNode }) =>
{children}
, Button: ({ children, disabled, onClick }: { children?: ReactNode; disabled?: boolean; onClick?: () => void }) => ( @@ -47,21 +67,7 @@ vi.mock('../../../store/store', () => ({ setResponseSubmitAttempt: vi.fn((v: unknown) => v), setStimulusSubmitAttempt: vi.fn((v: unknown) => v), })), - useStoreSelector: vi.fn((selector: (s: Record) => unknown) => selector({ - answers: {}, - analysisProvState: {}, - clickedPrevious: false, - modes: { dataCollectionEnabled: true }, - reactiveAnswers: {}, - matrixAnswers: {}, - rankingAnswers: {}, - responseSubmitAttempted: { trial1_0: false }, - sequence: { - order: 'fixed', orderPath: '', components: [], skip: [], - }, - trialValidation: { trial1_0: {} }, - completed: false, - })), + useStoreSelector: vi.fn((selector: (s: Record) => unknown) => selector(mockStoreState.current)), })); vi.mock('../../../store/hooks/useStudyConfig', () => ({ @@ -152,6 +158,15 @@ const baseConfig: IndividualComponent = { // ── ResponseBlock ───────────────────────────────────────────────────────────── describe('ResponseBlock', () => { + beforeEach(() => { + mockStoreState.current = structuredClone(baseStoreState); + Object.defineProperty(globalThis, 'CSS', { + value: { escape: (value: string) => value }, + configurable: true, + }); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + test('renders without error', () => { const html = renderToStaticMarkup( , @@ -225,6 +240,50 @@ describe('ResponseBlock', () => { expect(html).toContain('Check Answer'); }); + test('keeps Next disabled when correct-answer feedback requires checking', () => { + const configWithFeedback = { + ...baseConfig, + provideFeedback: true, + correctAnswer: [{ id: 'q1', answer: 'correct' }], + } as IndividualComponent; + const { container } = render( + , + ); + + const nextBtn = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Next', + ); + + expect(nextBtn).toHaveProperty('disabled', true); + }); + + test('shows response issue summary after submit attempt when responses are unresolved', () => { + mockStoreState.current = { + ...structuredClone(baseStoreState), + responseSubmitAttempted: { trial1_0: true }, + trialValidation: { + trial1_0: { + belowStimulus: { valid: false, values: { q1: '' } }, + }, + }, + }; + const unresolvedConfig = { + ...baseConfig, + response: [ + { + type: 'shortText', id: 'q1', prompt: 'Question 1', + }, + ], + } as IndividualComponent; + + const { container } = render( + , + ); + + expect(container.textContent).toContain('Please review'); + expect(container.textContent).toContain('1 unanswered question'); + }); + test('does not add required=true for textOnly responses', () => { const textOnlyConfig = { type: 'questionnaire', diff --git a/src/components/response/tests/utils.spec.ts b/src/components/response/tests/utils.spec.ts index ac6d3d0ce0..64e01d5a85 100644 --- a/src/components/response/tests/utils.spec.ts +++ b/src/components/response/tests/utils.spec.ts @@ -22,6 +22,7 @@ import { shouldBypassValidationForStandaloneDontKnow, usesStandaloneDontKnowField, } from '../responseErrors'; +import { validateResponse } from '../responseValidation'; describe('generateInitFields', () => { const originalWindow = globalThis.window; @@ -297,6 +298,192 @@ describe('generateValidation custom', () => { }); }); +describe('validateResponse', () => { + const requiredShortText: Response = { + id: 'q1', prompt: 'Question', type: 'shortText', required: true, + }; + + test('required empty scalar returns unanswered and blocks progression', () => { + expect(validateResponse(requiredShortText, '', { q1: '' })).toEqual({ + valid: false, + issueType: 'unanswered', + blocksProgression: true, + }); + }); + + test('optional empty scalar returns valid and non-blocking', () => { + const response: Response = { + ...requiredShortText, + required: false, + }; + + expect(validateResponse(response, '', { q1: '' })).toEqual({ + valid: true, + issueType: 'none', + blocksProgression: false, + }); + }); + + test('optional invalid numerical value is invalid but non-blocking', () => { + const response: NumericalResponse = { + id: 'q1', prompt: 'Question', type: 'numerical', required: false, min: 1, + }; + + expect(validateResponse(response, 0, { q1: 0 })).toMatchObject({ + valid: false, + issueType: 'invalid', + message: 'Please enter a value of 1 or greater', + blocksProgression: false, + }); + }); + + test('numerical min, max, and range are inclusive', () => { + const response: NumericalResponse = { + id: 'q1', prompt: 'Question', type: 'numerical', required: true, min: 1, max: 10, + }; + + expect(validateResponse(response, 1, { q1: 1 }).valid).toBe(true); + expect(validateResponse(response, 10, { q1: 10 }).valid).toBe(true); + expect(validateResponse(response, 0, { q1: 0 }).message).toBe('Please enter a value between 1 and 10'); + expect(validateResponse(response, 11, { q1: 11 }).message).toBe('Please enter a value between 1 and 10'); + }); + + test('checkbox and dropdown min/max produce current messages', () => { + const checkboxResponse: CheckboxResponse = { + id: 'checkbox', prompt: 'Question', type: 'checkbox', required: true, options: [], minSelections: 2, maxSelections: 3, + }; + const dropdownResponse: DropdownResponse = { + id: 'dropdown', prompt: 'Question', type: 'dropdown', required: true, options: [], maxSelections: 1, + }; + + expect(validateResponse(checkboxResponse, ['A'], { checkbox: ['A'] }).message).toBe('Please select at least 2 options'); + expect(validateResponse(checkboxResponse, ['A', 'B', 'C', 'D'], { checkbox: ['A', 'B', 'C', 'D'] }).message).toBe('Please select at most 3 options'); + expect(validateResponse(dropdownResponse, ['A', 'B'], { dropdown: ['A', 'B'] }).message).toBe('Please select at most 1 options'); + }); + + test('checkbox requiredValue exact set equality ignores order', () => { + const response: CheckboxResponse = { + id: 'q1', prompt: 'Question', type: 'checkbox', required: true, options: [], requiredValue: ['A', 'B'], + }; + + expect(validateResponse(response, ['B', 'A'], { q1: ['B', 'A'] }).valid).toBe(true); + expect(validateResponse(response, ['A'], { q1: ['A'] })).toMatchObject({ + valid: false, + issueType: 'invalid', + reason: 'requiredValueMismatch', + blocksProgression: true, + }); + }); + + test('matrix required states distinguish untouched, partial, and complete values', () => { + const response: MatrixResponse = { + id: 'matrix', prompt: 'Question', type: 'matrix-radio', required: true, answerOptions: ['0', '1'], questionOptions: ['q1', 'q2'], + }; + + expect(validateResponse(response, { q1: '', q2: '' }, { matrix: { q1: '', q2: '' } })).toMatchObject({ + issueType: 'unanswered', + blocksProgression: true, + }); + expect(validateResponse(response, { q1: '0', q2: '' }, { matrix: { q1: '0', q2: '' } })).toMatchObject({ + issueType: 'invalid', + message: 'Please answer all questions in the matrix to continue.', + }); + expect(validateResponse(response, { q1: '0', q2: '1' }, { matrix: { q1: '0', q2: '1' } }).valid).toBe(true); + }); + + test('standalone withDontKnow bypasses required, min/max, and requiredValue validation', () => { + const response: NumericalResponse = { + id: 'q1', prompt: 'Question', type: 'numerical', required: true, min: 10, requiredValue: 42, withDontKnow: true, + }; + + expect(validateResponse(response, '', { q1: '', 'q1-dontKnow': true })).toEqual({ + valid: true, + issueType: 'none', + blocksProgression: false, + }); + }); + + test('matrix withDontKnow does not use the standalone bypass', () => { + const response: MatrixResponse = { + id: 'matrix', prompt: 'Question', type: 'matrix-radio', required: true, withDontKnow: true, answerOptions: ['0', '1'], questionOptions: ['q1'], + }; + + expect(validateResponse(response, { q1: '' }, { matrix: { q1: '' }, 'matrix-dontKnow': true })).toMatchObject({ + issueType: 'unanswered', + blocksProgression: true, + }); + }); + + test('withOther selected without text is invalid', () => { + const response: Response = { + id: 'q1', prompt: 'Question', type: 'radio', required: true, options: [], withOther: true, + }; + + expect(validateResponse(response, 'other', { q1: 'other', 'q1-other': '' })).toMatchObject({ + issueType: 'invalid', + message: 'Please fill in Other to continue.', + }); + }); + + test('ranking required empty object is unanswered', () => { + const response: Response = { + id: 'ranking', prompt: 'Rank', type: 'ranking-categorical', required: true, options: [], + }; + + expect(validateResponse(response, {}, { ranking: {} })).toMatchObject({ + issueType: 'unanswered', + blocksProgression: true, + }); + }); + + test('custom response empty required value is unanswered', () => { + const response: CustomResponse = { + id: 'custom', prompt: 'Question', type: 'custom', required: true, path: 'custom-response/Example.tsx', + }; + + expect(validateResponse(response, null, { custom: null })).toMatchObject({ + issueType: 'unanswered', + blocksProgression: true, + }); + }); + + test('custom response custom validator message is invalid', () => { + const response: CustomResponse = { + id: 'custom', prompt: 'Question', type: 'custom', required: true, path: 'custom-response/Example.tsx', + }; + const customValidate: CustomResponseValidate = () => 'Custom validation failed.'; + + expect(validateResponse(response, { value: true }, { custom: { value: true } }, { customValidate })).toMatchObject({ + valid: false, + issueType: 'invalid', + message: 'Custom validation failed.', + blocksProgression: true, + }); + }); + + test('custom response load error is invalid for both required and optional responses', () => { + const requiredResponse: CustomResponse = { + id: 'custom-required', prompt: 'Question', type: 'custom', required: true, path: 'custom-response/Example.tsx', + }; + const optionalResponse: CustomResponse = { + ...requiredResponse, + id: 'custom-optional', + required: false, + }; + + expect(validateResponse(requiredResponse, null, {}, { loadError: 'Unable to load custom response module at custom-response/Example.tsx' })).toMatchObject({ + issueType: 'invalid', + message: 'Unable to load custom response module at custom-response/Example.tsx', + blocksProgression: true, + }); + expect(validateResponse(optionalResponse, null, {}, { loadError: 'Unable to load custom response module at custom-response/Example.tsx' })).toMatchObject({ + issueType: 'invalid', + message: 'Unable to load custom response module at custom-response/Example.tsx', + blocksProgression: false, + }); + }); +}); + describe('generateCustomResponseErrorMessage', () => { const response: CustomResponse = { id: 'custom-response-demo', diff --git a/src/components/response/utils.ts b/src/components/response/utils.ts index 15a08eb93e..950637a718 100644 --- a/src/components/response/utils.ts +++ b/src/components/response/utils.ts @@ -1,20 +1,12 @@ import { useForm } from '@mantine/form'; import { useEffect, useState } from 'react'; -import isEqual from 'lodash.isequal'; import { - CheckboxResponse, CustomResponse, JsonValue, RadioResponse, Response, + CheckboxResponse, 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, + generateInvalidResponseErrorMessage, usesStandaloneDontKnowField, } from './responseErrors'; @@ -157,40 +149,6 @@ export const mergeReactiveAnswers = ( return mergedValues ?? currentValues; }; -function validateCustomResponse( - 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 REQUIRED_ERROR_MESSAGE; - } - - if (response.requiredValue !== undefined && !isEmptyCustomResponseValue(value) && !isEqual(value, response.requiredValue)) { - return 'Incorrect input'; - } - - if (!customValidate) { - return null; - } - - if (response.required === false && isEmptyCustomResponseValue(value)) { - return null; - } - - return customValidate(value, values, response); -} - export const generateValidation = ( responses: Response[], customResponseValidators: Record = {}, @@ -201,70 +159,15 @@ export const generateValidation = ( if (response.required || response.type === 'custom') { validateObj = { ...validateObj, - [response.id]: (value: StoredAnswer['answer'][string], values: StoredAnswer['answer']) => { - if (response.type === 'custom') { - return validateCustomResponse( - response, - value, - values, - customResponseValidators[response.id], - customResponseLoadErrors[response.id], - ); - } - - if (shouldBypassValidationForStandaloneDontKnow(response, !!values[`${response.id}-dontKnow`])) { - 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 : REQUIRED_ERROR_MESSAGE; - } - return Object.values(value).every((val) => val !== '') ? null : REQUIRED_ERROR_MESSAGE; - } - if (Array.isArray(value)) { - if (response.requiredValue != null && !Array.isArray(response.requiredValue)) { - return 'Incorrect required value. Contact study administrator.'; - } - if (response.requiredValue != null && Array.isArray(response.requiredValue)) { - if (response.requiredValue.length !== value.length) { - return 'Incorrect input'; - } - const sortedReq = [...response.requiredValue].sort(); - const sortedVal = [...value].sort(); - - return sortedReq.every((val, index) => val === sortedVal[index]) ? null : 'Incorrect input'; - } - if (response.type === 'checkbox') { - return checkCheckboxResponseForValidation(response, value as string[], !!values[`${response.id}-dontKnow`]); - } - if (response.type === 'dropdown') { - return checkDropdownResponse(response, value as string[]); - } - return value.length === 0 ? REQUIRED_ERROR_MESSAGE : null; - } - - if (response.required && response.requiredValue != null && value != null) { - return value.toString() !== response.requiredValue.toString() ? 'Incorrect input' : null; - } - if (response.required) { - if ((value === null || value === undefined || value === '') && !values[`${response.id}-dontKnow`]) { - return REQUIRED_ERROR_MESSAGE; - } - if (response.type === 'numerical') { - return checkNumericalResponse(response, value as unknown as number); - } - } - - return value === null ? REQUIRED_ERROR_MESSAGE : null; - }, + [response.id]: (value: StoredAnswer['answer'][string], values: StoredAnswer['answer']) => generateInvalidResponseErrorMessage( + response, + value, + values, + { + customValidate: customResponseValidators[response.id], + loadError: customResponseLoadErrors[response.id], + }, + ), }; } });