Implement textarea validation for character limits in submission service; enhance user feedback with error messages for minimum and maximum character requirements. Update translations and UI components to support new validation features.
This commit is contained in:
@@ -109,6 +109,10 @@ export type ExerciseQuestionLabelKey =
|
||||
| 'hotspot.take.placeholder'
|
||||
| 'hotspot.preview.helper'
|
||||
| 'textarea.edit.placeholder'
|
||||
| 'textarea.edit.min_characters_label'
|
||||
| 'textarea.edit.min_characters_placeholder'
|
||||
| 'textarea.edit.max_characters_label'
|
||||
| 'textarea.edit.max_characters_placeholder'
|
||||
| 'textarea.take.placeholder'
|
||||
| 'submission.common.no_answer'
|
||||
| 'submission.common.other'
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './question-type-lookup';
|
||||
export * from './question-type-capabilities';
|
||||
export * from './question-answer-payload';
|
||||
export * from './question-scoring-rules';
|
||||
export * from './question-scoring';
|
||||
|
||||
export * from './exercise-types';
|
||||
export * from './render-contract';
|
||||
@@ -12,3 +13,4 @@ export * from './form-state';
|
||||
export * from './answer-data';
|
||||
export * from './answer-codecs';
|
||||
export * from './renderer-registry';
|
||||
export * from './textarea-validation';
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ExerciseQuestionModel } from './exercise-types';
|
||||
|
||||
export interface TextareaCharacterLimits {
|
||||
minCharacters?: number;
|
||||
maxCharacters?: number;
|
||||
}
|
||||
|
||||
export interface TextareaAnswerValidation extends TextareaCharacterLimits {
|
||||
characterCount: number;
|
||||
isValid: boolean;
|
||||
reason: 'below_min' | 'above_max' | null;
|
||||
}
|
||||
|
||||
type SettingsLike = Pick<ExerciseQuestionModel, 'settings'> | Record<string, unknown> | null | undefined;
|
||||
|
||||
export function sanitizeTextareaCharacterLimit(value: unknown): number | undefined {
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
|
||||
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function resolveSettings(input: SettingsLike): Record<string, unknown> {
|
||||
if (!input || typeof input !== 'object') return {};
|
||||
|
||||
if ('settings' in input && input.settings && typeof input.settings === 'object' && !Array.isArray(input.settings)) {
|
||||
return input.settings as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/gi, (_, code) => String.fromCharCode(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16)));
|
||||
}
|
||||
|
||||
export function getTextareaCharacterLimits(input: SettingsLike): TextareaCharacterLimits {
|
||||
const settings = resolveSettings(input);
|
||||
const minCharacters = sanitizeTextareaCharacterLimit(settings.minCharacters);
|
||||
const rawMaxCharacters = sanitizeTextareaCharacterLimit(settings.maxCharacters);
|
||||
const maxCharacters =
|
||||
minCharacters !== undefined && rawMaxCharacters !== undefined && rawMaxCharacters < minCharacters
|
||||
? minCharacters
|
||||
: rawMaxCharacters;
|
||||
|
||||
return {
|
||||
minCharacters,
|
||||
maxCharacters
|
||||
};
|
||||
}
|
||||
|
||||
export function getTextareaAnswerText(value: string | null | undefined): string {
|
||||
const html = String(value ?? '');
|
||||
|
||||
return decodeHtmlEntities(
|
||||
html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, '\n')
|
||||
.replace(/<li\b[^>]*>/gi, '- ')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
).trim();
|
||||
}
|
||||
|
||||
export function getTextareaAnswerCharacterCount(value: string | null | undefined): number {
|
||||
return getTextareaAnswerText(value).length;
|
||||
}
|
||||
|
||||
export function validateTextareaAnswer(
|
||||
value: string | null | undefined,
|
||||
input: SettingsLike
|
||||
): TextareaAnswerValidation {
|
||||
const { minCharacters, maxCharacters } = getTextareaCharacterLimits(input);
|
||||
const characterCount = getTextareaAnswerCharacterCount(value);
|
||||
|
||||
if (minCharacters !== undefined && characterCount < minCharacters) {
|
||||
return {
|
||||
characterCount,
|
||||
minCharacters,
|
||||
maxCharacters,
|
||||
isValid: false,
|
||||
reason: 'below_min'
|
||||
};
|
||||
}
|
||||
|
||||
if (maxCharacters !== undefined && characterCount > maxCharacters) {
|
||||
return {
|
||||
characterCount,
|
||||
minCharacters,
|
||||
maxCharacters,
|
||||
isValid: false,
|
||||
reason: 'above_max'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
characterCount,
|
||||
minCharacters,
|
||||
maxCharacters,
|
||||
isValid: true,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getTextareaAnswerCharacterCount,
|
||||
getTextareaAnswerText,
|
||||
getTextareaCharacterLimits,
|
||||
sanitizeTextareaCharacterLimit,
|
||||
validateTextareaAnswer
|
||||
} from '../src';
|
||||
|
||||
describe('textarea validation helpers', () => {
|
||||
it('extracts plain text from rich-text HTML', () => {
|
||||
expect(getTextareaAnswerText('<p>Hello <strong>world</strong></p><p>Again</p>')).toBe('Hello world\nAgain');
|
||||
expect(getTextareaAnswerCharacterCount('<p>Hello <strong>world</strong></p><p>Again</p>')).toBe(17);
|
||||
});
|
||||
|
||||
it('normalizes min and max character settings', () => {
|
||||
expect(getTextareaCharacterLimits({ minCharacters: '10', maxCharacters: '5' })).toEqual({
|
||||
minCharacters: 10,
|
||||
maxCharacters: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes individual character limit values', () => {
|
||||
expect(sanitizeTextareaCharacterLimit('12.9')).toBe(12);
|
||||
expect(sanitizeTextareaCharacterLimit(-1)).toBeUndefined();
|
||||
expect(sanitizeTextareaCharacterLimit('abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects answers below the minimum', () => {
|
||||
expect(validateTextareaAnswer('<p>Short</p>', { minCharacters: 10 })).toMatchObject({
|
||||
characterCount: 5,
|
||||
isValid: false,
|
||||
reason: 'below_min',
|
||||
minCharacters: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects answers above the maximum', () => {
|
||||
expect(validateTextareaAnswer('<p>This is too long</p>', { maxCharacters: 4 })).toMatchObject({
|
||||
characterCount: 16,
|
||||
isValid: false,
|
||||
reason: 'above_max',
|
||||
maxCharacters: 4
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts answers within the configured range', () => {
|
||||
expect(validateTextareaAnswer('<p>Just right</p>', { minCharacters: 5, maxCharacters: 15 })).toMatchObject({
|
||||
characterCount: 10,
|
||||
isValid: true,
|
||||
reason: null,
|
||||
minCharacters: 5,
|
||||
maxCharacters: 15
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user