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:
@@ -1,14 +1,40 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres, { type Sql } from 'postgres';
|
||||
|
||||
// import * as schema from './schema';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
|
||||
|
||||
export const db = drizzle(connectionString);
|
||||
type DatabaseClient = ReturnType<typeof drizzle>;
|
||||
|
||||
const globalDatabase = globalThis as typeof globalThis & {
|
||||
__cioDatabaseClient?: Sql;
|
||||
__cioDb?: DatabaseClient;
|
||||
};
|
||||
|
||||
function createDatabaseClient() {
|
||||
return postgres(connectionString, {
|
||||
max: process.env.NODE_ENV === 'production' ? 10 : 1,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
max_lifetime: 60 * 30
|
||||
});
|
||||
}
|
||||
|
||||
const databaseClient = globalDatabase.__cioDatabaseClient ?? createDatabaseClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalDatabase.__cioDatabaseClient = databaseClient;
|
||||
}
|
||||
|
||||
export const db = globalDatabase.__cioDb ?? drizzle(databaseClient);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalDatabase.__cioDb = db;
|
||||
}
|
||||
|
||||
export type DbClient = typeof db;
|
||||
export type TxClient = Parameters<typeof db.transaction>[0] extends (tx: infer T) => any ? T : never;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,9 @@ export const TEXTAREA_FIXTURE: QuestionStoryFixture = {
|
||||
title: 'Explain why exercise feedback should be specific. [TEXTAREA]',
|
||||
questionType: 'TEXTAREA',
|
||||
settings: {
|
||||
description: 'Provide a detailed response.'
|
||||
description: 'Provide a detailed response.',
|
||||
minCharacters: 30,
|
||||
maxCharacters: 250
|
||||
}
|
||||
},
|
||||
answer: '<p>Specific feedback helps students improve on the next attempt quickly.</p>'
|
||||
|
||||
@@ -109,6 +109,10 @@ export const QUESTION_LABELS = {
|
||||
'hotspot.take.placeholder': 'Provide hotspot coordinates as JSON',
|
||||
'hotspot.preview.helper': 'Hotspot preview is provided by host app image and hotspot settings.',
|
||||
'textarea.edit.placeholder': 'Learners will answer with long-form text',
|
||||
'textarea.edit.min_characters_label': 'Minimum characters',
|
||||
'textarea.edit.min_characters_placeholder': 'Minimum characters',
|
||||
'textarea.edit.max_characters_label': 'Maximum characters',
|
||||
'textarea.edit.max_characters_placeholder': 'Maximum characters',
|
||||
'textarea.take.placeholder': 'Write your answer',
|
||||
'submission.common.no_answer': 'No answer',
|
||||
'submission.common.other': 'Other',
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
// Browser detection
|
||||
const browser = typeof window !== 'undefined';
|
||||
|
||||
function normalizeEditorContent(value: HTMLContent | undefined): string {
|
||||
const html = String(value ?? '').trim();
|
||||
|
||||
if (html === '' || html === '<p></p>' || html === '<p><br></p>') return '';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Handle content persistence
|
||||
$effect(() => {
|
||||
if (enablePersistence && browser && content) {
|
||||
@@ -94,6 +102,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
|
||||
const nextContent = normalizeEditorContent(content);
|
||||
const currentContent = normalizeEditorContent(editor.getHTML());
|
||||
|
||||
if (currentContent === nextContent) return;
|
||||
|
||||
editor.commands.setContent(nextContent, false);
|
||||
});
|
||||
|
||||
function onUpdate(props: { editor: Editor; transaction: Transaction }) {
|
||||
if (props?.editor && !props.editor.isDestroyed) {
|
||||
const newContent = props.editor.getHTML();
|
||||
|
||||
@@ -1,12 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import {
|
||||
getExerciseQuestionLabel,
|
||||
getTextareaCharacterLimits,
|
||||
sanitizeTextareaCharacterLimit,
|
||||
type ExerciseQuestionModel,
|
||||
type ExerciseQuestionRendererProps
|
||||
} from '@cio/question-types';
|
||||
import { Input } from '../../../../base/input';
|
||||
import { Textarea } from '../../../../base/textarea';
|
||||
|
||||
let { labels }: ExerciseQuestionRendererProps = $props();
|
||||
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const characterLimits = $derived(getTextareaCharacterLimits(question));
|
||||
|
||||
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
|
||||
onQuestionChange({ ...question, ...partial });
|
||||
}
|
||||
|
||||
function patchSettings(next: Record<string, unknown>) {
|
||||
patchQuestion({ settings: { ...(question.settings ?? {}), ...next } });
|
||||
}
|
||||
|
||||
function updateCharacterLimit(settingKey: 'minCharacters' | 'maxCharacters', value: string) {
|
||||
const parsedValue = value === '' ? undefined : sanitizeTextareaCharacterLimit(value);
|
||||
|
||||
if (parsedValue === undefined) {
|
||||
patchSettings({ [settingKey]: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
patchSettings({ [settingKey]: parsedValue });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-3">
|
||||
<Textarea class="ui:w-full" rows={4} disabled={true} placeholder={label('textarea.edit.placeholder')} />
|
||||
|
||||
<div class="ui:grid ui:gap-3 ui:md:grid-cols-2">
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-sm ui:font-medium">{label('textarea.edit.min_characters_label')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={characterLimits.minCharacters === undefined ? '' : String(characterLimits.minCharacters)}
|
||||
{disabled}
|
||||
placeholder={label('textarea.edit.min_characters_placeholder')}
|
||||
onchange={(event) => updateCharacterLimit('minCharacters', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-sm ui:font-medium">{label('textarea.edit.max_characters_label')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={characterLimits.maxCharacters === undefined ? '' : String(characterLimits.maxCharacters)}
|
||||
{disabled}
|
||||
placeholder={label('textarea.edit.max_characters_placeholder')}
|
||||
onchange={(event) => updateCharacterLimit('maxCharacters', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import {
|
||||
getExerciseQuestionLabel,
|
||||
getTextareaAnswerCharacterCount,
|
||||
getTextareaCharacterLimits,
|
||||
type ExerciseQuestionRendererProps
|
||||
} from '@cio/question-types';
|
||||
import { Editor as EditorComponent } from '../../../editor';
|
||||
|
||||
let {
|
||||
@@ -16,16 +21,109 @@
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const content = $derived(answer?.type === 'TEXTAREA' ? answer.text : String(answer ?? ''));
|
||||
const characterLimits = $derived(getTextareaCharacterLimits(question));
|
||||
const characterCount = $derived(getTextareaAnswerCharacterCount(content));
|
||||
const shouldShowCharacterCount = $derived(
|
||||
characterLimits.minCharacters !== undefined && characterLimits.maxCharacters !== undefined
|
||||
);
|
||||
|
||||
let detachEditorListeners: (() => void) | undefined;
|
||||
|
||||
onDestroy(() => {
|
||||
detachEditorListeners?.();
|
||||
});
|
||||
|
||||
function normalizeAnswerHtml(value: string | null | undefined): string {
|
||||
const html = String(value ?? '').trim();
|
||||
|
||||
if (getTextareaAnswerCharacterCount(html) < 1) return '';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function updateAnswer(value: string | null | undefined) {
|
||||
onAnswerChange({ type: 'TEXTAREA', text: normalizeAnswerHtml(value) });
|
||||
}
|
||||
|
||||
function getEditorDom(editor: Editor): HTMLElement | null {
|
||||
try {
|
||||
return editor.view.dom as HTMLElement;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentCharacterCount(editor: Editor): number {
|
||||
return getTextareaAnswerCharacterCount(editor.getHTML());
|
||||
}
|
||||
|
||||
function getSelectedCharacterCount(editor: Editor): number {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
|
||||
if (empty) return 0;
|
||||
|
||||
return editor.state.doc.textBetween(from, to, '\n', '\n').length;
|
||||
}
|
||||
|
||||
function canInsertCharacters(editor: Editor, insertedCharacterCount: number): boolean {
|
||||
const maxCharacters = characterLimits.maxCharacters;
|
||||
|
||||
if (maxCharacters === undefined || insertedCharacterCount < 1) return true;
|
||||
|
||||
const nextCharacterCount =
|
||||
getCurrentCharacterCount(editor) - getSelectedCharacterCount(editor) + insertedCharacterCount;
|
||||
|
||||
return nextCharacterCount <= maxCharacters;
|
||||
}
|
||||
|
||||
function getBeforeInputCharacterCount(event: InputEvent): number {
|
||||
switch (event.inputType) {
|
||||
case 'insertParagraph':
|
||||
case 'insertLineBreak':
|
||||
return 1;
|
||||
default:
|
||||
return typeof event.data === 'string' ? event.data.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorReady(editor: Editor) {
|
||||
if (!editor?.view?.dom || disabled) return;
|
||||
detachEditorListeners?.();
|
||||
|
||||
const editorDom = getEditorDom(editor);
|
||||
|
||||
if (!editorDom || disabled) return;
|
||||
|
||||
const blurHandler = () => {
|
||||
if (!editor.isDestroyed) onAnswerChange({ type: 'TEXTAREA', text: String(editor.getHTML() ?? '') });
|
||||
if (!editor.isDestroyed) updateAnswer(editor.getHTML());
|
||||
};
|
||||
|
||||
const beforeInputHandler = (event: Event) => {
|
||||
const inputEvent = event as InputEvent;
|
||||
|
||||
if (!inputEvent.inputType?.startsWith('insert')) return;
|
||||
|
||||
const insertedCharacterCount = getBeforeInputCharacterCount(inputEvent);
|
||||
if (canInsertCharacters(editor, insertedCharacterCount)) return;
|
||||
|
||||
inputEvent.preventDefault();
|
||||
};
|
||||
|
||||
const pasteHandler = (event: ClipboardEvent) => {
|
||||
const pastedText = event.clipboardData?.getData('text/plain') ?? '';
|
||||
if (canInsertCharacters(editor, pastedText.length)) return;
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
editorDom.addEventListener('blur', blurHandler);
|
||||
editorDom.addEventListener('beforeinput', beforeInputHandler);
|
||||
editorDom.addEventListener('paste', pasteHandler);
|
||||
|
||||
detachEditorListeners = () => {
|
||||
editorDom.removeEventListener('blur', blurHandler);
|
||||
editorDom.removeEventListener('beforeinput', beforeInputHandler);
|
||||
editorDom.removeEventListener('paste', pasteHandler);
|
||||
};
|
||||
editor.view.dom.addEventListener('blur', blurHandler);
|
||||
onDestroy(() => {
|
||||
if (editor?.view?.dom) editor.view.dom.removeEventListener('blur', blurHandler);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,7 +134,13 @@
|
||||
showToolBar={!disabled}
|
||||
placeholder={label('textarea.take.placeholder')}
|
||||
editorClass="ui:h-40"
|
||||
onContentChange={(nextContent) => onAnswerChange({ type: 'TEXTAREA', text: String(nextContent ?? '') })}
|
||||
onContentChange={updateAnswer}
|
||||
onEditorReady={handleEditorReady}
|
||||
/>
|
||||
|
||||
{#if shouldShowCharacterCount}
|
||||
<span class="ui:text-muted-foreground ui:block ui:text-right ui:text-xs">
|
||||
{characterCount} / {characterLimits.maxCharacters}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user